feat(ui): make every bottom sheet draggable

One bottom sheet on top of the Radix dialog, so focus, Escape, the
backdrop, aria and the page scroll lock stay where they were. The sheet
follows a touch with a transform, the backdrop lightens with it, and a
release past a quarter of the height or a flick down closes it; anything
less springs back. Grip and header always drag; content only from its
very top, and sideways gestures never count.

Every `side="bottom"` sheet and ResponsiveSheet on a phone render through
it: the More sheet, the capture and form sheets, the Coach drawer on a
phone and the day. The day opens on the new preview step and pulls up to
90 dvh, its footer held on the bottom edge. Focus now returns to whatever
opened a sheet, not only to a Radix trigger. The command palette fills
the window and keeps no grip or drag.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 10:20:28 +02:00
1 parent fdd0f50130
commit 1cc1cda7aa
15 files changed
+1855 -93

No files matched your search

+1
View File
@@ -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) |
+8 -5
View File
@@ -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"/,
);
});
});
+31
View File
@@ -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"
+16 -65
View File
@@ -630,9 +630,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,
@@ -650,51 +651,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
@@ -705,10 +663,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();
@@ -730,30 +693,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>
);
+13 -6
View File
@@ -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 ? (
+4 -2
View File
@@ -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");
});
});
+289
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
View File
@@ -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"
+373
View File
@@ -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);
});
});
});
+497
View File
@@ -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();
}
},
};
}
+239
View File
@@ -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);
});
});
+186
View File
@@ -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;
}