dd694477b2
Crash fix: - restoreBackup (file + cloud pull) now validates each row through its Zod schema, backfilling new fields — an older backup no longer lands characters missing feats/concentration/etc and crashing the sheet. + test. pf2e parity (closing feature gaps vs 5e): - +9 missing classes (Animist, Exemplar, Gunslinger, Inventor, Kineticist, Magus, Psychic, Summoner, Thaumaturge) with data + class tips. - the wizard now enriches pf2e classes with their caster ability, so pf2e spellcasters get the Spells step + "Caster" tag like 5e. - editable Perception rank and spellcasting proficiency on the pf2e sheet (fixes wrong initiative / spell DC at higher levels); rank-10 spell slots. - pf2e monster resistances/weaknesses/immunities now apply in combat (flat amounts: resistance subtracts, weakness adds, 'all' matches any type). + tests. Glyph purge (finishing the emoji→Lucide migration): replaced ~40 leftover text-glyphs (✕ − + ✦ 🤫 ⬆ ⬇ ☑ ☐ ▶ ◀ ‹ › ★ ↻ ▸ ↑ ●) with Lucide icons across Modal (every dialog), the sheet sections, dice, settings, player panels, world pages, map editor, roll tray, and session UI. Gate: 293 unit + e2e green; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
import { useEffect, useRef, type ReactNode } from 'react';
|
|
import { X } from 'lucide-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);
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
|
|
// Focus into the dialog ONCE when it opens (not on every parent re-render —
|
|
// that was stealing focus to the close button on each keystroke). Prefer the
|
|
// first real field over the X button so composers land in their input.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
const panel = panelRef.current;
|
|
const target =
|
|
panel?.querySelector<HTMLElement>('[data-autofocus]') ??
|
|
panel?.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select') ??
|
|
panel?.querySelector<HTMLElement>('a[href], button:not([disabled])');
|
|
target?.focus();
|
|
return () => { previouslyFocused.current?.focus(); };
|
|
}, [open]);
|
|
|
|
// Escape to close + focus trap. Reads onClose via a ref so a changing
|
|
// onClose identity never re-binds (and never re-triggers the focus effect).
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const panel = panelRef.current;
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onCloseRef.current();
|
|
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);
|
|
}, [open]);
|
|
|
|
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(
|
|
'paper-grain relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-xl 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">
|
|
<X size={16} aria-hidden />
|
|
</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>
|
|
);
|
|
}
|