diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index 383e781..0f00eee 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -110,6 +110,14 @@ export function AdminPage() { const [tzActiveIndex, setTzActiveIndex] = useState(0); const tzBlurTimer = useRef | null>(null); + // WR-07: clear any pending blur-close timer on unmount so it can't fire + // setState after the component is gone. + useEffect(() => { + return () => { + if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current); + }; + }, []); + // Members query const membersQuery = useQuery({ queryKey: ['admin', 'members'], @@ -189,6 +197,13 @@ export function AdminPage() { ? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery)) : ianaZones; + // WR-06: clamp the active index into the CURRENT filtered list every render. + // tzActiveIndex is reset to 0 on filter changes, but batched updates can leave + // it referencing an index past the end of a freshly-shrunk list for one render; + // aria-activedescendant and the visual highlight must both use this clamped value + // so the announced row and highlighted row never disagree. + const tzActiveIndexClamped = Math.min(tzActiveIndex, Math.max(0, filteredZones.length - 1)); + // Commit a zone selection from the list, then close. function selectTimezone(tz: string) { if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current); @@ -827,7 +842,7 @@ export function AdminPage() { aria-controls="tz-listbox" aria-autocomplete="list" aria-activedescendant={ - tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndex}` : undefined + tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndexClamped}` : undefined } autoComplete="off" value={tzOpen ? (tzSearch ?? '') : effectiveTimezoneInput} @@ -852,14 +867,32 @@ export function AdminPage() { setTzOpen(true); setTzSearch(''); } - setTzActiveIndex((i) => Math.min(i + 1, filteredZones.length - 1)); + // WR-06: clamp first, then advance — never start from a + // stale index past the end of the current filtered list. + setTzActiveIndex((i) => + Math.min( + Math.min(i, Math.max(0, filteredZones.length - 1)) + 1, + filteredZones.length - 1, + ), + ); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setTzActiveIndex((i) => Math.max(i - 1, 0)); + setTzActiveIndex((i) => + Math.max(Math.min(i, Math.max(0, filteredZones.length - 1)) - 1, 0), + ); } else if (e.key === 'Enter') { - if (tzOpen && filteredZones[tzActiveIndex]) { + // WR-06: commit the CLAMPED active option so Enter selects + // the same row the user sees highlighted / AT announces. + if (tzOpen && filteredZones[tzActiveIndexClamped]) { e.preventDefault(); - selectTimezone(filteredZones[tzActiveIndex]); + selectTimezone(filteredZones[tzActiveIndexClamped]); + } + } else if (e.key === 'Tab') { + // WR-07: commit the highlighted option on Tab WITHOUT + // preventDefault, so focus still advances to Save and the + // admin can't tab away leaving raw search text uncommitted. + if (tzOpen && filteredZones[tzActiveIndexClamped]) { + selectTimezone(filteredZones[tzActiveIndexClamped]); } } else if (e.key === 'Escape') { setTzOpen(false); @@ -867,11 +900,15 @@ export function AdminPage() { } }} onBlur={() => { - // Delay so an option's onClick fires before the list unmounts. + // WR-07: options call preventDefault() on onMouseDown, so a + // click never blurs the input first — but a real focus change + // (Tab handled above, or clicking elsewhere) still needs to + // close the listbox. Defer one tick so any in-flight option + // mousedown settles before we close. tzBlurTimer.current = setTimeout(() => { setTzOpen(false); setTzSearch(null); - }, 120); + }, 0); }} style={{ width: '100%', @@ -920,7 +957,9 @@ export function AdminPage() { )} {filteredZones.map((tz, i) => { - const active = i === tzActiveIndex; + // WR-06: highlight the clamped active row so the visual + // highlight matches aria-activedescendant exactly. + const active = i === tzActiveIndexClamped; return (