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 +