diff --git a/.gitignore b/.gitignore index 397d61a..d7f7401 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ next-env.d.ts # wrangler files .wrangler +# wrangler's "is there a newer version" cache, written on every install. +update-check .dev.vars* !.dev.vars.example !.env.example diff --git a/e2e/helpers.ts b/e2e/helpers.ts index c4e5b84..2c54984 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -19,3 +19,31 @@ export function aside(page: Page): Locator { export function asideRow(page: Page, name: string): Locator { return aside(page).getByRole("treeitem", { name, exact: true }); } + +/** + * Drags horizontally across an element, in `steps` moves of `dx / steps` px. + * + * `pauseMs` between the moves is what controls the gesture's *velocity*, which + * the swipe recogniser weighs alongside distance: without a pause Playwright + * replays the whole drag in a couple of milliseconds, and every swipe — however + * short — reads as a flick. + */ +export async function dragHorizontally( + page: Page, + target: Locator, + { dx, steps = 8, pauseMs = 0 }: { dx: number; steps?: number; pauseMs?: number }, +) { + const box = await target.boundingBox(); + if (!box) throw new Error("Swipe target has no bounding box"); + + const startX = box.x + box.width / 2; + const y = box.y + Math.min(box.height / 2, 160); + + await page.mouse.move(startX, y); + await page.mouse.down(); + for (let step = 1; step <= steps; step++) { + await page.mouse.move(startX + (dx * step) / steps, y); + if (pauseMs) await page.waitForTimeout(pauseMs); + } + await page.mouse.up(); +} diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts new file mode 100644 index 0000000..f045949 --- /dev/null +++ b/e2e/mobile.spec.ts @@ -0,0 +1,130 @@ +import { test, expect, type Page } from "@playwright/test"; +import { dragHorizontally } from "./helpers"; + +/** + * The touch layout's feed, which below `lg` replaces the aside entirely. + * + * Driven with the mouse rather than `page.touchscreen`: the gesture listens to + * pointer events, which both devices raise, and Playwright's touchscreen API + * cannot express a drag. What matters — the axis lock, the distance and + * velocity thresholds, the suppressed click — is identical either way. + */ +test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + +/** Waits for the mobile home rather than the aside `gotoApp` looks for. */ +async function gotoMobileApp(page: Page) { + await page.goto("/app"); + await expect(page.getByRole("tab", { name: "Recent" })).toBeVisible(); +} + +function panel(page: Page) { + return page.getByRole("tabpanel"); +} + +async function expectSelected(page: Page, name: "Recent" | "My Space") { + await expect(page.getByRole("tab", { name })).toHaveAttribute("aria-selected", "true"); +} + +test("swipes between Recent and My Space, and back", async ({ page }) => { + await gotoMobileApp(page); + await expectSelected(page, "Recent"); + + await dragHorizontally(page, panel(page), { dx: -220 }); + await expectSelected(page, "My Space"); + // The seeded folder's row only exists in the structure tab, so the panel + // really did change and not just the pill. + await expect(panel(page).getByRole("button", { name: /welcome/ })).toBeVisible(); + + await dragHorizontally(page, panel(page), { dx: 220 }); + await expectSelected(page, "Recent"); +}); + +test("the switcher follows the finger before the swipe is released", async ({ page }) => { + await gotoMobileApp(page); + + const box = await panel(page).boundingBox(); + if (!box) throw new Error("Panel has no bounding box"); + const startX = box.x + box.width / 2; + const y = box.y + 120; + + const main = page.getByRole("main"); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(startX - 40, y); + await page.mouse.move(startX - 120, y); + + // Mid-gesture the pill sits between the two tabs rather than waiting for the + // release, and its settle animation is suspended so it tracks the finger. + await expect(main).toHaveAttribute("data-swiping", "true"); + const progress = await main.evaluate((el) => + Number(getComputedStyle(el).getPropertyValue("--tab-progress")), + ); + expect(progress).toBeGreaterThan(0); + expect(progress).toBeLessThan(1); + + await page.mouse.up(); + await expect(main).not.toHaveAttribute("data-swiping", "true"); + await expectSelected(page, "My Space"); +}); + +test("a swipe that falls short springs back", async ({ page }) => { + await gotoMobileApp(page); + + // Under the commit distance, and slow enough not to count as a flick. + await dragHorizontally(page, panel(page), { dx: -60, pauseMs: 40 }); + await expectSelected(page, "Recent"); +}); + +test("a short flick still commits", async ({ page }) => { + await gotoMobileApp(page); + + await dragHorizontally(page, panel(page), { dx: -60 }); + await expectSelected(page, "My Space"); +}); + +test("swiping past the last tab stays there", async ({ page }) => { + await gotoMobileApp(page); + + // Right from the first tab: there is nothing before Recent to reveal. + await dragHorizontally(page, panel(page), { dx: 260 }); + await expectSelected(page, "Recent"); +}); + +test("a vertical drag scrolls the feed instead of changing tab", async ({ page }) => { + await gotoMobileApp(page); + + const box = await panel(page).boundingBox(); + if (!box) throw new Error("Panel has no bounding box"); + const x = box.x + box.width / 2; + await page.mouse.move(x, box.y + 120); + await page.mouse.down(); + // Mostly vertical, with the sideways wobble a real thumb adds. + for (let step = 1; step <= 8; step++) { + await page.mouse.move(x + step, box.y + 120 - step * 12); + } + await page.mouse.up(); + + await expectSelected(page, "Recent"); +}); + +test("swiping off a snippet card does not open the snippet", async ({ page }) => { + await gotoMobileApp(page); + + const card = page.getByRole("button", { name: "klipcode.md", exact: true }).first(); + await expect(card).toBeVisible(); + + await dragHorizontally(page, card, { dx: -220 }); + + await expectSelected(page, "My Space"); + await expect(page).toHaveURL(/\/app$/); +}); + +test("the tab buttons still switch on their own", async ({ page }) => { + await gotoMobileApp(page); + + await page.getByRole("tab", { name: "My Space" }).click(); + await expectSelected(page, "My Space"); + + await page.getByRole("tab", { name: "Recent" }).click(); + await expectSelected(page, "Recent"); +}); diff --git a/src/__tests__/tabSwipe.test.ts b/src/__tests__/tabSwipe.test.ts new file mode 100644 index 0000000..fca604f --- /dev/null +++ b/src/__tests__/tabSwipe.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; + +import { + applyTabSwipe, + contentShift, + detectSwipeAxis, + resolveSwipe, + setTabSwiping, + SWIPE_AXIS_SLOP, + SWIPE_COMMIT_RATIO, + SWIPE_COMMIT_VELOCITY, + TAB_PROGRESS_VAR, + TAB_SHIFT_VAR, + tabProgress, +} from "@/lib/tabSwipe"; + +/** The mobile feed: two tabs across a phone-width panel. */ +const WIDTH = 390; +const COUNT = 2; + +/** Distance that is unambiguously past the commit threshold. */ +const FAR = WIDTH * SWIPE_COMMIT_RATIO + 1; +/** Slow enough that only distance can commit the swipe. */ +const SLOW = 2000; + +// ── detectSwipeAxis() ───────────────────────────────────────────────────────── + +describe("detectSwipeAxis()", () => { + it("stays undecided until the finger has cleared the slop", () => { + expect(detectSwipeAxis(SWIPE_AXIS_SLOP - 1, 0)).toBeNull(); + expect(detectSwipeAxis(0, SWIPE_AXIS_SLOP - 1)).toBeNull(); + expect(detectSwipeAxis(0, 0)).toBeNull(); + }); + + it("commits to the dominant axis once it has", () => { + expect(detectSwipeAxis(40, 5)).toBe("horizontal"); + expect(detectSwipeAxis(-40, 5)).toBe("horizontal"); + expect(detectSwipeAxis(5, 40)).toBe("vertical"); + expect(detectSwipeAxis(5, -40)).toBe("vertical"); + }); + + it("gives a tie to the scroll, which is what a list is expected to do", () => { + expect(detectSwipeAxis(30, 30)).toBe("vertical"); + }); + + it("measures the slop on the axis that is actually moving", () => { + // A long vertical drag with a 2px horizontal wobble is a scroll. + expect(detectSwipeAxis(2, 200)).toBe("vertical"); + }); +}); + +// ── tabProgress() ───────────────────────────────────────────────────────────── + +describe("tabProgress()", () => { + it("moves toward the next tab as the finger goes left", () => { + expect(tabProgress(0, -WIDTH / 2, WIDTH, COUNT)).toBeCloseTo(0.5); + expect(tabProgress(0, -WIDTH, WIDTH, COUNT)).toBeCloseTo(1); + }); + + it("moves toward the previous tab as the finger goes right", () => { + expect(tabProgress(1, WIDTH / 4, WIDTH, COUNT)).toBeCloseTo(0.75); + }); + + it("never leaves the range of tabs that exist", () => { + expect(tabProgress(0, WIDTH, WIDTH, COUNT)).toBe(0); + expect(tabProgress(1, -WIDTH * 3, WIDTH, COUNT)).toBe(COUNT - 1); + }); + + it("holds still for a degenerate surface", () => { + expect(tabProgress(1, -200, 0, COUNT)).toBe(1); + expect(tabProgress(0, -200, WIDTH, 1)).toBe(0); + }); +}); + +// ── contentShift() ──────────────────────────────────────────────────────────── + +describe("contentShift()", () => { + it("follows the finger, damped, in the same direction", () => { + const shift = contentShift(0, -100, WIDTH, COUNT); + expect(shift).toBeLessThan(0); + expect(Math.abs(shift)).toBeLessThan(100); + }); + + it("resists much harder when there is no tab to reveal", () => { + const free = Math.abs(contentShift(0, -100, WIDTH, COUNT)); + const againstTheEdge = Math.abs(contentShift(0, 100, WIDTH, COUNT)); + expect(againstTheEdge).toBeGreaterThan(0); + expect(againstTheEdge).toBeLessThan(free); + }); + + it("caps the follow however far the finger goes", () => { + const far = Math.abs(contentShift(0, -5000, WIDTH, COUNT)); + const further = Math.abs(contentShift(0, -50_000, WIDTH, COUNT)); + expect(far).toBe(further); + expect(far).toBeLessThanOrEqual(56); + }); + + it("does not move at all without a gesture, or without a surface", () => { + expect(contentShift(0, 0, WIDTH, COUNT)).toBe(0); + expect(contentShift(0, -100, 0, COUNT)).toBe(0); + }); +}); + +// ── resolveSwipe() ──────────────────────────────────────────────────────────── + +describe("resolveSwipe()", () => { + it("moves to the next tab on a long drag left", () => { + expect(resolveSwipe(0, -FAR, SLOW, WIDTH, COUNT)).toBe(1); + }); + + it("moves back on a long drag right", () => { + expect(resolveSwipe(1, FAR, SLOW, WIDTH, COUNT)).toBe(0); + }); + + it("springs back when a slow drag falls short", () => { + const short = WIDTH * SWIPE_COMMIT_RATIO - 1; + expect(resolveSwipe(0, -short, SLOW, WIDTH, COUNT)).toBe(0); + }); + + it("commits a short drag that was flicked", () => { + const short = WIDTH * SWIPE_COMMIT_RATIO - 1; + const quick = short / (SWIPE_COMMIT_VELOCITY * 2); + expect(resolveSwipe(0, -short, quick, WIDTH, COUNT)).toBe(1); + }); + + it("ignores a flick too small to be a gesture at all", () => { + // Below the axis slop nothing was ever painted, so nothing may commit. + expect(resolveSwipe(0, -(SWIPE_AXIS_SLOP - 1), 1, WIDTH, COUNT)).toBe(0); + }); + + it("stays put at the ends", () => { + expect(resolveSwipe(0, FAR, SLOW, WIDTH, COUNT)).toBe(0); + expect(resolveSwipe(1, -FAR, SLOW, WIDTH, COUNT)).toBe(1); + }); + + it("can still commit when the surface has no measurable width", () => { + expect(resolveSwipe(0, -100, 10, WIDTH, COUNT)).toBe(1); + }); + + it("does nothing with fewer than two tabs", () => { + expect(resolveSwipe(0, -FAR, SLOW, WIDTH, 1)).toBe(0); + }); +}); + +// ── painting ────────────────────────────────────────────────────────────────── + +describe("applyTabSwipe() / setTabSwiping()", () => { + /** A stand-in for the container element: only `style` and `dataset` matter. */ + function fakeElement() { + const properties = new Map(); + return { + dataset: {} as Record, + style: { + setProperty: (name: string, value: string) => void properties.set(name, value), + }, + read: (name: string) => properties.get(name), + }; + } + + it("writes both custom properties, the shift in px", () => { + const el = fakeElement(); + applyTabSwipe(el as unknown as HTMLElement, 0.25, -12.5); + expect(el.read(TAB_PROGRESS_VAR)).toBe("0.25"); + expect(el.read(TAB_SHIFT_VAR)).toBe("-12.5px"); + }); + + it("adds and removes the flag that suspends the settle animation", () => { + const el = fakeElement(); + setTabSwiping(el as unknown as HTMLElement, true); + expect(el.dataset.swiping).toBe("true"); + setTabSwiping(el as unknown as HTMLElement, false); + expect("swiping" in el.dataset).toBe(false); + }); + + it("tolerates an element that is not mounted", () => { + expect(() => applyTabSwipe(null, 1, 0)).not.toThrow(); + expect(() => setTabSwiping(null, true)).not.toThrow(); + }); +}); diff --git a/src/components/MobileHome/FeedTabs.tsx b/src/components/MobileHome/FeedTabs.tsx index c7f850a..e2e1bc4 100644 --- a/src/components/MobileHome/FeedTabs.tsx +++ b/src/components/MobileHome/FeedTabs.tsx @@ -1,5 +1,6 @@ "use client"; +import { SWIPE_TRANSITION, TAB_PROGRESS_VAR } from "@/lib/tabSwipe"; import { cn } from "@/lib/utils"; /** @@ -16,14 +17,14 @@ import { cn } from "@/lib/utils"; * segment is exactly half, the pill moves by its own width, and its inner copy * counter-slides by half of its own doubled width. No refs, no measuring, no * resize observer. + * + * The pill's position is not read from `active` but from {@link + * TAB_PROGRESS_VAR}, inherited from the ancestor `useSwipeTabs` writes to. A + * press moves that property between whole numbers and the transition below + * animates the gap; a swipe moves it continuously and suspends the transition. + * One pill, one animation, two ways to ask for it. */ -/** Tailwind v4 compiles `translate-x-full` to the `translate` property, not to - * `transform` — transitioning the wrong one leaves the pill snapping. Kept as - * classes rather than an inline style so `motion-reduce` can still win. */ -const SLIDE = - "transition-[translate] duration-[420ms] ease-[cubic-bezier(0.34,1.4,0.64,1)] motion-reduce:transition-none"; - /** The three stacked copies of the row must agree on their columns exactly. */ const ROW = "flex h-11 items-center"; const CELL = "flex-1 truncate px-3 text-center text-[13.5px] font-medium"; @@ -47,13 +48,8 @@ export function FeedTabs({ panelId: string; onSelect: (id: T) => void; }) { - const shifted = active === tabs[1].id; - return ( -
+