diff --git a/.changeset/bright-fields-speak.md b/.changeset/bright-fields-speak.md new file mode 100644 index 00000000000..576c8aa4986 --- /dev/null +++ b/.changeset/bright-fields-speak.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +SelectPanel: Announce the filter input label when focus moves into the panel diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index e004536ca55..35412dd8a83 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -212,6 +212,17 @@ export function FilteredActionList({ const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex' const [listContainerElement, setListContainerElement] = useState(null) + const [activeDescendantListRef] = useState(() => { + let current: HTMLUListElement | null = null + return { + get current() { + return current + }, + setCurrent(node: HTMLUListElement | null) { + current = node + }, + } + }) const activeDescendantRef = useRef() const listId = useId(actionListProps?.id) @@ -291,10 +302,11 @@ export function FilteredActionList({ const listContainerRefCallback = useCallback( (node: HTMLUListElement | null) => { + activeDescendantListRef.setCurrent(node) setListContainerElement(node) onListContainerRefChanged?.(node) }, - [onListContainerRefChanged], + [activeDescendantListRef, onListContainerRefChanged], ) useEffect(() => { // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent @@ -418,12 +430,13 @@ export function FilteredActionList({ useAnnouncements( items, - usingRovingTabindex ? listRef : {current: listContainerElement}, + usingRovingTabindex ? listRef : activeDescendantListRef, readInputRef, announcementsEnabled, loading, messageText, _PrivateFocusManagement, + filterValue, ) useScrollFlash(readScrollContainerRef) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.test.tsx b/packages/react/src/FilteredActionList/useAnnouncements.test.tsx new file mode 100644 index 00000000000..a350026b17a --- /dev/null +++ b/packages/react/src/FilteredActionList/useAnnouncements.test.tsx @@ -0,0 +1,127 @@ +import {act, renderHook, waitFor} from '@testing-library/react' +import {announce} from '@primer/live-region-element' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {useAnnouncements} from './useAnnouncements' + +vi.mock('@primer/live-region-element', () => ({ + announce: vi.fn(), +})) + +describe('useAnnouncements', () => { + beforeEach(() => { + vi.mocked(announce).mockClear() + }) + + it('includes the input label in the initial focus announcement', async () => { + const input = document.createElement('input') + input.setAttribute('aria-label', 'Filter fields') + + const list = document.createElement('ul') + const activeOption = document.createElement('li') + activeOption.setAttribute('role', 'option') + activeOption.setAttribute('data-is-active-descendant', 'true') + activeOption.textContent = 'Start date' + list.append(activeOption) + const listRef: {current: HTMLUListElement | null} = {current: null} + + renderHook(() => + useAnnouncements([{text: 'Start date'}], listRef, {current: input}, true, false, undefined, 'active-descendant'), + ) + + act(() => { + input.dispatchEvent(new FocusEvent('focus')) + listRef.current = list + }) + + await waitFor(() => + expect(announce).toHaveBeenCalledWith( + 'Filter fields, filter text box and list of items, Focused item: Start date, not selected, 1 of 1', + {delayMs: 500, from: undefined}, + ), + ) + }) + + it('uses aria-labelledby before aria-label in the initial focus announcement', async () => { + const label = document.createElement('span') + label.id = 'filter-label' + label.textContent = 'Issue fields' + document.body.append(label) + + const input = document.createElement('input') + input.setAttribute('aria-label', 'Filter fields') + input.setAttribute('aria-labelledby', label.id) + + const list = document.createElement('ul') + const activeOption = document.createElement('li') + activeOption.setAttribute('role', 'option') + activeOption.setAttribute('data-is-active-descendant', 'true') + activeOption.textContent = 'Start date' + list.append(activeOption) + + renderHook(() => + useAnnouncements( + [{text: 'Start date'}], + {current: list}, + {current: input}, + true, + false, + undefined, + 'active-descendant', + ), + ) + + act(() => input.dispatchEvent(new FocusEvent('focus'))) + + await waitFor(() => + expect(announce).toHaveBeenCalledWith( + 'Issue fields, filter text box and list of items, Focused item: Start date, not selected, 1 of 1', + {delayMs: 500, from: undefined}, + ), + ) + + label.remove() + }) + + it('does not invent an input label when the input is unavailable', async () => { + const list = document.createElement('ul') + const activeOption = document.createElement('li') + activeOption.setAttribute('role', 'option') + activeOption.setAttribute('data-is-active-descendant', 'true') + activeOption.textContent = 'Start date' + list.append(activeOption) + + const inputRef = {current: null} + const {rerender} = renderHook( + ({items}) => useAnnouncements(items, {current: list}, inputRef, true, false, undefined, 'active-descendant'), + {initialProps: {items: [{text: 'Start date'}]}}, + ) + + rerender({items: [{text: 'Start date'}, {text: 'Priority'}]}) + + await waitFor(() => + expect(announce).toHaveBeenCalledWith('List updated, Focused item: Start date, not selected, 1 of 2', { + delayMs: 500, + from: undefined, + }), + ) + }) + + it('does not announce a list update when items are recreated with the same state', () => { + const list = document.createElement('ul') + const activeOption = document.createElement('li') + activeOption.setAttribute('role', 'option') + activeOption.setAttribute('data-is-active-descendant', 'true') + activeOption.textContent = 'Start date' + list.append(activeOption) + + const {rerender} = renderHook( + ({items}) => + useAnnouncements(items, {current: list}, {current: null}, true, false, undefined, 'active-descendant'), + {initialProps: {items: [{id: 'start-date', text: 'Start date', selected: false}]}}, + ) + + rerender({items: [{id: 'start-date', text: 'Start date', selected: false}]}) + + expect(announce).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 027e7b3d6e3..6a639913e91 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -9,13 +9,29 @@ 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 useFirstRender = () => { - const firstRender = useRef(true) - useEffect(() => { - firstRender.current = false - }, []) - // eslint-disable-next-line react-hooks/refs - return firstRender.current +const getInputLabel = (input: HTMLInputElement | null) => { + if (!input) return + + const labelledBy = input.getAttribute('aria-labelledby') + if (labelledBy) { + const label = labelledBy + .split(/\s+/) + .map(id => input.ownerDocument.getElementById(id)?.textContent.trim()) + .filter(Boolean) + .join(' ') + + if (label) return label + } + + const ariaLabel = input.getAttribute('aria-label') + if (ariaLabel) return ariaLabel + + const label = Array.from(input.labels ?? []) + .map(label => label.textContent.trim()) + .filter(Boolean) + .join(' ') + + return label || undefined } const getItemWithActiveDescendant = ( @@ -38,6 +54,19 @@ const getItemWithActiveDescendant = ( return {index, text, selected} } +const getAnnouncementState = ( + items: FilteredActionListProps['items'], + loading: boolean, + message?: {title: string; description: string}, + filterValue?: string, +) => + JSON.stringify({ + items: items.map((item, index) => [item.id ?? item.text ?? index, item.text, item.selected]), + loading, + message, + filterValue, + }) + export const useAnnouncements = ( items: FilteredActionListProps['items'], listContainerRef: React.RefObject, @@ -46,6 +75,7 @@ export const useAnnouncements = ( loading: boolean = false, message?: {title: string; description: string}, focusManagement?: 'active-descendant' | 'roving-tabindex', + filterValue?: string, ) => { const usingRovingTabindex = focusManagement === 'roving-tabindex' @@ -78,9 +108,12 @@ export const useAnnouncements = ( const activeItem = getItemWithActiveDescendant(listContainerRef, items) if (!activeItem) return const {index, text, selected} = activeItem + const inputLabel = getInputLabel(inputElement) const announcementText = [ - `Focus on filter text box and list of items`, + inputLabel + ? `${inputLabel}, filter text box and list of items` + : 'Focus on filter text box and list of items', `Focused item: ${text}`, `${selected ? 'selected' : 'not selected'}`, `${index + 1} of ${items.length}`, @@ -100,10 +133,12 @@ export const useAnnouncements = ( [listContainerRef, inputRef, items, liveRegion, announce, usingRovingTabindex, selectedItems], ) - const isFirstRender = useFirstRender() + const announcementState = getAnnouncementState(items, loading, message, filterValue) + const previousAnnouncementState = useRef(announcementState) useEffect( function announceListUpdates() { - if (isFirstRender) return // ignore on first render as announceInitialFocus will also announce + if (previousAnnouncementState.current === announcementState) return + previousAnnouncementState.current = announcementState liveRegion?.clear() // clear previous announcements @@ -143,7 +178,7 @@ export const useAnnouncements = ( }, [ announce, - isFirstRender, + announcementState, items, listContainerRef, liveRegion, diff --git a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx index 9faab5de79a..80c41610158 100644 --- a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx @@ -445,3 +445,42 @@ export const WithInitialFocusEnabled = ({onCancel, secondaryAction}: ParamProps) /> ) } + +export const WithSearchInputAnnouncement = () => { + const issueFields: ItemInput[] = [ + {text: 'Start date', description: 'Date when work on issue will begin', id: 1}, + {text: 'Priority', description: 'Level of importance for this issue', id: 2}, + {text: 'Size', description: 'Estimated effort for this issue', id: 3}, + ] + const [selected, setSelected] = useState([]) + const [filter, setFilter] = useState('') + const filteredItems = issueFields.filter(item => item.text?.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + + return ( + <> +

+ Turn on a screen reader, then open the SelectPanel. The announcement identifies the Filter fields input before + the initially focused option. +

+ ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + /> + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index e8fb4341ede..0cebed65d9f 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -731,7 +731,7 @@ for (const usingRemoveActiveDescendant of [false, true]) { expect(getLiveRegion().getMessage('polite')!.trim()).toEqual('3 items available, 0 selected.') } else { expect(getLiveRegion().getMessage('polite')!.trim()).toEqual( - 'List updated, Focused item: item one, not selected, 1 of 3', + 'Filter items, filter text box and list of items, Focused item: item one, not selected, 1 of 3', ) } }, @@ -797,7 +797,7 @@ for (const usingRemoveActiveDescendant of [false, true]) { expect(getLiveRegion().getMessage('polite')!.trim()).toEqual('3 items available, 0 selected.') } else { expect(getLiveRegion().getMessage('polite')!.trim()).toEqual( - 'List updated, Focused item: item one, not selected, 1 of 3', + 'Filter items, filter text box and list of items, Focused item: item one, not selected, 1 of 3', ) } }, diff --git a/script/check-classname-tests.mjs b/script/check-classname-tests.mjs index 3af18a8256d..a271626203f 100755 --- a/script/check-classname-tests.mjs +++ b/script/check-classname-tests.mjs @@ -27,6 +27,7 @@ const IGNORED_FILES = [ 'packages/react/src/__tests__/Caret.test.tsx', // Asserts server rendering behavior, not rendered markup 'packages/react/src/__tests__/ssr.test.tsx', + 'packages/react/src/FilteredActionList/useAnnouncements.test.tsx', 'packages/react/src/TreeView/useRovingTabIndex.test.tsx', ]