From 2e933c81f205514a706ace548591577cc7f4d0db Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 6 Sep 2024 16:47:10 +0200 Subject: [PATCH 01/20] brute force announcements --- ...FilteredActionListWithModernActionList.tsx | 43 +++++++++++++++++++ .../react/src/SelectPanel/SelectPanel.tsx | 24 +++++++---- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index 146dbae64ec..5b0160938b4 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -21,6 +21,7 @@ import type {SxProp} from '../sx' import {isValidElementType} from 'react-is' import type {RenderItemFn} from '../deprecated/ActionList/List' import {CircleSlashIcon} from '@primer/octicons-react' +import {announce} from '@primer/live-region-element' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -126,6 +127,40 @@ export function FilteredActionList({ return itemsInGroup } + React.useEffect( + function announceInitialFocus() { + const focusHandler = () => { + const listElement = listContainerRef.current + const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') + + if (listElement && activeItemElement?.textContent) { + const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) + const announcementText = ` + Focus on filter text box and list of labels. + Focused item, ${activeItemElement.textContent.trim()}, ${activeItemIndex + 1} of ${items.length} + ` + announce(announcementText, {delayMs: 500}) + } + } + + const inputElement = inputRef.current + inputElement?.addEventListener('focus', focusHandler) + return () => inputElement?.removeEventListener('focus', focusHandler) + }, + [inputRef, items], + ) + + const isFirstRender = useFirstRender() + React.useEffect( + function announceListUpdates() { + if (isFirstRender) return // ignore on first render + const announcementText = + items.length > 0 ? `List updated. Focused item, ${items[0].text}, 1 of ${items.length}` : 'No items.' + announce(announcementText, {delayMs: 500}) + }, + [isFirstRender, items], + ) + return ( @@ -248,3 +283,11 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) { } FilteredActionList.displayName = 'FilteredActionList' + +const useFirstRender = () => { + const firstRender = useRef(true) + useEffect(() => { + firstRender.current = false + }, []) + return firstRender.current +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 5763081361b..2259601bb50 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -17,6 +17,7 @@ import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useId} from '../hooks/useId' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion' +import {useFeatureFlag} from '../FeatureFlags' interface SelectPanelSingleSelection { selected: ItemInput | undefined @@ -174,6 +175,9 @@ export function SelectPanel({ } }, [inputLabel, textInputProps]) + // FilteredActionListWithModernActionList handles announcements + const modernActionListEnabled = useFeatureFlag('primer_react_select_panel_with_modern_action_list') + return ( - + {modernActionListEnabled ? null : ( + + )} From 24b9805684cbbaea39ece1d0a1903c2e14d9c6af Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 6 Sep 2024 17:02:15 +0200 Subject: [PATCH 02/20] abstract announcements, add to both --- ...eredActionListWithDeprecatedActionList.tsx | 2 + ...FilteredActionListWithModernActionList.tsx | 45 +-------------- .../FilteredActionList/useAnnouncements.tsx | 55 +++++++++++++++++++ .../react/src/SelectPanel/SelectPanel.tsx | 18 +----- 4 files changed, 61 insertions(+), 59 deletions(-) create mode 100644 packages/react/src/FilteredActionList/useAnnouncements.tsx diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx index 3d114a7c08e..6665dc21149 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx @@ -18,6 +18,7 @@ import useScrollFlash from '../hooks/useScrollFlash' import {VisuallyHidden} from '../internal/components/VisuallyHidden' import type {SxProp} from '../sx' import {CircleSlashIcon} from '@primer/octicons-react' +import {useAnnouncements} from './useAnnouncements' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -109,6 +110,7 @@ export function FilteredActionList({ }, [items]) useScrollFlash(scrollContainerRef) + useAnnouncements(items, listContainerRef, inputRef) return ( diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index 5b0160938b4..a8abf172ff0 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -21,7 +21,7 @@ import type {SxProp} from '../sx' import {isValidElementType} from 'react-is' import type {RenderItemFn} from '../deprecated/ActionList/List' import {CircleSlashIcon} from '@primer/octicons-react' -import {announce} from '@primer/live-region-element' +import {useAnnouncements} from './useAnnouncements' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -115,6 +115,7 @@ export function FilteredActionList({ }, [items]) useScrollFlash(scrollContainerRef) + useAnnouncements(items, listContainerRef, inputRef) function getItemListForEachGroup(groupId: string) { const itemsInGroup = [] @@ -127,40 +128,6 @@ export function FilteredActionList({ return itemsInGroup } - React.useEffect( - function announceInitialFocus() { - const focusHandler = () => { - const listElement = listContainerRef.current - const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') - - if (listElement && activeItemElement?.textContent) { - const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) - const announcementText = ` - Focus on filter text box and list of labels. - Focused item, ${activeItemElement.textContent.trim()}, ${activeItemIndex + 1} of ${items.length} - ` - announce(announcementText, {delayMs: 500}) - } - } - - const inputElement = inputRef.current - inputElement?.addEventListener('focus', focusHandler) - return () => inputElement?.removeEventListener('focus', focusHandler) - }, - [inputRef, items], - ) - - const isFirstRender = useFirstRender() - React.useEffect( - function announceListUpdates() { - if (isFirstRender) return // ignore on first render - const announcementText = - items.length > 0 ? `List updated. Focused item, ${items[0].text}, 1 of ${items.length}` : 'No items.' - announce(announcementText, {delayMs: 500}) - }, - [isFirstRender, items], - ) - return ( @@ -283,11 +250,3 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) { } FilteredActionList.displayName = 'FilteredActionList' - -const useFirstRender = () => { - const firstRender = useRef(true) - useEffect(() => { - firstRender.current = false - }, []) - return firstRender.current -} diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx new file mode 100644 index 00000000000..0189254f800 --- /dev/null +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -0,0 +1,55 @@ +import {announce} from '@primer/live-region-element' +import {useEffect, useRef} from 'react' +import type {FilteredActionListProps} from './FilteredActionListEntry' + +const useFirstRender = () => { + const firstRender = useRef(true) + useEffect(() => { + firstRender.current = false + }, []) + return firstRender.current +} + +export const useAnnouncements = ( + items: FilteredActionListProps['items'], + listContainerRef: React.RefObject, + inputRef: React.RefObject, +) => { + useEffect( + function announceInitialFocus() { + const listElement = listContainerRef.current + + const focusHandler = () => { + // give @primer/behaviors a moment to apply active-descendant + window.requestAnimationFrame(() => { + const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') + if (listElement && activeItemElement?.textContent) { + const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) + const announcementText = ` + Focus on filter text box and list of labels. + Focused item, ${activeItemElement.textContent.trim()}, ${activeItemIndex + 1} of ${items.length} + ` + + announce(announcementText, {delayMs: 500 /* we add a delay so that it does not interrupt user action */}) + } + }) + } + + const inputElement = inputRef.current + inputElement?.addEventListener('focus', focusHandler) + return () => inputElement?.removeEventListener('focus', focusHandler) + }, + [listContainerRef, inputRef, items], + ) + + const isFirstRender = useFirstRender() + useEffect( + function announceListUpdates() { + if (isFirstRender) return // ignore on first render as announceInitialFocus will also announce + const announcementText = + items.length > 0 ? `List updated. Focused item, ${items[0].text}, 1 of ${items.length}` : 'No matching items.' + announce(announcementText, {delayMs: 500 /* we add a delay so that it does not interrupt user action */}) + }, + [isFirstRender, items], + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 2259601bb50..4edb159affb 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -16,8 +16,7 @@ import {useProvidedRefOrCreate} from '../hooks' import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useId} from '../hooks/useId' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' -import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion' -import {useFeatureFlag} from '../FeatureFlags' +import {LiveRegion, LiveRegionOutlet} from '../internal/components/LiveRegion' interface SelectPanelSingleSelection { selected: ItemInput | undefined @@ -175,9 +174,6 @@ export function SelectPanel({ } }, [inputLabel, textInputProps]) - // FilteredActionListWithModernActionList handles announcements - const modernActionListEnabled = useFeatureFlag('primer_react_select_panel_with_modern_action_list') - return ( - {modernActionListEnabled ? null : ( - - )} + From 9ed9c42da65dac62fc9d99cfb563c4a0379e49ca Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 6 Sep 2024 17:03:37 +0200 Subject: [PATCH 03/20] add comment for context --- packages/react/src/FilteredActionList/useAnnouncements.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 0189254f800..f35747404be 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -1,3 +1,6 @@ +// Announcements for FilteredActionList (and SelectPanel) based +// on https://github.com/github/multi-select-user-testing + import {announce} from '@primer/live-region-element' import {useEffect, useRef} from 'react' import type {FilteredActionListProps} from './FilteredActionListEntry' From b0503292e8045f2674ed0d372e0d033904e022d9 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 6 Sep 2024 17:18:08 +0200 Subject: [PATCH 04/20] use text instead of text content --- packages/react/src/FilteredActionList/useAnnouncements.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index f35747404be..ad7899deb6d 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -28,9 +28,11 @@ export const useAnnouncements = ( const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') if (listElement && activeItemElement?.textContent) { const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) + const activeItemText = items[activeItemIndex].text + const announcementText = ` Focus on filter text box and list of labels. - Focused item, ${activeItemElement.textContent.trim()}, ${activeItemIndex + 1} of ${items.length} + Focused item, ${activeItemText}, ${activeItemIndex + 1} of ${items.length} ` announce(announcementText, {delayMs: 500 /* we add a delay so that it does not interrupt user action */}) From 959a509dc55bd14d0b88956631778d329be0e27f Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 6 Sep 2024 17:18:51 +0200 Subject: [PATCH 05/20] add selection state to announceInitialFocus --- packages/react/src/FilteredActionList/useAnnouncements.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index ad7899deb6d..f4cfe98c6f0 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -29,10 +29,13 @@ export const useAnnouncements = ( if (listElement && activeItemElement?.textContent) { const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) const activeItemText = items[activeItemIndex].text + const selected = items[activeItemIndex].selected const announcementText = ` Focus on filter text box and list of labels. - Focused item, ${activeItemText}, ${activeItemIndex + 1} of ${items.length} + Focused item, ${activeItemText}, ${selected ? 'selected' : 'not selected'}, ${activeItemIndex + 1} of ${ + items.length + } ` announce(announcementText, {delayMs: 500 /* we add a delay so that it does not interrupt user action */}) From be474136e455ebccac35c50b1c8319da7f55c19a Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 6 Sep 2024 17:24:15 +0200 Subject: [PATCH 06/20] add selected state to announcement --- .../FilteredActionList/useAnnouncements.tsx | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index f4cfe98c6f0..ef897e25270 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -5,6 +5,9 @@ import {announce} from '@primer/live-region-element' import {useEffect, useRef} from 'react' import type {FilteredActionListProps} from './FilteredActionListEntry' +// we add a delay so that it does not interrupt default screen reader announcement and queues after it +const delayMs = 500 + const useFirstRender = () => { const firstRender = useRef(true) useEffect(() => { @@ -29,16 +32,15 @@ export const useAnnouncements = ( if (listElement && activeItemElement?.textContent) { const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) const activeItemText = items[activeItemIndex].text - const selected = items[activeItemIndex].selected + const activeItemSelected = items[activeItemIndex].selected const announcementText = ` - Focus on filter text box and list of labels. - Focused item, ${activeItemText}, ${selected ? 'selected' : 'not selected'}, ${activeItemIndex + 1} of ${ - items.length - } - ` - - announce(announcementText, {delayMs: 500 /* we add a delay so that it does not interrupt user action */}) + Focus on filter text box and list of labels. + Focused item, ${activeItemText}, + ${activeItemSelected ? 'selected' : 'not selected'}, + ${activeItemIndex + 1} of ${items.length} + ` + announce(announcementText, {delayMs}) } }) } @@ -54,9 +56,23 @@ export const useAnnouncements = ( useEffect( function announceListUpdates() { if (isFirstRender) return // ignore on first render as announceInitialFocus will also announce - const announcementText = - items.length > 0 ? `List updated. Focused item, ${items[0].text}, 1 of ${items.length}` : 'No matching items.' - announce(announcementText, {delayMs: 500 /* we add a delay so that it does not interrupt user action */}) + + if (items.length === 0) { + announce('No matching items.', {delayMs}) + return + } + + const activeItemIndex = 0 + const activeItemText = items[activeItemIndex].text + const activeItemSelected = items[activeItemIndex].selected + + const announcementText = ` + List updated. + Focused item, ${activeItemText}, + ${activeItemSelected ? 'selected' : 'not selected'}, + ${1} of ${items.length} + ` + announce(announcementText, {delayMs}) }, [isFirstRender, items], ) From 55a81006afa7be4b29821350ea600e30b19e43d5 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 17:31:20 +0200 Subject: [PATCH 07/20] make announcements a one line string --- .../FilteredActionList/useAnnouncements.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index ef897e25270..b289d14ab38 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -29,17 +29,19 @@ export const useAnnouncements = ( // give @primer/behaviors a moment to apply active-descendant window.requestAnimationFrame(() => { const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') + if (listElement && activeItemElement?.textContent) { + // TODO: This does not work with groups yet! const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) const activeItemText = items[activeItemIndex].text const activeItemSelected = items[activeItemIndex].selected - const announcementText = ` - Focus on filter text box and list of labels. - Focused item, ${activeItemText}, - ${activeItemSelected ? 'selected' : 'not selected'}, - ${activeItemIndex + 1} of ${items.length} - ` + const announcementText = [ + `Focus on filter text box and list of labels`, + `Focused item, ${activeItemText}`, + `${activeItemSelected ? 'selected' : 'not selected'}`, + `${activeItemIndex + 1} of ${items.length}`, + ].join(', ') announce(announcementText, {delayMs}) } }) @@ -66,12 +68,12 @@ export const useAnnouncements = ( const activeItemText = items[activeItemIndex].text const activeItemSelected = items[activeItemIndex].selected - const announcementText = ` - List updated. - Focused item, ${activeItemText}, - ${activeItemSelected ? 'selected' : 'not selected'}, - ${1} of ${items.length} - ` + const announcementText = [ + `List updated`, + `Focused item, ${activeItemText}`, + `${activeItemSelected ? 'selected' : 'not selected'}`, + `${1} of ${items.length}`, + ].join(', ') announce(announcementText, {delayMs}) }, [isFirstRender, items], From 0a484e30f84fb732d6e2b1902c871749f77ddc41 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 17:31:50 +0200 Subject: [PATCH 08/20] add tests (including failing) --- .../src/SelectPanel/SelectPanel.test.tsx | 99 ++++++++++++------- packages/react/src/utils/testing.tsx | 9 ++ 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 7afa0ea0569..eda1cd6dc4c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -5,6 +5,7 @@ import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List' import {userEvent} from '@testing-library/user-event' import ThemeProvider from '../ThemeProvider' import {FeatureFlags} from '../FeatureFlags' +import {getLiveRegion} from '../utils/testing' const renderWithFlag = (children: React.ReactNode, flag: boolean) => { return render( @@ -290,39 +291,39 @@ for (const useModernActionList of [false, true]) { }) }) - describe('filtering', () => { - function FilterableSelectPanel() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) - - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + function FilterableSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - return ( - - item.text?.includes(filter))} - placeholder="Select items" - placeholderText="Filter items" - selected={selected} - onSelectedChange={onSelectedChange} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - open={open} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - /> - - ) + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) } + return ( + + item.text?.includes(filter))} + placeholder="Select items" + placeholderText="Filter items" + selected={selected} + onSelectedChange={onSelectedChange} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + /> + + ) + } + + describe('filtering', () => { it('should filter the list of items when the user types into the input', async () => { const user = userEvent.setup() @@ -335,21 +336,51 @@ for (const useModernActionList of [false, true]) { await user.type(document.activeElement!, 'two') expect(screen.getAllByRole('option')).toHaveLength(1) }) + }) + + describe.only('screen reader announcements', () => { + it('should announce initial focused item', async () => { + const user = userEvent.setup() + renderWithFlag(, useModernActionList) + + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() + + // we wait because announcement is intentionally updated after a timeout to not interrupt user input + await waitFor(async () => { + expect(getLiveRegion().getMessage('polite')).toBe( + 'Focus on filter text box and list of labels, Focused item, item one, not selected, 1 of 3', + ) + }) + }) + + it('should announce filtered results', async () => { + const user = userEvent.setup() + renderWithFlag(, useModernActionList) - it.todo('should announce the number of results') + await user.click(screen.getByText('Select items')) + await user.type(document.activeElement!, 'o') + expect(screen.getAllByRole('option')).toHaveLength(2) + + await waitFor(async () => { + expect(getLiveRegion().getMessage('polite')).toBe( + 'List updated, Focused item, item one, not selected, 1 of 2', + ) + }) + }) it('should announce when no results are available', async () => { const user = userEvent.setup() renderWithFlag(, useModernActionList) + await user.click(screen.getByText('Select items')) - expect(screen.getAllByRole('option')).toHaveLength(3) await user.type(document.activeElement!, 'zero') expect(screen.queryByRole('option')).toBeNull() expect(screen.getByText('No matches')).toBeVisible() + await waitFor(async () => { - // we wait because status is intentionally updated after a timeout to not interrupt user input - expect(screen.getByRole('status')).toHaveTextContent('No matching items') + expect(getLiveRegion().getMessage('polite')).toBe('No matching items.') }) }) }) diff --git a/packages/react/src/utils/testing.tsx b/packages/react/src/utils/testing.tsx index 5f013cd9624..5f5f1caa257 100644 --- a/packages/react/src/utils/testing.tsx +++ b/packages/react/src/utils/testing.tsx @@ -7,6 +7,7 @@ import axe from 'axe-core' import customRules from '@github/axe-github' import {ThemeProvider} from '..' import {default as defaultTheme} from '../theme' +import type {LiveRegionElement} from '@primer/live-region-element' type ComputedStyles = Record> @@ -270,3 +271,11 @@ export function checkStoriesForAxeViolations(name: string, storyDir?: string) { }) }) } + +export function getLiveRegion(): LiveRegionElement { + const liveRegion = document.querySelector('live-region') + if (liveRegion) { + return liveRegion as LiveRegionElement + } + throw new Error('No live-region found') +} From 7dc054369e6b541c89125b30773bfac9de0397eb Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 17:33:10 +0200 Subject: [PATCH 09/20] use getLiveRegion helper everyone --- .../react/src/live-region/__tests__/Announce.test.tsx | 10 +--------- .../react/src/live-region/__tests__/AriaAlert.test.tsx | 10 +--------- .../src/live-region/__tests__/AriaStatus.test.tsx | 10 +--------- 3 files changed, 3 insertions(+), 27 deletions(-) diff --git a/packages/react/src/live-region/__tests__/Announce.test.tsx b/packages/react/src/live-region/__tests__/Announce.test.tsx index 5586297a8dd..b3364bf9c2b 100644 --- a/packages/react/src/live-region/__tests__/Announce.test.tsx +++ b/packages/react/src/live-region/__tests__/Announce.test.tsx @@ -1,15 +1,7 @@ import {render, screen} from '@testing-library/react' import React from 'react' -import type {LiveRegionElement} from '@primer/live-region-element' import {Announce} from '../Announce' - -function getLiveRegion(): LiveRegionElement { - const liveRegion = document.querySelector('live-region') - if (liveRegion) { - return liveRegion as LiveRegionElement - } - throw new Error('No live-region found') -} +import {getLiveRegion} from '../../utils/testing' describe('Announce', () => { beforeEach(() => { diff --git a/packages/react/src/live-region/__tests__/AriaAlert.test.tsx b/packages/react/src/live-region/__tests__/AriaAlert.test.tsx index e51e4558d44..91c4d83731e 100644 --- a/packages/react/src/live-region/__tests__/AriaAlert.test.tsx +++ b/packages/react/src/live-region/__tests__/AriaAlert.test.tsx @@ -1,15 +1,7 @@ import {render, screen} from '@testing-library/react' import React from 'react' -import type {LiveRegionElement} from '@primer/live-region-element' import {AriaAlert} from '../AriaAlert' - -function getLiveRegion(): LiveRegionElement { - const liveRegion = document.querySelector('live-region') - if (liveRegion) { - return liveRegion as LiveRegionElement - } - throw new Error('No live-region found') -} +import {getLiveRegion} from '../../utils/testing' describe('AriaAlert', () => { beforeEach(() => { diff --git a/packages/react/src/live-region/__tests__/AriaStatus.test.tsx b/packages/react/src/live-region/__tests__/AriaStatus.test.tsx index 29bed2c78fb..16465c03a5c 100644 --- a/packages/react/src/live-region/__tests__/AriaStatus.test.tsx +++ b/packages/react/src/live-region/__tests__/AriaStatus.test.tsx @@ -1,16 +1,8 @@ import {render, screen} from '@testing-library/react' import React from 'react' -import type {LiveRegionElement} from '@primer/live-region-element' import {AriaStatus} from '../AriaStatus' import {userEvent} from '@testing-library/user-event' - -function getLiveRegion(): LiveRegionElement { - const liveRegion = document.querySelector('live-region') - if (liveRegion) { - return liveRegion as LiveRegionElement - } - throw new Error('No live-region found') -} +import {getLiveRegion} from '../../utils/testing' describe('AriaStatus', () => { beforeEach(() => { From 6229793b592fb4032b2a915db9987c0f475b91db Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 17:56:17 +0200 Subject: [PATCH 10/20] calculate item index by looking at optionElements --- packages/react/src/FilteredActionList/useAnnouncements.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index b289d14ab38..63ca5c1b8de 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -31,8 +31,9 @@ export const useAnnouncements = ( const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') if (listElement && activeItemElement?.textContent) { - // TODO: This does not work with groups yet! - const activeItemIndex = Array.from(listElement.children).indexOf(activeItemElement) + const optionElements = listElement.querySelectorAll('[role="option"]') + + const activeItemIndex = Array.from(optionElements).indexOf(activeItemElement) const activeItemText = items[activeItemIndex].text const activeItemSelected = items[activeItemIndex].selected From f7766b868ea1f22afe9edeed3b909bac029a8f32 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 18:14:36 +0200 Subject: [PATCH 11/20] better punctuation --- packages/react/src/FilteredActionList/useAnnouncements.tsx | 4 ++-- packages/react/src/SelectPanel/SelectPanel.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 63ca5c1b8de..d24fe830d2a 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -39,7 +39,7 @@ export const useAnnouncements = ( const announcementText = [ `Focus on filter text box and list of labels`, - `Focused item, ${activeItemText}`, + `Focused item: ${activeItemText}`, `${activeItemSelected ? 'selected' : 'not selected'}`, `${activeItemIndex + 1} of ${items.length}`, ].join(', ') @@ -71,7 +71,7 @@ export const useAnnouncements = ( const announcementText = [ `List updated`, - `Focused item, ${activeItemText}`, + `Focused item: ${activeItemText}`, `${activeItemSelected ? 'selected' : 'not selected'}`, `${1} of ${items.length}`, ].join(', ') diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index eda1cd6dc4c..3183a3056ec 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -349,7 +349,7 @@ for (const useModernActionList of [false, true]) { // we wait because announcement is intentionally updated after a timeout to not interrupt user input await waitFor(async () => { expect(getLiveRegion().getMessage('polite')).toBe( - 'Focus on filter text box and list of labels, Focused item, item one, not selected, 1 of 3', + 'Focus on filter text box and list of labels, Focused item: item one, not selected, 1 of 3', ) }) }) @@ -364,7 +364,7 @@ for (const useModernActionList of [false, true]) { await waitFor(async () => { expect(getLiveRegion().getMessage('polite')).toBe( - 'List updated, Focused item, item one, not selected, 1 of 2', + 'List updated, Focused item: item one, not selected, 1 of 2', ) }) }) From 436a08b06045521aeb94f8d3d1ee25f9f9932ae8 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 18:17:26 +0200 Subject: [PATCH 12/20] update test to include updates --- packages/react/src/SelectPanel/SelectPanel.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 3183a3056ec..953941a2f15 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -367,6 +367,15 @@ for (const useModernActionList of [false, true]) { 'List updated, Focused item: item one, not selected, 1 of 2', ) }) + + await user.type(document.activeElement!, 'ne') // now: one + expect(screen.getAllByRole('option')).toHaveLength(1) + + await waitFor(async () => { + expect(getLiveRegion().getMessage('polite')).toBe( + 'List updated, Focused item: item one, not selected, 1 of 1', + ) + }) }) it('should announce when no results are available', async () => { From 45abe7dd5a534eb05be14a2e49214218afdc0373 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 18:20:05 +0200 Subject: [PATCH 13/20] remove only --- packages/react/src/SelectPanel/SelectPanel.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 953941a2f15..b8bfae0d05c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -338,7 +338,7 @@ for (const useModernActionList of [false, true]) { }) }) - describe.only('screen reader announcements', () => { + describe('screen reader announcements', () => { it('should announce initial focused item', async () => { const user = userEvent.setup() renderWithFlag(, useModernActionList) From 932f3a4338aa15455e12c6898eb48a97324dd861 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 18:26:10 +0200 Subject: [PATCH 14/20] expand type for old filtered actionlist --- packages/react/src/FilteredActionList/useAnnouncements.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index d24fe830d2a..b1faec17f9b 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -18,7 +18,7 @@ const useFirstRender = () => { export const useAnnouncements = ( items: FilteredActionListProps['items'], - listContainerRef: React.RefObject, + listContainerRef: React.RefObject, // compatible with new and old inputRef: React.RefObject, ) => { useEffect( From d9268a02e61f9ec83406d2cdd6769c60be42dafc Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Mon, 9 Sep 2024 18:38:38 +0200 Subject: [PATCH 15/20] add timeout to help tests --- packages/react/src/SelectPanel/SelectPanel.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index b8bfae0d05c..916ecd2739a 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -362,11 +362,14 @@ for (const useModernActionList of [false, true]) { await user.type(document.activeElement!, 'o') expect(screen.getAllByRole('option')).toHaveLength(2) - await waitFor(async () => { - expect(getLiveRegion().getMessage('polite')).toBe( - 'List updated, Focused item: item one, not selected, 1 of 2', - ) - }) + await waitFor( + async () => { + expect(getLiveRegion().getMessage('polite')).toBe( + 'List updated, Focused item: item one, not selected, 1 of 2', + ) + }, + {timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement + ) await user.type(document.activeElement!, 'ne') // now: one expect(screen.getAllByRole('option')).toHaveLength(1) From 83937c26d9dc292db3d93c9dbbad51da594e62a8 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 12 Sep 2024 15:06:15 +0200 Subject: [PATCH 16/20] remove changes from deprecated version --- ...ilteredActionListWithDeprecatedActionList.tsx | 2 -- .../react/src/SelectPanel/SelectPanel.test.tsx | 3 +++ packages/react/src/SelectPanel/SelectPanel.tsx | 16 +++++++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx index 58df94ccb88..7bec423a15c 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx @@ -17,7 +17,6 @@ import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' import useScrollFlash from '../hooks/useScrollFlash' import {VisuallyHidden} from '../internal/components/VisuallyHidden' import type {SxProp} from '../sx' -import {useAnnouncements} from './useAnnouncements' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -109,7 +108,6 @@ export function FilteredActionList({ }, [items]) useScrollFlash(scrollContainerRef) - useAnnouncements(items, listContainerRef, inputRef) return ( diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 7b3613a656d..2e8d04a17f4 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -385,6 +385,9 @@ for (const useModernActionList of [false, true]) { }) describe('screen reader announcements', () => { + // this is only implemented with the feature flag + if (!useModernActionList) return + it('should announce initial focused item', async () => { const user = userEvent.setup() renderWithFlag(, useModernActionList) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 9649fc07447..8c0aea638fd 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -16,7 +16,8 @@ import {useProvidedRefOrCreate} from '../hooks' import type {FocusZoneHookSettings} from '../hooks/useFocusZone' import {useId} from '../hooks/useId' import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' -import {LiveRegion, LiveRegionOutlet} from '../internal/components/LiveRegion' +import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion' +import {useFeatureFlag} from '../FeatureFlags' interface SelectPanelSingleSelection { selected: ItemInput | undefined @@ -174,6 +175,8 @@ export function SelectPanel({ } }, [inputLabel, textInputProps]) + const usingModernActionList = useFeatureFlag('primer_react_select_panel_with_modern_action_list') + return ( + {usingModernActionList ? null : ( + + )} From 7754ed94a88a78b5e84bcb5abb99776aada23192 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 12 Sep 2024 15:07:49 +0200 Subject: [PATCH 17/20] Create small-melons-fail.md --- .changeset/small-melons-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-melons-fail.md diff --git a/.changeset/small-melons-fail.md b/.changeset/small-melons-fail.md new file mode 100644 index 00000000000..c2465b10950 --- /dev/null +++ b/.changeset/small-melons-fail.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +SelectPanel: Add announcements for screen readers (behind feature flag `primer_react_select_panel_with_modern_action_list`) From 8539ff8e5148411a176752e84c8033cab4cdb43a Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 12 Sep 2024 16:18:41 +0200 Subject: [PATCH 18/20] always use data-is-active-descendant, not first --- .../FilteredActionList/useAnnouncements.tsx | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index b1faec17f9b..2cbdb3429b4 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -65,18 +65,28 @@ export const useAnnouncements = ( return } - const activeItemIndex = 0 - const activeItemText = items[activeItemIndex].text - const activeItemSelected = items[activeItemIndex].selected - - const announcementText = [ - `List updated`, - `Focused item: ${activeItemText}`, - `${activeItemSelected ? 'selected' : 'not selected'}`, - `${1} of ${items.length}`, - ].join(', ') - announce(announcementText, {delayMs}) + // give @primer/behaviors a moment to update active-descendant + window.requestAnimationFrame(() => { + const listElement = listContainerRef.current + const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') + + if (!listElement || !activeItemElement?.textContent) return + + const optionElements = listElement.querySelectorAll('[role="option"]') + + const activeItemIndex = Array.from(optionElements).indexOf(activeItemElement) + const activeItemText = items[activeItemIndex].text + const activeItemSelected = items[activeItemIndex].selected + + const announcementText = [ + `List updated`, + `Focused item: ${activeItemText}`, + `${activeItemSelected ? 'selected' : 'not selected'}`, + `${1} of ${items.length}`, + ].join(', ') + announce(announcementText, {delayMs}) + }) }, - [isFirstRender, items], + [listContainerRef, inputRef, items, isFirstRender], ) } From ac7986514c8b5eb3b4e1d2826565d8a9e1767e98 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 12 Sep 2024 16:23:40 +0200 Subject: [PATCH 19/20] abstractionssss --- .../FilteredActionList/useAnnouncements.tsx | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 2cbdb3429b4..b8d53aa9a3c 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -16,6 +16,23 @@ const useFirstRender = () => { return firstRender.current } +const getItemWithActiveDescendant = (listRef, items) => { + const listElement = listRef.current + const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') + + if (!listElement || !activeItemElement?.textContent) return + + const optionElements = listElement.querySelectorAll('[role="option"]') + + const index = Array.from(optionElements).indexOf(activeItemElement) + const activeItem = items[index] + + const text = activeItem.text + const selected = activeItem.selected + + return {index, text, selected} +} + export const useAnnouncements = ( items: FilteredActionListProps['items'], listContainerRef: React.RefObject, // compatible with new and old @@ -23,28 +40,18 @@ export const useAnnouncements = ( ) => { useEffect( function announceInitialFocus() { - const listElement = listContainerRef.current - const focusHandler = () => { // give @primer/behaviors a moment to apply active-descendant window.requestAnimationFrame(() => { - const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') - - if (listElement && activeItemElement?.textContent) { - const optionElements = listElement.querySelectorAll('[role="option"]') - - const activeItemIndex = Array.from(optionElements).indexOf(activeItemElement) - const activeItemText = items[activeItemIndex].text - const activeItemSelected = items[activeItemIndex].selected - - const announcementText = [ - `Focus on filter text box and list of labels`, - `Focused item: ${activeItemText}`, - `${activeItemSelected ? 'selected' : 'not selected'}`, - `${activeItemIndex + 1} of ${items.length}`, - ].join(', ') - announce(announcementText, {delayMs}) - } + const {index, text, selected} = getItemWithActiveDescendant(listContainerRef, items) + + const announcementText = [ + `Focus on filter text box and list of labels`, + `Focused item: ${text}`, + `${selected ? 'selected' : 'not selected'}`, + `${index + 1} of ${items.length}`, + ].join(', ') + announce(announcementText, {delayMs}) }) } @@ -67,22 +74,13 @@ export const useAnnouncements = ( // give @primer/behaviors a moment to update active-descendant window.requestAnimationFrame(() => { - const listElement = listContainerRef.current - const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') - - if (!listElement || !activeItemElement?.textContent) return - - const optionElements = listElement.querySelectorAll('[role="option"]') - - const activeItemIndex = Array.from(optionElements).indexOf(activeItemElement) - const activeItemText = items[activeItemIndex].text - const activeItemSelected = items[activeItemIndex].selected + const {index, text, selected} = getItemWithActiveDescendant(listContainerRef, items) const announcementText = [ `List updated`, - `Focused item: ${activeItemText}`, - `${activeItemSelected ? 'selected' : 'not selected'}`, - `${1} of ${items.length}`, + `Focused item: ${text}`, + `${selected ? 'selected' : 'not selected'}`, + `${index} of ${items.length}`, ].join(', ') announce(announcementText, {delayMs}) }) From 0008445bb998b595d217d3cabf759ebaa56382a9 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 12 Sep 2024 17:26:14 +0200 Subject: [PATCH 20/20] type veiligheid --- .../src/FilteredActionList/useAnnouncements.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index b8d53aa9a3c..8b419d92584 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -16,7 +16,10 @@ const useFirstRender = () => { return firstRender.current } -const getItemWithActiveDescendant = (listRef, items) => { +const getItemWithActiveDescendant = ( + listRef: React.RefObject, + items: FilteredActionListProps['items'], +) => { const listElement = listRef.current const activeItemElement = listElement?.querySelector('[data-is-active-descendant]') @@ -35,7 +38,7 @@ const getItemWithActiveDescendant = (listRef, items) => { export const useAnnouncements = ( items: FilteredActionListProps['items'], - listContainerRef: React.RefObject, // compatible with new and old + listContainerRef: React.RefObject, inputRef: React.RefObject, ) => { useEffect( @@ -43,7 +46,9 @@ export const useAnnouncements = ( const focusHandler = () => { // give @primer/behaviors a moment to apply active-descendant window.requestAnimationFrame(() => { - const {index, text, selected} = getItemWithActiveDescendant(listContainerRef, items) + const activeItem = getItemWithActiveDescendant(listContainerRef, items) + if (!activeItem) return + const {index, text, selected} = activeItem const announcementText = [ `Focus on filter text box and list of labels`, @@ -74,7 +79,9 @@ export const useAnnouncements = ( // give @primer/behaviors a moment to update active-descendant window.requestAnimationFrame(() => { - const {index, text, selected} = getItemWithActiveDescendant(listContainerRef, items) + const activeItem = getItemWithActiveDescendant(listContainerRef, items) + if (!activeItem) return + const {index, text, selected} = activeItem const announcementText = [ `List updated`,