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:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -258,19 +258,18 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
|
|||||||
placeholder="Curse of Strahd"
|
placeholder="Curse of Strahd"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="System">
|
{/* System is fixed once a campaign exists (content + characters depend on it). */}
|
||||||
<Select
|
{!campaign && (
|
||||||
value={system}
|
<Field label="System">
|
||||||
disabled={!!campaign}
|
<Select value={system} onChange={(e) => setSystem(e.target.value as typeof system)}>
|
||||||
onChange={(e) => setSystem(e.target.value as typeof system)}
|
{SYSTEM_OPTIONS.map((o) => (
|
||||||
>
|
<option key={o.id} value={o.id}>
|
||||||
{SYSTEM_OPTIONS.map((o) => (
|
{o.label}
|
||||||
<option key={o.id} value={o.id}>
|
</option>
|
||||||
{o.label}
|
))}
|
||||||
</option>
|
</Select>
|
||||||
))}
|
</Field>
|
||||||
</Select>
|
)}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user