Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ describe('Combobox', () => {
const listbox = result.container.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull();
expect(window.getComputedStyle(listbox!).display).toEqual('none');
// The listbox should be aria-hidden when pre-rendered but the dropdown is not open,
// to prevent screen readers from announcing the listbox content (e.g. double reading the selected value).
expect(listbox).toHaveAttribute('aria-hidden', 'true');
});

it('renders the popup under document.body by default', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ export const useCombobox_unstable = (props: ComboboxProps, ref: React.Ref<HTMLIn
...baseState,
};

// When pre-rendering the listbox for focus (hasFocus=true but open=false), hide it from AT.
// This prevents screen readers (e.g. Narrator) from announcing the listbox content
// when the combobox is focused but the dropdown is not open, which caused the selected value
// to be read twice.
if (state.listbox && !open) {
state.listbox['aria-hidden'] = 'true';
}

const { targetDocument } = useFluent();

useOnClickOutside({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ describe('Dropdown', () => {
const listbox = result.container.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull();
expect(window.getComputedStyle(listbox!).display).toEqual('none');
// The listbox should be aria-hidden when pre-rendered but the dropdown is not open,
// to prevent screen readers from announcing the listbox content (e.g. double reading the selected value).
expect(listbox).toHaveAttribute('aria-hidden', 'true');
});

it('renders an open listbox', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ export const useDropdown_unstable = (props: DropdownProps, ref: React.Ref<HTMLBu
...baseState,
};

// When pre-rendering the listbox for focus (hasFocus=true but open=false), hide it from AT.
// This prevents screen readers (e.g. Narrator) from announcing the listbox content
// when the dropdown is focused but not open, which caused the selected value to be read twice.
if (state.listbox && !open) {
state.listbox['aria-hidden'] = 'true';
}

const onClearButtonClick = useEventCallback(
mergeCallbacks(state.clearButton?.onClick, (ev: React.MouseEvent<HTMLButtonElement>) => {
clearSelection(ev);
Expand Down
42 changes: 41 additions & 1 deletion packages/react/src/components/ComboBox/ComboBox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';

import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { isConformant } from '../../common/isConformant';
Expand Down Expand Up @@ -550,6 +550,46 @@ describe('ComboBox', () => {
expect(combobox.getAttribute('aria-activedescendant')).toEqual('two');
});

it('does not call select() on the input more than once when re-focusing after a keyboard interaction', () => {
// Regression test for https://github.com/microsoft/fluentui/issues/35858
// When _focusInputAfterClose is left as true after a keyboard open/close cycle,
// componentDidUpdate was calling _onFocus() (and thus input.select()) on every render
// while the combobox was focused. This caused screen readers (e.g. Narrator) to
// announce the selected value twice.
const { getByRole } = render(<ComboBox defaultSelectedKey="1" options={DEFAULT_OPTIONS2} />);
const combobox = getByRole('combobox') as HTMLInputElement;

// Spy before any interaction to capture all select() calls
const selectSpy = jest.spyOn(combobox, 'select');

// 1. Focus the combobox and open it with keyboard (sets _focusInputAfterClose = true)
act(() => {
combobox.focus();
});
userEvent.keyboard('{enter}');

// 2. Select an option to close the dropdown
userEvent.keyboard('{enter}');
selectSpy.mockClear();

// 3. Navigate away
act(() => {
fireEvent.blur(combobox);
});
selectSpy.mockClear();

// 4. Navigate back
act(() => {
fireEvent.focus(combobox);
});

// select() should be called at most once (from _onFocus via the focus handler),
// NOT a second time from componentDidUpdate.
expect(selectSpy.mock.calls.length).toBeLessThanOrEqual(1);

selectSpy.mockRestore();
});

it('Cannot insert text while disabled', () => {
const { getByRole } = render(<ComboBox defaultSelectedKey="1" options={DEFAULT_OPTIONS2} disabled />);
const combobox = getByRole('combobox');
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,10 @@ class ComboBoxInternal extends React.Component<IComboBoxInternalProps, IComboBox
(!allowFreeform && !allowFreeInput) ||
text !== prevProps.text)))
) {
// Reset the flag before calling _onFocus to prevent duplicate calls on subsequent renders.
// Without this reset, screen readers (e.g. Narrator) may announce the selected value twice
// when the user navigates back to the combobox after a previous keyboard interaction.
this._focusInputAfterClose = false;
this._onFocus();
}

Expand Down
Loading