diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx
index be5b32156db1..b0e8af1ed162 100644
--- a/packages/main/cypress/specs/Toolbar.cy.tsx
+++ b/packages/main/cypress/specs/Toolbar.cy.tsx
@@ -5,6 +5,7 @@ import ToolbarSelectOption from "../../src/ToolbarSelectOption.js";
import ToolbarSeparator from "../../src/ToolbarSeparator.js";
import ToolbarSpacer from "../../src/ToolbarSpacer.js";
import ToolbarItem from "../../src/ToolbarItem.js";
+import CheckBox from "../../src/CheckBox.js";
import add from "@ui5/webcomponents-icons/dist/add.js";
import decline from "@ui5/webcomponents-icons/dist/decline.js";
import employee from "@ui5/webcomponents-icons/dist/employee.js";
@@ -46,6 +47,157 @@ describe("Toolbar general interaction", () => {
});
});
+ it("Should move focus inside checkbox group and leave group on boundary with single arrow press", () => {
+ cy.mount(
+
+
+
+
+
+
+
+
+ );
+
+ cy.get("[ui5-checkbox][text='Checkbox 1']")
+ .realClick()
+ .should("be.focused");
+
+ cy.realPress("ArrowRight");
+ cy.get("[ui5-checkbox][text='Checkbox 2']")
+ .should("be.focused");
+
+ cy.realPress("ArrowRight");
+ cy.get("[ui5-checkbox][text='Checkbox 3']")
+ .should("be.focused");
+
+ cy.realPress("ArrowRight");
+ cy.get("[ui5-toolbar-button][text='After group']")
+ .should("be.focused");
+ });
+
+ it("Should navigate into and out of overflow button with single arrow press", () => {
+ cy.viewport(320, 1080);
+
+ cy.mount(
+
+
+
+
+
+
+
+ );
+
+ cy.get("#overflow-arrow-toolbar")
+ .shadow()
+ .find(".ui5-tb-overflow-btn")
+ .should("not.have.class", "ui5-tb-overflow-btn-hidden");
+
+ cy.get("#overflow-arrow-toolbar")
+ .then($toolbar => {
+ const toolbar = $toolbar[0] as Toolbar & {
+ _setCurrentItem: (item: ToolbarItem | HTMLElement) => void;
+ overflowButtonDOM: HTMLElement;
+ };
+ toolbar._setCurrentItem(toolbar.overflowButtonDOM);
+ toolbar.overflowButtonDOM.focus();
+ });
+
+ cy.realPress("ArrowRight");
+ cy.get("#overflow-arrow-toolbar")
+ .then($toolbar => {
+ const toolbar = $toolbar[0] as Toolbar & {
+ _lastFocusedItem?: ToolbarItem | HTMLElement;
+ };
+ const firstToolbarItem = $toolbar.find("[ui5-toolbar-button][text='One Long']")[0] as ToolbarItem;
+ expect(toolbar._lastFocusedItem).to.equal(firstToolbarItem);
+ });
+ });
+
+ it("Should navigate between toolbar items with Left/Right arrow keys", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("[ui5-toolbar-button][text='First']").realClick().should("be.focused");
+
+ cy.realPress("ArrowRight");
+ cy.get("[ui5-toolbar-button][text='Second']").should("be.focused");
+
+ cy.realPress("ArrowRight");
+ cy.get("[ui5-toolbar-button][text='Third']").should("be.focused");
+
+ cy.realPress("ArrowLeft");
+ cy.get("[ui5-toolbar-button][text='Second']").should("be.focused");
+ });
+
+ it("Should move focus to first/last item with Home/End keys", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("[ui5-toolbar-button][text='Second']").realClick().should("be.focused");
+
+ cy.realPress("End");
+ cy.get("[ui5-toolbar-button][text='Last']").should("be.focused");
+
+ cy.realPress("Home");
+ cy.get("[ui5-toolbar-button][text='First']").should("be.focused");
+ });
+
+ it("Should not scroll the page when pressing Up/Down inside the toolbar", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("[ui5-toolbar-button][text='Button']").realClick().should("be.focused");
+
+ cy.window().its("scrollY").as("scrollBefore");
+ cy.realPress("ArrowDown");
+ cy.window().its("scrollY").then(scrollAfter => {
+ cy.get("@scrollBefore").should("equal", scrollAfter);
+ });
+ });
+
+ it("Should focus first overflow item when overflow popover opens", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.wait(500);
+
+ cy.get("[ui5-toolbar]")
+ .shadow()
+ .find(".ui5-tb-overflow-btn")
+ .should("not.have.class", "ui5-tb-overflow-btn-hidden")
+ .realClick();
+
+ cy.get("[ui5-toolbar]")
+ .shadow()
+ .find(".ui5-overflow-popover")
+ .should("have.attr", "open", "open");
+
+ cy.get("[ui5-toolbar-button][text='One']")
+ .should("be.focused");
+ });
+
it("shouldn't have toolbar button as popover opener when there is spacer before last toolbar item", () => {
cy.mount(
@@ -482,11 +634,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]")
- .shadow()
- .find(".ui5-tb-item")
- .eq(3)
+ // 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/src/Input.ts b/packages/main/src/Input.ts
index e6a83907a0d8..e6b8eee6083c 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 { ToolbarMovementInfo } from "./ToolbarItemBase.js";
// Templates
import InputTemplate from "./InputTemplate.js";
@@ -1660,6 +1661,27 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement
return this.nativeInput;
}
+ getToolbarMovementInfo(): ToolbarMovementInfo | 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 caretIndex = input.selectionStart ?? 0;
+ const valueLength = input.value?.length ?? 0;
+
+ return {
+ currentIndex: caretIndex,
+ itemCount: valueLength + 1,
+ };
+ }
+
/**
* Returns a reference to the native input element
* @protected
diff --git a/packages/main/src/RadioButton.ts b/packages/main/src/RadioButton.ts
index 36b06b30eed1..4df2410d4b7a 100644
--- a/packages/main/src/RadioButton.ts
+++ b/packages/main/src/RadioButton.ts
@@ -68,7 +68,6 @@ let activeRadio: RadioButton;
* @public
* @csspart outer-ring - Used to style the outer ring of the `ui5-radio-button`.
* @csspart inner-ring - Used to style the inner ring of the `ui5-radio-button`.
- * @csspart root - Used to style the root DOM element of the component.
*/
@customElement({
tag: "ui5-radio-button",
diff --git a/packages/main/src/TextArea.ts b/packages/main/src/TextArea.ts
index d42797eeba13..384657af74cb 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 { ToolbarMovementInfo } from "./ToolbarItemBase.js";
import TextAreaTemplate from "./TextAreaTemplate.js";
@@ -436,6 +438,27 @@ class TextArea extends UI5Element implements IFormInputElement {
return this.getDomRef()!.querySelector("textarea")!;
}
+ getToolbarMovementInfo(): ToolbarMovementInfo | 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 caretIndex = textArea.selectionStart ?? 0;
+ const valueLength = textArea.value?.length ?? 0;
+
+ return {
+ currentIndex: caretIndex,
+ itemCount: valueLength + 1,
+ };
+ }
+
_onkeydown(e: KeyboardEvent) {
this._keyDown = true;
diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts
index 5fff5e3880d0..af13877a7e9a 100644
--- a/packages/main/src/Toolbar.ts
+++ b/packages/main/src/Toolbar.ts
@@ -8,6 +8,16 @@ 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,
+ isUp,
+ isDown,
+ 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,12 +37,13 @@ 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";
import type Popover from "./Popover.js";
import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
+import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js";
type ToolbarMinWidthChangeEventDetail = {
minWidth: number,
@@ -57,8 +68,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 +132,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 +145,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 +190,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 +208,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 +323,12 @@ class Toolbar extends UI5Element {
this.detachListeners();
this.attachListeners();
if (getActiveElement() === this.overflowButtonDOM?.getFocusDomRef() && this.hideOverflowButton) {
- const lastItem = this.interactiveItems.at(-1);
- lastItem?.focus();
+ const items = this._getNavigableItems();
+ const lastItem = items.at(-1);
+ if (lastItem) {
+ this._lastFocusedItem = lastItem;
+ lastItem.focusForToolbarNavigation(false);
+ }
}
this.prePopulateAlwaysOverflowItems();
}
@@ -315,10 +340,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 +528,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 +544,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 +585,332 @@ class Toolbar extends UI5Element {
getCachedItemWidth(id: string) {
return this.ITEMS_WIDTH_MAP.get(id);
}
+
+ /**
+ * Keyboard Navigation
+ */
+
+ _applyDisabledItemsAccessibility() {
+ this.standardItems
+ .filter(item => item.isInteractive && !item.hidden && !item.isOverflowed
+ && "disabled" in item && (item as { disabled?: boolean }).disabled)
+ .forEach(item => {
+ const focusRef = item.getFocusDomRef();
+ if (focusRef) {
+ focusRef.tabIndex = -1;
+ focusRef.setAttribute("aria-disabled", "true");
+ }
+ });
+ }
+
+ _applyRovingTabIndex() {
+ const items = this._getNavigationChain();
+
+ if (!items.length) {
+ return;
+ }
+
+ if (!this._lastFocusedItem || !items.includes(this._lastFocusedItem)) {
+ this._lastFocusedItem = items[0];
+ }
+
+ this._setCurrentItem(this._lastFocusedItem);
+
+ this._applyDisabledItemsAccessibility();
+ }
+
+ _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)) {
+ const moved = this._focusOutsideToolbar(isTabPrevious(e), e.composedPath());
+ if (moved) {
+ e.preventDefault();
+ }
+ return;
+ }
+
+ if (isUp(e) || isDown(e)) {
+ e.preventDefault();
+ 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 movementInfo = currentTarget.getToolbarMovementInfo();
+ if (movementInfo) {
+ const { currentIndex, itemCount } = movementInfo;
+ const atForwardBoundary = itemCount <= 1 || (isForward && currentIndex >= itemCount - 1);
+ const atBackwardBoundary = itemCount <= 1 || (isBackward && currentIndex <= 0);
+
+ if (atForwardBoundary || atBackwardBoundary) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (isForward) {
+ this._moveToNext();
+ } else {
+ this._moveToPrev();
+ }
+ return;
+ }
+
+ if (currentTarget.moveWithinToolbarItem(isForward)) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ // Not at boundary -> nested control (or fallback mover) handles traversal.
+ 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;
+ const allItems = this._getNavigableItems();
+ allItems.forEach(i => {
+ i.setToolbarForcedTabIndex(i === item ? "0" : "-1");
+ });
+
+ const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null;
+ if (overflowButton) {
+ overflowButton.tabIndex = item === overflowButton ? 0 : -1;
+ }
+ }
+
+ _moveToNext() {
+ this._moveToItem((current, items) => (current + 1) % items.length, true);
+ }
+
+ _moveToPrev() {
+ this._moveToItem((current, items) => (current === 0 ? items.length - 1 : current - 1), 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);
+ const nextItem = items[nextIndex];
+ this._setCurrentItem(nextItem);
+
+ if (nextItem instanceof ToolbarItemBase) {
+ nextItem.focusForToolbarNavigation(isForward);
+ } else {
+ nextItem.focus();
+ }
+ }
+ _focusOutsideToolbar(backward: boolean, path: Array) {
+ const active = getActiveElement() as HTMLElement | null;
+ const tabbables = getTabbableElements(document.body);
+
+ if (!tabbables.length) {
+ return false;
+ }
+
+ const currentIndexFromActive = active
+ ? tabbables.findIndex(el => el === active || el.contains(active) || active.contains(el))
+ : -1;
+
+ const currentIndexFromPath = currentIndexFromActive === -1
+ ? tabbables.findIndex(el => path.includes(el))
+ : -1;
+
+ const currentIndex = currentIndexFromActive !== -1 ? currentIndexFromActive : currentIndexFromPath;
+
+ const isInsideToolbar = (el: HTMLElement) => this._isNodeInsideElement(el, this);
+
+ if (currentIndex !== -1) {
+ const step = backward ? -1 : 1;
+ for (let i = currentIndex + step; i >= 0 && i < tabbables.length; i += step) {
+ const candidate = tabbables[i];
+ if (!isInsideToolbar(candidate)) {
+ candidate.focus();
+ return true;
+ }
+ }
+ }
+
+ const insideIndices = tabbables
+ .map((el, index) => ({ el, index }))
+ .filter(({ el }) => isInsideToolbar(el))
+ .map(({ index }) => index);
+
+ if (!insideIndices.length) {
+ return false;
+ }
+
+ const firstInside = insideIndices[0];
+ const lastInside = insideIndices[insideIndices.length - 1];
+ const startIndex = backward ? firstInside - 1 : lastInside + 1;
+ const step = backward ? -1 : 1;
+
+ for (let i = startIndex; i >= 0 && i < tabbables.length; i += step) {
+ const candidate = tabbables[i];
+ if (!isInsideToolbar(candidate)) {
+ candidate.focus();
+ return true;
+ }
+ }
+
+ return false;
+ }
}
Toolbar.define();
diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts
index b0337805ff69..6293bb7b66ea 100644
--- a/packages/main/src/ToolbarItem.ts
+++ b/packages/main/src/ToolbarItem.ts
@@ -1,9 +1,11 @@
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 ToolbarItemTemplate from "./ToolbarItemTemplate.js";
import ToolbarItemCss from "./generated/themes/ToolbarItem.css.js";
import ToolbarItemBase from "./ToolbarItemBase.js";
+import type { ToolbarMovementInfo } from "./ToolbarItemBase.js";
import type { DefaultSlot } from "@ui5/webcomponents-base";
/**
@@ -17,6 +19,14 @@ import type { DefaultSlot } from "@ui5/webcomponents-base";
interface IToolbarItemContent extends HTMLElement {
overflowCloseEvents?: string[];
hasOverflow?: boolean;
+ getToolbarMovementInfo?: () => ToolbarMovementInfo | undefined;
+}
+
+interface IItemNavigationOwner extends HTMLElement {
+ _itemNavigation?: {
+ _getCurrentItem: () => unknown;
+ };
+ _getFocusableItems?: () => Array;
}
/**
@@ -53,8 +63,20 @@ interface IToolbarItemContent extends HTMLElement {
class ToolbarItem extends ToolbarItemBase {
_maxWidth = 0;
_wrapperChecked = false;
+ _lastFocusedNavigationTarget?: HTMLElement;
fireCloseOverflowRef = this.fireCloseOverflow.bind(this);
+ get handlesOwnKeyboardNavigation(): boolean {
+ const child = this.item[0] as IToolbarItemContent | undefined;
+ if (!child) {
+ return false;
+ }
+
+ return this._supportsItemNavigationMovementInfo(child)
+ || typeof child.getToolbarMovementInfo === "function"
+ || this.item.length > 1;
+ }
+
closeOverflowSet = {
"ui5-button": ["click"],
"ui5-select": ["change"],
@@ -147,6 +169,217 @@ 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,
+ };
+ }
+
+ _supportsItemNavigationMovementInfo(child: IToolbarItemContent): boolean {
+ const itemNavigationOwner = child as IItemNavigationOwner;
+ return typeof itemNavigationOwner._itemNavigation?._getCurrentItem === "function"
+ && typeof itemNavigationOwner._getFocusableItems === "function";
+ }
+
+ _getItemNavigationMovementInfo(child: IToolbarItemContent): ToolbarMovementInfo | undefined {
+ if (!this._supportsItemNavigationMovementInfo(child)) {
+ return undefined;
+ }
+
+ const itemNavigationOwner = child as IItemNavigationOwner;
+ const items = itemNavigationOwner._getFocusableItems!();
+ const current = itemNavigationOwner._itemNavigation!._getCurrentItem();
+ const currentIndex = current ? items.indexOf(current) : -1;
+
+ if (currentIndex === -1) {
+ return undefined;
+ }
+
+ return {
+ currentIndex,
+ itemCount: items.length,
+ };
+ }
+
+ getToolbarMovementInfo(): ToolbarMovementInfo | undefined {
+ const child = this.item[0] as IToolbarItemContent | undefined;
+ if (!child) {
+ return undefined;
+ }
+
+ const itemNavigationInfo = this._getItemNavigationMovementInfo(child);
+ if (itemNavigationInfo) {
+ return itemNavigationInfo;
+ }
+
+ if (typeof child.getToolbarMovementInfo === "function") {
+ return child.getToolbarMovementInfo();
+ }
+
+ // Multi-child item (e.g. radio button group, checkbox group): report
+ // current position so toolbar knows when to cross the boundary.
+ if (this.item.length > 1) {
+ const { items, currentIndex } = this._getCurrentNavigationState();
+ if (currentIndex !== -1) {
+ return { currentIndex, itemCount: items.length };
+ }
+ }
+
+ return undefined;
+ }
+
+ moveWithinToolbarItem(isForward: boolean): boolean {
+ if (this.item.length <= 1) {
+ return false;
+ }
+
+ const { items, currentIndex } = this._getCurrentNavigationState();
+ if (currentIndex === -1) {
+ return false;
+ }
+
+ const nextIndex = isForward ? currentIndex + 1 : currentIndex - 1;
+ if (nextIndex < 0 || nextIndex >= items.length) {
+ return false;
+ }
+
+ this._handleNavigationTarget(items[nextIndex]);
+ return true;
+ }
+
+ setToolbarForcedTabIndex(tabIndex: string) {
+ this.forcedTabIndex = tabIndex;
+
+ const { items, current } = this._getCurrentNavigationState();
+ if (!items.length) {
+ super.setToolbarForcedTabIndex(tabIndex);
+ return;
+ }
+
+ if (current) {
+ this._lastFocusedNavigationTarget = current;
+ }
+
+ const fallbackTarget = items[0];
+ const focusTarget = this._lastFocusedNavigationTarget && items.includes(this._lastFocusedNavigationTarget)
+ ? this._lastFocusedNavigationTarget
+ : fallbackTarget;
+
+ items.forEach(target => {
+ target.tabIndex = tabIndex === "0" && target === focusTarget ? 0 : -1;
+ });
+ }
+
+ 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..6023d45507a1 100644
--- a/packages/main/src/ToolbarItemBase.ts
+++ b/packages/main/src/ToolbarItemBase.ts
@@ -8,6 +8,15 @@ type ToolbarItemEventDetail = {
targetRef: HTMLElement;
}
+export type ToolbarMovementInfo = {
+ currentIndex: number;
+ itemCount: number;
+};
+
+export interface ToolbarMovementEnabler {
+ getToolbarMovementInfo(): ToolbarMovementInfo | undefined;
+}
+
@event("close-overflow", {
bubbles: true,
})
@@ -48,6 +57,73 @@ class ToolbarItemBase extends UI5Element {
@property({ type: Boolean })
preventOverflowClosing = false;
+ /**
+ * Roving tabindex managed by toolbar for horizontal navigation.
+ * @private
+ */
+ @property({ noAttribute: true })
+ forcedTabIndex = "-1";
+
+ /**
+ * Defines whether the item exposes internal navigation semantics.
+ *
+ * Toolbar uses this to keep grouped content as a single tab stop while
+ * still allowing arrow navigation within the item.
+ * @default false
+ * @protected
+ * @since 2.22.0
+ */
+ get handlesOwnKeyboardNavigation(): boolean {
+ return false;
+ }
+
+ _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
+ */
+ focusForToolbarNavigation(isForward: boolean) {
+ if (isForward) {
+ // no-op
+ }
+ this.getToolbarFocusTarget()?.focus();
+ }
+
+ moveWithinToolbarItem(isForward: boolean): boolean { // eslint-disable-line @typescript-eslint/no-unused-vars
+ return false;
+ }
+
+ getToolbarMovementInfo(): ToolbarMovementInfo | undefined {
+ return undefined;
+ }
+
_isOverflowed: boolean = false;
get isOverflowed(): boolean {
@@ -104,6 +180,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;
}