186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
useEffect,
|
|
useRef,
|
|
type ReactNode,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
} from 'react';
|
|
|
|
interface ModalProps {
|
|
title: string;
|
|
description?: string;
|
|
children?: ReactNode;
|
|
primaryActionLabel?: string;
|
|
secondaryActionLabel?: string;
|
|
onPrimaryAction?: () => void | Promise<void>;
|
|
onSecondaryAction?: () => void;
|
|
onOpenChange?: (open: boolean) => void;
|
|
isOpen: boolean;
|
|
}
|
|
|
|
/**
|
|
* Generic, accessible modal component with backdrop, focus management, and semantic structure.
|
|
* Controlled via the `isOpen` prop; callers handle URL state and routing.
|
|
*/
|
|
export default function Modal({
|
|
title,
|
|
description,
|
|
children,
|
|
primaryActionLabel,
|
|
secondaryActionLabel,
|
|
onPrimaryAction,
|
|
onSecondaryAction,
|
|
onOpenChange,
|
|
isOpen,
|
|
}: ModalProps) {
|
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
|
const previouslyFocusedElementRef = useRef<Element | null>(null);
|
|
|
|
// When the modal opens, remember previous focus and move focus into the dialog
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
previouslyFocusedElementRef.current = document.activeElement;
|
|
const focusable = getFirstFocusable(dialogRef.current);
|
|
if (focusable) {
|
|
focusable.focus();
|
|
} else if (dialogRef.current) {
|
|
dialogRef.current.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// When closing, restore focus
|
|
if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) {
|
|
previouslyFocusedElementRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Basic focus trap with keyboard handling (Tab / Shift+Tab, Escape)
|
|
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
if (event.key === 'Escape') {
|
|
if (onOpenChange) {
|
|
onOpenChange(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'Tab') {
|
|
const focusable = getFocusableElements(dialogRef.current);
|
|
if (focusable.length === 0) return;
|
|
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1] ?? first;
|
|
|
|
if (!first || !last) {
|
|
return;
|
|
}
|
|
|
|
if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
} else if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
if (event.target === event.currentTarget && onOpenChange) {
|
|
onOpenChange(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-title"
|
|
aria-describedby={description ? 'modal-description' : undefined}
|
|
onKeyDown={handleKeyDown}
|
|
onClick={handleBackdropClick}
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
className="w-full max-w-md rounded-2xl bg-deep-graphite border border-charcoal-outline shadow-2xl outline-none"
|
|
tabIndex={-1}
|
|
>
|
|
<div className="px-6 pt-5 pb-3 border-b border-charcoal-outline/80">
|
|
<h2
|
|
id="modal-title"
|
|
className="text-lg font-semibold text-white"
|
|
>
|
|
{title}
|
|
</h2>
|
|
{description && (
|
|
<p
|
|
id="modal-description"
|
|
className="mt-2 text-sm text-gray-400"
|
|
>
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-6 py-4 text-sm text-gray-100">{children}</div>
|
|
|
|
{(primaryActionLabel || secondaryActionLabel) && (
|
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-charcoal-outline/80">
|
|
{secondaryActionLabel && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onSecondaryAction?.();
|
|
onOpenChange?.(false);
|
|
}}
|
|
className="min-h-[40px] rounded-full px-4 py-2 text-sm font-medium text-gray-200 bg-iron-gray border border-charcoal-outline hover:bg-charcoal-outline transition-colors"
|
|
>
|
|
{secondaryActionLabel}
|
|
</button>
|
|
)}
|
|
{primaryActionLabel && (
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
if (onPrimaryAction) {
|
|
await onPrimaryAction();
|
|
}
|
|
}}
|
|
className="min-h-[40px] rounded-full px-4 py-2 text-sm font-semibold text-white bg-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue transition-all"
|
|
>
|
|
{primaryActionLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getFocusableElements(root: HTMLElement | null): HTMLElement[] {
|
|
if (!root) return [];
|
|
const selectors = [
|
|
'a[href]',
|
|
'button:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
];
|
|
const nodes = Array.from(
|
|
root.querySelectorAll<HTMLElement>(selectors.join(',')),
|
|
);
|
|
return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
|
|
}
|
|
|
|
function getFirstFocusable(root: HTMLElement | null): HTMLElement | null {
|
|
const elements = getFocusableElements(root);
|
|
return elements[0] ?? null;
|
|
} |