mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'feat/bottom-sheet' into release/v1.44.2
This commit is contained in:
commit
ac9693fac9
19 files changed
+2367
-96
No files matched your search
@@ -81,7 +81,9 @@ jobs:
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
# WebKit for the one phone spec that needs Safari's engine
|
||||
# (`bottom-sheet.spec.ts`, the `webkit-iphone` project).
|
||||
- run: pnpm exec playwright install --with-deps chromium webkit
|
||||
|
||||
# `pnpm build` (not bare `next build`): the prebuild hook generates
|
||||
# the versioned service-worker stamp AND the static i18n catalog
|
||||
|
||||
@@ -98,6 +98,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
|
||||
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
|
||||
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
|
||||
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
|
||||
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
|
||||
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
|
||||
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The bottom sheet's drag on WebKit with an iPhone profile (the
|
||||
* `webkit-iphone` project), against the three kinds of bottom sheet the app
|
||||
* has: the More sheet (a plain `<Sheet side="bottom">`), the day sheet (the
|
||||
* preview step) and a capture form (`ResponsiveSheet` on a phone).
|
||||
*
|
||||
* Playwright can tap on WebKit but not move a touch, so a drag is dispatched
|
||||
* through the page as the browser would deliver it: `pointerdown` /
|
||||
* `pointermove` / `pointerup` with `pointerType: "touch"` and the matching
|
||||
* `touchstart` / `touchmove` / `touchend`, on the element under the finger,
|
||||
* paced in real time so the release speed is real. Nothing calls into the
|
||||
* sheet's own code. What synthetic events cannot do is scroll natively, so
|
||||
* "content scrolls first" is proven as "a scrolled body does not move the
|
||||
* sheet", with the scroll position set the way a scroll would leave it.
|
||||
*
|
||||
* Stable hooks only: `bottom-nav-more` / `bottom-nav-more-sheet`,
|
||||
* `day-panel` with `data-sheet-snap`, `day-body`, `day-footer`,
|
||||
* `sheet-grip`, `sheet-close-button`, `sheet-overlay`, `capture-picker-*`.
|
||||
*/
|
||||
|
||||
interface Drag {
|
||||
dx?: number;
|
||||
dy: number;
|
||||
/** Duration of the movement, ms. */
|
||||
ms: number;
|
||||
steps?: number;
|
||||
/** Rest before lifting the finger, ms (a slow, deliberate release). */
|
||||
holdMs?: number;
|
||||
}
|
||||
|
||||
/** A touch drag from (x, y), dispatched as WebKit delivers one. */
|
||||
async function touchDrag(page: Page, x: number, y: number, drag: Drag) {
|
||||
await page.evaluate(
|
||||
async ({ x, y, dx, dy, ms, steps, holdMs }) => {
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) throw new Error(`nothing under ${x},${y}`);
|
||||
const id = 11;
|
||||
const touchAt = (cx: number, cy: number) => {
|
||||
const init = {
|
||||
identifier: id,
|
||||
target,
|
||||
clientX: cx,
|
||||
clientY: cy,
|
||||
pageX: cx,
|
||||
pageY: cy,
|
||||
screenX: cx,
|
||||
screenY: cy,
|
||||
radiusX: 4,
|
||||
radiusY: 4,
|
||||
force: 1,
|
||||
};
|
||||
try {
|
||||
return new Touch(init);
|
||||
} catch {
|
||||
return init as unknown as Touch;
|
||||
}
|
||||
};
|
||||
const touchEvent = (type: string, cx: number, cy: number) => {
|
||||
const touch = touchAt(cx, cy);
|
||||
const touches = type === "touchend" ? [] : [touch];
|
||||
try {
|
||||
return new TouchEvent(type, {
|
||||
touches,
|
||||
targetTouches: touches,
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
} catch {
|
||||
const event = new Event(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperty(event, "touches", { value: touches });
|
||||
Object.defineProperty(event, "targetTouches", { value: touches });
|
||||
Object.defineProperty(event, "changedTouches", { value: [touch] });
|
||||
return event;
|
||||
}
|
||||
};
|
||||
const fire = (
|
||||
pointer: "pointerdown" | "pointermove" | "pointerup",
|
||||
cx: number,
|
||||
cy: number,
|
||||
) => {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(pointer, {
|
||||
pointerId: id,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
clientX: cx,
|
||||
clientY: cy,
|
||||
buttons: pointer === "pointerup" ? 0 : 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
const touch = {
|
||||
pointerdown: "touchstart",
|
||||
pointermove: "touchmove",
|
||||
pointerup: "touchend",
|
||||
}[pointer];
|
||||
target.dispatchEvent(touchEvent(touch, cx, cy));
|
||||
};
|
||||
const wait = (t: number) => new Promise((r) => setTimeout(r, t));
|
||||
fire("pointerdown", x, y);
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
await wait(ms / steps);
|
||||
fire("pointermove", x + (dx * i) / steps, y + (dy * i) / steps);
|
||||
}
|
||||
if (holdMs) await wait(holdMs);
|
||||
fire("pointerup", x + dx, y + dy);
|
||||
},
|
||||
{
|
||||
x,
|
||||
y,
|
||||
dx: drag.dx ?? 0,
|
||||
dy: drag.dy,
|
||||
ms: drag.ms,
|
||||
steps: drag.steps ?? Math.max(4, Math.round(drag.ms / 16)),
|
||||
holdMs: drag.holdMs ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's pointer capture for a dispatched pointer. A real touch is
|
||||
* captured by the browser; a dispatched one is not, and Radix's slider only
|
||||
* follows a pointer it holds. This gives it the same bookkeeping, nothing
|
||||
* more: events still go to the element they are dispatched on.
|
||||
*/
|
||||
async function emulateCapture(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const held = new WeakMap<Element, Set<number>>();
|
||||
Element.prototype.setPointerCapture = function (id: number) {
|
||||
const set = held.get(this) ?? new Set<number>();
|
||||
set.add(id);
|
||||
held.set(this, set);
|
||||
};
|
||||
Element.prototype.hasPointerCapture = function (id: number) {
|
||||
return held.get(this)?.has(id) ?? false;
|
||||
};
|
||||
Element.prototype.releasePointerCapture = function (id: number) {
|
||||
held.get(this)?.delete(id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function centre(locator: Locator) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("not on screen");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2, box };
|
||||
}
|
||||
|
||||
async function topOf(locator: Locator) {
|
||||
return (await locator.boundingBox())?.y ?? NaN;
|
||||
}
|
||||
|
||||
/** Waits for the sheet to come to rest (open slide or settle finished). */
|
||||
async function settled(locator: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
locator.evaluate((el) =>
|
||||
el.getAnimations().every((a) => a.playState !== "running"),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await locator.page().waitForTimeout(300);
|
||||
}
|
||||
|
||||
const more = (page: Page) =>
|
||||
page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
|
||||
async function openMore(page: Page) {
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
await expect(more(page)).toBeVisible();
|
||||
await settled(more(page));
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test.describe("the bottom sheet", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page, context }, testInfo) => {
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
await mockDay(page);
|
||||
});
|
||||
|
||||
test("a slow drag past a quarter of its height closes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
const title = more(page).getByRole("heading").first();
|
||||
const { x, y } = await centre(title);
|
||||
const { box } = await centre(more(page));
|
||||
await touchDrag(page, x, y, {
|
||||
dy: box.height * 0.4,
|
||||
ms: 700,
|
||||
holdMs: 160,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a short drag springs back", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
await expect(more(page)).toBeVisible();
|
||||
const before = await topOf(more(page));
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
// Dragging: the sheet follows the finger and the backdrop lightens.
|
||||
const during = page.evaluate(
|
||||
() =>
|
||||
new Promise<{ moved: number; opacity: number }>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const sheet = document.querySelector(
|
||||
'[data-testid="bottom-nav-more-sheet"]',
|
||||
) as HTMLElement;
|
||||
const overlay = document.querySelector(
|
||||
'[data-slot="sheet-overlay"]',
|
||||
) as HTMLElement;
|
||||
resolve({
|
||||
moved: new DOMMatrix(getComputedStyle(sheet).transform).f,
|
||||
opacity: Number(getComputedStyle(overlay).opacity),
|
||||
});
|
||||
}, 380);
|
||||
}),
|
||||
);
|
||||
await touchDrag(page, x, y, { dy: 60, ms: 300, holdMs: 200 });
|
||||
const seen = await during;
|
||||
expect(seen.moved).toBeGreaterThan(40);
|
||||
expect(seen.opacity).toBeLessThan(1);
|
||||
await settled(more(page));
|
||||
await expect(more(page)).toBeVisible();
|
||||
expect(Math.abs((await topOf(more(page))) - before)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("a fast flick closes it", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
const { box } = await centre(more(page));
|
||||
// Well short of a quarter of the height, but fast.
|
||||
await touchDrag(page, x, y, {
|
||||
dy: Math.min(70, box.height * 0.2),
|
||||
ms: 40,
|
||||
steps: 4,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the backdrop, the X and Escape close it; focus goes back to the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood");
|
||||
const trigger = page.locator('[data-testid="bottom-nav-more"]');
|
||||
|
||||
await openMore(page);
|
||||
await page.touchscreen.tap(195, 40);
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
|
||||
await openMore(page);
|
||||
const close = more(page).locator('[data-slot="sheet-close-button"]');
|
||||
const closeBox = (await close.boundingBox())!;
|
||||
expect(closeBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(closeBox.height).toBeGreaterThanOrEqual(44);
|
||||
await close.tap();
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
|
||||
// The keyboard path: open from the focused trigger, close with Escape.
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(more(page)).toBeVisible();
|
||||
await expect(more(page)).toHaveAttribute("role", "dialog");
|
||||
await expect(more(page)).toHaveAttribute("aria-modal", "true");
|
||||
await expect(more(page)).toHaveAccessibleName(/more/i);
|
||||
await expect
|
||||
.poll(() =>
|
||||
more(page).evaluate((el) => el.contains(document.activeElement)),
|
||||
)
|
||||
.toBe(true);
|
||||
// Tab stays inside.
|
||||
for (let i = 0; i < 14; i += 1) await page.keyboard.press("Tab");
|
||||
expect(
|
||||
await more(page).evaluate((el) => el.contains(document.activeElement)),
|
||||
).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("the page behind keeps its scroll position and is never pulled to refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const main = page.locator("#main-content");
|
||||
await expect(main).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Watch for the refresh disc for the rest of the test.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
||||
new MutationObserver(() => {
|
||||
if (document.querySelector('[data-slot="pull-to-refresh-indicator"]')) {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen += 1;
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
const ptrSeen = () =>
|
||||
page.evaluate(() => (window as unknown as { ptrSeen: number }).ptrSeen);
|
||||
|
||||
// The control: the same pull on the page itself does arm the disc, so
|
||||
// the watch above can see it (short of the refresh threshold).
|
||||
await touchDrag(page, 195, 320, { dy: 90, ms: 400, holdMs: 120 });
|
||||
await expect.poll(ptrSeen).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
||||
});
|
||||
|
||||
// A pull on the sheet, short (it springs back) and long (it closes),
|
||||
// from the dashboard at the top: the page behind never takes it.
|
||||
await openMore(page);
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
await touchDrag(page, x, y, { dy: 60, ms: 400, holdMs: 150 });
|
||||
await settled(more(page));
|
||||
const { box } = await centre(more(page));
|
||||
await touchDrag(page, x, y, { dy: box.height * 0.5, ms: 600, holdMs: 150 });
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
expect(await ptrSeen()).toBe(0);
|
||||
|
||||
// Scrolled down, the page is where it was after the sheet has gone.
|
||||
await main.evaluate((el) => el.scrollTo({ top: 400 }));
|
||||
const before = await main.evaluate((el) => el.scrollTop);
|
||||
expect(before).toBeGreaterThan(0);
|
||||
await openMore(page);
|
||||
const at = await centre(more(page).getByRole("heading").first());
|
||||
const sheetBox = (await more(page).boundingBox())!;
|
||||
await touchDrag(page, at.x, at.y, {
|
||||
dy: sheetBox.height * 0.5,
|
||||
ms: 600,
|
||||
holdMs: 150,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
expect(await main.evaluate((el) => el.scrollTop)).toBe(before);
|
||||
expect(await ptrSeen()).toBe(0);
|
||||
});
|
||||
|
||||
test("the day opens on its preview step, rises on a pull up, and its content scrolls before it drags", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A short window, so the day's content is longer than its sheet on
|
||||
// both steps and the body really scrolls.
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveAttribute("data-shell", "bottom");
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
await settled(panel);
|
||||
const viewport = page.viewportSize()!;
|
||||
|
||||
// On the preview step the footer stands on the window's bottom edge.
|
||||
const footer = panel.locator('[data-slot="day-footer"]');
|
||||
const footerBox = (await footer.boundingBox())!;
|
||||
expect(
|
||||
Math.abs(footerBox.y + footerBox.height - viewport.height),
|
||||
).toBeLessThan(2);
|
||||
|
||||
// A pull up on the content raises it to the full 90 dvh.
|
||||
const body = panel.locator('[data-slot="day-body"]');
|
||||
const b = await centre(body);
|
||||
await touchDrag(page, b.x, b.y, { dy: -180, ms: 500, holdMs: 150 });
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
await settled(panel);
|
||||
expect(await topOf(panel)).toBeCloseTo(viewport.height * 0.1, -1);
|
||||
|
||||
// Scrolled content: a pull down scrolls (the browser's), the sheet stays.
|
||||
const scrollable = await body.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight,
|
||||
);
|
||||
expect(scrollable).toBe(true);
|
||||
await body.evaluate((el) => el.scrollTo({ top: 60 }));
|
||||
const top = await topOf(panel);
|
||||
const inner = await centre(body);
|
||||
await touchDrag(page, inner.x, inner.y, {
|
||||
dy: 260,
|
||||
ms: 500,
|
||||
holdMs: 150,
|
||||
});
|
||||
await settled(panel);
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
expect(Math.abs((await topOf(panel)) - top)).toBeLessThan(1);
|
||||
|
||||
// At its very top the same pull drags the sheet: down to the preview
|
||||
// step, and from there out.
|
||||
await body.evaluate((el) => el.scrollTo({ top: 0 }));
|
||||
await touchDrag(page, inner.x, inner.y - 60, {
|
||||
dy: 150,
|
||||
ms: 500,
|
||||
holdMs: 150,
|
||||
});
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
await settled(panel);
|
||||
const grip = panel.locator('[data-slot="sheet-grip"]');
|
||||
const g = await centre(grip);
|
||||
await touchDrag(page, g.x, g.y, { dy: 300, ms: 600, holdMs: 150 });
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
});
|
||||
|
||||
test("the grip is a button that switches the day's two heights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
const grip = panel.locator('[data-slot="sheet-grip"]');
|
||||
await expect(grip).toHaveAttribute("aria-expanded", "false");
|
||||
await grip.tap();
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
await expect(grip).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("a slider inside a sheet moves sideways and the sheet stays", async ({
|
||||
page,
|
||||
}) => {
|
||||
await emulateCapture(page);
|
||||
await page.goto("/mood");
|
||||
await page.locator('[data-testid="bottom-nav-capture"]').tap();
|
||||
await page.locator('[data-testid="capture-picker-symptom"]').tap();
|
||||
const form = page.locator(
|
||||
'[data-slot="responsive-sheet-content"][data-state="open"]',
|
||||
);
|
||||
await expect(form).toBeVisible();
|
||||
await settled(form);
|
||||
const slider = form.locator('[data-slot="slider"]').first();
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
await settled(form);
|
||||
const top = await topOf(form);
|
||||
const s = await centre(slider);
|
||||
const thumb = form.locator('[role="slider"]').first();
|
||||
const valueOf = async () =>
|
||||
(await thumb.count()) ? thumb.getAttribute("aria-valuenow") : null;
|
||||
const before = await valueOf();
|
||||
// A sideways drag with a little vertical wobble, across the track.
|
||||
await touchDrag(page, s.box.x + 8, s.y, {
|
||||
dx: s.box.width * 0.7,
|
||||
dy: 6,
|
||||
ms: 400,
|
||||
});
|
||||
await expect.poll(valueOf).not.toBe(before);
|
||||
await settled(form);
|
||||
await expect(form).toBeVisible();
|
||||
expect(Math.abs((await topOf(form)) - top)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("under reduced motion it opens without a slide", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/mood");
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
await expect(more(page)).toBeVisible();
|
||||
expect(
|
||||
await more(page).evaluate(
|
||||
(el) =>
|
||||
el.getAnimations().filter((a) => a.playState === "running").length,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,8 @@ export default defineConfig({
|
||||
testIgnore: [
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
@@ -228,6 +230,8 @@ export default defineConfig({
|
||||
// Runs only in the service-worker project.
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// The Apple Health import journey uploads an archive into the one
|
||||
// shared account and its refusal control asserts that the account's
|
||||
// Apple-Health row count did not move. A second project running the
|
||||
@@ -304,6 +308,18 @@ export default defineConfig({
|
||||
colorScheme: "dark",
|
||||
},
|
||||
},
|
||||
{
|
||||
// The bottom sheet's drag, on the engine it is for: iOS Safari is
|
||||
// WebKit, and Chromium's touch scrolling and pointer capture behave
|
||||
// differently enough that a pass there proves little about an iPhone.
|
||||
// One spec only, so the extra browser costs the shards seconds.
|
||||
name: "webkit-iphone",
|
||||
testMatch: ["bottom-sheet.spec.ts"],
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
colorScheme: "dark",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
|
||||
@@ -117,16 +117,19 @@ describe("the shell owns the safe area", () => {
|
||||
// Sonner's own rules are unlayered; the toast lift must stay so.
|
||||
expect(layer).not.toMatch(/data-sonner-toaster/);
|
||||
|
||||
const sheet = code(read("src/components/ui/sheet.tsx"));
|
||||
const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1];
|
||||
expect(bottomBranch).toBeDefined();
|
||||
expect(bottomBranch).not.toMatch(/(?<![\w-])bottom-/);
|
||||
const sheet = code(read("src/components/ui/bottom-sheet.tsx"));
|
||||
const bottomClass = sheet.match(/BOTTOM_SHEET_CLASS\s*=\s*"([^"]*)"/)?.[1];
|
||||
expect(bottomClass).toBeDefined();
|
||||
expect(bottomClass).not.toMatch(/(?<![\w-])bottom-/);
|
||||
});
|
||||
|
||||
it("the sheet primitive names its side for those rules", () => {
|
||||
it("the sheet primitives name their side for those rules", () => {
|
||||
expect(read("src/components/ui/sheet.tsx")).toMatch(
|
||||
/data-sheet-side=\{side\}/,
|
||||
);
|
||||
expect(read("src/components/ui/bottom-sheet.tsx")).toMatch(
|
||||
/data-sheet-side="bottom"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -645,6 +645,37 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* A bottom sheet with a preview step (`BottomSheetContent preview`) is laid
|
||||
* out at its full height and, on the preview step, rests lowered by the
|
||||
* difference: moving between the steps, or dragging, is a transform and
|
||||
* never a relayout. Its footer is lifted by the same amount so it stays on
|
||||
* the window's bottom edge, and its scrolling body ends above that footer,
|
||||
* so nothing in it is out of reach on the lower step. The drag writes the
|
||||
* same three properties inline while a finger is down.
|
||||
*/
|
||||
[data-sheet-snap="preview"] {
|
||||
transform: translateY(
|
||||
calc(var(--sheet-height) - var(--sheet-preview-height))
|
||||
);
|
||||
}
|
||||
[data-sheet-snap] [data-sheet-footer] {
|
||||
transition: transform 260ms ease-out;
|
||||
}
|
||||
[data-sheet-snap="preview"] [data-sheet-footer] {
|
||||
transform: translateY(
|
||||
calc(var(--sheet-preview-height) - var(--sheet-height))
|
||||
);
|
||||
}
|
||||
[data-sheet-snap="preview"] [data-sheet-body] {
|
||||
margin-bottom: calc(var(--sheet-height) - var(--sheet-preview-height));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-sheet-snap] [data-sheet-footer] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Side and top sheets run the full height of the window. In an installed
|
||||
* app on a tablet the status bar sits over their first 24 px, and a phone
|
||||
|
||||
@@ -135,6 +135,9 @@ export default function CommandPalette() {
|
||||
<Sheet open={open} onOpenChange={setCommandPaletteOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
// The palette fills the window: no backdrop to pull it towards,
|
||||
// and its own Cancel and Escape close it.
|
||||
draggable={false}
|
||||
showCloseButton={false}
|
||||
data-slot="command-palette"
|
||||
data-variant="sheet"
|
||||
|
||||
@@ -629,9 +629,10 @@ function returnFocus(fallback?: HTMLElement | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal frames. The bottom sheet opens at a bit over half the height and
|
||||
* grows to full height from its handle: drag it up or tap it. Dragging down
|
||||
* shrinks a full sheet, then closes it.
|
||||
* The modal frames. On a phone the bottom sheet opens at a bit over half the
|
||||
* height, so the chart stays in view above it, and grows to the full 90 dvh
|
||||
* when it is pulled up or its grip is tapped (the sheet primitive's preview
|
||||
* step). Pulled down it closes, from either height.
|
||||
*/
|
||||
function DaySheet({
|
||||
shell,
|
||||
@@ -649,51 +650,8 @@ function DaySheet({
|
||||
render: (extra: Partial<DayViewProps>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [full, setFull] = useState(false);
|
||||
const drag = useRef<{ y: number; moved: boolean } | null>(null);
|
||||
const bottom = shell === "bottom";
|
||||
|
||||
const grabber = bottom ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="day-sheet-handle"
|
||||
aria-label={full ? t("day.sheetShrink") : t("day.sheetExpand")}
|
||||
aria-expanded={full}
|
||||
onPointerDown={(event) => {
|
||||
drag.current = { y: event.clientY, moved: false };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const start = drag.current;
|
||||
if (!start) return;
|
||||
if (Math.abs(event.clientY - start.y) > 8) start.moved = true;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = drag.current;
|
||||
drag.current = null;
|
||||
if (!start) return;
|
||||
const dy = event.clientY - start.y;
|
||||
if (!start.moved) return;
|
||||
if (dy < -40) setFull(true);
|
||||
else if (dy > 60) {
|
||||
if (full) setFull(false);
|
||||
else onDismiss();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// A drag ends in a click as well; only a tap toggles.
|
||||
if (drag.current?.moved) return;
|
||||
setFull((value) => !value);
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className="bg-muted-foreground/40 h-1.5 w-9 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
@@ -704,10 +662,15 @@ function DaySheet({
|
||||
<SheetContent
|
||||
ref={panelRef as React.Ref<HTMLDivElement>}
|
||||
side={bottom ? "bottom" : "right"}
|
||||
preview={bottom ? "58dvh" : undefined}
|
||||
previewLabels={
|
||||
bottom
|
||||
? { expand: t("day.sheetExpand"), shrink: t("day.sheetShrink") }
|
||||
: undefined
|
||||
}
|
||||
showCloseButton={false}
|
||||
data-slot="day-panel"
|
||||
data-shell={shell}
|
||||
data-full={bottom && full ? "true" : undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
focusTitle();
|
||||
@@ -729,30 +692,18 @@ function DaySheet({
|
||||
if (owner && owner !== panelRef.current) event.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
// Quicker than the primitive's 500 ms, which read as lag on a
|
||||
// layer opened from a tap; no slide at all under reduced motion.
|
||||
"bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none",
|
||||
"bg-card gap-0 p-0",
|
||||
bottom
|
||||
? cn(
|
||||
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
|
||||
// Full height leaves a strip of the page above the
|
||||
// sheet, and never less than the status bar of an
|
||||
// installed app plus a margin: a 2.5rem strip put the
|
||||
// handle under a 59 px Dynamic Island.
|
||||
full
|
||||
? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]"
|
||||
: "h-[58dvh]",
|
||||
)
|
||||
: "w-105 max-w-[90vw] sm:max-w-105",
|
||||
? "rounded-t-2xl"
|
||||
: // Quicker than the primitive's 500 ms, which read as lag on a
|
||||
// layer opened from a tap; no slide at all under reduced motion.
|
||||
"w-105 max-w-[90vw] data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none sm:max-w-105",
|
||||
)}
|
||||
>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("day.description")}
|
||||
</SheetDescription>
|
||||
{render({
|
||||
above: grabber,
|
||||
headerClassName: bottom ? undefined : "py-3",
|
||||
})}
|
||||
{render({ headerClassName: bottom ? undefined : "py-3" })}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -75,8 +75,6 @@ export interface DayViewProps {
|
||||
}>;
|
||||
titleId: string;
|
||||
titleRef?: Ref<HTMLHeadingElement>;
|
||||
/** Shell chrome above the header (the bottom sheet's grabber). */
|
||||
above?: ReactNode;
|
||||
/** Classes for the header row, which the docked shell aligns to the top bar. */
|
||||
headerClassName?: string;
|
||||
}
|
||||
@@ -103,7 +101,6 @@ export function DayView({
|
||||
Title,
|
||||
titleId,
|
||||
titleRef,
|
||||
above,
|
||||
headerClassName,
|
||||
}: DayViewProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -162,16 +159,20 @@ export function DayView({
|
||||
<div
|
||||
data-slot="day-view"
|
||||
data-day={date}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
// `bg-inherit` passes the sheet's surface down to the footer, which
|
||||
// paints it while it stands over the body (see `day-footer`).
|
||||
className={cn("flex min-h-0 flex-1 flex-col", compact && "bg-inherit")}
|
||||
>
|
||||
{above}
|
||||
<div
|
||||
data-slot="day-header"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
// The date starts on the body's reading edge (`px-4` / `px-6`).
|
||||
compact
|
||||
? "pt-0.5 pr-2 pb-2 pl-4"
|
||||
? // Under the sheet's grip: the title row's centre lands where
|
||||
// every bottom sheet puts it, 52 px down. The header drags
|
||||
// the sheet, so it never pans the page.
|
||||
"touch-none pt-1.5 pr-2 pb-2 pl-4"
|
||||
: "border-border border-b pr-3 pl-6",
|
||||
headerClassName,
|
||||
)}
|
||||
@@ -257,6 +258,7 @@ export function DayView({
|
||||
|
||||
<div
|
||||
data-slot="day-body"
|
||||
data-sheet-body
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
|
||||
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
|
||||
@@ -342,11 +344,16 @@ export function DayView({
|
||||
{canCapture || (timelineOn && !onTimeline) ? (
|
||||
<div
|
||||
data-slot="day-footer"
|
||||
// On the phone sheet's preview step the footer is held on the
|
||||
// window's bottom edge while the sheet moves (`bottom-sheet.tsx`),
|
||||
// over the body: it carries the sheet's own background for that.
|
||||
data-sheet-footer
|
||||
className={cn(
|
||||
"border-border flex shrink-0 items-center justify-between gap-2 border-t",
|
||||
compact
|
||||
? "px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"
|
||||
: "px-6 py-3.5",
|
||||
compact && "bg-inherit",
|
||||
)}
|
||||
>
|
||||
{timelineOn && !onTimeline ? (
|
||||
|
||||
@@ -274,7 +274,9 @@ export function BottomNav() {
|
||||
// The sheet auto-sizes to its content; padding-bottom respects
|
||||
// the iOS safe area so the inner buttons stay reachable above
|
||||
// the home-indicator on iPhone-X-class devices.
|
||||
className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
// `gap-0`: the grip sits flush above the header, and the grid
|
||||
// takes the header's old 16 px gap as its own top padding.
|
||||
className="shell-desktop:hidden mx-auto max-w-md gap-0 rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
data-testid="bottom-nav-more-sheet"
|
||||
>
|
||||
<SheetHeader>
|
||||
@@ -286,7 +288,7 @@ export function BottomNav() {
|
||||
(de "Benachrichtigungen") then have room to break onto a clean
|
||||
second line instead of clipping the last character at the tile
|
||||
edge. */}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pb-4">
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* The bottom sheet's anatomy, rendered statically: the grip, the 44 px X on
|
||||
* the title row, the preview step's wiring, and that every bottom sheet
|
||||
* (`<SheetContent side="bottom">`) takes this path while the other sides do
|
||||
* not. Radix portals do not render on the server, so its Dialog parts are
|
||||
* passed through as plain elements; what Radix itself does (focus, Escape,
|
||||
* the backdrop) and the drag in a browser are `e2e/bottom-sheet.spec.ts`.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("radix-ui", () => {
|
||||
type P = { children?: ReactNode; [key: string]: unknown };
|
||||
const strip = ({ asChild: _asChild, ...rest }: P) => rest;
|
||||
return {
|
||||
Dialog: {
|
||||
Root: ({ children }: P) => <>{children}</>,
|
||||
Portal: ({ children }: P) => <>{children}</>,
|
||||
Overlay: (props: P) => <div data-mock="overlay" {...strip(props)} />,
|
||||
Content: ({ children, ...props }: P) => (
|
||||
<div role="dialog" {...strip(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Close: ({ children, ...props }: P) => (
|
||||
<button type="button" {...strip(props)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Title: ({ children }: P) => <h2>{children}</h2>,
|
||||
Description: ({ children }: P) => <p>{children}</p>,
|
||||
Trigger: ({ children }: P) => <>{children}</>,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { BottomSheetContent, BOTTOM_SHEET_CLASS } =
|
||||
await import("../bottom-sheet");
|
||||
const { SheetContent } = await import("../sheet");
|
||||
|
||||
function attr(html: string, slot: string): string {
|
||||
const match = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
describe("<BottomSheetContent>", () => {
|
||||
it("draws a decorative grip and a 44 px X on the title row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent>body</BottomSheetContent>,
|
||||
);
|
||||
const grip = attr(html, "sheet-grip");
|
||||
expect(grip).toContain('aria-hidden="true"');
|
||||
expect(grip).toContain("h-6");
|
||||
expect(grip).toContain("touch-none");
|
||||
expect(html).toContain("h-1.25 w-9 rounded-full");
|
||||
const close = attr(html, "sheet-close-button");
|
||||
expect(close).toContain("size-11");
|
||||
expect(close).toContain("top-7.5");
|
||||
expect(html).toContain("common.close");
|
||||
// The swipe closes through a Radix Close of its own, out of reach.
|
||||
const swipe = attr(html, "sheet-swipe-close");
|
||||
expect(swipe).toContain("hidden");
|
||||
expect(swipe).toContain('tabindex="-1"');
|
||||
});
|
||||
|
||||
it("keeps its backdrop out of touch panning", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent>body</BottomSheetContent>,
|
||||
);
|
||||
expect(attr(html, "sheet-overlay")).toContain("touch-none");
|
||||
});
|
||||
|
||||
it("drops the grip, the drag and the X offset when it is not draggable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent draggable={false}>body</BottomSheetContent>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sheet-grip"');
|
||||
expect(html).not.toContain('data-slot="sheet-swipe-close"');
|
||||
expect(attr(html, "sheet-close-button")).toContain("top-3");
|
||||
});
|
||||
|
||||
it("opens a preview sheet on its preview step, with a grip that expands it", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent
|
||||
preview="58dvh"
|
||||
previewLabels={{ expand: "Expand", shrink: "Shrink" }}
|
||||
>
|
||||
body
|
||||
</BottomSheetContent>,
|
||||
);
|
||||
const content = attr(html, "sheet-content");
|
||||
expect(content).toContain('data-sheet-snap="preview"');
|
||||
expect(content).toContain("--sheet-height:90dvh");
|
||||
expect(content).toContain("--sheet-preview-height:58dvh");
|
||||
expect(content).toContain("h-(--sheet-height)");
|
||||
const grip = attr(html, "sheet-grip");
|
||||
expect(grip).toMatch(/^<button/);
|
||||
expect(grip).toContain('aria-label="Expand"');
|
||||
expect(grip).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
it("names its side for the keyboard rule and lets a consumer slot win", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent data-slot="mine" className="bg-card gap-0">
|
||||
body
|
||||
</BottomSheetContent>,
|
||||
);
|
||||
expect(html).toContain('data-sheet-side="bottom"');
|
||||
expect(html).toContain('data-slot="mine"');
|
||||
const content = attr(html, "mine");
|
||||
expect(content).toMatch(/\bbg-card\b/);
|
||||
expect(content).not.toContain("bg-background");
|
||||
expect(content).not.toMatch(/\bgap-4\b/);
|
||||
});
|
||||
|
||||
it("moves, opens and closes on its own timing and stays still under reduced motion", () => {
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=open]:duration-260");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=closed]:duration-220");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("ease-out");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("motion-reduce:animate-none");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("max-h-[90dvh]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SheetContent>", () => {
|
||||
it("renders every bottom sheet through the bottom sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetContent side="bottom">body</SheetContent>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sheet-grip"');
|
||||
expect(html).toContain('data-sheet-side="bottom"');
|
||||
});
|
||||
|
||||
it("leaves the side sheets as they were", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetContent side="right" preview="58dvh">
|
||||
body
|
||||
</SheetContent>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sheet-grip"');
|
||||
expect(html).not.toContain("data-sheet-snap");
|
||||
expect(html).toContain('data-sheet-side="right"');
|
||||
expect(attr(html, "sheet-close-button")).toContain("top-3 right-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { useSheetDrag } from "@/hooks/use-sheet-drag";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SheetSnap } from "@/lib/sheet-gesture";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The bottom sheet: every `<SheetContent side="bottom">` renders through
|
||||
* here, and so does `ResponsiveSheet` on a phone.
|
||||
*
|
||||
* It is Radix's Dialog with a drag on top. Focus moving in, the focus trap,
|
||||
* Escape, the backdrop tap, `role="dialog"` and its name, and the scroll
|
||||
* lock on the page behind all stay Radix's. What this adds:
|
||||
*
|
||||
* - focus goes back to whatever opened the sheet (Radix only knows a
|
||||
* `Dialog.Trigger`, which most openers are not), unless the caller's
|
||||
* `onCloseAutoFocus` places it itself; and `aria-modal`;
|
||||
* - a grip at the top edge; the grip and the header drag the sheet always,
|
||||
* the content only while it is scrolled to its very top
|
||||
* (`use-sheet-drag.ts`, rules in `lib/sheet-gesture.ts`);
|
||||
* - a release past a quarter of the height, or a flick down, closes it,
|
||||
* anything less springs back; sideways movement never counts;
|
||||
* - the backdrop lightens as the sheet goes down, and neither it nor the
|
||||
* grip and header ever scroll or pull the page behind;
|
||||
* - opt in, a preview step (`preview`): the sheet opens at that height and
|
||||
* a pull up, or a tap on the grip, takes it to the full 90 dvh. Its
|
||||
* footer (`data-sheet-footer`) stays on the window's bottom edge and its
|
||||
* scrolling body (`data-sheet-body`) ends above it on either step.
|
||||
*
|
||||
* The X sits on the title row, 44 px square; the keyboard and a screen
|
||||
* reader close the sheet through it or Escape, never through a gesture.
|
||||
*/
|
||||
|
||||
export interface BottomSheetOptions {
|
||||
/**
|
||||
* Opens the sheet at this height (a CSS length, e.g. `58dvh`) with the
|
||||
* full 90 dvh one pull up. Needs `previewLabels` for the grip, which then
|
||||
* becomes a button that toggles the two steps.
|
||||
*/
|
||||
preview?: string;
|
||||
previewLabels?: { expand: string; shrink: string };
|
||||
/**
|
||||
* False leaves out the grip and the drag, for a sheet that fills the whole
|
||||
* window (the command palette): there is no backdrop to pull it towards.
|
||||
*/
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export type BottomSheetContentProps = React.ComponentProps<
|
||||
typeof SheetPrimitive.Content
|
||||
> &
|
||||
BottomSheetOptions & { showCloseButton?: boolean };
|
||||
|
||||
/**
|
||||
* No `bottom-*` here: the sheet's `bottom` comes from the keyboard rule in
|
||||
* `globals.css`, which a utility would outrank. In 260 ms, out 220 ms, both
|
||||
* easing out; no slide at all under reduced motion. The `after:` strip
|
||||
* continues the sheet's own background below its edge, so the short
|
||||
* resisting travel above the top never shows the page underneath.
|
||||
*/
|
||||
export const BOTTOM_SHEET_CLASS =
|
||||
"bg-background fixed inset-x-0 z-50 flex max-h-[90dvh] flex-col gap-4 border-t shadow-lg transition-transform ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none motion-reduce:transition-none after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-10 after:bg-inherit after:content-['']";
|
||||
|
||||
const OVERLAY_CLASS =
|
||||
"fixed inset-0 z-50 touch-none bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ease-out data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none";
|
||||
|
||||
/** Puts a preview sheet back on its preview step each time it opens. */
|
||||
function OnOpen({ run }: { run: () => void }) {
|
||||
React.useLayoutEffect(() => {
|
||||
run();
|
||||
// Once per mount, which is once per opening.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SheetGrip({
|
||||
snap,
|
||||
labels,
|
||||
onToggle,
|
||||
}: {
|
||||
snap: SheetSnap;
|
||||
labels?: { expand: string; shrink: string };
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const bar = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 h-1.25 w-9 rounded-full"
|
||||
/>
|
||||
);
|
||||
if (labels) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="sheet-grip"
|
||||
aria-label={snap === "full" ? labels.shrink : labels.expand}
|
||||
aria-expanded={snap === "full"}
|
||||
onClick={onToggle}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{bar}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-grip"
|
||||
aria-hidden="true"
|
||||
className="flex h-6 shrink-0 touch-none items-center justify-center"
|
||||
>
|
||||
{bar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomSheetContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
preview,
|
||||
previewLabels,
|
||||
draggable = true,
|
||||
ref,
|
||||
style,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onClickCapture,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...props
|
||||
}: BottomSheetContentProps) {
|
||||
const { t } = useTranslations();
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const overlayRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const probeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const closeRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
// What had focus when the sheet opened. Radix hands focus back only to a
|
||||
// `Dialog.Trigger`, and most bottom sheets open from a plain button (the
|
||||
// More tab, a chart's tooltip), so focus fell to the body on close.
|
||||
const openerRef = React.useRef<HTMLElement | null>(null);
|
||||
const initial: SheetSnap = preview ? "preview" : "full";
|
||||
const [snap, setSnap] = React.useState<SheetSnap>(initial);
|
||||
|
||||
const drag = useSheetDrag({
|
||||
enabled: draggable,
|
||||
hasPreview: !!preview,
|
||||
snap,
|
||||
onSnap: setSnap,
|
||||
onClose: () => closeRef.current?.click(),
|
||||
contentRef,
|
||||
overlayRef,
|
||||
probeRef,
|
||||
});
|
||||
|
||||
const setContent = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetPrimitive.Portal data-slot="sheet-portal">
|
||||
<SheetPrimitive.Overlay
|
||||
ref={overlayRef}
|
||||
data-slot="sheet-overlay"
|
||||
className={OVERLAY_CLASS}
|
||||
/>
|
||||
<SheetPrimitive.Content
|
||||
ref={setContent}
|
||||
data-slot="sheet-content"
|
||||
// Read by the keyboard rule in `globals.css`: a bottom sheet stands
|
||||
// on the on-screen keyboard. Its own attribute rather than
|
||||
// `data-slot`, which consumers replace with theirs.
|
||||
data-sheet-side="bottom"
|
||||
data-sheet-snap={preview ? snap : undefined}
|
||||
// Radix makes the page behind inert and traps focus; this says so
|
||||
// to assistive technology that reads the attribute rather than the
|
||||
// hidden siblings.
|
||||
aria-modal="true"
|
||||
style={
|
||||
preview
|
||||
? ({
|
||||
"--sheet-height": "90dvh",
|
||||
"--sheet-preview-height": preview,
|
||||
...style,
|
||||
} as React.CSSProperties)
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
BOTTOM_SHEET_CLASS,
|
||||
preview && "h-(--sheet-height) max-h-(--sheet-height)",
|
||||
className,
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
onPointerDown?.(event);
|
||||
drag.onPointerDown?.(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
onPointerMove?.(event);
|
||||
drag.onPointerMove?.(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
onPointerUp?.(event);
|
||||
drag.onPointerUp?.(event);
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
onPointerCancel?.(event);
|
||||
drag.onPointerCancel?.(event);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
drag.onClickCapture?.(event);
|
||||
onClickCapture?.(event);
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
const active = document.activeElement;
|
||||
openerRef.current =
|
||||
active instanceof HTMLElement && active !== document.body
|
||||
? active
|
||||
: null;
|
||||
onOpenAutoFocus?.(event);
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
onCloseAutoFocus?.(event);
|
||||
const opener = openerRef.current;
|
||||
if (event.defaultPrevented || !opener?.isConnected) return;
|
||||
event.preventDefault();
|
||||
opener.focus({ preventScroll: true });
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{preview ? (
|
||||
<>
|
||||
<OnOpen run={() => setSnap(initial)} />
|
||||
<div
|
||||
ref={probeRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute top-0 left-0 w-px"
|
||||
style={{ height: preview }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{draggable ? (
|
||||
<SheetGrip
|
||||
snap={snap}
|
||||
labels={preview ? previewLabels : undefined}
|
||||
onToggle={() => setSnap(snap === "full" ? "preview" : "full")}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className={cn(
|
||||
// On the title row: the grip's 24 px plus the header's 16 px
|
||||
// top padding put the title's line centre 52 px down.
|
||||
draggable ? "top-7.5" : "top-3",
|
||||
"ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute right-2 inline-flex size-11 touch-none items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
{draggable ? (
|
||||
// What a swipe presses: the same close as the X, so the sheet's
|
||||
// `onOpenChange` hears one kind of close however it was asked.
|
||||
<SheetPrimitive.Close
|
||||
ref={closeRef}
|
||||
hidden
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
data-slot="sheet-swipe-close"
|
||||
/>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,9 @@ export function ResponsiveSheet({
|
||||
<SheetHeader
|
||||
data-slot="responsive-sheet-header"
|
||||
className={cn(
|
||||
"border-border/70 gap-1.5 border-b p-4",
|
||||
// Under the sheet's grip; the header drags the sheet, so a
|
||||
// touch on it never pans or pulls the page.
|
||||
"border-border/70 touch-none gap-1.5 border-b p-4",
|
||||
// Reserve the right gutter ONLY when the primitive paints its
|
||||
// absolute close button. With `showCloseButton={false}` the
|
||||
// consumer's own trailing control (headerAction) sits flush to
|
||||
|
||||
+41
-14
@@ -6,6 +6,10 @@ import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
BottomSheetContent,
|
||||
type BottomSheetOptions,
|
||||
} from "@/components/ui/bottom-sheet";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
@@ -45,14 +49,43 @@ function SheetOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet from one edge. `side="bottom"` is the bottom sheet
|
||||
* (`bottom-sheet.tsx`): a grip, the drag, and the options in
|
||||
* `BottomSheetOptions`, which the other sides ignore.
|
||||
*/
|
||||
function SheetContent({
|
||||
side = "right",
|
||||
preview,
|
||||
previewLabels,
|
||||
draggable,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> &
|
||||
BottomSheetOptions & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
if (side === "bottom") {
|
||||
return (
|
||||
<BottomSheetContent
|
||||
preview={preview}
|
||||
previewLabels={previewLabels}
|
||||
draggable={draggable}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <EdgeSheetContent side={side} {...props} />;
|
||||
}
|
||||
|
||||
function EdgeSheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
side,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
side: "top" | "right" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,14 +94,10 @@ function SheetContent({
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
// Read by the safe-area and on-screen-keyboard rules in
|
||||
// `globals.css`: a bottom sheet stands on the keyboard, the
|
||||
// full-height sides keep clear of the status bar and the notch.
|
||||
// Its own attribute rather than `data-slot`, which consumers
|
||||
// replace with theirs (`responsive-sheet-content`, `day-panel`).
|
||||
// A bottom sheet's `bottom` comes from that rule (0 until the
|
||||
// keyboard opens), so its branch below carries no `bottom-*`: a
|
||||
// utility would outrank the layered rule and pin it to the edge.
|
||||
// Read by the safe-area rules in `globals.css`: the full-height
|
||||
// sides keep clear of the status bar and the notch. Its own
|
||||
// attribute rather than `data-slot`, which consumers replace with
|
||||
// theirs.
|
||||
data-sheet-side={side}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
@@ -78,8 +107,6 @@ function SheetContent({
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -90,8 +117,8 @@ function SheetContent({
|
||||
// a 36 px floor, matching the Dialog close-X treatment from
|
||||
// MB2 (Decision I). 36 px (not 44 px) is the intentional
|
||||
// exception for fixed icon-only close affordances per the
|
||||
// dialog primitive's rationale: 44 px would crowd the sheet
|
||||
// header out of proportion on the bottom-sheet branch.
|
||||
// dialog primitive's rationale. The bottom sheet's X is 44 px
|
||||
// on its title row (`bottom-sheet.tsx`).
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none sm:min-h-9 sm:min-w-9 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SHEET_SETTLE_MS,
|
||||
createSheetDrag,
|
||||
type SheetDragEvent,
|
||||
} from "../use-sheet-drag";
|
||||
import type { SheetSnap } from "@/lib/sheet-gesture";
|
||||
|
||||
/**
|
||||
* The drag controller against stand-in elements: what it writes to the
|
||||
* sheet, the backdrop and the footer, and when it closes, settles or leaves
|
||||
* the touch alone. The browser-level proof (real touches, scrolling, focus)
|
||||
* is `e2e/bottom-sheet.spec.ts` on WebKit.
|
||||
*/
|
||||
|
||||
interface FakeEl {
|
||||
style: Record<string, string>;
|
||||
offsetHeight: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollTop: number;
|
||||
parentElement: FakeEl | null;
|
||||
overflowY: string;
|
||||
own?: boolean;
|
||||
closest: (selector: string) => FakeEl | null;
|
||||
getAnimations: () => Animation[];
|
||||
setPointerCapture: (id: number) => void;
|
||||
querySelector: (selector: string) => FakeEl | null;
|
||||
}
|
||||
|
||||
function fake(partial: Partial<FakeEl> = {}): FakeEl {
|
||||
const node: FakeEl = {
|
||||
style: {},
|
||||
offsetHeight: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
overflowY: "visible",
|
||||
closest: () => (node.own ? node : null),
|
||||
getAnimations: () => [],
|
||||
setPointerCapture: vi.fn(),
|
||||
querySelector: () => null,
|
||||
...partial,
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function setup({
|
||||
preview = false,
|
||||
height = 400,
|
||||
previewHeight = 250,
|
||||
}: { preview?: boolean; height?: number; previewHeight?: number } = {}) {
|
||||
let clock = 0;
|
||||
let snap: SheetSnap = preview ? "preview" : "full";
|
||||
const frames: Array<() => void> = [];
|
||||
const timers: Array<() => void> = [];
|
||||
const footer = fake();
|
||||
const content = fake({
|
||||
offsetHeight: height,
|
||||
querySelector: () => footer,
|
||||
});
|
||||
const header = fake({ parentElement: content });
|
||||
const body = fake({
|
||||
parentElement: content,
|
||||
overflowY: "auto",
|
||||
scrollHeight: 2000,
|
||||
clientHeight: 300,
|
||||
});
|
||||
const row = fake({ parentElement: body });
|
||||
const overlay = fake();
|
||||
const probe = fake({ offsetHeight: previewHeight });
|
||||
const onClose = vi.fn();
|
||||
const onSnap = vi.fn((next: SheetSnap) => {
|
||||
snap = next;
|
||||
});
|
||||
const controller = createSheetDrag({
|
||||
content: () => content as unknown as HTMLElement,
|
||||
overlay: () => overlay as unknown as HTMLElement,
|
||||
probe: () => (preview ? (probe as unknown as HTMLElement) : null),
|
||||
hasPreview: () => preview,
|
||||
snap: () => snap,
|
||||
onSnap,
|
||||
onClose,
|
||||
now: () => clock,
|
||||
frame: (callback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
later: (callback) => {
|
||||
timers.push(callback);
|
||||
},
|
||||
reducedMotion: () => false,
|
||||
computedTransform: (element) => {
|
||||
const inline = (element as unknown as FakeEl).style.transform;
|
||||
if (inline) {
|
||||
const y = Number(/,\s*(-?[\d.]+)px/.exec(inline)?.[1] ?? 0);
|
||||
return `matrix(1, 0, 0, 1, 0, ${y})`;
|
||||
}
|
||||
return snap === "preview"
|
||||
? `matrix(1, 0, 0, 1, 0, ${height - previewHeight})`
|
||||
: "none";
|
||||
},
|
||||
overflowY: (element) => (element as unknown as FakeEl).overflowY,
|
||||
});
|
||||
|
||||
const ev = (
|
||||
target: FakeEl,
|
||||
x: number,
|
||||
y: number,
|
||||
extra: Partial<SheetDragEvent> = {},
|
||||
): SheetDragEvent => ({
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
target: target as unknown as EventTarget,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A drag of `dy` in `steps` moves, `stepMs` apart, then an optional rest. */
|
||||
function drag(
|
||||
target: FakeEl,
|
||||
dy: number,
|
||||
{ steps = 10, stepMs = 16, dx = 0, restMs = 0 } = {},
|
||||
) {
|
||||
controller.pointerDown(ev(target, 100, 100));
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
clock += stepMs;
|
||||
controller.pointerMove(
|
||||
ev(target, 100 + (dx * i) / steps, 100 + (dy * i) / steps),
|
||||
);
|
||||
}
|
||||
clock += restMs;
|
||||
controller.pointerUp(ev(target, 100 + dx, 100 + dy));
|
||||
}
|
||||
|
||||
const flushFrames = () => {
|
||||
while (frames.length) frames.shift()!();
|
||||
};
|
||||
|
||||
return {
|
||||
controller,
|
||||
content,
|
||||
header,
|
||||
body,
|
||||
row,
|
||||
overlay,
|
||||
footer,
|
||||
onClose,
|
||||
onSnap,
|
||||
frames,
|
||||
timers,
|
||||
flushFrames,
|
||||
drag,
|
||||
ev,
|
||||
getSnap: () => snap,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSheetDrag", () => {
|
||||
it("closes after a slow drag past a quarter of the height", () => {
|
||||
const s = setup();
|
||||
s.drag(s.header, 140, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
// It leaves from where the finger let go, with the backdrop already lighter.
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 140px, 0)");
|
||||
expect(Number(s.overlay.style.opacity)).toBeCloseTo(1 - 140 / 400, 6);
|
||||
expect(s.controller.swallowClick()).toBe(true);
|
||||
expect(s.controller.swallowClick()).toBe(false);
|
||||
});
|
||||
|
||||
it("lets its own close through: the swallowed click comes after it", () => {
|
||||
const s = setup();
|
||||
let swallowedDuringClose: boolean | null = null;
|
||||
s.onClose.mockImplementation(() => {
|
||||
// The close presses a Radix Close button; a swallow armed already
|
||||
// would eat that click and the sheet would never close.
|
||||
swallowedDuringClose = s.controller.swallowClick();
|
||||
});
|
||||
s.drag(s.header, 200, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(swallowedDuringClose).toBe(false);
|
||||
expect(s.controller.swallowClick()).toBe(true);
|
||||
});
|
||||
|
||||
it("springs back after a short slow drag", () => {
|
||||
const s = setup();
|
||||
s.drag(s.header, 60, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
expect(s.content.style.transition).toContain(`${SHEET_SETTLE_MS}ms`);
|
||||
expect(s.overlay.style.opacity).toBe("1");
|
||||
// Once the glide has run, the stylesheet owns the position again.
|
||||
s.timers.forEach((run) => run());
|
||||
expect(s.content.style.transform).toBe("");
|
||||
expect(s.content.style.transition).toBe("");
|
||||
expect(s.overlay.style.opacity).toBe("");
|
||||
});
|
||||
|
||||
it("closes on a fast flick even when short", () => {
|
||||
const s = setup();
|
||||
s.drag(s.header, 60, { steps: 4, stepMs: 10 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("writes moves once per frame, never per event", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 100));
|
||||
for (let y = 110; y <= 200; y += 10) {
|
||||
s.controller.pointerMove(s.ev(s.header, 100, y));
|
||||
}
|
||||
expect(s.frames).toHaveLength(1);
|
||||
s.flushFrames();
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 100px, 0)");
|
||||
});
|
||||
|
||||
it("drags content down from its very top", () => {
|
||||
const s = setup();
|
||||
s.drag(s.row, 200, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves scrolled content to scroll", () => {
|
||||
const s = setup();
|
||||
s.body.scrollTop = 40;
|
||||
s.drag(s.row, 300, { stepMs: 40 });
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
expect(s.content.style.transform).toBeUndefined();
|
||||
expect(s.frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets an upward swipe on content scroll it", () => {
|
||||
const s = setup();
|
||||
s.drag(s.row, -200);
|
||||
expect(s.frames).toHaveLength(0);
|
||||
expect(s.onSnap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never counts a sideways gesture", () => {
|
||||
const s = setup();
|
||||
s.drag(s.row, 30, { dx: 200 });
|
||||
s.drag(s.header, 30, { dx: 200 });
|
||||
expect(s.frames).toHaveLength(0);
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a slider or a field its own gesture", () => {
|
||||
const s = setup();
|
||||
const slider = fake({ parentElement: s.body, own: true });
|
||||
s.drag(slider, 300, { stepMs: 40 });
|
||||
expect(s.frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores the mouse", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 0, 0, { pointerType: "mouse" }));
|
||||
s.controller.pointerMove(s.ev(s.header, 0, 300, { pointerType: "mouse" }));
|
||||
s.controller.pointerUp(s.ev(s.header, 0, 300, { pointerType: "mouse" }));
|
||||
expect(s.frames).toHaveLength(0);
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resists above the top", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 400));
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 0));
|
||||
s.flushFrames();
|
||||
const y = Number(/(-?[\d.]+)px/.exec(s.content.style.transform)?.[1]);
|
||||
expect(y).toBeLessThan(0);
|
||||
expect(y).toBeGreaterThan(-24);
|
||||
});
|
||||
|
||||
it("puts the sheet back when a second finger lands", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 100));
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 200));
|
||||
s.controller.pointerDown(
|
||||
s.ev(s.header, 50, 50, { pointerId: 2, isPrimary: false }),
|
||||
);
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
s.controller.pointerUp(s.ev(s.header, 100, 400));
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("touchmove", () => {
|
||||
const touch = (y: number, x = 100) => {
|
||||
const event = {
|
||||
touches: [{ clientX: x, clientY: y }],
|
||||
cancelable: true,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
return event;
|
||||
};
|
||||
|
||||
it("is cancelled under a drag, and on the chrome from the start", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 100));
|
||||
const first = touch(102);
|
||||
s.controller.touchMove(first);
|
||||
expect(first.preventDefault).toHaveBeenCalled();
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 150));
|
||||
const later = touch(150);
|
||||
s.controller.touchMove(later);
|
||||
expect(later.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is left alone for content that scrolls", () => {
|
||||
const s = setup();
|
||||
s.body.scrollTop = 10;
|
||||
s.controller.pointerDown(s.ev(s.row, 100, 100));
|
||||
const event = touch(160);
|
||||
s.controller.touchMove(event);
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is cancelled for a pull down on content at its top", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.row, 100, 100));
|
||||
const down = touch(106);
|
||||
s.controller.touchMove(down);
|
||||
expect(down.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is left alone for an upward swipe on content", () => {
|
||||
const s = setup();
|
||||
s.controller.pointerDown(s.ev(s.row, 100, 100));
|
||||
const up = touch(80);
|
||||
s.controller.touchMove(up);
|
||||
expect(up.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a preview step", () => {
|
||||
it("rises to full height on a pull up from the content", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.drag(s.row, -300, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onSnap).toHaveBeenCalledWith("full");
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
expect(s.footer.style.transform).toBe("translate3d(0, 0px, 0)");
|
||||
});
|
||||
|
||||
it("keeps the footer on the window edge while the sheet moves up", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.controller.pointerDown(s.ev(s.header, 100, 500));
|
||||
s.controller.pointerMove(s.ev(s.header, 100, 400));
|
||||
s.flushFrames();
|
||||
// Sheet at 350 - 100 = 250; the footer lifted by the same 250.
|
||||
expect(s.content.style.transform).toBe("translate3d(0, 250px, 0)");
|
||||
expect(s.footer.style.transform).toBe("translate3d(0, -250px, 0)");
|
||||
expect(s.overlay.style.opacity).toBe("1");
|
||||
});
|
||||
|
||||
it("goes back to the preview step on a short pull down from full height", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.drag(s.row, -300, { stepMs: 40, restMs: 200 });
|
||||
s.timers.forEach((run) => run());
|
||||
expect(s.getSnap()).toBe("full");
|
||||
s.drag(s.header, 250, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onSnap).toHaveBeenLastCalledWith("preview");
|
||||
expect(s.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes from the preview step past a quarter of what it shows", () => {
|
||||
const s = setup({ preview: true, height: 800, previewHeight: 450 });
|
||||
s.drag(s.header, 130, { stepMs: 40, restMs: 200 });
|
||||
expect(s.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,15 +84,21 @@ export function usePullToRefresh({
|
||||
setPullDistance(0);
|
||||
};
|
||||
|
||||
// A sheet or dialog is open: Radix's scroll lock marks the body while it
|
||||
// holds the page. A drag inside the sheet (down to close it, or along
|
||||
// its content) is the sheet's, never a pull on the page behind it; the
|
||||
// listeners sit on `window`, so without this they saw every one.
|
||||
const modalOpen = () => document.body.hasAttribute("data-scroll-locked");
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (busy || e.touches.length !== 1 || !atTop()) return;
|
||||
if (busy || e.touches.length !== 1 || !atTop() || modalOpen()) return;
|
||||
startY = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (busy || startY === null) return;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (dy <= 0 || !atTop()) {
|
||||
if (dy <= 0 || !atTop() || modalOpen()) {
|
||||
// Upward drag or the page started scrolling — stand down quietly.
|
||||
if (pulling) reset();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
backdropOpacity,
|
||||
contentDrags,
|
||||
dragOffset,
|
||||
footerLift,
|
||||
lockAxis,
|
||||
releaseVelocity,
|
||||
settleSheet,
|
||||
translateYOf,
|
||||
type SheetSample,
|
||||
type SheetSnap,
|
||||
} from "@/lib/sheet-gesture";
|
||||
|
||||
/**
|
||||
* The bottom sheet's drag: the sheet follows a touch with `translateY`, the
|
||||
* backdrop fades with it, and on release the sheet closes or settles
|
||||
* (`settleSheet`). Moves are written straight to the elements once per
|
||||
* animation frame; React renders only when the resting place changes.
|
||||
*
|
||||
* Only touch and pen drag. A mouse, the keyboard and a screen reader close
|
||||
* the sheet through its X, Escape or the backdrop, which Radix owns, as it
|
||||
* owns focus and the scroll lock on the page behind.
|
||||
*/
|
||||
|
||||
/** The settle after a release, matched to the sheet's closing animation. */
|
||||
export const SHEET_SETTLE_MS = 220;
|
||||
const SETTLE_EASE = "cubic-bezier(0, 0, 0.2, 1)";
|
||||
|
||||
/** A touch on one of these is the control's own (typing, a slider). */
|
||||
const OWN_GESTURE =
|
||||
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="slider"], [data-sheet-no-drag]';
|
||||
|
||||
type Mode = "pending" | "drag" | "ignore";
|
||||
|
||||
interface Gesture {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
/** The nearest vertically scrolling element under the touch, or null on the sheet's chrome (grip, header, footer). */
|
||||
scroller: HTMLElement | null;
|
||||
mode: Mode;
|
||||
base: number;
|
||||
height: number;
|
||||
previewOffset: number | null;
|
||||
footer: HTMLElement | null;
|
||||
offset: number;
|
||||
samples: SheetSample[];
|
||||
}
|
||||
|
||||
export interface SheetDragEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
isPrimary: boolean;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
target: EventTarget | null;
|
||||
}
|
||||
|
||||
export interface SheetTouchMoveEvent {
|
||||
touches: ArrayLike<{ clientX: number; clientY: number }>;
|
||||
cancelable: boolean;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
export interface SheetDragOptions {
|
||||
content: () => HTMLElement | null;
|
||||
overlay: () => HTMLElement | null;
|
||||
/** An element whose height is the preview height; null without a preview. */
|
||||
probe: () => HTMLElement | null;
|
||||
hasPreview: () => boolean;
|
||||
snap: () => SheetSnap;
|
||||
onSnap: (snap: SheetSnap) => void;
|
||||
onClose: () => void;
|
||||
now?: () => number;
|
||||
frame?: (callback: () => void) => number;
|
||||
cancelFrame?: (id: number) => void;
|
||||
later?: (callback: () => void, ms: number) => unknown;
|
||||
reducedMotion?: () => boolean;
|
||||
computedTransform?: (element: HTMLElement) => string;
|
||||
overflowY?: (element: HTMLElement) => string;
|
||||
}
|
||||
|
||||
export interface SheetDragController {
|
||||
pointerDown: (event: SheetDragEvent) => void;
|
||||
pointerMove: (event: SheetDragEvent) => void;
|
||||
pointerUp: (event: SheetDragEvent) => void;
|
||||
pointerCancel: (event: SheetDragEvent) => void;
|
||||
/** Non-passive `touchmove` on the sheet: stops the page and the content from scrolling under a drag. */
|
||||
touchMove: (event: SheetTouchMoveEvent) => void;
|
||||
/** True once for the click that ends a drag, which must not press what it ends on. */
|
||||
swallowClick: () => boolean;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function translate(y: number): string {
|
||||
return `translate3d(0, ${y}px, 0)`;
|
||||
}
|
||||
|
||||
export function createSheetDrag(
|
||||
options: SheetDragOptions,
|
||||
): SheetDragController {
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const frame =
|
||||
options.frame ?? ((callback) => requestAnimationFrame(() => callback()));
|
||||
const cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id));
|
||||
const later =
|
||||
options.later ?? ((callback, ms) => window.setTimeout(callback, ms));
|
||||
const reducedMotion =
|
||||
options.reducedMotion ??
|
||||
(() =>
|
||||
typeof window !== "undefined" &&
|
||||
!!window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const computedTransform =
|
||||
options.computedTransform ??
|
||||
((element) => getComputedStyle(element).transform);
|
||||
const overflowY =
|
||||
options.overflowY ?? ((element) => getComputedStyle(element).overflowY);
|
||||
|
||||
let gesture: Gesture | null = null;
|
||||
let pendingFrame = 0;
|
||||
let swallow = false;
|
||||
let settleToken = 0;
|
||||
|
||||
function scrollerUnder(
|
||||
target: EventTarget | null,
|
||||
content: HTMLElement,
|
||||
): HTMLElement | null {
|
||||
let node = target as HTMLElement | null;
|
||||
while (node) {
|
||||
const overflow = overflowY(node);
|
||||
if (
|
||||
(overflow === "auto" || overflow === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight + 1
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
if (node === content) return null;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function previewOffsetOf(height: number): number | null {
|
||||
if (!options.hasPreview()) return null;
|
||||
const probe = options.probe();
|
||||
if (!probe) return null;
|
||||
return Math.max(0, height - probe.offsetHeight);
|
||||
}
|
||||
|
||||
function paint() {
|
||||
pendingFrame = 0;
|
||||
const g = gesture;
|
||||
const content = options.content();
|
||||
if (!g || !content) return;
|
||||
content.style.transform = translate(g.offset);
|
||||
const overlay = options.overlay();
|
||||
const lowest = g.previewOffset ?? 0;
|
||||
if (overlay) {
|
||||
overlay.style.opacity = String(
|
||||
backdropOpacity(g.offset, lowest, g.height),
|
||||
);
|
||||
}
|
||||
if (g.footer && g.previewOffset !== null) {
|
||||
g.footer.style.transform = translate(
|
||||
-footerLift(g.offset, g.previewOffset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (!pendingFrame) pendingFrame = frame(paint);
|
||||
}
|
||||
|
||||
function clearInline(content: HTMLElement, footer: HTMLElement | null) {
|
||||
content.style.transition = "";
|
||||
content.style.transform = "";
|
||||
const overlay = options.overlay();
|
||||
if (overlay) {
|
||||
overlay.style.transition = "";
|
||||
overlay.style.opacity = "";
|
||||
}
|
||||
if (footer) {
|
||||
footer.style.transition = "";
|
||||
footer.style.transform = "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Glide to a resting place, then hand the position back to the stylesheet. */
|
||||
function settleTo(g: Gesture, snap: SheetSnap) {
|
||||
const content = options.content();
|
||||
if (!content) return;
|
||||
if (pendingFrame) {
|
||||
cancelFrame(pendingFrame);
|
||||
pendingFrame = 0;
|
||||
}
|
||||
const target =
|
||||
snap === "preview" && g.previewOffset !== null ? g.previewOffset : 0;
|
||||
if (snap !== options.snap()) options.onSnap(snap);
|
||||
const token = ++settleToken;
|
||||
if (reducedMotion()) {
|
||||
clearInline(content, g.footer);
|
||||
return;
|
||||
}
|
||||
const transition = `${SHEET_SETTLE_MS}ms ${SETTLE_EASE}`;
|
||||
content.style.transition = `transform ${transition}`;
|
||||
content.style.transform = translate(target);
|
||||
const overlay = options.overlay();
|
||||
if (overlay) {
|
||||
overlay.style.transition = `opacity ${transition}`;
|
||||
overlay.style.opacity = "1";
|
||||
}
|
||||
if (g.footer && g.previewOffset !== null) {
|
||||
g.footer.style.transition = `transform ${transition}`;
|
||||
g.footer.style.transform = translate(
|
||||
-footerLift(target, g.previewOffset),
|
||||
);
|
||||
}
|
||||
const footer = g.footer;
|
||||
later(() => {
|
||||
if (token !== settleToken) return;
|
||||
clearInline(content, footer);
|
||||
}, SHEET_SETTLE_MS + 40);
|
||||
}
|
||||
|
||||
function beginDrag(g: Gesture, content: HTMLElement) {
|
||||
// The opening slide (a CSS animation) would hide a move written under
|
||||
// it; finish it, then start from wherever the sheet is right now, which
|
||||
// may be part-way through a settle.
|
||||
for (const animation of content.getAnimations?.() ?? []) {
|
||||
if ("animationName" in animation) animation.finish();
|
||||
}
|
||||
settleToken += 1;
|
||||
const base = translateYOf(computedTransform(content));
|
||||
content.style.transition = "none";
|
||||
content.style.transform = translate(base);
|
||||
const overlay = options.overlay();
|
||||
if (overlay) overlay.style.transition = "none";
|
||||
if (g.footer) g.footer.style.transition = "none";
|
||||
g.base = base;
|
||||
g.offset = base;
|
||||
g.mode = "drag";
|
||||
}
|
||||
|
||||
function reset() {
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
return {
|
||||
pointerDown(event) {
|
||||
swallow = false;
|
||||
if (event.pointerType !== "touch" && event.pointerType !== "pen") return;
|
||||
if (!event.isPrimary) {
|
||||
// A second finger: whatever the first was doing, put the sheet back.
|
||||
if (gesture?.mode === "drag") settleTo(gesture, options.snap());
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const content = options.content();
|
||||
if (!content) return;
|
||||
const target = event.target as Element | null;
|
||||
if (
|
||||
target &&
|
||||
typeof target.closest === "function" &&
|
||||
target.closest(OWN_GESTURE)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scroller = scrollerUnder(event.target, content);
|
||||
if (scroller && scroller.scrollTop > 0) return;
|
||||
const height = content.offsetHeight;
|
||||
gesture = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scroller,
|
||||
mode: "pending",
|
||||
base: 0,
|
||||
height,
|
||||
previewOffset: previewOffsetOf(height),
|
||||
footer: options.hasPreview()
|
||||
? content.querySelector<HTMLElement>("[data-sheet-footer]")
|
||||
: null,
|
||||
offset: 0,
|
||||
samples: [],
|
||||
};
|
||||
},
|
||||
|
||||
pointerMove(event) {
|
||||
const g = gesture;
|
||||
if (!g || event.pointerId !== g.pointerId || g.mode === "ignore") return;
|
||||
const content = options.content();
|
||||
if (!content) return;
|
||||
const dx = event.clientX - g.startX;
|
||||
const dy = event.clientY - g.startY;
|
||||
if (g.mode === "pending") {
|
||||
const axis = lockAxis(dx, dy);
|
||||
if (axis === null) return;
|
||||
if (axis === "x") {
|
||||
g.mode = "ignore";
|
||||
return;
|
||||
}
|
||||
if (
|
||||
g.scroller &&
|
||||
!contentDrags({
|
||||
scrollTop: g.scroller.scrollTop,
|
||||
dy,
|
||||
snap: options.snap(),
|
||||
hasPreview: g.previewOffset !== null,
|
||||
})
|
||||
) {
|
||||
g.mode = "ignore";
|
||||
return;
|
||||
}
|
||||
beginDrag(g, content);
|
||||
try {
|
||||
content.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// A pointer the browser no longer tracks; the moves still arrive.
|
||||
}
|
||||
}
|
||||
g.offset = dragOffset(g.base, dy);
|
||||
g.samples.push({ t: now(), y: g.offset });
|
||||
if (g.samples.length > 32) g.samples.splice(0, g.samples.length - 32);
|
||||
schedule();
|
||||
},
|
||||
|
||||
pointerUp(event) {
|
||||
const g = gesture;
|
||||
if (!g || event.pointerId !== g.pointerId) return;
|
||||
if (g.mode !== "drag") {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const decision = settleSheet({
|
||||
offset: g.offset,
|
||||
velocity: releaseVelocity(g.samples, now()),
|
||||
height: g.height,
|
||||
previewOffset: g.previewOffset,
|
||||
});
|
||||
if (decision.kind === "close") {
|
||||
if (pendingFrame) {
|
||||
cancelFrame(pendingFrame);
|
||||
pendingFrame = 0;
|
||||
}
|
||||
// The sheet leaves from where the finger let go: the closing
|
||||
// animation starts from this inline position and opacity.
|
||||
paint();
|
||||
reset();
|
||||
// Before the swallow is armed: the close is itself a click.
|
||||
options.onClose();
|
||||
swallow = true;
|
||||
return;
|
||||
}
|
||||
swallow = true;
|
||||
reset();
|
||||
settleTo(g, decision.snap);
|
||||
},
|
||||
|
||||
pointerCancel(event) {
|
||||
const g = gesture;
|
||||
if (!g || event.pointerId !== g.pointerId) return;
|
||||
reset();
|
||||
if (g.mode === "drag") settleTo(g, options.snap());
|
||||
},
|
||||
|
||||
touchMove(event) {
|
||||
const g = gesture;
|
||||
if (!g || g.mode === "ignore") return;
|
||||
if (g.mode === "drag") {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Still deciding: the chrome never scrolls or rubber-bands the page,
|
||||
// and content at its top must not start a scroll the drag would lose.
|
||||
const touch = event.touches[0];
|
||||
if (!touch || !event.cancelable) return;
|
||||
if (!g.scroller) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - g.startX;
|
||||
const dy = touch.clientY - g.startY;
|
||||
if (
|
||||
Math.abs(dy) > Math.abs(dx) &&
|
||||
contentDrags({
|
||||
scrollTop: g.scroller.scrollTop,
|
||||
dy,
|
||||
snap: options.snap(),
|
||||
hasPreview: g.previewOffset !== null,
|
||||
})
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
swallowClick() {
|
||||
if (!swallow) return false;
|
||||
swallow = false;
|
||||
return true;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (pendingFrame) cancelFrame(pendingFrame);
|
||||
pendingFrame = 0;
|
||||
settleToken += 1;
|
||||
gesture = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseSheetDragArgs {
|
||||
enabled: boolean;
|
||||
hasPreview: boolean;
|
||||
snap: SheetSnap;
|
||||
onSnap: (snap: SheetSnap) => void;
|
||||
onClose: () => void;
|
||||
contentRef: React.RefObject<HTMLElement | null>;
|
||||
overlayRef: React.RefObject<HTMLElement | null>;
|
||||
probeRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export interface SheetDragHandlers {
|
||||
onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onPointerMove?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onPointerUp?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onPointerCancel?: (event: React.PointerEvent<HTMLElement>) => void;
|
||||
onClickCapture?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export function useSheetDrag({
|
||||
enabled,
|
||||
hasPreview,
|
||||
snap,
|
||||
onSnap,
|
||||
onClose,
|
||||
contentRef,
|
||||
overlayRef,
|
||||
probeRef,
|
||||
}: UseSheetDragArgs): SheetDragHandlers {
|
||||
const latest = useRef({ hasPreview, snap, onSnap, onClose });
|
||||
useEffect(() => {
|
||||
latest.current = { hasPreview, snap, onSnap, onClose };
|
||||
});
|
||||
|
||||
// Made on first use, from an event or the effect, never during render.
|
||||
const controllerRef = useRef<SheetDragController | null>(null);
|
||||
const controller = useCallback((): SheetDragController => {
|
||||
controllerRef.current ??= createSheetDrag({
|
||||
content: () => contentRef.current,
|
||||
overlay: () => overlayRef.current,
|
||||
probe: () => probeRef.current,
|
||||
hasPreview: () => latest.current.hasPreview,
|
||||
snap: () => latest.current.snap,
|
||||
onSnap: (next) => latest.current.onSnap(next),
|
||||
onClose: () => latest.current.onClose(),
|
||||
});
|
||||
return controllerRef.current;
|
||||
}, [contentRef, overlayRef, probeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const content = contentRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
const drag = controller();
|
||||
const onContentTouch = (event: TouchEvent) => drag.touchMove(event);
|
||||
// A touch on the backdrop never scrolls or pulls the page behind it.
|
||||
const onOverlayTouch = (event: TouchEvent) => {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
};
|
||||
content?.addEventListener("touchmove", onContentTouch, { passive: false });
|
||||
overlay?.addEventListener("touchmove", onOverlayTouch, { passive: false });
|
||||
return () => {
|
||||
content?.removeEventListener("touchmove", onContentTouch);
|
||||
overlay?.removeEventListener("touchmove", onOverlayTouch);
|
||||
drag.dispose();
|
||||
};
|
||||
}, [enabled, controller, contentRef, overlayRef]);
|
||||
|
||||
if (!enabled) return {};
|
||||
return {
|
||||
onPointerDown: (event) => controller().pointerDown(event),
|
||||
onPointerMove: (event) => controller().pointerMove(event),
|
||||
onPointerUp: (event) => controller().pointerUp(event),
|
||||
onPointerCancel: (event) => controller().pointerCancel(event),
|
||||
onClickCapture: (event) => {
|
||||
if (controller().swallowClick()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHEET_AXIS_SLOP,
|
||||
SHEET_OVERDRAG,
|
||||
backdropOpacity,
|
||||
contentDrags,
|
||||
dragOffset,
|
||||
footerLift,
|
||||
lockAxis,
|
||||
releaseVelocity,
|
||||
rubberBand,
|
||||
settleSheet,
|
||||
translateYOf,
|
||||
} from "../sheet-gesture";
|
||||
|
||||
describe("lockAxis", () => {
|
||||
it("waits inside the slop", () => {
|
||||
expect(lockAxis(0, 0)).toBeNull();
|
||||
expect(lockAxis(SHEET_AXIS_SLOP - 1, -(SHEET_AXIS_SLOP - 1))).toBeNull();
|
||||
});
|
||||
|
||||
it("reads vertical and sideways once past it", () => {
|
||||
expect(lockAxis(2, SHEET_AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(2, -20)).toBe("y");
|
||||
expect(lockAxis(SHEET_AXIS_SLOP, 3)).toBe("x");
|
||||
expect(lockAxis(-30, 12)).toBe("x");
|
||||
});
|
||||
|
||||
it("gives a diagonal tie to the sideways gesture", () => {
|
||||
expect(lockAxis(12, 12)).toBe("x");
|
||||
expect(lockAxis(-12, 12)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rubberBand / dragOffset", () => {
|
||||
it("follows the finger 1:1 below the top", () => {
|
||||
expect(dragOffset(0, 120)).toBe(120);
|
||||
expect(dragOffset(300, -200)).toBe(100);
|
||||
expect(dragOffset(300, -300)).toBe(0);
|
||||
});
|
||||
|
||||
it("resists above the top and never passes the limit", () => {
|
||||
const small = -dragOffset(0, -4);
|
||||
expect(small).toBeGreaterThan(3);
|
||||
expect(small).toBeLessThan(4);
|
||||
const far = -dragOffset(0, -2000);
|
||||
expect(far).toBeLessThan(SHEET_OVERDRAG);
|
||||
expect(far).toBeGreaterThan(SHEET_OVERDRAG * 0.95);
|
||||
// From a lowered resting place the resistance starts at the top only.
|
||||
expect(dragOffset(300, -350)).toBeCloseTo(-rubberBand(50), 6);
|
||||
});
|
||||
|
||||
it("is monotonic", () => {
|
||||
let previous = Infinity;
|
||||
for (let d = 0; d <= 500; d += 25) {
|
||||
const y = dragOffset(0, -d);
|
||||
expect(y).toBeLessThanOrEqual(previous);
|
||||
previous = y;
|
||||
}
|
||||
});
|
||||
|
||||
it("has no travel for no distance", () => {
|
||||
expect(rubberBand(0)).toBe(0);
|
||||
expect(rubberBand(-5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseVelocity", () => {
|
||||
it("measures px/ms over the samples inside the window", () => {
|
||||
const samples = [
|
||||
{ t: 0, y: 0 },
|
||||
{ t: 50, y: 10 },
|
||||
{ t: 100, y: 60 },
|
||||
{ t: 150, y: 110 },
|
||||
];
|
||||
// Window 100 ms before t=150 keeps t=50..150: (110-10)/100.
|
||||
expect(releaseVelocity(samples, 150)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it("is zero when the finger rested before letting go", () => {
|
||||
const samples = [
|
||||
{ t: 0, y: 0 },
|
||||
{ t: 40, y: 200 },
|
||||
];
|
||||
expect(releaseVelocity(samples, 400)).toBe(0);
|
||||
});
|
||||
|
||||
it("is zero with a single sample", () => {
|
||||
expect(releaseVelocity([{ t: 10, y: 5 }], 20)).toBe(0);
|
||||
expect(releaseVelocity([], 20)).toBe(0);
|
||||
});
|
||||
|
||||
it("is negative for an upward flick", () => {
|
||||
const samples = [
|
||||
{ t: 0, y: 100 },
|
||||
{ t: 40, y: 20 },
|
||||
];
|
||||
expect(releaseVelocity(samples, 40)).toBeCloseTo(-2, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleSheet without a preview step", () => {
|
||||
const base = { height: 400, previewOffset: null };
|
||||
|
||||
it("closes past a quarter of the height", () => {
|
||||
expect(settleSheet({ ...base, offset: 101, velocity: 0 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
});
|
||||
|
||||
it("springs back short of it", () => {
|
||||
expect(settleSheet({ ...base, offset: 99, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on a flick down faster than 0.5 px/ms", () => {
|
||||
expect(settleSheet({ ...base, offset: 30, velocity: 0.6 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 30, velocity: 0.5 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not close on a flick down from above the top", () => {
|
||||
expect(settleSheet({ ...base, offset: -10, velocity: 2 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleSheet with a preview step", () => {
|
||||
// 800 px tall, the preview shows 450: resting offset 350.
|
||||
const base = { height: 800, previewOffset: 350 };
|
||||
|
||||
it("closes a quarter of the visible preview below it", () => {
|
||||
expect(settleSheet({ ...base, offset: 350 + 113, velocity: 0 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 350 + 112, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("goes to the nearer step on a slow release", () => {
|
||||
expect(settleSheet({ ...base, offset: 100, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 250, velocity: 0 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("rises to the top on a flick up", () => {
|
||||
expect(settleSheet({ ...base, offset: 330, velocity: -0.8 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a full sheet to the preview step on a flick down, and closes below it", () => {
|
||||
expect(settleSheet({ ...base, offset: 60, velocity: 1.2 })).toEqual({
|
||||
kind: "snap",
|
||||
snap: "preview",
|
||||
});
|
||||
expect(settleSheet({ ...base, offset: 360, velocity: 1.2 })).toEqual({
|
||||
kind: "close",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("backdropOpacity", () => {
|
||||
it("is whole at and above the lowest resting place", () => {
|
||||
expect(backdropOpacity(0, 0, 400)).toBe(1);
|
||||
expect(backdropOpacity(-20, 0, 400)).toBe(1);
|
||||
expect(backdropOpacity(100, 300, 800)).toBe(1);
|
||||
});
|
||||
|
||||
it("fades in proportion to the way out", () => {
|
||||
expect(backdropOpacity(200, 0, 400)).toBeCloseTo(0.5, 6);
|
||||
expect(backdropOpacity(300 + 250, 300, 800)).toBeCloseTo(0.5, 6);
|
||||
expect(backdropOpacity(900, 0, 400)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("footerLift", () => {
|
||||
it("holds the footer on the window edge between the steps", () => {
|
||||
expect(footerLift(0, 350)).toBe(0);
|
||||
expect(footerLift(200, 350)).toBe(200);
|
||||
expect(footerLift(350, 350)).toBe(350);
|
||||
});
|
||||
|
||||
it("lets it leave with the sheet below the preview step", () => {
|
||||
expect(footerLift(500, 350)).toBe(350);
|
||||
expect(footerLift(-10, 350)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentDrags", () => {
|
||||
const top = { scrollTop: 0, hasPreview: false, snap: "full" as const };
|
||||
|
||||
it("scrolls first when the content is scrolled", () => {
|
||||
expect(contentDrags({ ...top, scrollTop: 1, dy: 30 })).toBe(false);
|
||||
expect(contentDrags({ ...top, scrollTop: 120, dy: -30 })).toBe(false);
|
||||
});
|
||||
|
||||
it("drags down from the very top", () => {
|
||||
expect(contentDrags({ ...top, dy: 30 })).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an upward swipe scroll, unless it raises a preview sheet", () => {
|
||||
expect(contentDrags({ ...top, dy: -30 })).toBe(false);
|
||||
expect(
|
||||
contentDrags({ ...top, hasPreview: true, snap: "full", dy: -30 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
contentDrags({ ...top, hasPreview: true, snap: "preview", dy: -30 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateYOf", () => {
|
||||
it("reads 2D and 3D matrices", () => {
|
||||
expect(translateYOf("none")).toBe(0);
|
||||
expect(translateYOf("")).toBe(0);
|
||||
expect(translateYOf("matrix(1, 0, 0, 1, 0, 123.5)")).toBe(123.5);
|
||||
expect(
|
||||
translateYOf("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -7, 0, 1)"),
|
||||
).toBe(-7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The bottom sheet's gesture arithmetic, free of the DOM so every rule can be
|
||||
* tested on numbers: when a drag counts, how far the sheet follows, how fast
|
||||
* the finger left, and where the sheet comes to rest.
|
||||
*
|
||||
* Offsets are the sheet's `translateY` in px: 0 is fully raised, positive is
|
||||
* further down. A sheet with a preview step rests at `previewOffset` (its
|
||||
* full height less the preview height) until it is pulled up to 0.
|
||||
*/
|
||||
|
||||
/** A release this far below the lowest resting place closes the sheet, as a share of the height still visible there. */
|
||||
export const SHEET_DISMISS_RATIO = 0.25;
|
||||
/** A downward release faster than this (px/ms) closes the sheet. */
|
||||
export const SHEET_FLICK_VELOCITY = 0.5;
|
||||
/** Travel (px) before a touch is read as vertical or sideways. */
|
||||
export const SHEET_AXIS_SLOP = 8;
|
||||
/** The most the sheet rises above its top resting place, however far the finger goes. */
|
||||
export const SHEET_OVERDRAG = 24;
|
||||
/** Only the movement in this window (ms) before release counts towards the velocity. */
|
||||
export const SHEET_VELOCITY_WINDOW = 100;
|
||||
|
||||
export type SheetAxis = "x" | "y";
|
||||
export type SheetSnap = "full" | "preview";
|
||||
export type SheetSettle = { kind: "close" } | { kind: "snap"; snap: SheetSnap };
|
||||
|
||||
/**
|
||||
* Which way a touch is going, once it has gone far enough to tell. Null while
|
||||
* it is still inside the slop. A tie goes sideways, so a diagonal start never
|
||||
* takes a slider or a wide table away from its own gesture.
|
||||
*/
|
||||
export function lockAxis(
|
||||
dx: number,
|
||||
dy: number,
|
||||
slop: number = SHEET_AXIS_SLOP,
|
||||
): SheetAxis | null {
|
||||
if (Math.abs(dx) < slop && Math.abs(dy) < slop) return null;
|
||||
return Math.abs(dy) > Math.abs(dx) ? "y" : "x";
|
||||
}
|
||||
|
||||
/**
|
||||
* Travel past the top: 1:1 at first, then ever stiffer, never more than
|
||||
* `limit`.
|
||||
*/
|
||||
export function rubberBand(
|
||||
distance: number,
|
||||
limit: number = SHEET_OVERDRAG,
|
||||
): number {
|
||||
if (distance <= 0) return 0;
|
||||
return (distance * limit) / (distance + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sheet sits for a finger `delta` px from where the drag started,
|
||||
* the sheet then resting at `base`. Down and back up to the top it follows
|
||||
* the finger exactly; above the top it resists.
|
||||
*/
|
||||
export function dragOffset(base: number, delta: number): number {
|
||||
const raw = base + delta;
|
||||
return raw >= 0 ? raw : -rubberBand(-raw);
|
||||
}
|
||||
|
||||
export interface SheetSample {
|
||||
/** ms */
|
||||
t: number;
|
||||
/** offset, px */
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's speed at release (px/ms, positive downward), from the samples
|
||||
* inside the velocity window. A finger that came to rest before letting go
|
||||
* leaves fewer than two samples there and so no speed.
|
||||
*/
|
||||
export function releaseVelocity(
|
||||
samples: readonly SheetSample[],
|
||||
now: number,
|
||||
window: number = SHEET_VELOCITY_WINDOW,
|
||||
): number {
|
||||
let first: SheetSample | null = null;
|
||||
let last: SheetSample | null = null;
|
||||
for (const sample of samples) {
|
||||
if (now - sample.t > window) continue;
|
||||
if (first === null) first = sample;
|
||||
last = sample;
|
||||
}
|
||||
if (first === null || last === null || last.t <= first.t) return 0;
|
||||
return (last.y - first.y) / (last.t - first.t);
|
||||
}
|
||||
|
||||
export interface SheetSettleInput {
|
||||
offset: number;
|
||||
velocity: number;
|
||||
/** The sheet's own height, px. */
|
||||
height: number;
|
||||
/** The preview step's resting offset; null for a sheet without one. */
|
||||
previewOffset: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a released sheet goes.
|
||||
*
|
||||
* - Further than a quarter of its visible height below its lowest resting
|
||||
* place: it closes.
|
||||
* - Flicked down: it closes once it is below that resting place, otherwise
|
||||
* it drops to the preview step (or back to the top without one).
|
||||
* - Flicked up: it rises to the top.
|
||||
* - Otherwise it goes to the nearer resting place.
|
||||
*/
|
||||
export function settleSheet({
|
||||
offset,
|
||||
velocity,
|
||||
height,
|
||||
previewOffset,
|
||||
}: SheetSettleInput): SheetSettle {
|
||||
const lowest = previewOffset ?? 0;
|
||||
const visible = Math.max(height - lowest, 1);
|
||||
if (offset - lowest > SHEET_DISMISS_RATIO * visible) return { kind: "close" };
|
||||
if (velocity > SHEET_FLICK_VELOCITY) {
|
||||
if (offset > lowest) return { kind: "close" };
|
||||
return { kind: "snap", snap: previewOffset === null ? "full" : "preview" };
|
||||
}
|
||||
if (velocity < -SHEET_FLICK_VELOCITY || previewOffset === null) {
|
||||
return { kind: "snap", snap: "full" };
|
||||
}
|
||||
return {
|
||||
kind: "snap",
|
||||
snap: offset < previewOffset / 2 ? "full" : "preview",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop's opacity: whole while the sheet is at or above its lowest
|
||||
* resting place, fading in step with the sheet on its way out.
|
||||
*/
|
||||
export function backdropOpacity(
|
||||
offset: number,
|
||||
lowest: number,
|
||||
height: number,
|
||||
): number {
|
||||
const travel = Math.max(height - lowest, 1);
|
||||
const out = (offset - lowest) / travel;
|
||||
return Math.min(1, Math.max(0, 1 - out));
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a preview sheet's footer is lifted against the sheet's own
|
||||
* offset, so it stays on the bottom edge of the window between the preview
|
||||
* step and the top, and leaves with the sheet below the preview step.
|
||||
*/
|
||||
export function footerLift(offset: number, previewOffset: number): number {
|
||||
return Math.min(Math.max(offset, 0), previewOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a vertical touch that began inside scrolling content moves the
|
||||
* sheet. Content that is scrolled at all scrolls first; at the very top a
|
||||
* downward pull moves the sheet, and an upward one only raises a sheet that
|
||||
* is still on its preview step (otherwise it scrolls the content).
|
||||
*/
|
||||
export function contentDrags({
|
||||
scrollTop,
|
||||
dy,
|
||||
snap,
|
||||
hasPreview,
|
||||
}: {
|
||||
scrollTop: number;
|
||||
dy: number;
|
||||
snap: SheetSnap;
|
||||
hasPreview: boolean;
|
||||
}): boolean {
|
||||
if (scrollTop > 0) return false;
|
||||
if (dy > 0) return true;
|
||||
return hasPreview && snap === "preview";
|
||||
}
|
||||
|
||||
/** The `translateY` of a computed `transform` value (`none`, `matrix()` or `matrix3d()`). */
|
||||
export function translateYOf(transform: string): number {
|
||||
if (!transform || transform === "none") return 0;
|
||||
const values = transform
|
||||
.slice(transform.indexOf("(") + 1, transform.lastIndexOf(")"))
|
||||
.split(",")
|
||||
.map((part) => Number.parseFloat(part));
|
||||
if (transform.startsWith("matrix3d(")) return values[13] ?? 0;
|
||||
if (transform.startsWith("matrix(")) return values[5] ?? 0;
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user