UI: interactive back-swipe (left-band, snapshot backdrop)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Replace the 24px edge-swipe with a router-level interactive back gesture: a rightward drag in the left ~38% band tracks the finger while a cheap themed backdrop parallaxes in, and on release commits the back-navigation (past ~40% of the width or a rightward flick) or snaps back. The live parent mounts only on commit, which suppresses that one route slide for a seamless hand-off. Pure decision logic (shouldArm/lockAxis/decideCommit + the per-gesture controller) lives in lib/backswipe.ts with unit tests; the DOM wiring, hit-test and settle animation in lib/backswipe.svelte.ts. The gesture yields to native vertical scroll, skips the rack / draggable pending tiles / text inputs / the board while zoomed-in, and is disabled inside Telegram (native BackButton owns it). parentPath in App.svelte is now the single source for both the Telegram button and the swipe. Tests: backswipe unit (13) + e2e (3; Chromium+WebKit: commit, zoomed-in skip, vertical scroll). Docs: UI_DESIGN.md Back bullet.
This commit is contained in:
+11
-1
@@ -22,7 +22,17 @@ Login uses `Screen`.
|
|||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
- **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 stays disabled inside Telegram, whose native
|
||||||
|
BackButton owns this.
|
||||||
- **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte`
|
- **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).
|
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 ⚙️
|
Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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/');
|
||||||
|
});
|
||||||
+69
-12
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
import { app, bootstrap } from './lib/app.svelte';
|
||||||
import { navigate, router, type RouteName } from './lib/router.svelte';
|
import { navigate, router, type RouteName } from './lib/router.svelte';
|
||||||
import { t } from './lib/i18n/index.svelte';
|
import { t } from './lib/i18n/index.svelte';
|
||||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||||
|
import { backSwipe, initBackSwipe } from './lib/backswipe.svelte';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -16,18 +17,27 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
return initBackSwipe(parentPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inside Telegram, drive its native header back button: show it on any sub-screen
|
// parentPath is the route a back action returns to from the current screen, or null at a
|
||||||
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
|
// navigation root. It is the single source for both Telegram's native Back button and the
|
||||||
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
// 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(() => {
|
$effect(() => {
|
||||||
if (!insideTelegram()) return;
|
if (!insideTelegram()) return;
|
||||||
const r = router.route;
|
const p = parentPath();
|
||||||
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
|
telegramBackButton(p !== null, () => navigate(p ?? '/'));
|
||||||
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
|
|
||||||
const target = sub ? `/game/${r.params.id}` : '/';
|
|
||||||
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
// 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 enterSign = $derived(dir === 'forward' ? 1 : -1);
|
||||||
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
||||||
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
|
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
|
// 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
|
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
|
||||||
@@ -69,9 +96,10 @@
|
|||||||
{#if !app.ready}
|
{#if !app.ready}
|
||||||
<div class="splash">{t('common.loading')}</div>
|
<div class="splash">{t('common.loading')}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="router">
|
<div class="router" style="--bsp:{backSwipe.progress};">
|
||||||
|
{#if backSwipe.active}<div class="backdrop" aria-hidden="true"></div>{/if}
|
||||||
{#key routeKey}
|
{#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'}
|
{#if router.route.name === 'login'}
|
||||||
<Login />
|
<Login />
|
||||||
{:else if router.route.name === 'new'}
|
{:else if router.route.name === 'new'}
|
||||||
@@ -113,9 +141,38 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Keep a horizontal drag from also triggering the browser's history-swipe underneath. */
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
}
|
}
|
||||||
.pane {
|
.pane {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import Header from './Header.svelte';
|
import Header from './Header.svelte';
|
||||||
import AdBanner from './AdBanner.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
|
// 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`
|
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
|
||||||
@@ -33,26 +32,9 @@
|
|||||||
// true to bring it back.
|
// true to bring it back.
|
||||||
const SHOW_AD_BANNER = false;
|
const SHOW_AD_BANNER = false;
|
||||||
|
|
||||||
// Edge-swipe back: a left-edge rightward drag returns to `back`, the standard
|
// The interactive back-swipe lives at the router level (App.svelte + lib/backswipe.svelte),
|
||||||
// mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer
|
// which can render the parent backdrop and track the finger across the whole screen; Screen
|
||||||
// handlers (which capture/stop the event) can never swallow it; armed only from the very
|
// only carries `back` for the Header chevron.
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import { insideTelegram } from './telegram';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
insideTelegram: insideTelegram(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
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,113 @@
|
|||||||
|
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,
|
||||||
|
insideTelegram: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
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, at a root, or inside Telegram', () => {
|
||||||
|
expect(shouldArm({ ...base, clientX: 0.38 * 400 + 1 })).toBe(false);
|
||||||
|
expect(shouldArm({ ...base, pointerType: 'mouse' })).toBe(false);
|
||||||
|
expect(shouldArm({ ...base, hasParent: false })).toBe(false);
|
||||||
|
expect(shouldArm({ ...base, insideTelegram: true })).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;
|
||||||
|
/** Whether the app is running inside Telegram, where its native Back button owns this. */
|
||||||
|
insideTelegram: 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, while not inside Telegram.
|
||||||
|
*/
|
||||||
|
export function shouldArm(i: ArmInput): boolean {
|
||||||
|
if (!i.hasParent || i.insideTelegram) 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