Phase 17: UI Optimization & Polish #24
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user