From 1cc1cda7aae04d93ef732057f3b4a879b9c38c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:20:28 +0200 Subject: [PATCH 1/3] 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. --- docs/contributing/ui-standards.md | 1 + src/__tests__/pwa-mobile-shell-guard.test.ts | 13 +- src/app/globals.css | 31 ++ .../command-palette/command-palette.tsx | 3 + src/components/day/day-layer.tsx | 81 +-- src/components/day/day-view.tsx | 19 +- src/components/layout/bottom-nav.tsx | 6 +- .../ui/__tests__/bottom-sheet.test.tsx | 151 ++++++ src/components/ui/bottom-sheet.tsx | 289 ++++++++++ src/components/ui/responsive-sheet.tsx | 4 +- src/components/ui/sheet.tsx | 55 +- src/hooks/__tests__/use-sheet-drag.test.ts | 373 +++++++++++++ src/hooks/use-sheet-drag.ts | 497 ++++++++++++++++++ src/lib/__tests__/sheet-gesture.test.ts | 239 +++++++++ src/lib/sheet-gesture.ts | 186 +++++++ 15 files changed, 1855 insertions(+), 93 deletions(-) create mode 100644 src/components/ui/__tests__/bottom-sheet.test.tsx create mode 100644 src/components/ui/bottom-sheet.tsx create mode 100644 src/hooks/__tests__/use-sheet-drag.test.ts create mode 100644 src/hooks/use-sheet-drag.ts create mode 100644 src/lib/__tests__/sheet-gesture.test.ts create mode 100644 src/lib/sheet-gesture.ts diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index d0c0f576e..ee32c5dd8 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -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) | `` 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 ` - ) : null; - return ( } 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", )} > {t("day.description")} - {render({ - above: grabber, - headerClassName: bottom ? undefined : "py-3", - })} + {render({ headerClassName: bottom ? undefined : "py-3" })} ); diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index 320486873..8b33287ff 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -75,8 +75,6 @@ export interface DayViewProps { }>; titleId: string; titleRef?: Ref; - /** 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({
- {above}
{timelineOn && !onTimeline ? ( diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index cffb18ea7..dbb11755a 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -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" > @@ -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. */} -
+
{moreHub.map((item) => { const isActive = isActiveLink(item.href); return ( diff --git a/src/components/ui/__tests__/bottom-sheet.test.tsx b/src/components/ui/__tests__/bottom-sheet.test.tsx new file mode 100644 index 000000000..68baef3cb --- /dev/null +++ b/src/components/ui/__tests__/bottom-sheet.test.tsx @@ -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 + * (``) 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) =>
, + Content: ({ children, ...props }: P) => ( +
+ {children} +
+ ), + Close: ({ children, ...props }: P) => ( + + ), + Title: ({ children }: P) =>

{children}

, + Description: ({ children }: P) =>

{children}

, + 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("", () => { + it("draws a decorative grip and a 44 px X on the title row", () => { + const html = renderToStaticMarkup( + body, + ); + 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( + body, + ); + 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( + body, + ); + 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( + + body + , + ); + 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(/^ + ); + } + return ( + + ); +} + +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(null); + const overlayRef = React.useRef(null); + const probeRef = React.useRef(null); + const closeRef = React.useRef(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(null); + const initial: SheetSnap = preview ? "preview" : "full"; + const [snap, setSnap] = React.useState(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 ( + + +