diff --git a/packages/main/cypress/specs/Bar.cy.tsx b/packages/main/cypress/specs/Bar.cy.tsx
index 452afe4e859c3..39fc5250828ed 100644
--- a/packages/main/cypress/specs/Bar.cy.tsx
+++ b/packages/main/cypress/specs/Bar.cy.tsx
@@ -1,5 +1,6 @@
import Bar from "../../src/Bar.js";
import Button from "../../src/Button.js";
+import Input from "../../src/Input.js";
describe("Bar Accessibility", () => {
it("Should use accessibleName property as aria-label", () => {
@@ -64,4 +65,146 @@ describe("Bar Accessibility", () => {
.find(".ui5-bar-root")
.should("have.attr", "aria-label", "External Navigation Label");
});
+});
+
+describe("Bar Keyboard Navigation", () => {
+ it("ArrowRight moves focus forward through all three slots", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-mid").should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("be.focused");
+ });
+
+ it("ArrowLeft moves focus backward", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-mid").should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("be.focused");
+ cy.realPress("ArrowLeft");
+ cy.get("#btn-mid").should("be.focused");
+ cy.realPress("ArrowLeft");
+ cy.get("#btn-start").should("be.focused");
+ });
+
+ it("ArrowRight at last item does not move focus", () => {
+ cy.mount(
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("be.focused");
+ });
+
+ it("ArrowLeft at first item does not move focus", () => {
+ cy.mount(
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("ArrowLeft");
+ cy.get("#btn-start").should("be.focused");
+ });
+
+ it("End key jumps to last focusable item", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("End");
+ cy.get("#btn-end").should("be.focused");
+ });
+
+ it("Home key jumps to first focusable item", () => {
+ cy.mount(
+
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("End");
+ cy.get("#btn-end").should("be.focused");
+ cy.realPress("Home");
+ cy.get("#btn-start").should("be.focused");
+ });
+
+ it("ArrowRight inside input with mid-text caret does not move focus", () => {
+ cy.mount(
+
+
+
+
+ );
+
+ cy.get("#input-start").realClick();
+ // place caret at position 2 (middle of "hello")
+ cy.get("#input-start").shadow().find("input").then($input => {
+ $input[0].setSelectionRange(2, 2);
+ });
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("not.be.focused");
+ });
+
+ it("ArrowRight at end of input text moves focus to next item", () => {
+ cy.mount(
+
+
+
+
+ );
+
+ cy.get("#input-start").realClick();
+ cy.get("#input-start").shadow().find("input").then($input => {
+ $input[0].setSelectionRange(5, 5); // end of "hello"
+ });
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("be.focused");
+ });
+
+ it("Navigation is disabled when accessibleRole is None", () => {
+ cy.mount(
+
+
+
+
+ );
+
+ cy.get("#btn-start").realClick().should("be.focused");
+ cy.realPress("ArrowRight");
+ cy.get("#btn-end").should("not.be.focused");
+ });
});
\ No newline at end of file
diff --git a/packages/main/src/Bar.ts b/packages/main/src/Bar.ts
index fc6b293e5fd1b..1cb9140d6ec14 100644
--- a/packages/main/src/Bar.ts
+++ b/packages/main/src/Bar.ts
@@ -1,4 +1,4 @@
-import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js";
+import UI5Element, { instanceOfUI5Element } from "@ui5/webcomponents-base/dist/UI5Element.js";
import type { DefaultSlot, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
@@ -6,6 +6,16 @@ import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
+import isElementHidden from "@ui5/webcomponents-base/dist/util/isElementHidden.js";
+import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
+import {
+ isLeft,
+ isRight,
+ isHome,
+ isEnd,
+} from "@ui5/webcomponents-base/dist/Keys.js";
+import type { ToolbarArrowNavState } from "./IToolbarArrowNavProvider.js";
+import { isToolbarArrowNavProvider } from "./IToolbarArrowNavProvider.js";
import type BarDesign from "./types/BarDesign.js";
import type BarAccessibleRole from "./types/BarAccessibleRole.js";
@@ -36,6 +46,13 @@ import type { AriaRole } from "@ui5/webcomponents-base/dist/types.js";
*
* ### Keyboard Handling
*
+ * The `ui5-bar` provides advanced keyboard handling among interactive components inside it, no matter in which slot they are placed.
+ *
+ * #### Regular Navigation
+ * - [Left] / [Right] - navigate backward/forward among interactive components
+ * - [Home] / [End] - move to first/last interactive components
+ * - [Tab] / [Shift]+[Tab] - navigate forward/backward among interactive components
+ *
* #### Fast Navigation
* This component provides a build in fast navigation group which can be used via [F6] / [Shift] + [F6] / [Ctrl] + [Alt/Option] / [Down] or [Ctrl] + [Alt/Option] + [Up].
* In order to use this functionality, you need to import the following module:
@@ -76,9 +93,9 @@ class Bar extends UI5Element {
*
* - By default, accessibleRole is set to "Toolbar", which renders the ARIA role "toolbar".
*
- * - Use the default accessibleRole value "Toolbar" only when the component contains two or more active, interactive elements (such as buttons, links, or input fields) within the bar.
+ * - Use the default accessibleRole value "Toolbar" only when the component contains three or more active, interactive elements (such as buttons, links, or input fields) within the bar.
*
- * - If there is only one or no active element, set accessibleRole to "None" to avoid rendering the ARIA role "toolbar", as that role implies a grouping of multiple interactive controls.
+ * - If there is only one, two or no active element, set accessibleRole to "None" to avoid rendering the ARIA role "toolbar", as that role implies a grouping of multiple interactive controls.
*
* @public
* @default "Toolbar"
@@ -128,6 +145,11 @@ class Bar extends UI5Element {
endContent!: Slot;
_handleResizeBound: () => void;
+ _onKeyDownBound: (e: KeyboardEvent) => void;
+ _captureActiveElementBound: (e: KeyboardEvent) => void;
+ _activeAtKeyDown: HTMLElement | null = null;
+ _hasArrowNavProvider = false;
+ _arrowNavStateAtKeyDown: ToolbarArrowNavState | undefined = undefined;
get accInfo() {
return {
@@ -148,6 +170,8 @@ class Bar extends UI5Element {
super();
this._handleResizeBound = this.handleResize.bind(this);
+ this._onKeyDownBound = this._onKeyDown.bind(this);
+ this._captureActiveElementBound = this._captureActiveElement.bind(this);
}
handleResize() {
@@ -166,6 +190,9 @@ class Bar extends UI5Element {
this.getDomRef()!.querySelectorAll(".ui5-bar-content-container").forEach(child => {
ResizeHandler.register(child as HTMLElement, this._handleResizeBound);
}, this);
+
+ this.addEventListener("keydown", this._captureActiveElementBound, true);
+ this.addEventListener("keydown", this._onKeyDownBound);
}
onExitDOM() {
@@ -174,11 +201,203 @@ class Bar extends UI5Element {
this.getDomRef()!.querySelectorAll(".ui5-bar-content-container").forEach(child => {
ResizeHandler.deregister(child as HTMLElement, this._handleResizeBound);
}, this);
+
+ this.removeEventListener("keydown", this._captureActiveElementBound, true);
+ this.removeEventListener("keydown", this._onKeyDownBound);
}
get effectiveRole() {
return this.accessibleRole.toLowerCase() === "toolbar" ? "toolbar" as AriaRole : undefined;
}
+
+ _collectFocusableElements(): Array {
+ const contentSlotSelectors = [
+ "slot[name=\"startContent\"]",
+ "slot:not([name])",
+ "slot[name=\"endContent\"]",
+ ];
+ const focusableElements: Array = [];
+
+ contentSlotSelectors.forEach(slotSelector => {
+ const contentSlot = this.shadowRoot!.querySelector(slotSelector);
+ if (!contentSlot) {
+ return;
+ }
+ (contentSlot.assignedElements({ flatten: true }) as HTMLElement[]).forEach(assignedElement => {
+ focusableElements.push(...this._getFocusableFromElement(assignedElement));
+ });
+ });
+ return focusableElements;
+ }
+
+ _getFocusableFromElement(element: HTMLElement): Array {
+ if (isElementHidden(element)) {
+ return [];
+ }
+
+ if (instanceOfUI5Element(element)) {
+ const focusDomRef = element.getFocusDomRef();
+ if (focusDomRef && focusDomRef.tabIndex >= 0 && !isElementHidden(focusDomRef) && !(focusDomRef as HTMLInputElement).disabled) {
+ return [focusDomRef];
+ }
+ return [];
+ }
+
+ if (element.tabIndex >= 0 && !(element as HTMLInputElement).disabled) {
+ return [element];
+ }
+
+ // Non-focusable container: recurse into children
+ const childFocusables: Array = [];
+ Array.from(element.children).forEach(child => {
+ childFocusables.push(...this._getFocusableFromElement(child as HTMLElement));
+ });
+ return childFocusables;
+ }
+
+ _hasCaretNavigation(element: EventTarget | null): element is HTMLInputElement | HTMLTextAreaElement {
+ if (!(element instanceof HTMLElement)) {
+ return false;
+ }
+ const tagName = element.tagName.toLowerCase();
+ if (tagName === "textarea") {
+ return true;
+ }
+ if (tagName !== "input") {
+ return false;
+ }
+ const inputType = (element as HTMLInputElement).type.toLowerCase();
+ return ["text", "search", "url", "tel", "password", ""].includes(inputType);
+ }
+
+ _findOwnerArrowNavProvider(activeElement: HTMLElement) {
+ const slotSelectors = [
+ "slot[name=\"startContent\"]",
+ "slot:not([name])",
+ "slot[name=\"endContent\"]",
+ ];
+ for (const slotSelector of slotSelectors) {
+ const slotElement = this.shadowRoot!.querySelector(slotSelector);
+ if (!slotElement) {
+ continue;
+ }
+ for (const slottedElement of slotElement.assignedElements({ flatten: true }) as HTMLElement[]) {
+ if (isToolbarArrowNavProvider(slottedElement) && this._isNodeInsideElement(activeElement, slottedElement)) {
+ return slottedElement;
+ }
+ }
+ }
+ return null;
+ }
+
+ _captureActiveElement() {
+ this._activeAtKeyDown = getActiveElement() as HTMLElement | null;
+ const provider = this._activeAtKeyDown ? this._findOwnerArrowNavProvider(this._activeAtKeyDown) : null;
+ this._hasArrowNavProvider = provider !== null;
+ this._arrowNavStateAtKeyDown = provider ? provider.getArrowNavState() : undefined;
+ }
+
+ _onKeyDown(e: KeyboardEvent) {
+ if (this.effectiveRole !== "toolbar") {
+ 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;
+ }
+
+ if (isForward || isBackward) {
+ if (this._hasArrowNavProvider) {
+ const navState = this._arrowNavStateAtKeyDown;
+ if (!navState) {
+ return;
+ }
+ if (isForward && !navState.atRightEnd) {
+ return;
+ }
+ if (isBackward && !navState.atLeftEnd) {
+ return;
+ }
+ } else if (e.defaultPrevented) {
+ // Fallback for composite widgets not implementing IToolbarArrowNavProvider.
+ // Focus stayed on the same element → component was at its boundary.
+ if (getActiveElement() !== this._activeAtKeyDown) {
+ return;
+ }
+ }
+ }
+
+ const items = this._collectFocusableElements();
+ if (items.length === 0) {
+ return;
+ }
+
+ const active = getActiveElement() as HTMLElement | null;
+ if (!active) {
+ return;
+ }
+
+ const currentIndex = items.findIndex(item => this._isNodeInsideElement(active, item));
+ if (currentIndex === -1) {
+ return;
+ }
+
+ if (this._hasCaretNavigation(active)) {
+ const input = active as HTMLInputElement;
+ if (isHomeKey || isEndKey) {
+ return;
+ }
+ const selectionStart = input.selectionStart ?? 0;
+ if (isForward && selectionStart !== input.value.length) {
+ return;
+ }
+ if (isBackward && selectionStart !== 0) {
+ return;
+ }
+ }
+
+ let nextIndex: number;
+ if (isHomeKey) {
+ nextIndex = 0;
+ } else if (isEndKey) {
+ nextIndex = items.length - 1;
+ } else if (isForward) {
+ nextIndex = Math.min(currentIndex + 1, items.length - 1);
+ } else {
+ nextIndex = Math.max(currentIndex - 1, 0);
+ }
+
+ if (nextIndex === currentIndex) {
+ return;
+ }
+
+ items[nextIndex].focus();
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ _isNodeInsideElement(node: Node, element: HTMLElement): boolean {
+ 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;
+ }
}
Bar.define();
diff --git a/packages/main/src/SegmentedButton.ts b/packages/main/src/SegmentedButton.ts
index 1511864375e7d..bf0ca0cba64c9 100644
--- a/packages/main/src/SegmentedButton.ts
+++ b/packages/main/src/SegmentedButton.ts
@@ -21,6 +21,8 @@ import {
isEscape,
isSpaceShift,
} from "@ui5/webcomponents-base/dist/Keys.js";
+import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
+import type { ToolbarArrowNavState, IToolbarArrowNavProvider } from "./IToolbarArrowNavProvider.js";
import { LIST_ITEM_SELECTED } from "./generated/i18n/i18n-defaults.js";
import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js";
import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js";
@@ -81,7 +83,7 @@ type SegmentedButtonSelectionChangeEventDetail = {
bubbles: true,
})
-class SegmentedButton extends UI5Element {
+class SegmentedButton extends UI5Element implements IToolbarArrowNavProvider {
eventDetails!: {
"selection-change": SegmentedButtonSelectionChangeEventDetail,
}
@@ -217,6 +219,24 @@ class SegmentedButton extends UI5Element {
return this._itemNavigation._getCurrentItem();
}
+ getArrowNavState(): ToolbarArrowNavState | undefined {
+ const items = this.navigatableItems;
+ const active = getActiveElement() as HTMLElement | null;
+ if (!active) {
+ return undefined;
+ }
+ const idx = items.findIndex(item =>
+ item === active || item.shadowRoot?.contains(active) || item.contains(active)
+ );
+ if (idx === -1) {
+ return undefined;
+ }
+ return {
+ atLeftEnd: idx === 0,
+ atRightEnd: idx === items.length - 1,
+ };
+ }
+
_selectItem(e: MouseEvent | KeyboardEvent) {
const target = e.target as SegmentedButtonItem;
const isTargetSegmentedButtonItem = target.hasAttribute("ui5-segmented-button-item");
diff --git a/packages/main/test/pages/Bar.html b/packages/main/test/pages/Bar.html
index d2468cc03bf68..e694972aa1787 100644
--- a/packages/main/test/pages/Bar.html
+++ b/packages/main/test/pages/Bar.html
@@ -71,6 +71,26 @@
Decline
Cancel
+
+ Mixed content (texts, buttons, links, inputs)
+
+ Back
+ Home
+
+ Search:
+
+ Search
+
+
+
+
+
+
+
+ Help
+ Settings
+ Save
+