diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index 620c050e1855..c39e3565cb5e 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -5,11 +5,13 @@ 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"; import Button from "../../src/Button.js"; import Dialog from "../../src/Dialog.js"; +import Input from "../../src/Input.js"; describe("Toolbar general interaction", () => { it("Should not return null upon calling getDomRef for all direct child items", () => { @@ -46,6 +48,256 @@ 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 from overflow button to last visible toolbar item with ArrowLeft", () => { + 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("ArrowLeft"); + + // After ArrowLeft from overflow button, focus lands on the last visible toolbar item + cy.get("#overflow-arrow-toolbar") + .then($toolbar => { + const toolbar = $toolbar[0] as Toolbar & { + _lastFocusedItem?: ToolbarItem | HTMLElement; + _getNavigableItems: () => ToolbarItem[]; + }; + const navigableItems = toolbar._getNavigableItems(); + const expectedLastItem = navigableItems.at(-1); + expect(toolbar._lastFocusedItem).to.equal(expectedLastItem); + }); + }); + + 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 wrap on ArrowRight at last item or ArrowLeft at first item", () => { + cy.mount( + + + + + + ); + + // ArrowLeft on first item does nothing + cy.get("[ui5-toolbar-button][text='First']").realClick().should("be.focused"); + cy.realPress("ArrowLeft"); + cy.get("[ui5-toolbar-button][text='First']").should("be.focused"); + + // ArrowRight on last item does nothing + cy.get("[ui5-toolbar-button][text='Third']").realClick().should("be.focused"); + cy.realPress("ArrowRight"); + cy.get("[ui5-toolbar-button][text='Third']").should("be.focused"); + }); + + it("Should allow Tab to navigate between toolbar items (all items have tabIndex=0)", () => { + cy.mount( + + + + + + ); + + cy.get("[ui5-toolbar-button][text='A']").realClick().should("be.focused"); + + cy.realPress("Tab"); + cy.get("[ui5-toolbar-button][text='B']").should("be.focused"); + + cy.realPress("Tab"); + cy.get("[ui5-toolbar-button][text='C']").should("be.focused"); + }); + + it("Should respect Input caret position when deciding arrow navigation", () => { + cy.mount( + + + + + + + + ); + + // Focus the input and move caret to start + cy.get("#toolbar-input").realClick(); + cy.get("#toolbar-input").realPress("Home"); + + // ArrowLeft at caret=0 (atLeftEnd) → toolbar moves to previous item + cy.realPress("ArrowLeft"); + cy.get("[ui5-toolbar-button][text='Before']").should("be.focused"); + + // Now focus input and move caret to end + cy.get("#toolbar-input").realClick(); + cy.get("#toolbar-input").realPress("End"); + + // ArrowRight at caret=end (atRightEnd) → toolbar moves to next item + cy.realPress("ArrowRight"); + cy.get("[ui5-toolbar-button][text='After']").should("be.focused"); + }); + + it("Should keep focus within Input while caret is not at boundary", () => { + cy.mount( + + + + + + + + ); + + cy.get("#mid-toolbar-input").realClick(); + + // Home key is NOT intercepted by toolbar when an Input has focus (caret-aware items) + cy.realPress("Home"); + cy.get("#mid-toolbar-input").should("be.focused"); + + // Set caret to position 2 (mid-string) via DOM to avoid End key exiting to last item + cy.get("#mid-toolbar-input").then($input => { + const ui5Input = $input[0] as Input & { nativeInput: HTMLInputElement | null }; + const native = ui5Input.nativeInput; + if (native) { + native.focus(); + native.setSelectionRange(2, 2); + } + }); + + // ArrowRight with caret mid-string → stays in input, toolbar does not navigate + cy.realPress("ArrowRight"); + cy.get("#mid-toolbar-input").should("be.focused"); + + // Move caret back to mid + cy.get("#mid-toolbar-input").then($input => { + const ui5Input = $input[0] as Input & { nativeInput: HTMLInputElement | null }; + const native = ui5Input.nativeInput; + if (native) { + native.focus(); + native.setSelectionRange(2, 2); + } + }); + + // ArrowLeft with caret mid-string → stays in input + cy.realPress("ArrowLeft"); + cy.get("#mid-toolbar-input").should("be.focused"); + }); + + 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( @@ -162,6 +414,30 @@ describe("Toolbar general interaction", () => { .should("exist", "hidden class attached to tb button, meaning it's not shown as expected"); }); + it("Should handle toolbar-select with width larger than the toolbar", () => { + cy.mount( +
+ + + Option 1 + Option 2 + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + cy.get("#toolbar-wide-select") + .shadow() + .find(".ui5-tb-overflow-btn") + .should("not.have.class", "ui5-tb-overflow-btn-hidden"); + + cy.get("[ui5-toolbar-select]") + .should("have.prop", "isOverflowed", true); + }); + it("Should call event handlers on item", () => { cy.mount( @@ -482,9 +758,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-button]") - .last() + // 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/cypress/specs/ToolbarSelect.cy.tsx b/packages/main/cypress/specs/ToolbarSelect.cy.tsx index f7c1fef6a698..cde5b8dd0fab 100644 --- a/packages/main/cypress/specs/ToolbarSelect.cy.tsx +++ b/packages/main/cypress/specs/ToolbarSelect.cy.tsx @@ -299,17 +299,16 @@ describe("Toolbar general interaction", () => { ); - // Set up button click handler - cy.get("#btn").then($btn => { - $btn.get(0).addEventListener("ui5-click", () => { - // First, deselect all options - const select = document.querySelector("ui5-toolbar-select"); + // Attach click handler via document to avoid jQuery wrapping issues + cy.document().then(doc => { + const btn = doc.getElementById("btn"); + btn?.addEventListener("click", () => { + const select = doc.querySelector("ui5-toolbar-select"); const options = select?.querySelectorAll("ui5-toolbar-select-option"); options?.forEach(opt => { (opt as ToolbarSelectOption).selected = false; }); - // Then select option 2 - const opt2 = document.getElementById("opt2") as ToolbarSelectOption; + const opt2 = doc.getElementById("opt2") as ToolbarSelectOption; opt2.selected = true; }); }); @@ -360,11 +359,12 @@ describe("Toolbar general interaction", () => { ); // Set up button to attempt selecting multiple options - cy.get("#selectMultiple").then($btn => { - $btn.get(0).addEventListener("ui5-click", () => { - const opt1 = document.getElementById("opt1") as ToolbarSelectOption; - const opt2 = document.getElementById("opt2") as ToolbarSelectOption; - const opt3 = document.getElementById("opt3") as ToolbarSelectOption; + cy.document().then(doc => { + const btn = doc.getElementById("selectMultiple"); + btn?.addEventListener("click", () => { + const opt1 = doc.getElementById("opt1") as ToolbarSelectOption; + const opt2 = doc.getElementById("opt2") as ToolbarSelectOption; + const opt3 = doc.getElementById("opt3") as ToolbarSelectOption; // Try to select multiple options opt1.selected = true; diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts index e6a83907a0d8..7f6fc97738ff 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 { ToolbarArrowNavState } from "./ToolbarItemBase.js"; // Templates import InputTemplate from "./InputTemplate.js"; @@ -1660,6 +1661,24 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return this.nativeInput; } + getArrowNavState(): ToolbarArrowNavState | 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 caret = input.selectionStart ?? 0; + const len = input.value?.length ?? 0; + + return { atLeftEnd: caret === 0, atRightEnd: caret >= len }; + } + /** * Returns a reference to the native input element * @protected diff --git a/packages/main/src/TextArea.ts b/packages/main/src/TextArea.ts index d42797eeba13..b1876662dadf 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 { ToolbarArrowNavState } from "./ToolbarItemBase.js"; import TextAreaTemplate from "./TextAreaTemplate.js"; @@ -436,6 +438,24 @@ class TextArea extends UI5Element implements IFormInputElement { return this.getDomRef()!.querySelector("textarea")!; } + getArrowNavState(): ToolbarArrowNavState | 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 caret = textArea.selectionStart ?? 0; + const len = textArea.value?.length ?? 0; + + return { atLeftEnd: caret === 0, atRightEnd: caret >= len }; + } + _onkeydown(e: KeyboardEvent) { this._keyDown = true; diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index e631640780dc..1375d7b004fc 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -8,6 +8,14 @@ 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, + 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,7 +35,7 @@ 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"; @@ -57,8 +65,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 +129,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 +142,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 +187,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 +205,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 +320,12 @@ class Toolbar extends UI5Element { this.detachListeners(); this.attachListeners(); if (getActiveElement() === this.overflowButtonDOM?.getFocusDomRef() && this.hideOverflowButton) { - const lastItem = this.standardItems.filter(item => item.isInteractive).at(-1); - lastItem?.focus(); + const items = this.standardItems.filter(item => item.isToolbarNavigatable); + const lastItem = items.at(-1); + if (lastItem) { + this._lastFocusedItem = lastItem; + lastItem.focusForToolbarNavigation(false); + } } this.prePopulateAlwaysOverflowItems(); } @@ -315,10 +337,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 +525,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 +541,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 +582,248 @@ class Toolbar extends UI5Element { getCachedItemWidth(id: string) { return this.ITEMS_WIDTH_MAP.get(id); } + + /** + * Keyboard Navigation + */ + + _applyRovingTabIndex() { + const items = this._getNavigableItems(); + + if (!items.length) { + return; + } + + items.forEach(item => item.setToolbarForcedTabIndex("0")); + + const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null; + if (overflowButton && !this.hideOverflowButton) { + overflowButton.tabIndex = 0; + } + + if (!this._lastFocusedItem || !this._getNavigationChain().includes(this._lastFocusedItem)) { + this._lastFocusedItem = items[0]; + } + } + + _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)) { + this._setCurrentItem( + this._findItemByPath(e.composedPath()) + || this._findOverflowButtonByPath(e.composedPath()) + || this._findCurrentTargetByActiveElement() + || this._lastFocusedItem!, + ); + 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 state = currentTarget.getArrowNavState(); + if (state) { + const atEnd = isForward ? state.atRightEnd : state.atLeftEnd; + if (!atEnd) { + return; + } + } + } + + if (currentTarget instanceof ToolbarItemBase && (isHomeKey || isEndKey)) { + const state = currentTarget.getArrowNavState(); + if (state) { + 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; + } + + _moveToNext() { + this._moveToItem((current, items) => Math.min(current + 1, items.length - 1), true); + } + + _moveToPrev() { + this._moveToItem(current => Math.max(current - 1, 0), 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); + + if (nextIndex === currentIndex) { + return; + } + + const nextItem = items[nextIndex]; + this._setCurrentItem(nextItem); + + if (nextItem instanceof ToolbarItemBase) { + nextItem.focusForToolbarNavigation(isForward); + } else { + nextItem.focus(); + } + } } Toolbar.define(); diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts index b0337805ff69..78753fc5bf5d 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -1,9 +1,15 @@ 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 { + isLeft, + isRight, +} from "@ui5/webcomponents-base/dist/Keys.js"; import ToolbarItemTemplate from "./ToolbarItemTemplate.js"; import ToolbarItemCss from "./generated/themes/ToolbarItem.css.js"; import ToolbarItemBase from "./ToolbarItemBase.js"; +import type { ToolbarArrowNavState } from "./ToolbarItemBase.js"; import type { DefaultSlot } from "@ui5/webcomponents-base"; /** @@ -17,6 +23,14 @@ import type { DefaultSlot } from "@ui5/webcomponents-base"; interface IToolbarItemContent extends HTMLElement { overflowCloseEvents?: string[]; hasOverflow?: boolean; + getArrowNavState?: () => ToolbarArrowNavState | undefined; +} + +interface IItemNavigationOwner extends HTMLElement { + _itemNavigation?: { + _getCurrentItem: () => HTMLElement | undefined; + }; + _getFocusableItems?: () => Array<{ getFocusDomRef?: () => HTMLElement }>; } /** @@ -53,7 +67,9 @@ interface IToolbarItemContent extends HTMLElement { class ToolbarItem extends ToolbarItemBase { _maxWidth = 0; _wrapperChecked = false; + _lastFocusedNavigationTarget?: HTMLElement; fireCloseOverflowRef = this.fireCloseOverflow.bind(this); + _onMultiChildKeydownRef = this._onMultiChildKeydown.bind(this); closeOverflowSet = { "ui5-button": ["click"], @@ -72,10 +88,16 @@ class ToolbarItem extends ToolbarItemBase { onBeforeRendering(): void { this.checkForWrapper(); this.attachCloseOverflowHandlers(); + if (this.item.length > 1) { + this.addEventListener("keydown", this._onMultiChildKeydownRef, true); + } else { + this.removeEventListener("keydown", this._onMultiChildKeydownRef, true); + } } onExitDOM(): void { this.detachCloseOverflowHandlers(); + this.removeEventListener("keydown", this._onMultiChildKeydownRef, true); } /** @@ -147,6 +169,206 @@ 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, + }; + } + + _onMultiChildKeydown(e: KeyboardEvent) { + const isForward = this.effectiveDir === "rtl" ? isLeft(e) : isRight(e); + const isBackward = this.effectiveDir === "rtl" ? isRight(e) : isLeft(e); + if (!isForward && !isBackward) { + return; + } + + const { items, currentIndex } = this._getCurrentNavigationState(); + if (currentIndex === -1) { + return; + } + + const nextIndex = isForward ? currentIndex + 1 : currentIndex - 1; + if (nextIndex < 0 || nextIndex >= items.length) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this._handleNavigationTarget(items[nextIndex]); + } + + getArrowNavState(): ToolbarArrowNavState | undefined { + const child = this.item[0] as IToolbarItemContent | undefined; + if (!child) { + return undefined; + } + + // Priority 1: ItemNavigation-based components (e.g. Breadcrumbs, SegmentedButton) + const itemNavOwner = child as IItemNavigationOwner; + if (typeof itemNavOwner._itemNavigation?._getCurrentItem === "function" + && typeof itemNavOwner._getFocusableItems === "function") { + const items = itemNavOwner._getFocusableItems(); + const current = itemNavOwner._itemNavigation._getCurrentItem(); + const currentIndex = current + ? items.findIndex(item => (item.getFocusDomRef ? item.getFocusDomRef() : item) === current) + : -1; + if (currentIndex !== -1) { + return { + atLeftEnd: currentIndex === 0, + atRightEnd: currentIndex === items.length - 1, + }; + } + } + + // Priority 2: explicit new interface (e.g. Input, TextArea) + if (typeof child.getArrowNavState === "function") { + return child.getArrowNavState(); + } + + // Priority 3: proprietary multi-child fallback (e.g. bare checkbox group) + if (this.item.length <= 1) { + return undefined; + } + + const { items, currentIndex } = this._getCurrentNavigationState(); + if (currentIndex === -1) { + return undefined; + } + + return { + atLeftEnd: currentIndex === 0, + atRightEnd: currentIndex === items.length - 1, + }; + } + + setToolbarForcedTabIndex(tabIndex: string) { + this.forcedTabIndex = tabIndex; + + const { items, current } = this._getCurrentNavigationState(); + if (!items.length) { + super.setToolbarForcedTabIndex(tabIndex); + return; + } + + if (current) { + this._lastFocusedNavigationTarget = current; + } + + items.forEach(target => { + target.tabIndex = Number(tabIndex); + }); + } + + 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..64a64101c992 100644 --- a/packages/main/src/ToolbarItemBase.ts +++ b/packages/main/src/ToolbarItemBase.ts @@ -8,6 +8,15 @@ type ToolbarItemEventDetail = { targetRef: HTMLElement; } +export type ToolbarArrowNavState = { + atLeftEnd: boolean; + atRightEnd: boolean; +}; + +export interface ToolbarArrowNavigation { + getArrowNavState(): ToolbarArrowNavState; +} + @event("close-overflow", { bubbles: true, }) @@ -48,6 +57,54 @@ class ToolbarItemBase extends UI5Element { @property({ type: Boolean }) preventOverflowClosing = false; + /** + * Roving tabindex managed by toolbar for horizontal navigation. + * @private + */ + @property({ noAttribute: true }) + forcedTabIndex = "-1"; + + _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 + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + focusForToolbarNavigation(isForward: boolean) { + this.getToolbarFocusTarget()?.focus(); + } + + getArrowNavState(): ToolbarArrowNavState | undefined { + return undefined; + } + _isOverflowed: boolean = false; get isOverflowed(): boolean { @@ -104,6 +161,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; }