Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | 65x 65x 65x 65x 65x 65x 13x | import { type ReactNode, useId } from "react";
import { ArrowLeft } from "lucide-react";
import { useIsMobile } from "../../hooks/useIsMobile";
import { useModalA11y } from "./use-modal-a11y";
type MobileFullPageProps = {
children: ReactNode;
/** Title shown in the mobile header bar. If omitted, no header is rendered (for pages that manage their own header). */
title?: string;
/** Back navigation handler. Also what `Escape` triggers. */
onBack?: () => void;
/** Optional right-side actions in the header */
headerActions?: ReactNode;
/**
* Accessible name for a surface that renders no visible title.
*
* Supplying `title` or `label` is what opts the surface into the modal
* contract below — see the component docs.
*/
label?: string;
};
/**
* On mobile: renders a full-screen overlay (z-[60]) that covers the
* DashboardLayout chrome (header + bottom nav). Matches the DrawerDialog
* MobileFullScreen pattern.
*
* On desktop: passes children through unchanged (no wrapper).
*
* THE MODAL CONTRACT
* A surface that covers the entire viewport is a modal, and this one used not
* to behave like one: Tab walked out of it into the hidden chrome underneath,
* `Escape` did nothing, the page behind kept scrolling and nothing announced
* the surface. With a `title` (or a `label` for a surface that draws its own
* header) it now gets the full contract via `useModalA11y` — `role="dialog"`,
* `aria-modal="true"`, an accessible name, focus trap, restore-on-close,
* `Escape` → `onBack`, and a background scroll lock.
*
* Without either, it stays the plain layout wrapper it has always been. That
* is deliberate: `aria-modal` on an unnamed dialog is itself an accessibility
* failure, and inventing a name here would hide the problem rather than fix
* it. Pages that take the viewport over should pass `label`.
*/
export function MobileFullPage({
children,
title,
onBack,
headerActions,
label,
}: MobileFullPageProps) {
const isMobile = useIsMobile();
const titleId = useId();
const accessibleName = title ?? label;
const isModal = isMobile && accessibleName !== undefined;
const { surfaceRef, onKeyDown } = useModalA11y({
open: isModal,
onClose: onBack,
});
if (!isMobile) return <>{children}</>;
return (
<div
ref={surfaceRef}
{...(isModal
? {
role: "dialog",
"aria-modal": true,
...(title
? { "aria-labelledby": titleId }
: { "aria-label": label }),
tabIndex: -1,
onKeyDown,
}
: {})}
className="fixed inset-0 z-[60] flex flex-col bg-background-base outline-none"
>
{title && (
<div className="flex h-14 items-center gap-3 border-b border-border-default bg-background-elevated px-4 flex-shrink-0">
{onBack && (
<button
type="button"
onClick={onBack}
className="p-2 -ml-2 text-foreground-muted hover:text-foreground-default rounded-md"
aria-label="Go back"
>
<ArrowLeft className="h-5 w-5" />
</button>
)}
<h2
id={titleId}
className="text-lg font-semibold text-foreground-default truncate"
>
{title}
</h2>
{headerActions && (
<div className="flex items-center gap-2 ml-auto">
{headerActions}
</div>
)}
</div>
)}
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
);
}
|