All files / src/components/ui mobile-full-page.tsx

100% Statements 8/8
100% Branches 16/16
100% Functions 1/1
100% Lines 7/7

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>
	);
}