Files
gridpilot.gg/apps/website/components/ui/Modal.tsx
2025-12-11 21:06:25 +01:00

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