diff --git a/.changeset/use-announcements.md b/.changeset/use-announcements.md new file mode 100644 index 00000000000..1265668b413 --- /dev/null +++ b/.changeset/use-announcements.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Debounce list update announcements in `useAnnouncements` to prevent race conditions during rapid filtering. This ensures screen readers don't overwhelm users with intermediate states during fast typing. diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index dcc3a03062a..daff3bb3f17 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -8,6 +8,7 @@ import type {ItemInput} from '../SelectPanel' // we add a delay so that it does not interrupt default screen reader announcement and queues after it const delayMs = 500 +const debounceMs = 300 const useFirstRender = () => { const firstRender = useRef(true) @@ -105,40 +106,44 @@ export const useAnnouncements = ( function announceListUpdates() { if (isFirstRender) return // ignore on first render as announceInitialFocus will also announce - liveRegion?.clear() // clear previous announcements + const timeoutId = window.setTimeout(() => { + liveRegion?.clear() // clear previous announcements - if (items.length === 0 && !loading) { - announce(`${message?.title}. ${message?.description}`, {delayMs}) - return - } + if (items.length === 0 && !loading) { + announce(`${message?.title}. ${message?.description}`, {delayMs}) + return + } - if (usingRovingTabindex) { - const announcementText = `${items.length} item${items.length > 1 ? 's' : ''} available, ${selectedItems} selected.` - - announce(announcementText, { - delayMs, - from: liveRegion ? liveRegion : undefined, - }) - } else { - // give @primer/behaviors a moment to update active-descendant - window.requestAnimationFrame(() => { - const activeItem = getItemWithActiveDescendant(listContainerRef, items) - if (!activeItem) return - const {index, text, selected} = activeItem - - const announcementText = [ - `List updated`, - `Focused item: ${text}`, - `${selected ? 'selected' : 'not selected'}`, - `${index + 1} of ${items.length}`, - ].join(', ') + if (usingRovingTabindex) { + const announcementText = `${items.length} item${items.length > 1 ? 's' : ''} available, ${selectedItems} selected.` announce(announcementText, { delayMs, - from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one + from: liveRegion ? liveRegion : undefined, }) - }) - } + } else { + // give @primer/behaviors a moment to update active-descendant + window.requestAnimationFrame(() => { + const activeItem = getItemWithActiveDescendant(listContainerRef, items) + if (!activeItem) return + const {index, text, selected} = activeItem + + const announcementText = [ + `List updated`, + `Focused item: ${text}`, + `${selected ? 'selected' : 'not selected'}`, + `${index + 1} of ${items.length}`, + ].join(', ') + + announce(announcementText, { + delayMs, + from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one + }) + }) + } + }, debounceMs) + + return () => window.clearTimeout(timeoutId) }, [ announce,