diff --git a/packages/main/cypress/specs/Bar.cy.tsx b/packages/main/cypress/specs/Bar.cy.tsx
index 452afe4e859c..39fc5250828e 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 fc6b293e5fd1..d50e2f1b16af 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,14 @@ 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 BarDesign from "./types/BarDesign.js";
import type BarAccessibleRole from "./types/BarAccessibleRole.js";
@@ -36,6 +44,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 +91,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 +143,7 @@ class Bar extends UI5Element {
endContent!: Slot;
_handleResizeBound: () => void;
+ _onKeyDownBound: (e: KeyboardEvent) => void;
get accInfo() {
return {
@@ -148,6 +164,7 @@ class Bar extends UI5Element {
super();
this._handleResizeBound = this.handleResize.bind(this);
+ this._onKeyDownBound = this._onKeyDown.bind(this);
}
handleResize() {
@@ -166,6 +183,8 @@ 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._onKeyDownBound, true);
}
onExitDOM() {
@@ -174,11 +193,151 @@ 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._onKeyDownBound, true);
}
get effectiveRole() {
return this.accessibleRole.toLowerCase() === "toolbar" ? "toolbar" as AriaRole : undefined;
}
+
+ _collectFocusableElements(): Array {
+ const slotSelectors = [
+ "slot[name=\"startContent\"]",
+ "slot:not([name])",
+ "slot[name=\"endContent\"]",
+ ];
+ const result: Array = [];
+
+ slotSelectors.forEach(sel => {
+ const slotEl = this.shadowRoot!.querySelector(sel);
+ if (!slotEl) {
+ return;
+ }
+ (slotEl.assignedElements({ flatten: true }) as HTMLElement[]).forEach(el => {
+ result.push(...this._getFocusableFromElement(el));
+ });
+ });
+ return result;
+ }
+
+ _getFocusableFromElement(el: HTMLElement): Array {
+ if (isElementHidden(el)) {
+ return [];
+ }
+
+ if (instanceOfUI5Element(el)) {
+ const focusRef = el.getFocusDomRef();
+ if (focusRef && focusRef.tabIndex >= 0 && !isElementHidden(focusRef) && !(focusRef as HTMLInputElement).disabled) {
+ return [focusRef];
+ }
+ return [];
+ }
+
+ if (el.tabIndex >= 0 && !(el as HTMLInputElement).disabled) {
+ return [el];
+ }
+
+ // Non-focusable container: recurse into children
+ const nested: Array = [];
+ Array.from(el.children).forEach(child => {
+ nested.push(...this._getFocusableFromElement(child as HTMLElement));
+ });
+ return nested;
+ }
+
+ _hasCaretNavigation(el: EventTarget | null): el is HTMLInputElement | HTMLTextAreaElement {
+ if (!(el instanceof HTMLElement)) {
+ return false;
+ }
+ const tag = el.tagName.toLowerCase();
+ if (tag === "textarea") {
+ return true;
+ }
+ if (tag !== "input") {
+ return false;
+ }
+ const type = (el as HTMLInputElement).type.toLowerCase();
+ return ["text", "search", "url", "tel", "password", ""].includes(type);
+ }
+
+ _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;
+ }
+
+ 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;
+ }
+ if (isForward && input.selectionStart !== input.value.length) {
+ return;
+ }
+ if (isBackward && input.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/test/pages/Bar.html b/packages/main/test/pages/Bar.html
index d2468cc03bf6..461f75e4421a 100644
--- a/packages/main/test/pages/Bar.html
+++ b/packages/main/test/pages/Bar.html
@@ -71,6 +71,20 @@
Decline
Cancel
+
+ Mixed content (texts, buttons, links, inputs)
+
+ Back
+ Home
+
+ Search:
+
+ Search
+
+ Help
+ Settings
+ Save
+