UI: interactive back-swipe (left-band, snapshot backdrop) #40
+12
-1
@@ -22,7 +22,18 @@ Login uses `Screen`.
|
||||
## Navigation
|
||||
|
||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
||||
`.chev`) — lighter than a glyph.
|
||||
`.chev`) — lighter than a glyph. Outside Telegram a screen also returns with an
|
||||
**interactive back-swipe** (`lib/backswipe.ts` pure logic + `lib/backswipe.svelte.ts`
|
||||
wiring, rendered by `App.svelte`): a rightward drag begun in the **left ~38 % band**
|
||||
(touch/pen only) tracks the finger — the current pane slides right while a cheap themed
|
||||
**backdrop** parallaxes in under a lightening scrim — and on release the screen the swipe
|
||||
ends toward wins (commit past ~40 % of the width or a rightward flick, otherwise snap
|
||||
back). The live parent mounts only on commit, which suppresses that one route slide so the
|
||||
hand-off is seamless. The gesture yields to native **vertical scroll** on a vertical move
|
||||
and skips the rack, a draggable pending tile, text inputs and the board **while zoomed-in**
|
||||
(so it never fights the board pan). It works **inside Telegram** too — Telegram's only
|
||||
close/minimise gesture is *vertical* (disabled separately via `disableVerticalSwipes`), so
|
||||
a horizontal back-swipe has nothing to fight; the native header BackButton complements it.
|
||||
- **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte`
|
||||
dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred).
|
||||
Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// The interactive back-swipe (left-band rightward drag → parent screen). It ignores mouse
|
||||
// pointers and the Playwright desktop projects have no touch input, so these specs dispatch
|
||||
// PointerEvents with pointerType:'touch' straight at the window capture listener. The
|
||||
// controller hit-tests via elementFromPoint, so the start coordinate picks the element under
|
||||
// the press — starting on the board lets us assert the zoom-out-only rule directly.
|
||||
//
|
||||
// A phone-sized viewport: the gesture's left band is a fraction of the screen width, so on a
|
||||
// wide desktop the centred board sits outside it. Phones are the real target and there the
|
||||
// full-width board fills the band.
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
const MID_Y = 422; // mid-board on the 844-tall viewport
|
||||
const START_X = 30; // inside the left band, over the board's left column
|
||||
|
||||
async function openAnnGame(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await page.waitForTimeout(400); // let the open slide settle before measuring/aiming
|
||||
}
|
||||
|
||||
// A minimal Telegram WebApp stub: non-empty initData makes insideTelegram() true and drives
|
||||
// the Mini App auto-auth into the lobby (the mock gateway accepts any initData).
|
||||
function telegramStub() {
|
||||
return {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
themeParams: {},
|
||||
ready() {},
|
||||
expand() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** touchDrag dispatches a single-finger touch drag from (x0,y0) to (x1,y1) over the window. */
|
||||
async function touchDrag(page: Page, x0: number, y0: number, x1: number, y1: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ x0, y0, x1, y1 }) => {
|
||||
const steps = 8;
|
||||
const fire = (type: string, x: number, y: number) =>
|
||||
window.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
pointerType: 'touch',
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
fire('pointerdown', x0, y0);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
fire('pointermove', x0 + ((x1 - x0) * i) / steps, y0 + ((y1 - y0) * i) / steps);
|
||||
}
|
||||
fire('pointerup', x1, y1);
|
||||
},
|
||||
{ x0, y0, x1, y1 },
|
||||
);
|
||||
}
|
||||
|
||||
test('a left-band rightward swipe on a zoomed-out board returns to the lobby', async ({ page }) => {
|
||||
await openAnnGame(page);
|
||||
|
||||
// Drag from the left band well past the commit threshold.
|
||||
await touchDrag(page, START_X, MID_Y, 300, MID_Y);
|
||||
|
||||
// Back at the lobby: the URL returns to the root and the seeded game row is shown again.
|
||||
await expect(page).toHaveURL(/#\/$/);
|
||||
await expect(page.getByRole('button', { name: /Ann/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a swipe on a zoomed-in board does not navigate (board pan owns it)', async ({ page }) => {
|
||||
await openAnnGame(page);
|
||||
|
||||
// Double-tap an empty cell to zoom in, then wait for the zoomed viewport.
|
||||
await page
|
||||
.locator('[data-cell]:not(.filled)')
|
||||
.nth(20)
|
||||
.evaluate((el: HTMLElement) => {
|
||||
el.click();
|
||||
el.click();
|
||||
});
|
||||
await expect(page.locator('.viewport.zoomed')).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await touchDrag(page, START_X, MID_Y, 300, MID_Y);
|
||||
|
||||
// Still on the game: the swipe was suppressed over the zoomed board.
|
||||
await page.waitForTimeout(300);
|
||||
expect(page.url()).toContain('/game/');
|
||||
});
|
||||
|
||||
test('a vertical drag in the band scrolls instead of navigating', async ({ page }) => {
|
||||
await openAnnGame(page);
|
||||
|
||||
// A downward-dominant drag must lose to the native scroll and never go back.
|
||||
await touchDrag(page, START_X, 260, START_X + 20, 700);
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
expect(page.url()).toContain('/game/');
|
||||
});
|
||||
|
||||
test('the back-swipe also works inside Telegram (its only swipe gesture is vertical)', async ({ page }) => {
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, telegramStub());
|
||||
await page.goto('/');
|
||||
|
||||
// Telegram auto-authenticates into the lobby (no guest click), then open the seeded game.
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await touchDrag(page, START_X, MID_Y, 300, MID_Y);
|
||||
|
||||
await expect(page).toHaveURL(/#\/$/);
|
||||
await expect(page.getByRole('button', { name: /Ann/ })).toBeVisible();
|
||||
});
|
||||
+67
-12
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { app, bootstrap } from './lib/app.svelte';
|
||||
import { navigate, router, type RouteName } from './lib/router.svelte';
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||
import { backSwipe, initBackSwipe } from './lib/backswipe.svelte';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
@@ -16,18 +17,27 @@
|
||||
|
||||
onMount(() => {
|
||||
void bootstrap();
|
||||
return initBackSwipe(parentPath);
|
||||
});
|
||||
|
||||
// Inside Telegram, drive its native header back button: show it on any sub-screen
|
||||
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
|
||||
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
||||
// parentPath is the route a back action returns to from the current screen, or null at a
|
||||
// navigation root. It is the single source for both Telegram's native Back button and the
|
||||
// interactive back-swipe, so the two can never diverge. The chat / check sub-screens step
|
||||
// back to their game; every other sub-screen to the lobby.
|
||||
function parentPath(): string | null {
|
||||
const r = router.route;
|
||||
if (r.name === 'lobby' || r.name === 'login') return null;
|
||||
if (r.name === 'gameChat' || r.name === 'gameCheck') return `/game/${r.params.id}`;
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Inside Telegram, drive its native header back button from parentPath: show it on any
|
||||
// sub-screen, hide it on the lobby/login. The app's own back chevron is hidden in Telegram
|
||||
// (Header.svelte) so only the native one shows.
|
||||
$effect(() => {
|
||||
if (!insideTelegram()) return;
|
||||
const r = router.route;
|
||||
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
|
||||
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
|
||||
const target = sub ? `/game/${r.params.id}` : '/';
|
||||
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
|
||||
const p = parentPath();
|
||||
telegramBackButton(p !== null, () => navigate(p ?? '/'));
|
||||
});
|
||||
|
||||
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
||||
@@ -52,7 +62,24 @@
|
||||
const enterSign = $derived(dir === 'forward' ? 1 : -1);
|
||||
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
||||
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
|
||||
const animMs = $derived(app.reduceMotion ? 0 : 260);
|
||||
// A committed back-swipe slides the pane off manually, so its one navigation must not
|
||||
// also replay the route slide — suppressTransition collapses that single transition to 0ms.
|
||||
const animMs = $derived(app.reduceMotion || backSwipe.suppressTransition ? 0 : 260);
|
||||
|
||||
// After a committed back-swipe navigates, release the one-shot suppression and reset the
|
||||
// drag rune once the parent pane has mounted. The effect depends only on routeKey (the
|
||||
// resets are untracked) so setting the flag never clears itself, and the reset lands in the
|
||||
// same flush as the keyed re-render — the incoming parent pane never paints mid-slide.
|
||||
$effect(() => {
|
||||
routeKey;
|
||||
untrack(() => {
|
||||
if (backSwipe.active || backSwipe.suppressTransition) {
|
||||
backSwipe.active = false;
|
||||
backSwipe.progress = 0;
|
||||
backSwipe.suppressTransition = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
|
||||
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
|
||||
@@ -69,9 +96,10 @@
|
||||
{#if !app.ready}
|
||||
<div class="splash">{t('common.loading')}</div>
|
||||
{:else}
|
||||
<div class="router">
|
||||
<div class="router" style="--bsp:{backSwipe.progress};">
|
||||
{#if backSwipe.active}<div class="backdrop" aria-hidden="true"></div>{/if}
|
||||
{#key routeKey}
|
||||
<div class="pane" in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
|
||||
<div class="pane" class:dragging={backSwipe.active} in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
|
||||
{#if router.route.name === 'login'}
|
||||
<Login />
|
||||
{:else if router.route.name === 'new'}
|
||||
@@ -117,5 +145,32 @@
|
||||
.pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
/* While an interactive back-swipe is in flight the current pane tracks the finger
|
||||
(--bsp: 0..1) and casts a leading-edge shadow over the revealed parent backdrop. The
|
||||
transform is gated on .dragging so it never establishes a containing block for the
|
||||
position:fixed tile-drag ghost at rest. */
|
||||
.pane.dragging {
|
||||
transform: translateX(calc(var(--bsp) * 100%));
|
||||
box-shadow: -12px 0 28px rgba(0, 0, 0, 0.22);
|
||||
will-change: transform;
|
||||
}
|
||||
/* A cheap placeholder for the screen being returned to: the app background parallaxing in
|
||||
from the left under a scrim that lightens as the swipe completes. The live parent mounts
|
||||
only once the gesture commits. */
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: var(--bg);
|
||||
transform: translateX(calc((var(--bsp) - 1) * 30%));
|
||||
}
|
||||
.backdrop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
opacity: calc((1 - var(--bsp)) * 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import Header from './Header.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
|
||||
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
|
||||
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
|
||||
@@ -33,26 +32,9 @@
|
||||
// true to bring it back.
|
||||
const SHOW_AD_BANNER = false;
|
||||
|
||||
// Edge-swipe back: a left-edge rightward drag returns to `back`, the standard
|
||||
// mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer
|
||||
// handlers (which capture/stop the event) can never swallow it; armed only from the very
|
||||
// left edge (<=24px), touch/pen only, so it never competes with the board's gestures.
|
||||
$effect(() => {
|
||||
function onDown(e: PointerEvent) {
|
||||
if (!back || e.pointerType === 'mouse' || e.clientX > 24) return;
|
||||
const x0 = e.clientX;
|
||||
const y0 = e.clientY;
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
const dx = ev.clientX - x0;
|
||||
const dy = ev.clientY - y0;
|
||||
if (back && dx > 64 && Math.abs(dx) > Math.abs(dy) * 1.4) navigate(back);
|
||||
};
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
}
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
return () => window.removeEventListener('pointerdown', onDown, true);
|
||||
});
|
||||
// The interactive back-swipe lives at the router level (App.svelte + lib/backswipe.svelte),
|
||||
// which can render the parent backdrop and track the finger across the whole screen; Screen
|
||||
// only carries `back` for the Header chevron.
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* backswipe.svelte wires the framework-agnostic gesture in backswipe.ts to the DOM and to
|
||||
* a reactive rune the router binds. It owns the window pointer listeners, the hit-testing
|
||||
* that keeps the swipe clear of the rack, draggable tiles and a zoomed-in board, the
|
||||
* release settle animation, and the one-shot transition suppression that hands a committed
|
||||
* drag over to the freshly mounted parent without replaying the route slide.
|
||||
*/
|
||||
|
||||
import { app } from './app.svelte';
|
||||
import { createBackSwipe, shouldArm } from './backswipe';
|
||||
import { navigate } from './router.svelte';
|
||||
|
||||
/**
|
||||
* backSwipe is the reactive view of the in-flight gesture. App binds `progress` to the
|
||||
* pane/backdrop transforms and reads `suppressTransition` to skip the route slide for the
|
||||
* one navigation a committed drag performs.
|
||||
*/
|
||||
export const backSwipe = $state<{ active: boolean; progress: number; suppressTransition: boolean }>({
|
||||
active: false,
|
||||
progress: 0,
|
||||
suppressTransition: false,
|
||||
});
|
||||
|
||||
// Elements whose own pointer behaviour must win over the back-swipe: the rack (tile lift),
|
||||
// a draggable pending tile, the board only while zoomed-in (it pans natively then), and any
|
||||
// text-editing control. Mark any other conflicting element with data-noswipe.
|
||||
const SKIP_SELECTOR = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select, [data-noswipe]';
|
||||
|
||||
const SETTLE_MS = 180;
|
||||
|
||||
/**
|
||||
* initBackSwipe attaches the global pointer listeners powering the interactive back
|
||||
* gesture and returns a teardown. getParent yields the current screen's parent route path
|
||||
* (e.g. "/" or "/game/:id"), or null at a navigation root where the gesture must not arm.
|
||||
*/
|
||||
export function initBackSwipe(getParent: () => string | null): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
const ctrl = createBackSwipe({
|
||||
onChange: (active, progress) => {
|
||||
backSwipe.active = active;
|
||||
backSwipe.progress = progress;
|
||||
},
|
||||
});
|
||||
|
||||
let parent: string | null = null;
|
||||
let tracking = false;
|
||||
let settling = false;
|
||||
|
||||
function clearActive(): void {
|
||||
backSwipe.active = false;
|
||||
backSwipe.progress = 0;
|
||||
}
|
||||
|
||||
// settleTo eases progress to target, paints the final value for one frame, then runs done.
|
||||
// The extra frame lets a committed pane finish sliding off before the parent swaps in.
|
||||
function settleTo(target: number, done: () => void): void {
|
||||
settling = true;
|
||||
const from = backSwipe.progress;
|
||||
const dur = app.reduceMotion ? 0 : SETTLE_MS;
|
||||
const t0 = performance.now();
|
||||
function frame(now: number): void {
|
||||
const k = dur === 0 ? 1 : Math.min(1, (now - t0) / dur);
|
||||
const eased = 1 - Math.pow(1 - k, 3);
|
||||
backSwipe.progress = from + (target - from) * eased;
|
||||
if (k < 1) requestAnimationFrame(frame);
|
||||
else requestAnimationFrame(() => {
|
||||
settling = false;
|
||||
done();
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent): void {
|
||||
const axis = ctrl.move({ x: e.clientX, y: e.clientY, t: e.timeStamp });
|
||||
if (axis === 'horizontal') e.preventDefault();
|
||||
else if (axis === 'vertical') detach(); // lost to native scroll
|
||||
}
|
||||
|
||||
function onUp(): void {
|
||||
detach();
|
||||
const decision = ctrl.end();
|
||||
if (decision === 'commit' && parent) {
|
||||
settleTo(1, () => {
|
||||
// Suppress the one route slide, then navigate. active/progress/suppressTransition are
|
||||
// cleared by App's routeKey effect in the same flush as the keyed swap, so the
|
||||
// off-screen outgoing pane never paints back at rest before the parent mounts.
|
||||
backSwipe.suppressTransition = true;
|
||||
navigate(parent!);
|
||||
});
|
||||
} else if (decision === 'cancel') {
|
||||
settleTo(0, clearActive);
|
||||
} else {
|
||||
clearActive();
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel(): void {
|
||||
detach();
|
||||
ctrl.cancel();
|
||||
settleTo(0, clearActive);
|
||||
}
|
||||
|
||||
function detach(): void {
|
||||
tracking = false;
|
||||
window.removeEventListener('pointermove', onMove, true);
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onCancel, true);
|
||||
}
|
||||
|
||||
function onDown(e: PointerEvent): void {
|
||||
if (settling) return;
|
||||
if (tracking) {
|
||||
// A second pointer (e.g. a pinch) aborts the in-flight back-swipe.
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
// Still active but not tracking means a committed drag is awaiting its (async) route
|
||||
// swap; ignore presses until the parent has mounted and the effect resets the rune.
|
||||
if (backSwipe.active) return;
|
||||
parent = getParent();
|
||||
if (
|
||||
!shouldArm({
|
||||
clientX: e.clientX,
|
||||
width: window.innerWidth,
|
||||
pointerType: e.pointerType,
|
||||
hasParent: !!parent,
|
||||
})
|
||||
)
|
||||
return;
|
||||
// Hit-test the element actually under the press (robust for capture-phase and synthetic
|
||||
// events alike) and skip the interactive/draggable zones.
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (el?.closest(SKIP_SELECTOR)) return;
|
||||
ctrl.start({ x: e.clientX, y: e.clientY, t: e.timeStamp }, window.innerWidth);
|
||||
tracking = true;
|
||||
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
window.addEventListener('pointercancel', onCancel, true);
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onDown, true);
|
||||
detach();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createBackSwipe, decideCommit, lockAxis, shouldArm } from './backswipe';
|
||||
|
||||
describe('shouldArm', () => {
|
||||
const base = {
|
||||
clientX: 10,
|
||||
width: 400,
|
||||
pointerType: 'touch',
|
||||
hasParent: true,
|
||||
};
|
||||
|
||||
it('arms a touch press inside the left band of a screen with a parent', () => {
|
||||
expect(shouldArm(base)).toBe(true);
|
||||
expect(shouldArm({ ...base, clientX: 0.38 * 400 - 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('does not arm past the band, for a mouse, or at a root', () => {
|
||||
expect(shouldArm({ ...base, clientX: 0.38 * 400 + 1 })).toBe(false);
|
||||
expect(shouldArm({ ...base, pointerType: 'mouse' })).toBe(false);
|
||||
expect(shouldArm({ ...base, hasParent: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('honours a custom band fraction', () => {
|
||||
expect(shouldArm({ ...base, clientX: 90, bandFrac: 0.2 })).toBe(false); // band = 80
|
||||
expect(shouldArm({ ...base, clientX: 70, bandFrac: 0.2 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockAxis', () => {
|
||||
it('stays pending inside the deadzone', () => {
|
||||
expect(lockAxis(5, 4, 10)).toBe('pending');
|
||||
});
|
||||
|
||||
it('locks horizontal for a rightward, horizontal-dominant move', () => {
|
||||
expect(lockAxis(40, 10, 10)).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('locks vertical for a vertical-dominant or leftward move', () => {
|
||||
expect(lockAxis(10, 40, 10)).toBe('vertical');
|
||||
expect(lockAxis(-40, 5, 10)).toBe('vertical'); // leftward never goes back
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideCommit', () => {
|
||||
it('commits a rightward flick and cancels a leftward flick regardless of distance', () => {
|
||||
expect(decideCommit(0.1, 0.8, 0.4, 0.5)).toBe(true);
|
||||
expect(decideCommit(0.9, -0.8, 0.4, 0.5)).toBe(false);
|
||||
});
|
||||
|
||||
it('decides by position when there is no flick', () => {
|
||||
expect(decideCommit(0.5, 0.1, 0.4, 0.5)).toBe(true);
|
||||
expect(decideCommit(0.3, 0.1, 0.4, 0.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBackSwipe lifecycle', () => {
|
||||
it('reports progress while dragging and commits a long drag', () => {
|
||||
const changes: Array<[boolean, number]> = [];
|
||||
const c = createBackSwipe({ onChange: (a, p) => changes.push([a, p]) });
|
||||
c.start({ x: 5, y: 100, t: 0 }, 400);
|
||||
expect(c.active).toBe(false);
|
||||
// Cross the deadzone horizontally -> becomes active.
|
||||
expect(c.move({ x: 40, y: 105, t: 16 })).toBe('horizontal');
|
||||
expect(c.active).toBe(true);
|
||||
expect(c.progress).toBeCloseTo(35 / 400, 5);
|
||||
// Drag well past the commit fraction.
|
||||
c.move({ x: 220, y: 110, t: 200 });
|
||||
expect(c.progress).toBeCloseTo(215 / 400, 5);
|
||||
expect(c.end()).toBe('commit');
|
||||
expect(changes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(changes.every(([a]) => a === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels a short, slow drag', () => {
|
||||
const c = createBackSwipe();
|
||||
c.start({ x: 5, y: 100, t: 0 }, 400);
|
||||
c.move({ x: 20, y: 101, t: 100 }); // cross the deadzone slowly
|
||||
c.move({ x: 25, y: 102, t: 300 }); // ~5% progress, last samples ~0.025 px/ms (no flick)
|
||||
expect(c.end()).toBe('cancel');
|
||||
});
|
||||
|
||||
it('releases to scroll on a vertical move and never activates', () => {
|
||||
const onChange = vi.fn();
|
||||
const c = createBackSwipe({ onChange });
|
||||
c.start({ x: 5, y: 100, t: 0 }, 400);
|
||||
expect(c.move({ x: 8, y: 160, t: 16 })).toBe('vertical');
|
||||
expect(c.active).toBe(false);
|
||||
// Once released, further moves stay inert and end() is idle.
|
||||
expect(c.move({ x: 80, y: 200, t: 32 })).toBe('vertical');
|
||||
expect(c.end()).toBe('idle');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits a fast rightward flick even when short', () => {
|
||||
const c = createBackSwipe();
|
||||
c.start({ x: 5, y: 100, t: 0 }, 400);
|
||||
c.move({ x: 30, y: 100, t: 10 }); // cross deadzone
|
||||
c.move({ x: 70, y: 100, t: 20 }); // 40px in 10ms -> 4 px/ms flick
|
||||
expect(c.end()).toBe('commit');
|
||||
});
|
||||
|
||||
it('cancel() drops an in-flight drag back to rest', () => {
|
||||
const c = createBackSwipe();
|
||||
c.start({ x: 5, y: 100, t: 0 }, 400);
|
||||
c.move({ x: 120, y: 100, t: 50 });
|
||||
c.cancel();
|
||||
expect(c.active).toBe(false);
|
||||
expect(c.progress).toBe(0);
|
||||
expect(c.end()).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* backswipe holds the framework-agnostic decision logic and per-gesture lifecycle behind
|
||||
* the interactive "swipe right to go back" gesture: a rightward drag begun in the left
|
||||
* band of a screen, during which the current screen tracks the finger and, on release,
|
||||
* either commits the back-navigation or snaps back. The view feeds raw pointer samples
|
||||
* and observes onChange to drive the transforms; all the geometry and thresholds live
|
||||
* here so they are unit-testable without a DOM.
|
||||
*/
|
||||
|
||||
/** Point is one pointer sample fed to the controller. */
|
||||
export interface Point {
|
||||
/** Viewport x in CSS pixels. */
|
||||
x: number;
|
||||
/** Viewport y in CSS pixels. */
|
||||
y: number;
|
||||
/** Monotonic timestamp in milliseconds (event.timeStamp), used for flick velocity. */
|
||||
t: number;
|
||||
}
|
||||
|
||||
/** Axis is the direction a drag has resolved to, or 'pending' before the deadzone is crossed. */
|
||||
export type Axis = 'pending' | 'horizontal' | 'vertical';
|
||||
|
||||
/** ArmInput carries the facts shouldArm needs from a pointerdown. */
|
||||
export interface ArmInput {
|
||||
/** Viewport x of the press in CSS pixels. */
|
||||
clientX: number;
|
||||
/** Viewport width in CSS pixels. */
|
||||
width: number;
|
||||
/** PointerEvent.pointerType — 'mouse' presses never arm the gesture. */
|
||||
pointerType: string;
|
||||
/** Whether the current screen has a parent to return to (false on a navigation root). */
|
||||
hasParent: boolean;
|
||||
/** Fraction of the width forming the left activation band; defaults to 0.38. */
|
||||
bandFrac?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* shouldArm decides whether a pointerdown may begin a back-swipe: a touch or pen press in
|
||||
* the left band of a screen that has a parent. It is intentionally Telegram-agnostic —
|
||||
* Telegram's only close/minimise gesture is vertical (handled by disableVerticalSwipes), so
|
||||
* a horizontal back-swipe has nothing to conflict with there.
|
||||
*/
|
||||
export function shouldArm(i: ArmInput): boolean {
|
||||
if (!i.hasParent) return false;
|
||||
if (i.pointerType === 'mouse') return false;
|
||||
const band = (i.bandFrac ?? 0.38) * i.width;
|
||||
return i.clientX <= band;
|
||||
}
|
||||
|
||||
/**
|
||||
* lockAxis resolves the drag axis once movement leaves the deadzone: a rightward,
|
||||
* horizontal-dominant move locks 'horizontal'; anything vertical-dominant or leftward
|
||||
* locks 'vertical' so the native scroll keeps it; below the deadzone it stays 'pending'.
|
||||
*/
|
||||
export function lockAxis(dx: number, dy: number, deadzonePx: number): Axis {
|
||||
if (Math.abs(dx) < deadzonePx && Math.abs(dy) < deadzonePx) return 'pending';
|
||||
if (dx > 0 && Math.abs(dx) > Math.abs(dy)) return 'horizontal';
|
||||
return 'vertical';
|
||||
}
|
||||
|
||||
/**
|
||||
* decideCommit returns whether a released drag should commit the back-navigation: a
|
||||
* rightward flick commits and a leftward flick cancels regardless of distance, otherwise
|
||||
* the decision is by position (progress past commitFrac). velocity is px/ms, rightward
|
||||
* positive.
|
||||
*/
|
||||
export function decideCommit(
|
||||
progress: number,
|
||||
velocity: number,
|
||||
commitFrac: number,
|
||||
flickVel: number,
|
||||
): boolean {
|
||||
if (velocity >= flickVel) return true;
|
||||
if (velocity <= -flickVel) return false;
|
||||
return progress >= commitFrac;
|
||||
}
|
||||
|
||||
/** BackSwipeOptions tunes the gesture thresholds and wires the view callback. */
|
||||
export interface BackSwipeOptions {
|
||||
/** Progress (0..1) past which a release commits by position; defaults to 0.4. */
|
||||
commitFrac?: number;
|
||||
/** Rightward flick velocity (px/ms) that commits regardless of distance; defaults to 0.5. */
|
||||
flickVel?: number;
|
||||
/** Movement (px) before the gesture locks to an axis; defaults to 10. */
|
||||
deadzonePx?: number;
|
||||
/** Reports drag state to the view on every change while dragging. */
|
||||
onChange?: (active: boolean, progress: number) => void;
|
||||
}
|
||||
|
||||
/** Decision is the outcome end() reports to the view. */
|
||||
export type Decision = 'commit' | 'cancel' | 'idle';
|
||||
|
||||
/** BackSwipeController drives one in-flight gesture from a single armed pointer. */
|
||||
export interface BackSwipeController {
|
||||
/** Whether the gesture has locked to a horizontal drag. */
|
||||
readonly active: boolean;
|
||||
/** Drag completion, 0 (rest) .. 1 (fully across the width). */
|
||||
readonly progress: number;
|
||||
/** Begin tracking from a pointerdown that already passed shouldArm and hit-testing. */
|
||||
start(p: Point, width: number): void;
|
||||
/** Feed a pointermove; returns the resolved axis (the caller preventDefaults 'horizontal'). */
|
||||
move(p: Point): Axis;
|
||||
/** Release; returns 'commit' or 'cancel' while dragging, else 'idle'. Leaves progress for the
|
||||
* view to settle from. */
|
||||
end(): Decision;
|
||||
/** Abort the gesture (pointercancel, a second pointer, or a route change underneath). */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* createBackSwipe builds a BackSwipeController. It tracks one armed pointer through an
|
||||
* optional pending phase (until the deadzone resolves the axis), a dragging phase that
|
||||
* reports progress through onChange, and a terminal end()/cancel() that yields the commit
|
||||
* decision. It holds no timers and no DOM references.
|
||||
*/
|
||||
export function createBackSwipe(opts: BackSwipeOptions = {}): BackSwipeController {
|
||||
const commitFrac = opts.commitFrac ?? 0.4;
|
||||
const flickVel = opts.flickVel ?? 0.5;
|
||||
const deadzonePx = opts.deadzonePx ?? 10;
|
||||
|
||||
let phase: 'idle' | 'pending' | 'dragging' = 'idle';
|
||||
let x0 = 0;
|
||||
let y0 = 0;
|
||||
let width = 1;
|
||||
// The last two samples, for a release flick velocity.
|
||||
let prevX = 0;
|
||||
let prevT = 0;
|
||||
let lastX = 0;
|
||||
let lastT = 0;
|
||||
let progress = 0;
|
||||
let active = false;
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get progress() {
|
||||
return progress;
|
||||
},
|
||||
start(p, w) {
|
||||
phase = 'pending';
|
||||
x0 = p.x;
|
||||
y0 = p.y;
|
||||
width = Math.max(1, w);
|
||||
prevX = lastX = p.x;
|
||||
prevT = lastT = p.t;
|
||||
progress = 0;
|
||||
active = false;
|
||||
},
|
||||
move(p) {
|
||||
if (phase === 'idle') return 'vertical';
|
||||
const dx = p.x - x0;
|
||||
const dy = p.y - y0;
|
||||
if (phase === 'pending') {
|
||||
const axis = lockAxis(dx, dy, deadzonePx);
|
||||
if (axis === 'pending') return 'pending';
|
||||
if (axis === 'vertical') {
|
||||
phase = 'idle';
|
||||
return 'vertical';
|
||||
}
|
||||
phase = 'dragging';
|
||||
active = true;
|
||||
}
|
||||
prevX = lastX;
|
||||
prevT = lastT;
|
||||
lastX = p.x;
|
||||
lastT = p.t;
|
||||
progress = Math.max(0, Math.min(1, dx / width));
|
||||
opts.onChange?.(active, progress);
|
||||
return 'horizontal';
|
||||
},
|
||||
end() {
|
||||
if (phase !== 'dragging') {
|
||||
phase = 'idle';
|
||||
return 'idle';
|
||||
}
|
||||
const dt = Math.max(1, lastT - prevT);
|
||||
const velocity = (lastX - prevX) / dt;
|
||||
phase = 'idle';
|
||||
return decideCommit(progress, velocity, commitFrac, flickVel) ? 'commit' : 'cancel';
|
||||
},
|
||||
cancel() {
|
||||
phase = 'idle';
|
||||
active = false;
|
||||
progress = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user