Fixes batch 1: modal focus, campaign system, combat difficulty, origin desc

- Modal: focus into the dialog ONCE on open (not on every parent re-render), and
  prefer the first field over the X button — fixes focus jumping to the close
  button on each keystroke in the handout composer (and every other composer).
- Campaign edit: hide the System selector entirely (it's fixed after creation).
- Combat difficulty: rate against the PCs actually in the encounter (falls back to
  the roster) so adding a player updates the reading.
- Character builder: ancestry/background description is now a scrollable panel
  instead of clamped to 3 lines (no longer cut off on small screens).

223 unit + key e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:48:31 +02:00
parent 99c7657f96
commit 426824fd78
4 changed files with 37 additions and 22 deletions
+20 -7
View File
@@ -15,17 +15,33 @@ interface ModalProps {
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) { export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(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(() => { useEffect(() => {
if (!open) return; if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null; previouslyFocused.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current; const panel = panelRef.current;
panel?.querySelector<HTMLElement>('[data-autofocus], input, button, textarea, select')?.focus(); 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) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
onClose(); onCloseRef.current();
return; return;
} }
if (e.key === 'Tab' && panel) { if (e.key === 'Tab' && panel) {
@@ -45,11 +61,8 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
} }
} }
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => { return () => document.removeEventListener('keydown', onKey);
document.removeEventListener('keydown', onKey); }, [open]);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null; if (!open) return null;
+4 -5
View File
@@ -258,12 +258,10 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
placeholder="Curse of Strahd" placeholder="Curse of Strahd"
/> />
</Field> </Field>
{/* System is fixed once a campaign exists (content + characters depend on it). */}
{!campaign && (
<Field label="System"> <Field label="System">
<Select <Select value={system} onChange={(e) => setSystem(e.target.value as typeof system)}>
value={system}
disabled={!!campaign}
onChange={(e) => setSystem(e.target.value as typeof system)}
>
{SYSTEM_OPTIONS.map((o) => ( {SYSTEM_OPTIONS.map((o) => (
<option key={o.id} value={o.id}> <option key={o.id} value={o.id}>
{o.label} {o.label}
@@ -271,6 +269,7 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
))} ))}
</Select> </Select>
</Field> </Field>
)}
<Field label="Description"> <Field label="Description">
<Textarea <Textarea
value={description} value={description}
@@ -378,7 +378,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
))} ))}
{options.length === 0 && <p className="text-xs text-muted">Loading</p>} {options.length === 0 && <p className="text-xs text-muted">Loading</p>}
</div> </div>
{sel?.desc && <p className="mt-1 line-clamp-3 text-xs text-muted">{sel.desc}</p>} {sel?.desc && <p className="mt-1 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">{sel.desc}</p>}
</div> </div>
); );
} }
+4 -1
View File
@@ -71,7 +71,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat // Difficulty budget from monster combatants vs the campaign's PCs. Once combat
// has started, rate against the levels captured at that time (snapshot) so the // has started, rate against the levels captured at that time (snapshot) so the
// reading — and the assistant's history — reflects the party as it was. // reading — and the assistant's history — reflects the party as it was.
const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level); // Rate against the PCs actually IN the fight (so adding a player updates the
// difficulty); fall back to the campaign roster before any PC is added.
const encounterPcLevels = encounter.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
const currentLevels = encounterPcLevels.length > 0 ? encounterPcLevels : characters.filter((c) => c.kind === 'pc').map((c) => c.level);
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels; const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
const monsters = encounter.combatants const monsters = encounter.combatants
.filter((c) => c.kind === 'monster') .filter((c) => c.kind === 'monster')