Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
130 changes: 130 additions & 0 deletions e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
179 changes: 179 additions & 0 deletions src/__tests__/tabSwipe.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
return {
dataset: {} as Record<string, string | undefined>,
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();
});
});
Loading
Loading