@@ -162,6 +414,30 @@ describe("Toolbar general interaction", () => {
.should("exist", "hidden class attached to tb button, meaning it's not shown as expected");
});
+ it("Should handle toolbar-select with width larger than the toolbar", () => {
+ cy.mount(
+
+
+
+ Option 1
+ Option 2
+
+
+
+ );
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(500);
+
+ cy.get("#toolbar-wide-select")
+ .shadow()
+ .find(".ui5-tb-overflow-btn")
+ .should("not.have.class", "ui5-tb-overflow-btn-hidden");
+
+ cy.get("[ui5-toolbar-select]")
+ .should("have.prop", "isOverflowed", true);
+ });
+
it("Should call event handlers on item", () => {
cy.mount(
@@ -482,9 +758,8 @@ describe("Toolbar general interaction", () => {
// Resize the viewport to make the overflow button disappear
cy.viewport(800, 1080);
- // Verify the focus shifts to the last interactive element outside the overflow popover
- cy.get("[ui5-toolbar-button]")
- .last()
+ // Verify the focus shifts to the last visible toolbar button
+ cy.get("[ui5-toolbar-button][text='Button 5']")
.should("be.focused");
});
diff --git a/packages/main/cypress/specs/ToolbarSelect.cy.tsx b/packages/main/cypress/specs/ToolbarSelect.cy.tsx
index f7c1fef6a698..cde5b8dd0fab 100644
--- a/packages/main/cypress/specs/ToolbarSelect.cy.tsx
+++ b/packages/main/cypress/specs/ToolbarSelect.cy.tsx
@@ -299,17 +299,16 @@ describe("Toolbar general interaction", () => {
>
);
- // Set up button click handler
- cy.get("#btn").then($btn => {
- $btn.get(0).addEventListener("ui5-click", () => {
- // First, deselect all options
- const select = document.querySelector("ui5-toolbar-select");
+ // Attach click handler via document to avoid jQuery wrapping issues
+ cy.document().then(doc => {
+ const btn = doc.getElementById("btn");
+ btn?.addEventListener("click", () => {
+ const select = doc.querySelector("ui5-toolbar-select");
const options = select?.querySelectorAll("ui5-toolbar-select-option");
options?.forEach(opt => {
(opt as ToolbarSelectOption).selected = false;
});
- // Then select option 2
- const opt2 = document.getElementById("opt2") as ToolbarSelectOption;
+ const opt2 = doc.getElementById("opt2") as ToolbarSelectOption;
opt2.selected = true;
});
});
@@ -360,11 +359,12 @@ describe("Toolbar general interaction", () => {
);
// Set up button to attempt selecting multiple options
- cy.get("#selectMultiple").then($btn => {
- $btn.get(0).addEventListener("ui5-click", () => {
- const opt1 = document.getElementById("opt1") as ToolbarSelectOption;
- const opt2 = document.getElementById("opt2") as ToolbarSelectOption;
- const opt3 = document.getElementById("opt3") as ToolbarSelectOption;
+ cy.document().then(doc => {
+ const btn = doc.getElementById("selectMultiple");
+ btn?.addEventListener("click", () => {
+ const opt1 = doc.getElementById("opt1") as ToolbarSelectOption;
+ const opt2 = doc.getElementById("opt2") as ToolbarSelectOption;
+ const opt3 = doc.getElementById("opt3") as ToolbarSelectOption;
// Try to select multiple options
opt1.selected = true;
diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts
index e6a83907a0d8..7f6fc97738ff 100644
--- a/packages/main/src/Input.ts
+++ b/packages/main/src/Input.ts
@@ -62,6 +62,7 @@ import InputType from "./types/InputType.js";
import type Popover from "./Popover.js";
import type Icon from "./Icon.js";
import type { IIcon } from "./Icon.js";
+import type { ToolbarArrowNavState } from "./ToolbarItemBase.js";
// Templates
import InputTemplate from "./InputTemplate.js";
@@ -1660,6 +1661,24 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
return this.nativeInput;
}
+ getArrowNavState(): ToolbarArrowNavState | undefined {
+ const input = this.getInputDOMRefSync();
+ if (!input) {
+ return undefined;
+ }
+
+ const active = getActiveElement() as HTMLElement | null;
+ const isInputFocused = !!active && (active === input || input.contains(active));
+ if (!isInputFocused) {
+ return undefined;
+ }
+
+ const caret = input.selectionStart ?? 0;
+ const len = input.value?.length ?? 0;
+
+ return { atLeftEnd: caret === 0, atRightEnd: caret >= len };
+ }
+
/**
* Returns a reference to the native input element
* @protected
diff --git a/packages/main/src/TextArea.ts b/packages/main/src/TextArea.ts
index d42797eeba13..b1876662dadf 100644
--- a/packages/main/src/TextArea.ts
+++ b/packages/main/src/TextArea.ts
@@ -19,6 +19,8 @@ import { isEscape } from "@ui5/webcomponents-base/dist/Keys.js";
import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js";
import type Popover from "./Popover.js";
import type InputComposition from "./features/InputComposition.js";
+import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
+import type { ToolbarArrowNavState } from "./ToolbarItemBase.js";
import TextAreaTemplate from "./TextAreaTemplate.js";
@@ -436,6 +438,24 @@ class TextArea extends UI5Element implements IFormInputElement {
return this.getDomRef()!.querySelector("textarea")!;
}
+ getArrowNavState(): ToolbarArrowNavState | undefined {
+ const textArea = this.getDomRef()?.querySelector("textarea");
+ if (!textArea) {
+ return undefined;
+ }
+
+ const active = getActiveElement() as HTMLElement | null;
+ const isTextAreaFocused = !!active && (active === textArea || textArea.contains(active));
+ if (!isTextAreaFocused) {
+ return undefined;
+ }
+
+ const caret = textArea.selectionStart ?? 0;
+ const len = textArea.value?.length ?? 0;
+
+ return { atLeftEnd: caret === 0, atRightEnd: caret >= len };
+ }
+
_onkeydown(e: KeyboardEvent) {
this._keyDown = true;
diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts
index e631640780dc..1375d7b004fc 100644
--- a/packages/main/src/Toolbar.ts
+++ b/packages/main/src/Toolbar.ts
@@ -8,6 +8,14 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
+import {
+ isLeft,
+ isRight,
+ isHome,
+ isEnd,
+ isTabNext,
+ isTabPrevious,
+} from "@ui5/webcomponents-base/dist/Keys.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import "@ui5/webcomponents-icons/dist/overflow.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
@@ -27,7 +35,7 @@ import type ToolbarAlign from "./types/ToolbarAlign.js";
import type ToolbarDesign from "./types/ToolbarDesign.js";
import ToolbarItemOverflowBehavior from "./types/ToolbarItemOverflowBehavior.js";
-import type ToolbarItemBase from "./ToolbarItemBase.js";
+import ToolbarItemBase from "./ToolbarItemBase.js";
import type ToolbarSeparator from "./ToolbarSeparator.js";
import type Button from "./Button.js";
@@ -57,8 +65,9 @@ function parsePxValue(styleSet: CSSStyleDeclaration, propertyName: string): numb
* ### Keyboard Handling
* The `ui5-toolbar` provides advanced keyboard handling.
*
- * - The control is not interactive, but can contain of interactive elements
- * - [Tab] - iterates through elements
+ * - [Left]/[Right] - navigate among toolbar items
+ * - [Home]/[End] - move to first/last toolbar item
+ * - [Tab] / [Shift]+[Tab] - exit the toolbar
*
* ### ES6 Module Import
* `import "@ui5/webcomponents/dist/Toolbar.js";`
@@ -120,6 +129,11 @@ class Toolbar extends UI5Element {
/**
* Defines the accessible ARIA name of the component.
+ *
+ * **Note:** It is strongly recommended to always set this property or `accessibleNameRef`
+ * when the toolbar has `role="toolbar"` (i.e. when it contains more than one interactive item).
+ * Without an accessible name, screen readers will announce the toolbar without any context,
+ * making it harder for keyboard-only and AT users to understand its purpose.
* @default undefined
* @public
*/
@@ -128,6 +142,9 @@ class Toolbar extends UI5Element {
/**
* Receives id(or many ids) of the elements that label the input.
+ *
+ * **Note:** When the toolbar has `role="toolbar"`, at least one of `accessibleName` or
+ * `accessibleNameRef` should be provided to satisfy WCAG 2.1 success criterion 4.1.2.
* @default undefined
* @public
*/
@@ -170,9 +187,12 @@ class Toolbar extends UI5Element {
_onResize!: ResizeObserverCallback;
_onCloseOverflow!: EventListener;
+ _onFocusIn!: (e: FocusEvent) => void;
+ _onKeyDown!: (e: KeyboardEvent) => void;
itemsToOverflow: Array = [];
itemsWidth = 0;
minContentWidth = 0;
+ _lastFocusedItem?: ToolbarItemBase | HTMLElement;
ITEMS_WIDTH_MAP: Map = new Map();
@@ -185,19 +205,17 @@ class Toolbar extends UI5Element {
constructor() {
super();
-
this._onResize = this.onResize.bind(this);
this._onCloseOverflow = this.closeOverflow.bind(this);
+ this._onFocusIn = this._onfocusin.bind(this);
+ this._onKeyDown = this._onkeydown.bind(this);
}
-
/**
* Read-only members
*/
-
get overflowButtonSize(): number {
return this.overflowButtonDOM?.getBoundingClientRect().width || 0;
}
-
get padding(): number {
const toolbarComputedStyle = getComputedStyle(this.getDomRef()!);
return calculateCSSREMValue(toolbarComputedStyle, "--_ui5-toolbar-padding-left")
@@ -302,8 +320,12 @@ class Toolbar extends UI5Element {
this.detachListeners();
this.attachListeners();
if (getActiveElement() === this.overflowButtonDOM?.getFocusDomRef() && this.hideOverflowButton) {
- const lastItem = this.standardItems.filter(item => item.isInteractive).at(-1);
- lastItem?.focus();
+ const items = this.standardItems.filter(item => item.isToolbarNavigatable);
+ const lastItem = items.at(-1);
+ if (lastItem) {
+ this._lastFocusedItem = lastItem;
+ lastItem.focusForToolbarNavigation(false);
+ }
}
this.prePopulateAlwaysOverflowItems();
}
@@ -315,10 +337,14 @@ class Toolbar extends UI5Element {
this.items.forEach(item => {
this.addItemsAdditionalProperties(item);
});
+ this._applyRovingTabIndex();
}
addItemsAdditionalProperties(item: ToolbarItemBase) {
item.isOverflowed = this.overflowItems.indexOf(item) !== -1;
+ if (item.isOverflowed) {
+ item.setToolbarForcedTabIndex("0");
+ }
const itemWrapper = this.shadowRoot!.querySelector(`#${item._individualSlot}`) as HTMLElement;
if (item.hasOverflow && !item.isOverflowed && itemWrapper) {
// We need to set the max-width to the self-overflow element in order ot prevent it from taking all the available space,
@@ -499,6 +525,8 @@ class Toolbar extends UI5Element {
onOverflowPopoverOpened() {
this.popoverOpen = true;
+ const firstItem = this.overflowItems.find(item => item.isInteractive && !item.hidden);
+ firstItem?.focusForToolbarNavigation(true);
}
onResize() {
@@ -513,10 +541,14 @@ class Toolbar extends UI5Element {
attachListeners() {
this.addEventListener("ui5-close-overflow", this._onCloseOverflow);
+ this.addEventListener("focusin", this._onFocusIn);
+ this.addEventListener("keydown", this._onKeyDown, true);
}
detachListeners() {
this.removeEventListener("ui5-close-overflow", this._onCloseOverflow);
+ this.removeEventListener("focusin", this._onFocusIn);
+ this.removeEventListener("keydown", this._onKeyDown, true);
}
onToolbarItemChange() {
@@ -550,6 +582,248 @@ class Toolbar extends UI5Element {
getCachedItemWidth(id: string) {
return this.ITEMS_WIDTH_MAP.get(id);
}
+
+ /**
+ * Keyboard Navigation
+ */
+
+ _applyRovingTabIndex() {
+ const items = this._getNavigableItems();
+
+ if (!items.length) {
+ return;
+ }
+
+ items.forEach(item => item.setToolbarForcedTabIndex("0"));
+
+ const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null;
+ if (overflowButton && !this.hideOverflowButton) {
+ overflowButton.tabIndex = 0;
+ }
+
+ if (!this._lastFocusedItem || !this._getNavigationChain().includes(this._lastFocusedItem)) {
+ this._lastFocusedItem = items[0];
+ }
+ }
+
+ _isFocusInsideOverflow(path: Array): boolean {
+ const popover = this.getOverflowPopover();
+ if (!popover) {
+ return false;
+ }
+ // Check popover shadow DOM (e.g. focus trap sentinels)
+ if ((path as Node[]).some(node => popover === node || popover.shadowRoot === node)) {
+ return true;
+ }
+ // Check if the event originates from a slotted overflow item (light DOM, not contained by popover)
+ const overflowItemSet = new Set(this.overflowItems);
+ return (path as Node[]).some(node => overflowItemSet.has(node as ToolbarItemBase));
+ }
+
+ _onfocusin(e: FocusEvent) {
+ if (this.popoverOpen && this._isFocusInsideOverflow(e.composedPath())) {
+ return;
+ }
+
+ const currentTarget = this._findItemByPath(e.composedPath())
+ || this._findOverflowButtonByPath(e.composedPath())
+ || this._findCurrentTargetByActiveElement();
+
+ if (currentTarget) {
+ this._setCurrentItem(currentTarget);
+ }
+ }
+
+ _onkeydown(e: KeyboardEvent) {
+ if (this.popoverOpen && this._isFocusInsideOverflow(e.composedPath())) {
+ return;
+ }
+
+ if (isTabNext(e) || isTabPrevious(e)) {
+ this._setCurrentItem(
+ this._findItemByPath(e.composedPath())
+ || this._findOverflowButtonByPath(e.composedPath())
+ || this._findCurrentTargetByActiveElement()
+ || this._lastFocusedItem!,
+ );
+ return;
+ }
+
+ const isForward = this.effectiveDir === "rtl" ? isLeft(e) : isRight(e);
+ const isBackward = this.effectiveDir === "rtl" ? isRight(e) : isLeft(e);
+ const isHomeKey = isHome(e);
+ const isEndKey = isEnd(e);
+
+ if (!isForward && !isBackward && !isHomeKey && !isEndKey) {
+ return;
+ }
+
+ const currentTarget = this._findItemByPath(e.composedPath())
+ || this._findOverflowButtonByPath(e.composedPath())
+ || this._findCurrentTargetByActiveElement()
+ || this._lastFocusedItem;
+ if (!currentTarget) {
+ return;
+ }
+
+ if (currentTarget instanceof ToolbarItemBase && (isForward || isBackward)) {
+ const state = currentTarget.getArrowNavState();
+ if (state) {
+ const atEnd = isForward ? state.atRightEnd : state.atLeftEnd;
+ if (!atEnd) {
+ return;
+ }
+ }
+ }
+
+ if (currentTarget instanceof ToolbarItemBase && (isHomeKey || isEndKey)) {
+ const state = currentTarget.getArrowNavState();
+ if (state) {
+ return;
+ }
+ }
+
+ if (isHomeKey) {
+ this._moveToFirst();
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+
+ if (isEndKey) {
+ this._moveToLast();
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+
+ if (isForward || isBackward) {
+ if (isForward) {
+ this._moveToNext();
+ } else {
+ this._moveToPrev();
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ _findItemByPath(path: Array): ToolbarItemBase | undefined {
+ return (path as HTMLElement[]).find(el => el instanceof ToolbarItemBase) as ToolbarItemBase;
+ }
+
+ _findOverflowButtonByPath(path: Array): HTMLElement | undefined {
+ const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null;
+ if (!overflowButton) {
+ return undefined;
+ }
+
+ const active = getActiveElement() as HTMLElement | null;
+ return path.includes(overflowButton)
+ || !!(active && this._isNodeInsideElement(active, overflowButton))
+ ? overflowButton
+ : undefined;
+ }
+
+ _isNodeInsideElement(node: Node, element: HTMLElement) {
+ let current: Node | null = node;
+
+ while (current) {
+ if (current === element) {
+ return true;
+ }
+
+ const root = current.getRootNode?.();
+ if (root instanceof ShadowRoot) {
+ current = root.host;
+ } else {
+ current = current.parentNode;
+ }
+ }
+
+ return false;
+ }
+
+ _findCurrentTargetByActiveElement(): ToolbarItemBase | HTMLElement | undefined {
+ const active = getActiveElement() as HTMLElement | null;
+ if (!active) {
+ return undefined;
+ }
+
+ const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null;
+ if (overflowButton && this._isNodeInsideElement(active, overflowButton)) {
+ return overflowButton;
+ }
+
+ return this._getNavigableItems().find(item => {
+ const focusRef = item.getFocusDomRef();
+ if (focusRef && this._isNodeInsideElement(active, focusRef)) {
+ return true;
+ }
+
+ return item._getNavigationTargets().some(target => {
+ return this._isNodeInsideElement(active, target);
+ });
+ });
+ }
+
+ _getNavigationChain() {
+ const chain: Array = [...this._getNavigableItems()];
+ const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null;
+
+ if (!this.hideOverflowButton && overflowButton) {
+ chain.push(overflowButton);
+ }
+
+ return chain;
+ }
+
+ _getNavigableItems() {
+ return this.items.filter(item => (item.isToolbarNavigatable ?? true) && !item.isOverflowed);
+ }
+
+ _setCurrentItem(item: ToolbarItemBase | HTMLElement) {
+ this._lastFocusedItem = item;
+ }
+
+ _moveToNext() {
+ this._moveToItem((current, items) => Math.min(current + 1, items.length - 1), true);
+ }
+
+ _moveToPrev() {
+ this._moveToItem(current => Math.max(current - 1, 0), false);
+ }
+
+ _moveToFirst() {
+ this._moveToItem(() => 0, true);
+ }
+
+ _moveToLast() {
+ this._moveToItem((_, items) => items.length - 1, false);
+ }
+
+ _moveToItem(indexCalc: (currentIndex: number, items: Array) => number, isForward: boolean) {
+ const items = this._getNavigationChain();
+ if (!items.length) {
+ return;
+ }
+ const currentIndex = this._lastFocusedItem ? items.indexOf(this._lastFocusedItem) : -1;
+ const nextIndex = indexCalc(currentIndex === -1 ? 0 : currentIndex, items);
+
+ if (nextIndex === currentIndex) {
+ return;
+ }
+
+ const nextItem = items[nextIndex];
+ this._setCurrentItem(nextItem);
+
+ if (nextItem instanceof ToolbarItemBase) {
+ nextItem.focusForToolbarNavigation(isForward);
+ } else {
+ nextItem.focus();
+ }
+ }
}
Toolbar.define();
diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts
index b0337805ff69..78753fc5bf5d 100644
--- a/packages/main/src/ToolbarItem.ts
+++ b/packages/main/src/ToolbarItem.ts
@@ -1,9 +1,15 @@
import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
+import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
+import {
+ isLeft,
+ isRight,
+} from "@ui5/webcomponents-base/dist/Keys.js";
import ToolbarItemTemplate from "./ToolbarItemTemplate.js";
import ToolbarItemCss from "./generated/themes/ToolbarItem.css.js";
import ToolbarItemBase from "./ToolbarItemBase.js";
+import type { ToolbarArrowNavState } from "./ToolbarItemBase.js";
import type { DefaultSlot } from "@ui5/webcomponents-base";
/**
@@ -17,6 +23,14 @@ import type { DefaultSlot } from "@ui5/webcomponents-base";
interface IToolbarItemContent extends HTMLElement {
overflowCloseEvents?: string[];
hasOverflow?: boolean;
+ getArrowNavState?: () => ToolbarArrowNavState | undefined;
+}
+
+interface IItemNavigationOwner extends HTMLElement {
+ _itemNavigation?: {
+ _getCurrentItem: () => HTMLElement | undefined;
+ };
+ _getFocusableItems?: () => Array<{ getFocusDomRef?: () => HTMLElement }>;
}
/**
@@ -53,7 +67,9 @@ interface IToolbarItemContent extends HTMLElement {
class ToolbarItem extends ToolbarItemBase {
_maxWidth = 0;
_wrapperChecked = false;
+ _lastFocusedNavigationTarget?: HTMLElement;
fireCloseOverflowRef = this.fireCloseOverflow.bind(this);
+ _onMultiChildKeydownRef = this._onMultiChildKeydown.bind(this);
closeOverflowSet = {
"ui5-button": ["click"],
@@ -72,10 +88,16 @@ class ToolbarItem extends ToolbarItemBase {
onBeforeRendering(): void {
this.checkForWrapper();
this.attachCloseOverflowHandlers();
+ if (this.item.length > 1) {
+ this.addEventListener("keydown", this._onMultiChildKeydownRef, true);
+ } else {
+ this.removeEventListener("keydown", this._onMultiChildKeydownRef, true);
+ }
}
onExitDOM(): void {
this.detachCloseOverflowHandlers();
+ this.removeEventListener("keydown", this._onMultiChildKeydownRef, true);
}
/**
@@ -147,6 +169,206 @@ class ToolbarItem extends ToolbarItemBase {
get hasOverflow(): boolean {
return this.item[0]?.hasOverflow ?? false;
}
+
+ getFocusDomRef(): HTMLElement | undefined {
+ const child = this.item[0];
+ if (child && typeof (child as HTMLElement & { getFocusDomRef?: () => HTMLElement }).getFocusDomRef === "function") {
+ return (child as HTMLElement & { getFocusDomRef: () => HTMLElement }).getFocusDomRef() || child;
+ }
+
+ if (child) {
+ return this._getFirstTabbableDescendant(child) || child;
+ }
+
+ return super.getFocusDomRef();
+ }
+
+ _getFirstTabbableDescendant(root: HTMLElement): HTMLElement | null {
+ return root.querySelector("a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])");
+ }
+
+ getFocusDomRefForNavigation(forward: boolean): HTMLElement | undefined {
+ const targets = this._getNavigationTargets();
+ if (!targets.length) {
+ return this.getFocusDomRef();
+ }
+
+ return forward ? targets[0] : targets[targets.length - 1];
+ }
+
+ _handleNavigationTarget(target: HTMLElement) {
+ this._lastFocusedNavigationTarget = target;
+ const hostTarget = this._resolveNavigationHost(target);
+
+ if (this._isRadioButtonHost(hostTarget)) {
+ const radio = hostTarget as HTMLElement & {
+ disabled?: boolean;
+ readonly?: boolean;
+ checked?: boolean;
+ click: () => void;
+ };
+
+ hostTarget.focus();
+
+ if (!radio.disabled && !radio.readonly && !radio.checked) {
+ radio.click();
+ }
+
+ return;
+ }
+
+ hostTarget.focus();
+ }
+
+ _resolveNavigationHost(target: HTMLElement): HTMLElement {
+ if (this._isUI5Host(target)) {
+ return target;
+ }
+
+ const root = target.getRootNode();
+ if (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
+ return root.host;
+ }
+
+ return target;
+ }
+
+ _isUI5Host(target: HTMLElement): boolean {
+ const ctor = target.constructor as { getMetadata?: () => unknown };
+ return typeof ctor.getMetadata === "function";
+ }
+
+ _isRadioButtonHost(target: HTMLElement): boolean {
+ return target.hasAttribute("ui5-radio-button");
+ }
+
+ _matchesNavigationTarget(target: HTMLElement, candidate: HTMLElement): boolean {
+ if (target === candidate || target.contains(candidate) || !!target.shadowRoot?.contains(candidate)) {
+ return true;
+ }
+
+ const host = this._resolveNavigationHost(candidate);
+ return target === host || target.contains(host) || !!target.shadowRoot?.contains(host);
+ }
+
+ _getNavigationTargets(): HTMLElement[] {
+ return this.item
+ .filter(child => !("disabled" in child && !!(child as { disabled?: boolean }).disabled))
+ .map(child => {
+ if (typeof (child as HTMLElement & { getFocusDomRef?: () => HTMLElement }).getFocusDomRef === "function") {
+ return (child as HTMLElement & { getFocusDomRef: () => HTMLElement }).getFocusDomRef() || child;
+ }
+
+ return this._getFirstTabbableDescendant(child) || child;
+ });
+ }
+
+ _getCurrentNavigationState() {
+ const items = this._getNavigationTargets();
+ const active = getActiveElement() as HTMLElement | null;
+ const current = active
+ ? items.find(item => this._matchesNavigationTarget(item, active))
+ : undefined;
+ const currentIndex = current ? items.indexOf(current) : -1;
+
+ return {
+ items,
+ current,
+ currentIndex,
+ };
+ }
+
+ _onMultiChildKeydown(e: KeyboardEvent) {
+ const isForward = this.effectiveDir === "rtl" ? isLeft(e) : isRight(e);
+ const isBackward = this.effectiveDir === "rtl" ? isRight(e) : isLeft(e);
+ if (!isForward && !isBackward) {
+ return;
+ }
+
+ const { items, currentIndex } = this._getCurrentNavigationState();
+ if (currentIndex === -1) {
+ return;
+ }
+
+ const nextIndex = isForward ? currentIndex + 1 : currentIndex - 1;
+ if (nextIndex < 0 || nextIndex >= items.length) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ this._handleNavigationTarget(items[nextIndex]);
+ }
+
+ getArrowNavState(): ToolbarArrowNavState | undefined {
+ const child = this.item[0] as IToolbarItemContent | undefined;
+ if (!child) {
+ return undefined;
+ }
+
+ // Priority 1: ItemNavigation-based components (e.g. Breadcrumbs, SegmentedButton)
+ const itemNavOwner = child as IItemNavigationOwner;
+ if (typeof itemNavOwner._itemNavigation?._getCurrentItem === "function"
+ && typeof itemNavOwner._getFocusableItems === "function") {
+ const items = itemNavOwner._getFocusableItems();
+ const current = itemNavOwner._itemNavigation._getCurrentItem();
+ const currentIndex = current
+ ? items.findIndex(item => (item.getFocusDomRef ? item.getFocusDomRef() : item) === current)
+ : -1;
+ if (currentIndex !== -1) {
+ return {
+ atLeftEnd: currentIndex === 0,
+ atRightEnd: currentIndex === items.length - 1,
+ };
+ }
+ }
+
+ // Priority 2: explicit new interface (e.g. Input, TextArea)
+ if (typeof child.getArrowNavState === "function") {
+ return child.getArrowNavState();
+ }
+
+ // Priority 3: proprietary multi-child fallback (e.g. bare checkbox group)
+ if (this.item.length <= 1) {
+ return undefined;
+ }
+
+ const { items, currentIndex } = this._getCurrentNavigationState();
+ if (currentIndex === -1) {
+ return undefined;
+ }
+
+ return {
+ atLeftEnd: currentIndex === 0,
+ atRightEnd: currentIndex === items.length - 1,
+ };
+ }
+
+ setToolbarForcedTabIndex(tabIndex: string) {
+ this.forcedTabIndex = tabIndex;
+
+ const { items, current } = this._getCurrentNavigationState();
+ if (!items.length) {
+ super.setToolbarForcedTabIndex(tabIndex);
+ return;
+ }
+
+ if (current) {
+ this._lastFocusedNavigationTarget = current;
+ }
+
+ items.forEach(target => {
+ target.tabIndex = Number(tabIndex);
+ });
+ }
+
+ focusForToolbarNavigation(isForward: boolean) {
+ const target = this.getFocusDomRefForNavigation(isForward);
+ if (target) {
+ this._lastFocusedNavigationTarget = target;
+ target.focus();
+ }
+ }
}
export type {
diff --git a/packages/main/src/ToolbarItemBase.ts b/packages/main/src/ToolbarItemBase.ts
index 23f754c43820..64a64101c992 100644
--- a/packages/main/src/ToolbarItemBase.ts
+++ b/packages/main/src/ToolbarItemBase.ts
@@ -8,6 +8,15 @@ type ToolbarItemEventDetail = {
targetRef: HTMLElement;
}
+export type ToolbarArrowNavState = {
+ atLeftEnd: boolean;
+ atRightEnd: boolean;
+};
+
+export interface ToolbarArrowNavigation {
+ getArrowNavState(): ToolbarArrowNavState;
+}
+
@event("close-overflow", {
bubbles: true,
})
@@ -48,6 +57,54 @@ class ToolbarItemBase extends UI5Element {
@property({ type: Boolean })
preventOverflowClosing = false;
+ /**
+ * Roving tabindex managed by toolbar for horizontal navigation.
+ * @private
+ */
+ @property({ noAttribute: true })
+ forcedTabIndex = "-1";
+
+ _getNavigationTargets(): HTMLElement[] {
+ const ref = this.getFocusDomRef();
+ return ref ? [ref] : [];
+ }
+
+ /**
+ * Called by toolbar to apply roving tabindex.
+ * Override in items that need custom tabindex handling.
+ * @private
+ */
+ setToolbarForcedTabIndex(tabIndex: string) {
+ this.forcedTabIndex = tabIndex;
+ const target = this.getToolbarFocusTarget();
+ if (target) {
+ target.tabIndex = Number(tabIndex);
+ }
+ }
+
+ /**
+ * Return the DOM element that should receive focus for toolbar navigation.
+ * Override in items with custom focus targets.
+ * @private
+ */
+ getToolbarFocusTarget(): HTMLElement | null {
+ return this.getFocusDomRef() as HTMLElement;
+ }
+
+ /**
+ * Focus entry point when toolbar navigates into this item.
+ * Override in complex items (e.g., Breadcrumbs) to handle direction-aware entry.
+ * @private
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ focusForToolbarNavigation(isForward: boolean) {
+ this.getToolbarFocusTarget()?.focus();
+ }
+
+ getArrowNavState(): ToolbarArrowNavState | undefined {
+ return undefined;
+ }
+
_isOverflowed: boolean = false;
get isOverflowed(): boolean {
@@ -104,6 +161,10 @@ class ToolbarItemBase extends UI5Element {
return true;
}
+ get isToolbarNavigatable(): boolean {
+ return this.isInteractive && !this.hidden && !("disabled" in this && (this as any).disabled);
+ }
+
get hasOverflow(): boolean {
return false;
}