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:
Marc-André Bombeck committed 2026-05-15 18:23:49 +02:00
1 parent fb6fb4f5e6
commit 65fd0bffdf
4 files changed
+464 -2

No files matched your search

+34
View File
@@ -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;
}