From 7edfa947d9619bbe7837592db0fcde7ae44747f2 Mon Sep 17 00:00:00 2001 From: gonzoblasco Date: Mon, 24 Aug 2026 14:20:43 -0300 Subject: [PATCH] fix(react-aria): only announce combobox section changes to VoiceOver --- .../test/combobox/ComboBox.test.js | 23 ++++++---- .../react-aria/src/combobox/useComboBox.ts | 46 +++++++++++-------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js b/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js index 9c1ea49af91..c51507a8c41 100644 --- a/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js +++ b/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js @@ -5135,7 +5135,7 @@ describe('ComboBox', function () { }); describe('keyboard navigating', function () { - it('should announce items when navigating with the arrow keys', async function () { + it('should not announce items when navigating with the arrow keys', async function () { renderComboBox(); await user.tab(); await user.keyboard('{ArrowDown}'); @@ -5143,17 +5143,19 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(announce).toHaveBeenLastCalledWith('One'); + // VoiceOver announces per-item details natively within a section, so we + // should not interrupt it with our own live region announcement. + expect(announce).not.toHaveBeenCalledWith('One'); await user.keyboard('{ArrowDown}'); act(() => { jest.runAllTimers(); }); - expect(announce).toHaveBeenLastCalledWith('Two'); + expect(announce).not.toHaveBeenCalledWith('Two'); }); - it('should announce when navigating to the selected item', async function () { + it('should not announce when navigating to the selected item', async function () { let {getByRole} = renderComboBox({selectedKey: '2'}); let combobox = getByRole('combobox'); act(() => { @@ -5164,7 +5166,8 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(announce).toHaveBeenLastCalledWith('Two, selected'); + // Same as above: VoiceOver handles per-item announcement natively. + expect(announce).not.toHaveBeenCalledWith('Two, selected'); }); it('should announce when navigating into a section with multiple items', async function () { @@ -5183,12 +5186,14 @@ describe('ComboBox', function () { 'Entered group Section One, with 3 options. One' ); + // Moving within the same section should not announce again, VoiceOver + // handles the per-item announcement natively. await user.keyboard('{ArrowDown}'); act(() => { jest.runAllTimers(); }); - expect(announce).toHaveBeenLastCalledWith('Two'); + expect(announce).not.toHaveBeenLastCalledWith('Two'); }); it('should announce when navigating into a section with a single item', async function () { @@ -5290,7 +5295,8 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(announce).toHaveBeenLastCalledWith('One'); + // No per-item announcement when arrowing; VoiceOver handles that natively. + expect(announce).not.toHaveBeenCalledWith('One'); await user.keyboard('{Enter}'); act(() => { @@ -5396,9 +5402,8 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); - expect(announce).toHaveBeenCalledTimes(2); + expect(announce).toHaveBeenCalledTimes(1); expect(announce).toHaveBeenNthCalledWith(1, '3 options available.'); - expect(announce).toHaveBeenNthCalledWith(2, 'One'); platformMock.mockRestore(); }); diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index 4887ea689eb..4d42759a503 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -391,9 +391,11 @@ export function useComboBox( } }; - // VoiceOver has issues with announcing aria-activedescendant properly on change - // (especially on iOS). We use a live region announcer to announce focus changes - // manually. In addition, section titles are announced when navigating into a new section. + // VoiceOver used to have issues with announcing aria-activedescendant properly on + // change (especially on iOS), so we announced focus changes manually via a live + // region. Current VoiceOver versions announce per-item details (selected state, + // role, item count) natively within a section, so we only announce when navigating + // into a new section, where VoiceOver is silent. let focusedItem = state.selectionManager.focusedKey != null && state.isOpen ? state.collection.getItem(state.selectionManager.focusedKey) @@ -404,22 +406,30 @@ export function useComboBox( let lastItem = useRef(itemKey); useEffect(() => { if (isAppleDevice() && focusedItem != null && itemKey != null && itemKey !== lastItem.current) { - let isSelected = state.selectionManager.isSelected(itemKey); let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; - let sectionTitle = - section?.['aria-label'] || - (typeof section?.rendered === 'string' ? section.rendered : '') || - ''; - - let announcement = stringFormatter.format('focusAnnouncement', { - isGroupChange: (section && sectionKey !== lastSection.current) ?? false, - groupTitle: sectionTitle, - groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, - optionText: focusedItem['aria-label'] || focusedItem.textValue || '', - isSelected - }); - - announce(announcement); + let isGroupChange = (section && sectionKey !== lastSection.current) ?? false; + + // VoiceOver now announces per-item details natively (selected state, role, + // item count) when arrowing within a section, so announcing every focused + // item here interrupts that richer announcement. Only announce manually when + // navigating into a new section, where VoiceOver is silent. + if (isGroupChange) { + let isSelected = state.selectionManager.isSelected(itemKey); + let sectionTitle = + section?.['aria-label'] || + (typeof section?.rendered === 'string' ? section.rendered : '') || + ''; + + let announcement = stringFormatter.format('focusAnnouncement', { + isGroupChange: true, + groupTitle: sectionTitle, + groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, + optionText: focusedItem['aria-label'] || focusedItem.textValue || '', + isSelected + }); + + announce(announcement); + } } lastSection.current = sectionKey;