All files / src/components/ui drawer-dialog.tsx

100% Statements 14/14
100% Branches 10/10
100% Functions 3/3
100% Lines 12/12

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 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146                                                                        1381x 1381x   1381x   531x                                                                                                       5x 5x   5x   4x                                                                               1584x   1584x 5x   1579x    
import { type ReactNode, useId } from "react";
import { ArrowLeft, X } from "lucide-react";
import { useIsMobile } from "../../hooks/useIsMobile";
import { useModalA11y } from "./use-modal-a11y";
 
/**
 * A modal surface that is a centred dialog on desktop and a full-screen page on
 * mobile. Public props are unchanged.
 *
 * Both variants now meet the modal contract — focus trap, restore-on-close,
 * `Escape`, background scroll lock, `role="dialog"` + `aria-modal="true"` —
 * through `useModalA11y`. Before this, Tab walked straight out of an open
 * drawer into the dashboard chrome behind it, and closing one dropped focus on
 * `<body>`.
 *
 * The scrim is `aria-hidden` and unfocusable: it stays a `<button>` so a click
 * dismisses, but the dialog's *named* close affordance is the X (or the mobile
 * back arrow), so a screen reader is offered exactly one way out rather than
 * two identically-labelled ones.
 */
type DrawerDialogProps = {
	open: boolean;
	onClose: () => void;
	title: string;
	children: ReactNode;
	/** Optional footer with action buttons */
	footer?: ReactNode;
};
 
function DesktopDialog({
	open,
	onClose,
	title,
	children,
	footer,
}: DrawerDialogProps) {
	const titleId = useId();
	const { surfaceRef, onKeyDown } = useModalA11y({ open, onClose });
 
	if (!open) return null;
 
	return (
		<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[10vh]">
			<button
				type="button"
				aria-hidden="true"
				tabIndex={-1}
				className="fixed inset-0 bg-black/50 cursor-default"
				onClick={onClose}
			/>
			<div
				ref={surfaceRef}
				role="dialog"
				aria-modal="true"
				aria-labelledby={titleId}
				tabIndex={-1}
				onKeyDown={onKeyDown}
				className="relative z-10 flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg bg-background-elevated shadow-xl outline-none mx-4 animate-page-in"
			>
				<div className="flex items-center justify-between px-6 py-4 border-b border-border-default flex-shrink-0">
					<h2
						id={titleId}
						className="text-lg font-semibold text-foreground-default"
					>
						{title}
					</h2>
					<button
						type="button"
						onClick={onClose}
						aria-label="Close dialog"
						className="p-1 text-foreground-subtle hover:text-foreground-default transition-colors"
					>
						<X className="h-5 w-5" />
					</button>
				</div>
				<div className="flex-1 overflow-y-auto p-6">{children}</div>
				{footer && (
					<div className="flex-shrink-0 px-6 py-4 border-t border-border-default">
						{footer}
					</div>
				)}
			</div>
		</div>
	);
}
 
function MobileFullScreen({
	open,
	onClose,
	title,
	children,
	footer,
}: DrawerDialogProps) {
	const titleId = useId();
	const { surfaceRef, onKeyDown } = useModalA11y({ open, onClose });
 
	if (!open) return null;
 
	return (
		<div
			ref={surfaceRef}
			role="dialog"
			aria-modal="true"
			aria-labelledby={titleId}
			tabIndex={-1}
			onKeyDown={onKeyDown}
			className="fixed inset-0 z-[60] flex flex-col bg-background-base outline-none animate-page-in"
		>
			{/* Header */}
			<div className="flex h-14 items-center gap-3 border-b border-border-default bg-background-elevated px-4 flex-shrink-0">
				<button
					type="button"
					onClick={onClose}
					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"
				>
					{title}
				</h2>
			</div>
			{/* Content */}
			<div className="flex-1 overflow-y-auto p-4">{children}</div>
			{/* Footer */}
			{footer && (
				<div className="flex-shrink-0 px-4 py-4 border-t border-border-default bg-background-elevated">
					{footer}
				</div>
			)}
		</div>
	);
}
 
export function DrawerDialog(props: DrawerDialogProps) {
	const isMobile = useIsMobile();
 
	if (isMobile) {
		return <MobileFullScreen {...props} />;
	}
	return <DesktopDialog {...props} />;
}