[combobox] VoiceOver + Safari reads no options after ArrowDown opens the list
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
# Bug report
## Current behavior
With VoiceOver + Safari on macOS, pressing ArrowDown in an empty, closed `Combobox.Input` opens the list, and VoiceOver announces `expanded list 10 items`. After that, no option is ever read. Further ArrowDown presses are silent, and Enter commits nothing audible.
The same combobox reads every option when the list is opened another way:
- typing a character first, then arrowing: `Apple (1 of 6)`, `Banana (2 of 6)`, …
- opening with `Combobox.Trigger`, then arrowing: `Apple (1 of 10)`, `Banana (2 of 10)`, …
In Chromium's accessibility tree everything looks correct. Focus stays on the input, and `aria-activedescendant` moves to each option. axe passes. Only the ArrowDown-to-open path is silent.
## Expected behavior
ArrowDown on a closed combobox opens the list and VoiceOver reads the highlighted option, then each option as the highlight moves, as it does when the list is opened by typing or by the trigger.
## Reproducible example
Any single-select `Combobox` whose input sits outside the popup (the default docs anatomy) with ~10 items:
```tsx
import { Combobox } from '@base-ui/react/combobox';
const fruits = ['Apple', 'Banana', 'Blueberry', 'Cherry', 'Grape', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear'];
export default function Repro() {
return (
Fruit
{(item: string) => (
{item}
)}
);
}
```
Steps: turn on VoiceOver, focus the input (Tab or click), press ArrowDown, then ArrowDown again.
### Isolated with plain HTML (no React, no Base UI)
A hand-written ARIA combobox with the same key handling reproduces the silence. The trigger is the list's **wrapper** becoming visible in the same moment `aria-activedescendant` is set:
| Plain-HTML variant (ArrowDown opens and highlights option 1) | VoiceOver |
|---|---|
| `
- ` toggles `hidden` itself | reads every option |
| listbox portalled to ``, toggles `hidden` itself | reads every option |
| listbox inside a wrapper `
| same wrapper, first highlight set **150ms after** the wrapper is shown | reads every option |
| same wrapper, opened with no highlight; the next ArrowDown highlights | reads every option |
| working variant plus `aria-hidden="true"` on `` while open | reads every option (so hiding outside nodes is not the cause) |
Core of the silent variant:
```html
- …10 role="option" items…
input.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowDown') return;
e.preventDefault();
if (popup.hidden) {
popup.hidden = false;
input.setAttribute('aria-expanded', 'true');
setActive(0); // same task as un-hiding the wrapper → silent
// setTimeout(() => setActive(0), 150); // → every option is read
} else {
setActive(active + 1);
}
});
```
Base UI's ArrowDown-to-open path has this shape. The popup's wrappers appear, and `useListNavigation`'s initial sync (`focusItemOnOpen: 'auto'` with the key recorded) sets the highlight in the same commit.
### A deferred highlight in Base UI is not enough on its own
I tried patching `floating-ui-react/hooks/useListNavigation` so that for `virtual` lists the "Initial sync" highlight is set 150ms after open. Unit tests and Chromium behaved correctly, but real VoiceOver + Safari with real keys was nondeterministic:
| Build | Trials that read options | Failure mode |
|---|---|---|
| @base-ui/react 1.8.0 | 0 of 5+ | silent, DOM focus stays on the input |
| 1.8.0 + deferred first highlight | 3 of 10 | VoiceOver says "scroll area …" on open and DOM focus moves to `body` |
Ruled out (4 trials each): page scroll (`scrollY` stays 0), `markOthers` `aria-hidden` on outside nodes (forced `ariaHidden: false` → 1 of 4), and an unrelated `role="alert"` on the page (removed → 1 of 4). Keydown events dispatched from page script never reproduce the problem. It needs a real key press with VoiceOver running.
Trials were captured by polling VoiceOver's AppleScript `content of last phrase` while System Events sent the keys, with `document.activeElement` and the active option mirrored into `document.title`. Happy to share the harness or raw transcripts.
## Base UI version
v1.8.0 (latest on npm at the time of writing)
## Which browser are you using?
Safari 26.6.2
## Which OS are you using?
macOS 26.6.2 (25G83)
## Which assistive tech are you using (if applicable)?
VoiceOver (macOS 26.6.2), default settings
## Additional context
Chromium shows the correct accessibility tree for every variant; only VoiceOver + WebKit loses track. Part of this may be a WebKit bug. Still, Base UI setting the initial highlight in the same commit that exposes the popup is the part a library can change. In our testing, the plain-HTML equivalent of "expose the popup, then highlight" is reliably read.
Contributor guide
Research direction
Reproduce the ArrowDown-to-open path with VoiceOver and Safari, then read the Combobox keyboard handling and floating-ui-react/hooks/useListNavigation initial sync, especially focusItemOnOpen: 'auto'. Compare the wrapper visibility and highlight timing with the working trigger and typing paths; done means real-key VoiceOver navigation reads the first and subsequent options without regressing unit or Chromium behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100