Build MVP: campaigns, characters, combat, dice, compendium
- Rules abstraction (5e + pf2e) behind one interface, fully tested - Pure combat engine: turn-order safe across add/remove/reorder/init-change - Dexie storage with real cascade deletes + Zod validation on write - Seedable dice engine with notation parser - Lazy SRD compendium (code-split), bestiary -> combat - Strict TS, 54 unit tests, Playwright e2e smoke (all green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Accessible dialog: Escape to close, focus trapping, restores focus on close. */
|
||||
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
const panel = panelRef.current;
|
||||
panel?.querySelector<HTMLElement>('[data-autofocus], input, button, textarea, select')?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' && panel) {
|
||||
const focusable = panel.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0]!;
|
||||
const last = focusable[focusable.length - 1]!;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
previouslyFocused.current?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} aria-hidden />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-lg border border-line bg-panel shadow-2xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
|
||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
{footer && <div className="flex justify-end gap-2 border-t border-line px-5 py-3">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user