diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index be5b32156db1..b0e8af1ed162 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -5,6 +5,7 @@ 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"; @@ -46,6 +47,157 @@ 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 into and out of overflow button with single arrow press", () => { + 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("ArrowRight"); + cy.get("#overflow-arrow-toolbar") + .then($toolbar => { + const toolbar = $toolbar[0] as Toolbar & { + _lastFocusedItem?: ToolbarItem | HTMLElement; + }; + const firstToolbarItem = $toolbar.find("[ui5-toolbar-button][text='One Long']")[0] as ToolbarItem; + expect(toolbar._lastFocusedItem).to.equal(firstToolbarItem); + }); + }); + + 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 scroll the page when pressing Up/Down inside the toolbar", () => { + cy.mount( +
+ + + +
+ ); + + cy.get("[ui5-toolbar-button][text='Button']").realClick().should("be.focused"); + + cy.window().its("scrollY").as("scrollBefore"); + cy.realPress("ArrowDown"); + cy.window().its("scrollY").then(scrollAfter => { + cy.get("@scrollBefore").should("equal", scrollAfter); + }); + }); + + 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( @@ -482,11 +634,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]") - .shadow() - .find(".ui5-tb-item") - .eq(3) + // 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/src/Input.ts b/packages/main/src/Input.ts index e6a83907a0d8..e6b8eee6083c 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 { ToolbarMovementInfo } from "./ToolbarItemBase.js"; // Templates import InputTemplate from "./InputTemplate.js"; @@ -1660,6 +1661,27 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return this.nativeInput; } + getToolbarMovementInfo(): ToolbarMovementInfo | 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 caretIndex = input.selectionStart ?? 0; + const valueLength = input.value?.length ?? 0; + + return { + currentIndex: caretIndex, + itemCount: valueLength + 1, + }; + } + /** * Returns a reference to the native input element * @protected diff --git a/packages/main/src/RadioButton.ts b/packages/main/src/RadioButton.ts index 36b06b30eed1..4df2410d4b7a 100644 --- a/packages/main/src/RadioButton.ts +++ b/packages/main/src/RadioButton.ts @@ -68,7 +68,6 @@ let activeRadio: RadioButton; * @public * @csspart outer-ring - Used to style the outer ring of the `ui5-radio-button`. * @csspart inner-ring - Used to style the inner ring of the `ui5-radio-button`. - * @csspart root - Used to style the root DOM element of the component. */ @customElement({ tag: "ui5-radio-button", diff --git a/packages/main/src/TextArea.ts b/packages/main/src/TextArea.ts index d42797eeba13..384657af74cb 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 { ToolbarMovementInfo } from "./ToolbarItemBase.js"; import TextAreaTemplate from "./TextAreaTemplate.js"; @@ -436,6 +438,27 @@ class TextArea extends UI5Element implements IFormInputElement { return this.getDomRef()!.querySelector("textarea")!; } + getToolbarMovementInfo(): ToolbarMovementInfo | 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 caretIndex = textArea.selectionStart ?? 0; + const valueLength = textArea.value?.length ?? 0; + + return { + currentIndex: caretIndex, + itemCount: valueLength + 1, + }; + } + _onkeydown(e: KeyboardEvent) { this._keyDown = true; diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 5fff5e3880d0..af13877a7e9a 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -8,6 +8,16 @@ 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, + isUp, + isDown, + 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,12 +37,13 @@ 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"; import type Popover from "./Popover.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; +import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; type ToolbarMinWidthChangeEventDetail = { minWidth: number, @@ -57,8 +68,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 +132,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 +145,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 +190,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 +208,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 +323,12 @@ class Toolbar extends UI5Element { this.detachListeners(); this.attachListeners(); if (getActiveElement() === this.overflowButtonDOM?.getFocusDomRef() && this.hideOverflowButton) { - const lastItem = this.interactiveItems.at(-1); - lastItem?.focus(); + const items = this._getNavigableItems(); + const lastItem = items.at(-1); + if (lastItem) { + this._lastFocusedItem = lastItem; + lastItem.focusForToolbarNavigation(false); + } } this.prePopulateAlwaysOverflowItems(); } @@ -315,10 +340,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 +528,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 +544,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 +585,332 @@ class Toolbar extends UI5Element { getCachedItemWidth(id: string) { return this.ITEMS_WIDTH_MAP.get(id); } + + /** + * Keyboard Navigation + */ + + _applyDisabledItemsAccessibility() { + this.standardItems + .filter(item => item.isInteractive && !item.hidden && !item.isOverflowed + && "disabled" in item && (item as { disabled?: boolean }).disabled) + .forEach(item => { + const focusRef = item.getFocusDomRef(); + if (focusRef) { + focusRef.tabIndex = -1; + focusRef.setAttribute("aria-disabled", "true"); + } + }); + } + + _applyRovingTabIndex() { + const items = this._getNavigationChain(); + + if (!items.length) { + return; + } + + if (!this._lastFocusedItem || !items.includes(this._lastFocusedItem)) { + this._lastFocusedItem = items[0]; + } + + this._setCurrentItem(this._lastFocusedItem); + + this._applyDisabledItemsAccessibility(); + } + + _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)) { + const moved = this._focusOutsideToolbar(isTabPrevious(e), e.composedPath()); + if (moved) { + e.preventDefault(); + } + return; + } + + if (isUp(e) || isDown(e)) { + e.preventDefault(); + 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 movementInfo = currentTarget.getToolbarMovementInfo(); + if (movementInfo) { + const { currentIndex, itemCount } = movementInfo; + const atForwardBoundary = itemCount <= 1 || (isForward && currentIndex >= itemCount - 1); + const atBackwardBoundary = itemCount <= 1 || (isBackward && currentIndex <= 0); + + if (atForwardBoundary || atBackwardBoundary) { + e.preventDefault(); + e.stopPropagation(); + + if (isForward) { + this._moveToNext(); + } else { + this._moveToPrev(); + } + return; + } + + if (currentTarget.moveWithinToolbarItem(isForward)) { + e.preventDefault(); + e.stopPropagation(); + } + + // Not at boundary -> nested control (or fallback mover) handles traversal. + 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; + const allItems = this._getNavigableItems(); + allItems.forEach(i => { + i.setToolbarForcedTabIndex(i === item ? "0" : "-1"); + }); + + const overflowButton = this.overflowButtonDOM as unknown as HTMLElement | null; + if (overflowButton) { + overflowButton.tabIndex = item === overflowButton ? 0 : -1; + } + } + + _moveToNext() { + this._moveToItem((current, items) => (current + 1) % items.length, true); + } + + _moveToPrev() { + this._moveToItem((current, items) => (current === 0 ? items.length - 1 : current - 1), 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); + const nextItem = items[nextIndex]; + this._setCurrentItem(nextItem); + + if (nextItem instanceof ToolbarItemBase) { + nextItem.focusForToolbarNavigation(isForward); + } else { + nextItem.focus(); + } + } + _focusOutsideToolbar(backward: boolean, path: Array) { + const active = getActiveElement() as HTMLElement | null; + const tabbables = getTabbableElements(document.body); + + if (!tabbables.length) { + return false; + } + + const currentIndexFromActive = active + ? tabbables.findIndex(el => el === active || el.contains(active) || active.contains(el)) + : -1; + + const currentIndexFromPath = currentIndexFromActive === -1 + ? tabbables.findIndex(el => path.includes(el)) + : -1; + + const currentIndex = currentIndexFromActive !== -1 ? currentIndexFromActive : currentIndexFromPath; + + const isInsideToolbar = (el: HTMLElement) => this._isNodeInsideElement(el, this); + + if (currentIndex !== -1) { + const step = backward ? -1 : 1; + for (let i = currentIndex + step; i >= 0 && i < tabbables.length; i += step) { + const candidate = tabbables[i]; + if (!isInsideToolbar(candidate)) { + candidate.focus(); + return true; + } + } + } + + const insideIndices = tabbables + .map((el, index) => ({ el, index })) + .filter(({ el }) => isInsideToolbar(el)) + .map(({ index }) => index); + + if (!insideIndices.length) { + return false; + } + + const firstInside = insideIndices[0]; + const lastInside = insideIndices[insideIndices.length - 1]; + const startIndex = backward ? firstInside - 1 : lastInside + 1; + const step = backward ? -1 : 1; + + for (let i = startIndex; i >= 0 && i < tabbables.length; i += step) { + const candidate = tabbables[i]; + if (!isInsideToolbar(candidate)) { + candidate.focus(); + return true; + } + } + + return false; + } } Toolbar.define(); diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts index b0337805ff69..6293bb7b66ea 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -1,9 +1,11 @@ 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 ToolbarItemTemplate from "./ToolbarItemTemplate.js"; import ToolbarItemCss from "./generated/themes/ToolbarItem.css.js"; import ToolbarItemBase from "./ToolbarItemBase.js"; +import type { ToolbarMovementInfo } from "./ToolbarItemBase.js"; import type { DefaultSlot } from "@ui5/webcomponents-base"; /** @@ -17,6 +19,14 @@ import type { DefaultSlot } from "@ui5/webcomponents-base"; interface IToolbarItemContent extends HTMLElement { overflowCloseEvents?: string[]; hasOverflow?: boolean; + getToolbarMovementInfo?: () => ToolbarMovementInfo | undefined; +} + +interface IItemNavigationOwner extends HTMLElement { + _itemNavigation?: { + _getCurrentItem: () => unknown; + }; + _getFocusableItems?: () => Array; } /** @@ -53,8 +63,20 @@ interface IToolbarItemContent extends HTMLElement { class ToolbarItem extends ToolbarItemBase { _maxWidth = 0; _wrapperChecked = false; + _lastFocusedNavigationTarget?: HTMLElement; fireCloseOverflowRef = this.fireCloseOverflow.bind(this); + get handlesOwnKeyboardNavigation(): boolean { + const child = this.item[0] as IToolbarItemContent | undefined; + if (!child) { + return false; + } + + return this._supportsItemNavigationMovementInfo(child) + || typeof child.getToolbarMovementInfo === "function" + || this.item.length > 1; + } + closeOverflowSet = { "ui5-button": ["click"], "ui5-select": ["change"], @@ -147,6 +169,217 @@ 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, + }; + } + + _supportsItemNavigationMovementInfo(child: IToolbarItemContent): boolean { + const itemNavigationOwner = child as IItemNavigationOwner; + return typeof itemNavigationOwner._itemNavigation?._getCurrentItem === "function" + && typeof itemNavigationOwner._getFocusableItems === "function"; + } + + _getItemNavigationMovementInfo(child: IToolbarItemContent): ToolbarMovementInfo | undefined { + if (!this._supportsItemNavigationMovementInfo(child)) { + return undefined; + } + + const itemNavigationOwner = child as IItemNavigationOwner; + const items = itemNavigationOwner._getFocusableItems!(); + const current = itemNavigationOwner._itemNavigation!._getCurrentItem(); + const currentIndex = current ? items.indexOf(current) : -1; + + if (currentIndex === -1) { + return undefined; + } + + return { + currentIndex, + itemCount: items.length, + }; + } + + getToolbarMovementInfo(): ToolbarMovementInfo | undefined { + const child = this.item[0] as IToolbarItemContent | undefined; + if (!child) { + return undefined; + } + + const itemNavigationInfo = this._getItemNavigationMovementInfo(child); + if (itemNavigationInfo) { + return itemNavigationInfo; + } + + if (typeof child.getToolbarMovementInfo === "function") { + return child.getToolbarMovementInfo(); + } + + // Multi-child item (e.g. radio button group, checkbox group): report + // current position so toolbar knows when to cross the boundary. + if (this.item.length > 1) { + const { items, currentIndex } = this._getCurrentNavigationState(); + if (currentIndex !== -1) { + return { currentIndex, itemCount: items.length }; + } + } + + return undefined; + } + + moveWithinToolbarItem(isForward: boolean): boolean { + if (this.item.length <= 1) { + return false; + } + + const { items, currentIndex } = this._getCurrentNavigationState(); + if (currentIndex === -1) { + return false; + } + + const nextIndex = isForward ? currentIndex + 1 : currentIndex - 1; + if (nextIndex < 0 || nextIndex >= items.length) { + return false; + } + + this._handleNavigationTarget(items[nextIndex]); + return true; + } + + setToolbarForcedTabIndex(tabIndex: string) { + this.forcedTabIndex = tabIndex; + + const { items, current } = this._getCurrentNavigationState(); + if (!items.length) { + super.setToolbarForcedTabIndex(tabIndex); + return; + } + + if (current) { + this._lastFocusedNavigationTarget = current; + } + + const fallbackTarget = items[0]; + const focusTarget = this._lastFocusedNavigationTarget && items.includes(this._lastFocusedNavigationTarget) + ? this._lastFocusedNavigationTarget + : fallbackTarget; + + items.forEach(target => { + target.tabIndex = tabIndex === "0" && target === focusTarget ? 0 : -1; + }); + } + + 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..6023d45507a1 100644 --- a/packages/main/src/ToolbarItemBase.ts +++ b/packages/main/src/ToolbarItemBase.ts @@ -8,6 +8,15 @@ type ToolbarItemEventDetail = { targetRef: HTMLElement; } +export type ToolbarMovementInfo = { + currentIndex: number; + itemCount: number; +}; + +export interface ToolbarMovementEnabler { + getToolbarMovementInfo(): ToolbarMovementInfo | undefined; +} + @event("close-overflow", { bubbles: true, }) @@ -48,6 +57,73 @@ class ToolbarItemBase extends UI5Element { @property({ type: Boolean }) preventOverflowClosing = false; + /** + * Roving tabindex managed by toolbar for horizontal navigation. + * @private + */ + @property({ noAttribute: true }) + forcedTabIndex = "-1"; + + /** + * Defines whether the item exposes internal navigation semantics. + * + * Toolbar uses this to keep grouped content as a single tab stop while + * still allowing arrow navigation within the item. + * @default false + * @protected + * @since 2.22.0 + */ + get handlesOwnKeyboardNavigation(): boolean { + return false; + } + + _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 + */ + focusForToolbarNavigation(isForward: boolean) { + if (isForward) { + // no-op + } + this.getToolbarFocusTarget()?.focus(); + } + + moveWithinToolbarItem(isForward: boolean): boolean { // eslint-disable-line @typescript-eslint/no-unused-vars + return false; + } + + getToolbarMovementInfo(): ToolbarMovementInfo | undefined { + return undefined; + } + _isOverflowed: boolean = false; get isOverflowed(): boolean { @@ -104,6 +180,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; }