fix(17): WR-06 WR-07 clamp tz combobox active index; Tab-to-commit, blur-timer unmount cleanup
This commit is contained in:
@@ -110,6 +110,14 @@ export function AdminPage() {
|
||||
const [tzActiveIndex, setTzActiveIndex] = useState(0);
|
||||
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
|
||||
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() {
|
||||
</li>
|
||||
)}
|
||||
{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 (
|
||||
<li
|
||||
key={tz}
|
||||
|
||||
Reference in New Issue
Block a user