Skip to content

feat(ui5-toolbar): implement WAI-ARIA toolbar keyboard navigation - #13622

Merged
plamenivanov91 merged 23 commits into
mainfrom
tb-arrow-nav-1-june
Jul 21, 2026
Merged

feat(ui5-toolbar): implement WAI-ARIA toolbar keyboard navigation#13622
plamenivanov91 merged 23 commits into
mainfrom
tb-arrow-nav-1-june

Conversation

@plamenivanov91

@plamenivanov91 plamenivanov91 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Replace Tab-through-every-item with a proper arrow-key navigation pattern
per the WAI-ARIA toolbar pattern.

Keyboard handling

  • [Left]/[Right] — navigate between toolbar items (RTL-aware); absorbed at the first/last item (no wrap)
  • [Home]/[End] — jump to first/last item, unless the focused item manages its own internal navigation (e.g. Input caret, Breadcrumbs), in which case the item handles the key
  • [Tab]/[Shift+Tab] — exit the toolbar via the browser's natural tab order; no roving tabindex, items keep their own natural tabindex
  • Overflow popover is fully isolated: arrow keys and focusin handling are suppressed while the popover is open. On open, the first interactive overflow item receives focus.

Focus tracking

_lastFocusedItem tracks the current navigation target for re-entry (e.g. Shift+Tab back into the toolbar). It never mutates tabindex and is reconciled after reflow — a tracked item that moves to overflow or is removed no longer causes navigation to restart from the first item. When the overflow button disappears (items move back into the toolbar), focus moves to the last navigable item via focusForToolbarNavigation.

Interactivity detection

An item counts as interactive (and toward role="toolbar") only when its resolved focus target is actually focusable:

  • Text-only content (ui5-text, ui5-title) is skipped
  • Focus-delegating hosts (ui5-date-picker) are resolved through getFocusDomRef and remain navigable
  • href-less anchors that opt in via an explicit tabindex (e.g. ui5-link rendered as a button, used by the Breadcrumbs overflow arrow) stay navigable

ToolbarItemBase — hook API for complex items

New overridable members (no-ops in base):

  • getArrowNavState() — reports { atLeftEnd, atRightEnd } so the toolbar knows when a key press is at an internal boundary and should be handled by the item rather than moving to the next item
  • focusForToolbarNavigation(isForward) — direction-aware focus entry point

ToolbarItem — three navigation paths

  • _itemNavigation introspection — components that use ItemNavigation internally (e.g. Breadcrumbs, SegmentedButton) are detected via duck-typing; currentIndex/itemCount derive the boundary state
  • IToolbarArrowNavProvider interface — components that expose caret/selection position as boundary info explicitly implement IToolbarArrowNavProvider and its mandatory getArrowNavState() method
  • Multi-child fallbackToolbarItems with more than one slotted child (radio/checkbox groups) are treated as a navigable group; Left/Right and Home/End move within the group, exiting only at its boundary

IToolbarArrowNavProvider

New public interface (IToolbarArrowNavProvider.ts) with ToolbarArrowNavState type and isToolbarArrowNavProvider() type guard. Components that manage an internal text cursor implement this interface explicitly — the method's purpose is self-documenting at the declaration site.

Input — caret-aware boundary

Input implements IToolbarArrowNavProvider, reporting atLeftEnd/atRightEnd from the caret position and only when the selection is collapsed — a non-collapsed selection is not treated as a boundary, so Left/Right collapse it natively instead of exiting to the next toolbar item.

Accessibility docs

  • Updated keyboard-handling JSDoc on ui5-toolbar
  • Added WCAG 2.1 notes to accessibleName / accessibleNameRef

Tests (Toolbar.cy.tsx, ToolbarSelect.cy.tsx)

  • Arrow Left/Right navigation; no-wrap at the ends; Home/End to first/last
  • Tab traverses items via natural tab order
  • Input caret-aware: Left/Right exits only at the caret boundary; a range selection at a boundary stays in the field
  • Home not intercepted by the toolbar when an Input has focus
  • RTL reverses Left/Right direction
  • Checkbox group: arrow traversal within the group, Home/End within the group, and boundary exit
  • Text-only items non-interactive; focus-delegating hosts interactive; self-overflowed Breadcrumbs stays navigable
  • First overflow item focused on popover open; overflow button ArrowLeft navigates to the last visible toolbar item
  • ToolbarSelect programmatic selection stabilised against the light-DOM option / rendered select value

JIRA: BGSOFUIPIRIN-7018
Fixes: #12945

…2 (prevent page scroll on unhandled up/down)
Refactor toolbar keyboard handling around a single toolbar-owned flow.

- centralize arrow and tab navigation in Toolbar
- add movement-info and roving-tabindex hooks to ToolbarItemBase
- adapt grouped ToolbarItem content through shared internal target logic
- restore caret-aware movement for Input and TextArea
- apply forced tabindex to toolbar button/select templates
- remove redundant select-owned keyboard handling
- add Toolbar regressions for checkbox groups and overflow-button exit
@ui5-webcomponents-bot

ui5-webcomponents-bot commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

🧹 Preview deployment cleaned up: https://pr-13622--ui5-webcomponents.netlify.app

@ui5-webcomponents-bot
ui5-webcomponents-bot temporarily deployed to preview June 2, 2026 08:48 Inactive
@ui5-webcomponents-bot
ui5-webcomponents-bot temporarily deployed to preview June 2, 2026 09:45 Inactive
- Skip focusin/keydown handling when focus is inside the open overflow
  popover, preventing arrow-nav logic from firing inside the popover
- Skip forcedTabIndex on overflowed ToolbarButton/ToolbarSelect so
  overflow items keep their natural tab order
- Fix Tab-exit containment check to use shadow-DOM-aware walk
  (_isNodeInsideElement) instead of contains/shadowRoot.contains
- Remove own-fallback movement info path from ToolbarItem; items without
  _itemNavigation or getToolbarMovementInfo are now treated as single
  tab stops
- Drop dead WeakMap tab-index restoration machinery (no longer needed
  now that overflow items manage their own tab order)
@ui5-webcomponents-bot
ui5-webcomponents-bot temporarily deployed to preview June 3, 2026 10:24 Inactive
@plamenivanov91 plamenivanov91 changed the title fix(ui5-toolbar): simplify toolbar item keyboard navigation feat(ui5-toolbar): implement WAI-ARIA toolbar keyboard navigation Jun 3, 2026
Comment thread packages/main/src/Toolbar.ts Outdated
Comment thread packages/main/src/ToolbarItem.ts Outdated
Comment thread packages/main/src/Toolbar.ts Outdated
Comment thread packages/main/src/RadioButton.ts
Comment thread packages/main/src/Toolbar.ts Outdated
…ocus model

Addresses the three review comments on the WAI-ARIA toolbar navigation and
applies follow-up hardening, without changing the established
arrow/Tab/Home/End behaviour.

Review comments
- Remove the roving tabindex layer entirely (arrow nav uses composedPath /
  getActiveElement / _lastFocusedItem + .focus(), never tabindex; the forced
  tabindex also double-added a tab stop on composite items). Drops
  _applyRovingTabIndex, setToolbarForcedTabIndex, getToolbarFocusTarget, the
  forcedTabIndex property and its reset in addItemsAdditionalProperties.
- isInteractive now checks whether the resolved focus target is actually
  focusable, so text-only content (ui5-text, ui5-title) no longer counts as a
  nav stop or toward role="toolbar". Resolves through getFocusDomRef so
  focus-delegating hosts (ui5-date-picker) stay navigable, and keeps href-less
  anchors that opt in via an explicit tabindex (ui5-link as a button, e.g. the
  Breadcrumbs overflow arrow) so a self-overflowed Breadcrumbs item is not
  skipped.
- Remove dead code: the unused ToolbarArrowNavigation interface, the
  write-only _lastFocusedNavigationTarget, and the dead `?? true` in
  _getNavigableItems.

Correctness hardening
- Input/TextArea getArrowNavState: a non-collapsed selection is no longer a
  navigation boundary; Left/Right collapse the selection natively.
- Home/End inside a multi-child group jump to the first/last child instead of
  being no-ops.
- Reconcile _lastFocusedItem after reflow so Tab re-entry and arrow/Home/End
  don't silently restart from the first item.

Lifecycle / cleanup
- Attach focusin/keydown/close-overflow listeners once in onEnterDOM/onExitDOM
  instead of per render; cache composedPath and resolve overflowButtonDOM once.
- Extract _resolveChildFocusTarget(); remove `as unknown as` double-casts, the
  `(this as any)` access, and the non-null assertion in the Tab branch.

Tests (Toolbar.cy.tsx)
- Text-only items non-interactive; focus-delegating hosts interactive;
  self-overflowed Breadcrumbs stays navigable.
- Input range-selection stays in the input at a boundary; TextArea caret-aware
  navigation; RTL arrow direction; Home/End within a multi-child group.
- ToolbarSelect programmatic-selection test stabilised against light-DOM
  option / rendered value.
dobrinyonkov
dobrinyonkov previously approved these changes Jul 17, 2026
@plamenivanov91
plamenivanov91 requested review from MapTo0 and niyap July 17, 2026 08:20
@plamenivanov91

Copy link
Copy Markdown
Contributor Author

Hello, @MapTo0 , @niyap

Can somebody from your team review Input and TextArea files please?

Regards,
Plamen

@MapTo0 MapTo0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I do not understand the TA use case. Someone places a TA in the toolbar?

Implementation looks a bit like a magic. Can we implement an interface in the components that might be placed within a toolbar? something like IToolbarCompatible. Tomorrow if someone see this method, he/she would not know why its there at all.

Furthermore, considering all native inputs should be tackled you can also check other components: ComboBox, MultiComboBox, FileUploader (not sure), SearchField.

What about non ui5-webcomponents elements placed within a toolbar? Can users also mix components?

@plamenivanov91

Copy link
Copy Markdown
Contributor Author

Honestly, I do not understand the TA use case. Someone places a TA in the toolbar?

Implementation looks a bit like a magic. Can we implement an interface in the components that might be placed within a toolbar? something like IToolbarCompatible. Tomorrow if someone see this method, he/she would not know why its there at all.

Furthermore, considering all native inputs should be tackled you can also check other components: ComboBox, MultiComboBox, FileUploader (not sure), SearchField.

What about non ui5-webcomponents elements placed within a toolbar? Can users also mix components?

Hello there,

We've discussed this implementation approach on the architecture round and there is nothing magical about it.

We have three mechanisms used to decide whether or not we are on the first/last index inside a complex control nested in the ToolbarItem and they are all explained in the commit message.

ToolbarItem — three navigation paths
_itemNavigation introspection — components that use ItemNavigation internally (e.g. Breadcrumbs, SegmentedButton) are detected via duck-typing; currentIndex/itemCount derive the boundary state
getArrowNavState interface — explicit opt-in for components that expose caret/selection position as boundary info (e.g. Input, TextArea)
Multi-child fallback — ToolbarItems with more than one slotted child (radio/checkbox groups) are treated as a navigable group; Left/Right and Home/End move within the group, exiting only at its boundary

The adaptation of the rest of controls you mentioned is not a Toolbar responsibility. The ones we altered are for demonstration purposes. Also they cover most of our sample code.

For non ui5-webcomponents (like groups) placed within a ToolbarItem we have different mechanism as you can see in the code and in the commit message.

Regards,
Plamen Ivanov

@plamenivanov91
plamenivanov91 requested a review from MapTo0 July 17, 2026 11:41
…or caret-aware items

Address PR feedback requesting an explicit interface contract instead of
duck-typing for components that report their internal navigation boundary
state to the toolbar.

- Add IToolbarArrowNavProvider interface (IToolbarArrowNavProvider.ts) with
  mandatory getArrowNavState() and the ToolbarArrowNavState type. Includes
  isToolbarArrowNavProvider() type guard used by ToolbarItem.
- Input now explicitly declares `implements IToolbarArrowNavProvider`,
  making the intent of getArrowNavState() self-documenting.
- ToolbarItem Priority 2 replaces duck-typing (`typeof child.getArrowNavState
  === "function"`) with the isToolbarArrowNavProvider() guard.
- IToolbarItemContent (slot interface) no longer carries the optional
  getArrowNavState? property ? overflow behavior and boundary reporting
  are now separate contracts.
- ToolbarItemBase imports ToolbarArrowNavState from IToolbarArrowNavProvider.ts
  directly; no re-export needed since this is a new, unmerged feature.
- Remove TextArea from the toolbar keyboard navigation scope (reverted
  getArrowNavState implementation and dropped the corresponding Cypress test).
The MDX compiler (acorn) treats {?} as a JSX expression and fails to
parse the @link inline tag, breaking the website docs build for
IToolbarItemContent.mdx. Replace with a plain backtick reference that
MDX handles correctly.
@plamenivanov91
plamenivanov91 merged commit df3a8e8 into main Jul 21, 2026
13 checks passed
@plamenivanov91
plamenivanov91 deleted the tb-arrow-nav-1-june branch July 21, 2026 12:31
@sap-ui5-webcomponents-release

Copy link
Copy Markdown

🎉 This PR is included in version v2.25.0 🎉

The release is available on v2.25.0

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ui5-toolbar][A11y]: toolbar keyboard interaction does not align with WCAG requirement

4 participants