fix(17): WR-06 WR-07 clamp tz combobox active index; Tab-to-commit, blur-timer unmount cleanup

This commit is contained in:
Lucas Berger
2026-06-18 13:44:44 -04:00
parent f601c0c408
commit 1c0f35748d
+47 -8
View File
@@ -110,6 +110,14 @@ export function AdminPage() {
const [tzActiveIndex, setTzActiveIndex] = useState(0); const [tzActiveIndex, setTzActiveIndex] = useState(0);
const tzBlurTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const tzBlurTimer = useRef<ReturnType<typeof setTimeout> | 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 // Members query
const membersQuery = useQuery({ const membersQuery = useQuery({
queryKey: ['admin', 'members'], queryKey: ['admin', 'members'],
@@ -189,6 +197,13 @@ export function AdminPage() {
? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery)) ? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery))
: ianaZones; : 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. // Commit a zone selection from the list, then close.
function selectTimezone(tz: string) { function selectTimezone(tz: string) {
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current); if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
@@ -827,7 +842,7 @@ export function AdminPage() {
aria-controls="tz-listbox" aria-controls="tz-listbox"
aria-autocomplete="list" aria-autocomplete="list"
aria-activedescendant={ aria-activedescendant={
tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndex}` : undefined tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndexClamped}` : undefined
} }
autoComplete="off" autoComplete="off"
value={tzOpen ? (tzSearch ?? '') : effectiveTimezoneInput} value={tzOpen ? (tzSearch ?? '') : effectiveTimezoneInput}
@@ -852,14 +867,32 @@ export function AdminPage() {
setTzOpen(true); setTzOpen(true);
setTzSearch(''); 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') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); 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') { } 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(); 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') { } else if (e.key === 'Escape') {
setTzOpen(false); setTzOpen(false);
@@ -867,11 +900,15 @@ export function AdminPage() {
} }
}} }}
onBlur={() => { 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(() => { tzBlurTimer.current = setTimeout(() => {
setTzOpen(false); setTzOpen(false);
setTzSearch(null); setTzSearch(null);
}, 120); }, 0);
}} }}
style={{ style={{
width: '100%', width: '100%',
@@ -920,7 +957,9 @@ export function AdminPage() {
</li> </li>
)} )}
{filteredZones.map((tz, i) => { {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 ( return (
<li <li
key={tz} key={tz}