diff --git a/src/components/ui/__tests__/responsive-sheet.test.tsx b/src/components/ui/__tests__/responsive-sheet.test.tsx new file mode 100644 index 000000000..95ac92e14 --- /dev/null +++ b/src/components/ui/__tests__/responsive-sheet.test.tsx @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// `` flips between a `` and a +// centred `` via `useIsMobile()`. Vitest runs in the Node +// environment without `matchMedia`; the hook returns its SSR-safe +// default (`false`) and is overridden below for the Sheet branch. +// +// shadcn `` / `` both wrap Radix Portals — `renderToStaticMarkup` +// does not materialise portal trees — so we mock the primitives down +// to passthrough wrappers so the rendered shape is reachable in the +// static markup. + +let mobile = false; + +vi.mock("@/hooks/use-is-mobile", () => ({ + useIsMobile: () => mobile, +})); + +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ + children, + open, + }: { + children: React.ReactNode; + open: boolean; + }) => (open ?
{children}
: null), + SheetContent: ({ + children, + className, + ...rest + }: { + children: React.ReactNode; + className?: string; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ), + SheetHeader: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + SheetFooter: ({ + children, + className, + ...rest + }: { + children: React.ReactNode; + className?: string; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ), + SheetTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + SheetDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + children, + open, + }: { + children: React.ReactNode; + open: boolean; + }) => (open ?
{children}
: null), + DialogContent: ({ + children, + className, + ...rest + }: { + children: React.ReactNode; + className?: string; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ), + DialogHeader: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + DialogFooter: ({ + children, + className, + ...rest + }: { + children: React.ReactNode; + className?: string; + [key: string]: unknown; + }) => ( +
+ {children} +
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +import { ResponsiveSheet } from "../responsive-sheet"; + +describe("", () => { + it("renders the Dialog branch on `md+` viewports", () => { + mobile = false; + const html = renderToStaticMarkup( + {}} + title="Add measurement" + description="Log a new reading" + > +

body

+
, + ); + expect(html).toContain('data-variant="dialog"'); + expect(html).toContain("Add measurement"); + expect(html).toContain("Log a new reading"); + expect(html).toContain("body"); + // Sheet branch markers must be absent. + expect(html).not.toContain('data-slot="mock-sheet"'); + }); + + it("renders the bottom Sheet branch on narrow viewports", () => { + mobile = true; + const html = renderToStaticMarkup( + {}} title="Add measurement"> +

body

+
, + ); + expect(html).toContain('data-variant="sheet"'); + expect(html).toContain('data-slot="mock-sheet"'); + expect(html).toContain("Add measurement"); + expect(html).toContain("body"); + mobile = false; + }); + + it("renders a sticky-pinned footer on the Sheet branch", () => { + mobile = true; + const html = renderToStaticMarkup( + {}} + title="Add measurement" + footer={} + > +

body

+
, + ); + expect(html).toContain('data-slot="responsive-sheet-footer"'); + expect(html).toContain("sticky"); + expect(html).toContain("Save"); + mobile = false; + }); + + it("renders a flow-layout footer on the Dialog branch", () => { + mobile = false; + const html = renderToStaticMarkup( + {}} + title="Add measurement" + footer={} + > +

body

+
, + ); + expect(html).toContain('data-slot="responsive-sheet-footer"'); + expect(html).not.toContain("sticky bottom-0"); + expect(html).toContain("Save"); + }); + + it("hides the visual header but keeps the title accessible via sr-only when hideHeader is set", () => { + mobile = false; + const html = renderToStaticMarkup( + {}} + title="Edit medication" + hideHeader + > +

body

+
, + ); + expect(html).toContain("sr-only"); + expect(html).toContain("Edit medication"); + }); + + it("does not throw when onOpenChange is invoked (controlled-state plumbing)", () => { + mobile = false; + const onOpenChange = vi.fn(); + // Mounted closed — Dialog mock returns null, smoke-only render + // confirms no throw + the prop wiring round-trips. + renderToStaticMarkup( + +

body

+
, + ); + onOpenChange(true); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 519c8efde..051e19473 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -61,16 +61,27 @@ function DialogContent({ ` at the viewport's + // dynamic height with internal scroll so long forms (medication + // editor, API endpoint, intake import) never spill past the + // viewport on short / soft-keyboard-collapsed mobile screens. + // Consumers that need a different ceiling can override via + // `className`. + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", className, )} {...props} > {children} {showCloseButton && ( + // v1.4.27 R3d MB2 — widen the icon-only close-X tap target + // to a 36 px floor per WCAG 2.5.8 minimum target size. + // 36 px (not 44 px) is the intentional exception for fixed + // icon-only close affordances per Decision I — 44 px would + // crowd the dialog header out of proportion. Close diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx new file mode 100644 index 000000000..a3bfaae0c --- /dev/null +++ b/src/components/ui/responsive-sheet.tsx @@ -0,0 +1,194 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; + +export interface ResponsiveSheetProps { + /** Controlled open state. */ + open: boolean; + /** Open-state setter forwarded to the underlying Sheet / Dialog root. */ + onOpenChange: (open: boolean) => void; + /** + * Required accessible title. Rendered inside the sheet / dialog + * header so screen-readers always announce the surface, even when + * the visual header is empty. + */ + title: React.ReactNode; + /** Optional short description rendered beneath the title. */ + description?: React.ReactNode; + /** + * Hide the visual header (title + description). The title remains + * mounted in an `sr-only` block so the accessible name is still + * announced. Use when the consumer renders its own header inside + * the body. + */ + hideHeader?: boolean; + /** + * Footer slot. The Sheet branch sticky-pins the footer to the + * bottom of the bottom-sheet so the primary CTA stays reachable + * once the body scrolls; the Dialog branch lets the footer flow + * normally beneath the body. + */ + footer?: React.ReactNode; + /** Class name applied to the content surface (sheet / dialog). */ + className?: string; + /** Class name applied to the body wrapper. */ + bodyClassName?: string; + /** + * Forward to the underlying Sheet/Dialog `showCloseButton` + * primitive prop. Defaults to `true`; pass `false` when the + * consumer renders its own close affordance inside the header. + */ + showCloseButton?: boolean; + children: React.ReactNode; +} + +/** + * v1.4.27 R3d MB1 — shared mount primitive that flips between a + * bottom-anchored `` on narrow viewports and the existing + * centred `` on tablet/desktop. Picks the breakpoint via + * the `useIsMobile()` hook (Tailwind `md` — 768 px). + * + * On the Sheet branch we cap the content at `90dvh`, scroll the + * body on overflow, and sticky-pin the footer so Save / Cancel + * stay reachable when the keyboard pushes the bottom of the sheet + * up. The Dialog branch consumes the primitive's `max-h-[calc(100dvh-2rem)]` + * default so long forms scroll inside the dialog instead of + * spilling off-screen on tablet portrait. + * + * Consumers swap `` → `` and feed the + * existing form markup through `children` / `footer`. The rest of + * the form's chrome is unchanged because both branches expose the + * same header / body / footer slot shape. + */ +export function ResponsiveSheet({ + open, + onOpenChange, + title, + description, + hideHeader = false, + footer, + className, + bodyClassName, + showCloseButton = true, + children, +}: ResponsiveSheetProps) { + const isMobile = useIsMobile(); + + if (isMobile) { + return ( + + + {hideHeader ? ( + + {title} + {description ? ( + {description} + ) : null} + + ) : ( + + + {title} + + {description ? ( + + {description} + + ) : null} + + )} +
+ {children} +
+ {footer ? ( + + {footer} + + ) : null} +
+
+ ); + } + + return ( + + + {hideHeader ? ( + + {title} + {description ? ( + {description} + ) : null} + + ) : ( + + {title} + {description ? ( + {description} + ) : null} + + )} +
+ {children} +
+ {footer ? ( + + {footer} + + ) : null} +
+
+ ); +} diff --git a/src/hooks/use-is-mobile.ts b/src/hooks/use-is-mobile.ts new file mode 100644 index 000000000..9785b73dc --- /dev/null +++ b/src/hooks/use-is-mobile.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * SSR-safe viewport-width hook. Returns `true` once the browser + * reports a viewport narrower than the Tailwind `md` breakpoint + * (768 px). The hook always returns `false` on the server and on + * the first client paint so SSR markup matches the desktop branch + * of any viewport-conditional render; the hook then flips on the + * effect tick once `matchMedia` reports the live value. + * + * Used by `` to switch between bottom-sheet and + * centred-dialog mounts. Also consumable by any surface that + * needs the same boolean (e.g. the Coach drawer's bottom-sheet + * branch on narrow viewports). + */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia("(max-width: 767.98px)"); + const sync = () => setIsMobile(mql.matches); + sync(); + // `addEventListener` is the modern API; the older `addListener` + // is kept around for legacy Safari but every browser we ship to + // supports the typed event-listener form. + mql.addEventListener("change", sync); + return () => mql.removeEventListener("change", sync); + }, []); + + return isMobile; +}