mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(ui): introduce the ResponsiveSheet primitive
Shared `<ResponsiveSheet>` flips between a bottom `<Sheet>` on narrow viewports and the existing centred `<Dialog>` at `md+`, with a sticky-pinned footer slot on the Sheet branch so primary CTAs stay reachable when the keyboard collapses the viewport. Picks the breakpoint via a new SSR-safe `useIsMobile` hook (`matchMedia`-driven, default `false` on the server so SSR markup always matches the desktop branch). Adds a `max-h-[calc(100dvh-2rem)] overflow-y-auto` default to `<DialogContent>` so the existing centred-dialog forms also scroll inside the viewport on short / soft-keyboard mobile screens instead of spilling off-screen. Smoke suite locks both branches, the header sr-only escape hatch, sticky-vs-flow footer shape, and controlled-state plumbing.
This commit is contained in:
1 parent
fb6fb4f5e6
commit
65fd0bffdf
4 files changed
+464
-2
No files matched your search
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// `<ResponsiveSheet>` flips between a `<Sheet side="bottom">` and a
|
||||
// centred `<Dialog>` 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 `<Sheet>` / `<Dialog>` 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 ? <div data-slot="mock-sheet">{children}</div> : null),
|
||||
SheetContent: ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div data-slot="mock-sheet-content" className={className} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SheetHeader: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div className={className}>{children}</div>,
|
||||
SheetFooter: ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div className={className} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SheetTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2>{children}</h2>
|
||||
),
|
||||
SheetDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
}) => (open ? <div data-slot="mock-dialog">{children}</div> : null),
|
||||
DialogContent: ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div data-slot="mock-dialog-content" className={className} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => <div className={className}>{children}</div>,
|
||||
DialogFooter: ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div className={className} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2>{children}</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ResponsiveSheet } from "../responsive-sheet";
|
||||
|
||||
describe("<ResponsiveSheet>", () => {
|
||||
it("renders the Dialog branch on `md+` viewports", () => {
|
||||
mobile = false;
|
||||
const html = renderToStaticMarkup(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Add measurement"
|
||||
description="Log a new reading"
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
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(
|
||||
<ResponsiveSheet open onOpenChange={() => {}} title="Add measurement">
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
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(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Add measurement"
|
||||
footer={<button type="button">Save</button>}
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
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(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Add measurement"
|
||||
footer={<button type="button">Save</button>}
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
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(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit medication"
|
||||
hideHeader
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
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(
|
||||
<ResponsiveSheet
|
||||
open={false}
|
||||
onOpenChange={onOpenChange}
|
||||
title="closed"
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
onOpenChange(true);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -61,16 +61,27 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"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)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
// v1.4.27 R3d MB1 — cap `<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.
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-5 right-6 inline-flex h-6 w-6 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"
|
||||
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-9 min-w-9 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">Close</span>
|
||||
|
||||
@@ -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 `<Sheet>` on narrow viewports and the existing
|
||||
* centred `<Dialog>` 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 `<Dialog>` → `<ResponsiveSheet>` 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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={showCloseButton}
|
||||
data-slot="responsive-sheet-content"
|
||||
data-variant="sheet"
|
||||
className={cn(
|
||||
// Cap at 90 dvh so the user sees a slice of the page
|
||||
// behind the sheet (clear "this is a sheet, not a
|
||||
// takeover") + the bottom-sheet rounded top edge that
|
||||
// matches the iOS feel.
|
||||
"flex max-h-[90dvh] flex-col gap-0 rounded-t-2xl p-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hideHeader ? (
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? (
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
) : (
|
||||
<SheetHeader
|
||||
data-slot="responsive-sheet-header"
|
||||
className="border-border/70 gap-1.5 border-b p-4 pr-12"
|
||||
>
|
||||
<SheetTitle className="text-base font-semibold">
|
||||
{title}
|
||||
</SheetTitle>
|
||||
{description ? (
|
||||
<SheetDescription className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
)}
|
||||
<div
|
||||
data-slot="responsive-sheet-body"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<SheetFooter
|
||||
data-slot="responsive-sheet-footer"
|
||||
className="border-border/70 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
>
|
||||
{footer}
|
||||
</SheetFooter>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={showCloseButton}
|
||||
data-slot="responsive-sheet-content"
|
||||
data-variant="dialog"
|
||||
className={cn("sm:max-w-md", className)}
|
||||
>
|
||||
{hideHeader ? (
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DialogHeader data-slot="responsive-sheet-header">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
)}
|
||||
<div
|
||||
data-slot="responsive-sheet-body"
|
||||
className={cn("flex flex-col gap-4", bodyClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<DialogFooter data-slot="responsive-sheet-footer">
|
||||
{footer}
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 `<ResponsiveSheet>` 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<boolean>(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;
|
||||
}
|
||||
Reference in new issue
Block a user