Skip to content

Accessibility improvements, Keyboard navigation in Safari - #600

Open
eschmar wants to merge 13 commits into
axadrn:mainfrom
PhaseSoftware:a11y-conformance
Open

Accessibility improvements, Keyboard navigation in Safari#600
eschmar wants to merge 13 commits into
axadrn:mainfrom
PhaseSoftware:a11y-conformance

Conversation

@eschmar

@eschmar eschmar commented Aug 8, 2026

Copy link
Copy Markdown

First off, really like your approach here, so nice that this project exists. Thanks!

Accessibility fixes for a number of components from the following agent loop:

  • Replayed Headless UI's and React Aria's own assertion and interaction helpers against shadcn-templ server-rendered markup
  • Fix failing tests iteratively with headless chromium and playwright, while verifying no visual changes happened (0 pixel diff in screenshots)
  • Hand over a three-way-split review page for each commit for manual human testing (shadcn-templ before, after and base ui next to each other with aria viewer)

Changes:

  • Keyboard navigation in Safari. Safari's default keyboard model skips <button> carrying no explicit tabindex. Base UI's useButton applies tabIndex 0 to everything it renders, while shadcn-templ relied on the native default (none).
  • Popups take focus in Safari. The menu, select and popover opened and then ignored the keyboard there; Safari refuses focus() for a short window after activating a button, which Base UI survives only because React focuses from an effect a task later.
  • Tabs gain arrow-key navigation; tabs.ListProps.ActivateOnFocus (ports Base UI's activateOnFocus, default false).
  • Menu opens on ArrowDown/ArrowUp, menu arrows now wrap
  • ARIA semantics: tabs gain the full tablist/tab/tabpanel pattern (it had none); the accordion panel gains aria-controls and a labelled role="region"; the menu is named by its trigger; the combobox now publishes aria-activedescendant to enable arrow navigation.
  • Updated docs

Deliberate differences from Base UI left as-is:

  • Disabled items stay out of arrow navigation. Base UI marks them aria-disabled and lets the arrows land on them; shadcn-templ's are natively disabled buttons that cannot take focus
  • The select listbox is left unnamed. Naming it after the trigger would need the content to resolve an id only Trigger can know, which would take JavaScript, and the reference leaves it unnamed too.
  • The select does not wrap and the menu does: Menu.Root defaults loopFocus to true, Select.Root has no such prop.
  • The accordion answers the arrow keys where Base UI does not. That predates this branch and the WAI-ARIA pattern lists those keys as optional, so it stays.

Tests before/after

  ┌─────────────────┬────────────────────┬────────────────────┬──────┐
  │                 │        main        │  a11y-conformance  │ gain │
  ├─────────────────┼────────────────────┼────────────────────┼──────┤
  │ Chromium        │ 101 pass / 49 fail │ 139 pass / 11 fail │ +38  │
  ├─────────────────┼────────────────────┼────────────────────┼──────┤
  │ WebKit (Safari) │ 94 pass / 56 fail  │ 136 pass / 14 fail │ +42  │
  └─────────────────┴────────────────────┴────────────────────┴──────┘

Ten of the eleven Chromium failures are checks shadcn/ui on Base UI fails too. The exception is one React Aria helper that asserts the combobox listbox leaves the DOM when it closes, server-rendered components hide instead. With these changes, Safari and Chromium should behave identically. The 3 tests failing additionally in Safari are due to the test tooling, playwright chromium vs. webkit.

Full disclosure, code by Claude Opus 5, review/verification by me. Hope this is helpful!

@joeblew999

Copy link
Copy Markdown

Thanks 🙏

eschmar added 13 commits August 15, 2026 12:11
Base UI's useButton applies tabIndex 0 to everything it renders and
ours leaned on the native default, which Safari does not honour: with
its default keyboard setting it walks text fields and links and skips a
<button> that carries no explicit tabindex. Every trigger in the
library was unreachable by Tab there, while the Base UI pendants beside
them were fine. The switch and the checkbox already worked, because a
span with a role has to declare one.

button.Button covers the triggers composed from it, and the accordion
and select triggers render their own button. Each keeps a caller's
tabindex when Attributes carries one, the way data-slot already does.
The anchor branch of button.Button is left alone: links are in Safari's
tab order already.
Opening moved focus into the popup and the arrow keys took it from
there, except in Safari, where focus stayed on the trigger and the
arrows then did nothing — the menu and the select opened and were
immediately dead to the keyboard. The popover had the same gap: its
panel never took focus there.

Safari refuses focus() for a short and variable window after it
activates a button or shows a popover. floating-ui-react's enqueueFocus
queues a single animation frame and that suffices for the reference,
because it focuses from a React effect, already a task later than the
handler that opened the popup. Ported here it is a race: a microtask
plus a frame, and a task plus a frame, both land inside the window
often enough to strand focus on the trigger.

So enqueueFocus keeps the reference's shape — one call site per popup,
the shouldFocus guard that stops a popup which closed meanwhile from
pulling focus back — and retries across a few frames instead of once,
stopping the moment it takes. That is immune to how long the window is.
Chromium succeeds on the first attempt.

Enter and Space stay on the detail-0 click a native button synthesises,
the way useClick and useListNavigation split it in the reference; only
the arrows are taken on keydown.
…ase ui

The list rendered three plain buttons over three plain divs, so a
screen reader announced unrelated controls followed by loose text.
Tabs.List, Tabs.Tab and Tabs.Panel bring the tablist, tab and tabpanel
roles, aria-selected, the panel each tab controls and the roving
tabindex; tabs.js moves the state attributes with the visual ones.

The aria-controls pair derives from the tabs id and the tab's value
rather than a generated id, so an htmx swap of one half keeps it.
useTabsList binds them and ours bound nothing, so reaching the last tab
meant tabbing through every one before it. The arrows follow the
orientation and the writing direction, Home and End jump to the ends,
disabled tabs are skipped and the movement wraps.

Moving focus does not activate. That is Base UI's activateOnFocus=false
default, and the right one here: a panel is free to load its content
when it becomes active, so selecting on every keystroke would cost a
request per arrow press. Enter and Space activate, through the click a
native button already fires, and the roving tabindex follows the focus
so tabbing away and back returns to where the user was.

ListProps.ActivateOnFocus ports the opt-in for the other behaviour.
aria-expanded was there but nothing tied the button to the content, so
the state change was announced with no way to reach what it revealed.
Accordion.Trigger brings aria-controls and Accordion.Panel the region
role labelled by its trigger; the item resolves an id once and the two
halves derive the pair from it, the way collapsible shares its id.
The popup carried no accessible name, so it was announced as a bare
menu. The menu-button pattern names it after its trigger, which had no
id to point at; the id derives from the one Root, Trigger and Content
already share.
It only had one when the caller passed TriggerProps.ID to point a label
at it, which left the control opening the listbox unaddressable
everywhere else. The fallback derives from the id the root already
shares, so a caller-supplied id still wins and keeps its label.

The listbox popup stays unlabelled: naming it after the trigger would
mean the content resolving an id only Trigger can know, and base ui
leaves it unnamed too.
It was the only disclosure trigger in the set without one, so a caller
wiring their own aria-describedby had nothing to point at. Derived from
the id Root, Trigger and Content already share, like dropdownmenu.
Arrowing the list moved data-highlighted and scrolled the option into
view but announced nothing. Focus stays on the input in this pattern,
so aria-activedescendant is the only channel there is and the arrows
were silent without it.

Items derive an id from the combobox id and their value, and
setHighlight points the input at it — there rather than in the key
handler, so hover, auto-highlight and open-with-selection get it too.

Select keeps moving real focus onto the option instead, the other
pattern the authoring practices allow and the one base ui uses.
The menu-button pattern opens on ArrowDown with the first item focused
and on ArrowUp with the last, which is how you reach the end of a long
menu in one keystroke; ours only answered click, Enter and Space and
let the arrows scroll the page.

open's focusFirst flag becomes focusOn, and itemsIn already filters
disabled items so ArrowUp lands on the last enabled one.

Opening consumes the key: the popup is open by the time the navigation
handler runs in the same dispatch, so without stopImmediatePropagation
it walks the highlight a second time and ArrowDown lands on the second
item.
ArrowDown on the last item stopped dead and ArrowUp on the first did
the same, so reaching the other end of a long menu meant arrowing all
the way back. Menu.Root defaults loopFocus to true in the reference,
and our own tabs list already wraps, so the menu was the odd one out
twice over. The context menu carried an identical copy of moveFocus and
the identical gap.

Disabled items stay out of the walk. The reference marks them
aria-disabled and lets the arrows land on them; ours are natively
disabled buttons, which cannot take focus at all, and changing that is
a wider decision than this commit.
The same gap the dropdown menu, the select and the popover had: opening
moved focus into the popup and the arrow keys took it from there, except
in Safari, where focus stayed on the trigger and the menu was dead to
the keyboard from the moment it appeared.

It was missed the first time because the context menu is not one of the
components the review harness renders, so nothing exercised it. Same
enqueueFocus port as the other three.

The combobox was checked for this too and needs nothing: it keeps focus
on the input and moves aria-activedescendant instead, so it never asks
Safari for the focus it would refuse.
The submenu arrived with this release carrying role=menu and nothing to
name it, the same gap the top level menu had: a screen reader announces
"menu" with no indication of which item opened it, which is the one
thing that tells you where you are once you are two levels deep.

Sub now generates the id its two halves share, the way DropdownMenu
already does for the root, SubTrigger renders it and SubContent points
aria-labelledby at it. Callers who pass their own SubTriggerProps.ID
keep it.
@eschmar

eschmar commented Aug 15, 2026

Copy link
Copy Markdown
Author

Rebased and included the new submenu

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants