Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions apps/web/src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from 'react';
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../lib/utils';

Expand All @@ -11,17 +11,58 @@ export interface ModalProps {
className?: string;
}

const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';

export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
const titleId = useId();
const dialogRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
// Remember what had focus so we can restore it when the dialog closes.
const previouslyFocused = document.activeElement as HTMLElement | null;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
// Trap focus inside the dialog so Tab can't reach the page behind it.
const root = dialogRef.current;
if (!root) return;
const focusable = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => el.offsetParent !== null,
);
if (focusable.length === 0) {
e.preventDefault();
root.focus();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
const active = document.activeElement;
if (e.shiftKey && (active === first || !root.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleEscape);

document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';

// Move focus into the dialog (first field, or the dialog itself).
const root = dialogRef.current;
(root?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? root)?.focus();

return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
previouslyFocused?.focus?.();
};
}, [open, onClose]);

Expand All @@ -32,18 +73,20 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
className="fixed inset-0 z-10050 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-labelledby={titleId}
>
<div className="absolute inset-0 bg-(--bg-backdrop)" onClick={onClose} aria-hidden />
<div
ref={dialogRef}
tabIndex={-1}
className={cn(
'relative z-10 w-full max-w-md rounded-(--radius-lg) border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-overlay)',
'relative z-10 w-full max-w-md rounded-(--radius-lg) border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-overlay) outline-none',
className,
)}
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-(--border-subtle) px-5 py-4">
<h2 id="modal-title" className="text-lg font-semibold text-(--txt-primary)">
<h2 id={titleId} className="text-lg font-semibold text-(--txt-primary)">
{title}
</h2>
</div>
Expand Down
Loading