From 701f1991df2e94e60a9dfa507945750a9dc3e68b Mon Sep 17 00:00:00 2001 From: Nikolay Hristov Date: Mon, 24 Aug 2026 16:54:37 +0300 Subject: [PATCH 1/3] feat(ui5-bar): add arrow left/arrow right/home/end navigation --- packages/main/cypress/specs/Bar.cy.tsx | 143 ++++++++++++++++++++++ packages/main/src/Bar.ts | 161 ++++++++++++++++++++++++- packages/main/test/pages/Bar.html | 14 +++ 3 files changed, 317 insertions(+), 1 deletion(-) 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..eec606bf0222e 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: @@ -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 d2468cc03bf68..461f75e4421a6 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 +
From b6b997faf835e7bd0d51fa05744c2675fa6a71b9 Mon Sep 17 00:00:00 2001 From: Nikolay Hristov Date: Wed, 26 Aug 2026 10:54:37 +0300 Subject: [PATCH 2/3] feat(ui5-bar): fix jsdoc --- packages/main/src/Bar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/main/src/Bar.ts b/packages/main/src/Bar.ts index eec606bf0222e..d50e2f1b16af3 100644 --- a/packages/main/src/Bar.ts +++ b/packages/main/src/Bar.ts @@ -91,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" From 04dedabb680a4d493dbb5ed369061a1311606f08 Mon Sep 17 00:00:00 2001 From: Nikolay Hristov Date: Tue, 1 Sep 2026 14:31:17 +0300 Subject: [PATCH 3/3] feat(ui5-bar): fix keyboard stealing --- packages/main/src/Bar.ts | 122 ++++++++++++++++++++------- packages/main/src/SegmentedButton.ts | 22 ++++- packages/main/test/pages/Bar.html | 6 ++ 3 files changed, 118 insertions(+), 32 deletions(-) diff --git a/packages/main/src/Bar.ts b/packages/main/src/Bar.ts index d50e2f1b16af3..1cb9140d6ec14 100644 --- a/packages/main/src/Bar.ts +++ b/packages/main/src/Bar.ts @@ -14,6 +14,8 @@ import { 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"; @@ -144,6 +146,10 @@ class Bar extends UI5Element { _handleResizeBound: () => void; _onKeyDownBound: (e: KeyboardEvent) => void; + _captureActiveElementBound: (e: KeyboardEvent) => void; + _activeAtKeyDown: HTMLElement | null = null; + _hasArrowNavProvider = false; + _arrowNavStateAtKeyDown: ToolbarArrowNavState | undefined = undefined; get accInfo() { return { @@ -165,6 +171,7 @@ class Bar extends UI5Element { this._handleResizeBound = this.handleResize.bind(this); this._onKeyDownBound = this._onKeyDown.bind(this); + this._captureActiveElementBound = this._captureActiveElement.bind(this); } handleResize() { @@ -184,7 +191,8 @@ class Bar extends UI5Element { ResizeHandler.register(child as HTMLElement, this._handleResizeBound); }, this); - this.addEventListener("keydown", this._onKeyDownBound, true); + this.addEventListener("keydown", this._captureActiveElementBound, true); + this.addEventListener("keydown", this._onKeyDownBound); } onExitDOM() { @@ -194,7 +202,8 @@ class Bar extends UI5Element { ResizeHandler.deregister(child as HTMLElement, this._handleResizeBound); }, this); - this.removeEventListener("keydown", this._onKeyDownBound, true); + this.removeEventListener("keydown", this._captureActiveElementBound, true); + this.removeEventListener("keydown", this._onKeyDownBound); } get effectiveRole() { @@ -202,63 +211,90 @@ class Bar extends UI5Element { } _collectFocusableElements(): Array { - const slotSelectors = [ + const contentSlotSelectors = [ "slot[name=\"startContent\"]", "slot:not([name])", "slot[name=\"endContent\"]", ]; - const result: Array = []; + const focusableElements: Array = []; - slotSelectors.forEach(sel => { - const slotEl = this.shadowRoot!.querySelector(sel); - if (!slotEl) { + contentSlotSelectors.forEach(slotSelector => { + const contentSlot = this.shadowRoot!.querySelector(slotSelector); + if (!contentSlot) { return; } - (slotEl.assignedElements({ flatten: true }) as HTMLElement[]).forEach(el => { - result.push(...this._getFocusableFromElement(el)); + (contentSlot.assignedElements({ flatten: true }) as HTMLElement[]).forEach(assignedElement => { + focusableElements.push(...this._getFocusableFromElement(assignedElement)); }); }); - return result; + return focusableElements; } - _getFocusableFromElement(el: HTMLElement): Array { - if (isElementHidden(el)) { + _getFocusableFromElement(element: HTMLElement): Array { + if (isElementHidden(element)) { return []; } - if (instanceOfUI5Element(el)) { - const focusRef = el.getFocusDomRef(); - if (focusRef && focusRef.tabIndex >= 0 && !isElementHidden(focusRef) && !(focusRef as HTMLInputElement).disabled) { - return [focusRef]; + if (instanceOfUI5Element(element)) { + const focusDomRef = element.getFocusDomRef(); + if (focusDomRef && focusDomRef.tabIndex >= 0 && !isElementHidden(focusDomRef) && !(focusDomRef as HTMLInputElement).disabled) { + return [focusDomRef]; } return []; } - if (el.tabIndex >= 0 && !(el as HTMLInputElement).disabled) { - return [el]; + if (element.tabIndex >= 0 && !(element as HTMLInputElement).disabled) { + return [element]; } // Non-focusable container: recurse into children - const nested: Array = []; - Array.from(el.children).forEach(child => { - nested.push(...this._getFocusableFromElement(child as HTMLElement)); + const childFocusables: Array = []; + Array.from(element.children).forEach(child => { + childFocusables.push(...this._getFocusableFromElement(child as HTMLElement)); }); - return nested; + return childFocusables; } - _hasCaretNavigation(el: EventTarget | null): el is HTMLInputElement | HTMLTextAreaElement { - if (!(el instanceof HTMLElement)) { + _hasCaretNavigation(element: EventTarget | null): element is HTMLInputElement | HTMLTextAreaElement { + if (!(element instanceof HTMLElement)) { return false; } - const tag = el.tagName.toLowerCase(); - if (tag === "textarea") { + const tagName = element.tagName.toLowerCase(); + if (tagName === "textarea") { return true; } - if (tag !== "input") { + if (tagName !== "input") { return false; } - const type = (el as HTMLInputElement).type.toLowerCase(); - return ["text", "search", "url", "tel", "password", ""].includes(type); + 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) { @@ -275,6 +311,27 @@ class Bar extends UI5Element { 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; @@ -295,10 +352,11 @@ class Bar extends UI5Element { if (isHomeKey || isEndKey) { return; } - if (isForward && input.selectionStart !== input.value.length) { + const selectionStart = input.selectionStart ?? 0; + if (isForward && selectionStart !== input.value.length) { return; } - if (isBackward && input.selectionStart !== 0) { + if (isBackward && selectionStart !== 0) { return; } } @@ -325,6 +383,7 @@ class Bar extends UI5Element { _isNodeInsideElement(node: Node, element: HTMLElement): boolean { let current: Node | null = node; + while (current) { if (current === element) { return true; @@ -336,6 +395,7 @@ class Bar extends UI5Element { current = current.parentNode; } } + return false; } } 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 461f75e4421a6..e694972aa1787 100644 --- a/packages/main/test/pages/Bar.html +++ b/packages/main/test/pages/Bar.html @@ -81,6 +81,12 @@ Search + + + + + + Help Settings Save