diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4ac0fc739..5a21adfa6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 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(); @@ -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", )} > {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 ( + + +