From 5587931ba9b0e2975ca0e581810ad320ad7741b6 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 10 Sep 2026 13:15:21 -0700 Subject: [PATCH 1/6] story repro --- .../SelectPanel/SelectPanel.dev.stories.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx index 9faab5de79a..7c864962665 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 WithSearchInputAnnouncementBug = () => { + 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. Focus moves to the Filter fields input, but the first option + is announced instead of the input's name and role. +

+ ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + /> + + ) +} From 325acd2c1ef21edc5fba52ba44f3fc8faf870f2c Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Fri, 11 Sep 2026 14:19:32 -0700 Subject: [PATCH 2/6] filter input announcement --- .changeset/bright-fields-speak.md | 5 + .../FilteredActionList/FilteredActionList.tsx | 17 ++- .../useAnnouncements.test.tsx | 108 ++++++++++++++++++ .../FilteredActionList/useAnnouncements.tsx | 49 ++++++-- .../SelectPanel/SelectPanel.dev.stories.tsx | 6 +- .../src/SelectPanel/SelectPanel.test.tsx | 4 +- .../__snapshots__/exports.test.ts.snap | 7 -- packages/react/src/index.ts | 4 - 8 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 .changeset/bright-fields-speak.md create mode 100644 packages/react/src/FilteredActionList/useAnnouncements.test.tsx 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..b8804df09d9 --- /dev/null +++ b/packages/react/src/FilteredActionList/useAnnouncements.test.tsx @@ -0,0 +1,108 @@ +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, + }), + ) + }) +}) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 027e7b3d6e3..4bc5cfae225 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -9,13 +9,21 @@ 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 + } + + return input.getAttribute('aria-label') ?? undefined } const getItemWithActiveDescendant = ( @@ -38,6 +46,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 +67,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 +100,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 +125,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 +170,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 7c864962665..80c41610158 100644 --- a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx @@ -446,7 +446,7 @@ export const WithInitialFocusEnabled = ({onCancel, secondaryAction}: ParamProps) ) } -export const WithSearchInputAnnouncementBug = () => { +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}, @@ -460,8 +460,8 @@ export const WithSearchInputAnnouncementBug = () => { return ( <>

- Turn on a screen reader, then open the SelectPanel. Focus moves to the Filter fields input, but the first option - is announced instead of the input's name and role. + Turn on a screen reader, then open the SelectPanel. The announcement identifies the Filter fields input before + the initially focused option.

should not update exports without a semver change 1`] = "AnchoredOverlay", "type AnchoredOverlayProps", "AnchoredPositionHookSettings", - "AriaAlert", - "type AriaAlertProps", - "AriaStatus", - "type AriaStatusProps", "asSlot", "Autocomplete", "type AutocompleteInputProps", @@ -154,9 +150,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type RelativeTimeProps", "ResponsiveValue", "SegmentedControl", - "type SegmentedControlActionProps", "type SegmentedControlButtonProps", - "type SegmentedControlDividerProps", "type SegmentedControlIconButtonProps", "type SegmentedControlProps", "Select", @@ -384,7 +378,6 @@ exports[`@primer/react/experimental > should not update exports without a semver "type TableBodyProps", "type TableCellProps", "type TableContainerProps", - "type TableGroupProps", "type TableHeaderProps", "type TableHeadProps", "type TableProps", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 48750d9e583..0c4ebf8767d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,8 +1,6 @@ 'use client' export {default as theme, type ThemeColorPaths, type ThemeShadowPaths} from './theme' -export {AriaStatus, AriaAlert} from './live-region' -export type {AriaStatusProps, AriaAlertProps} from './live-region' export {default as BaseStyles} from './BaseStyles' export type {BaseStylesProps} from './BaseStyles' export {default as ThemeProvider} from './ThemeProvider' @@ -165,8 +163,6 @@ export type { SegmentedControlProps, SegmentedControlButtonProps, SegmentedControlIconButtonProps, - SegmentedControlActionProps, - SegmentedControlDividerProps, } from './SegmentedControl' // Currently there is a duplicate Select component at the root of the dir, so need to be explicit about exporting from the src/Select dir export {default as Select} from './Select' From b6fc30a2e3e1ee1fc827e873891d3798121c0033 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Fri, 11 Sep 2026 15:12:08 -0700 Subject: [PATCH 3/6] fix the ci --- .../react/src/__tests__/__snapshots__/exports.test.ts.snap | 7 +++++++ packages/react/src/index.ts | 4 ++++ script/check-classname-tests.mjs | 1 + 3 files changed, 12 insertions(+) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 94bd40f056e..76afe2f6f89 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -23,6 +23,10 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "AnchoredOverlay", "type AnchoredOverlayProps", "AnchoredPositionHookSettings", + "AriaAlert", + "type AriaAlertProps", + "AriaStatus", + "type AriaStatusProps", "asSlot", "Autocomplete", "type AutocompleteInputProps", @@ -150,7 +154,9 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type RelativeTimeProps", "ResponsiveValue", "SegmentedControl", + "type SegmentedControlActionProps", "type SegmentedControlButtonProps", + "type SegmentedControlDividerProps", "type SegmentedControlIconButtonProps", "type SegmentedControlProps", "Select", @@ -378,6 +384,7 @@ exports[`@primer/react/experimental > should not update exports without a semver "type TableBodyProps", "type TableCellProps", "type TableContainerProps", + "type TableGroupProps", "type TableHeaderProps", "type TableHeadProps", "type TableProps", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0c4ebf8767d..48750d9e583 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,6 +1,8 @@ 'use client' export {default as theme, type ThemeColorPaths, type ThemeShadowPaths} from './theme' +export {AriaStatus, AriaAlert} from './live-region' +export type {AriaStatusProps, AriaAlertProps} from './live-region' export {default as BaseStyles} from './BaseStyles' export type {BaseStylesProps} from './BaseStyles' export {default as ThemeProvider} from './ThemeProvider' @@ -163,6 +165,8 @@ export type { SegmentedControlProps, SegmentedControlButtonProps, SegmentedControlIconButtonProps, + SegmentedControlActionProps, + SegmentedControlDividerProps, } from './SegmentedControl' // Currently there is a duplicate Select component at the root of the dir, so need to be explicit about exporting from the src/Select dir export {default as Select} from './Select' diff --git a/script/check-classname-tests.mjs b/script/check-classname-tests.mjs index cc2a0232cc4..ee8cf9e466f 100755 --- a/script/check-classname-tests.mjs +++ b/script/check-classname-tests.mjs @@ -26,6 +26,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', ] From bfdf6b7db65fe62e742160e4d3140ee4e2a39196 Mon Sep 17 00:00:00 2001 From: LiuLiu Date: Tue, 15 Sep 2026 11:51:27 -0700 Subject: [PATCH 4/6] Refactor aria-label retrieval logic in useAnnouncements Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../react/src/FilteredActionList/useAnnouncements.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 4bc5cfae225..dce350aa5e3 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -23,7 +23,15 @@ const getInputLabel = (input: HTMLInputElement | null) => { if (label) return label } - return input.getAttribute('aria-label') ?? undefined + 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 = ( From 6640e6579b0f4e4683c48e915adad0984b246af2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:55:54 +0000 Subject: [PATCH 5/6] Test stable SelectPanel announcements Co-authored-by: liuliu-dev <6688812+liuliu-dev@users.noreply.github.com> --- .../useAnnouncements.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/react/src/FilteredActionList/useAnnouncements.test.tsx b/packages/react/src/FilteredActionList/useAnnouncements.test.tsx index b8804df09d9..a350026b17a 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.test.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.test.tsx @@ -105,4 +105,23 @@ describe('useAnnouncements', () => { }), ) }) + + 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() + }) }) From a50673114e0754a1c4c3c0dc80eec3f902582787 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:04:44 +0000 Subject: [PATCH 6/6] Fix announcement hook lint error Co-authored-by: liuliu-dev <6688812+liuliu-dev@users.noreply.github.com> --- 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 dce350aa5e3..6a639913e91 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -27,7 +27,7 @@ const getInputLabel = (input: HTMLInputElement | null) => { if (ariaLabel) return ariaLabel const label = Array.from(input.labels ?? []) - .map(label => label.textContent?.trim()) + .map(label => label.textContent.trim()) .filter(Boolean) .join(' ')