From 7622f8045ccc8ef7ccbad36f01acbebb9f9272c4 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Mon, 1 Jun 2026 14:55:34 +0300 Subject: [PATCH 1/9] chore(ui5-toolbar): checkpoint - stable keyboard navigation at step 22 (prevent page scroll on unhandled up/down) --- packages/main/src/RadioButton.ts | 1 - packages/main/src/Toolbar.ts | 381 ++++++++++++++++++++++++++- packages/main/src/ToolbarItem.ts | 212 +++++++++++++++ packages/main/src/ToolbarItemBase.ts | 34 +++ packages/main/src/ToolbarSelect.ts | 13 + 5 files changed, 636 insertions(+), 5 deletions(-) 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/Toolbar.ts b/packages/main/src/Toolbar.ts index 5fff5e3880d0..02d5d9edac72 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -8,6 +8,12 @@ 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, +} 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"; @@ -57,8 +63,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 +127,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 +140,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 */ @@ -173,6 +188,8 @@ class Toolbar extends UI5Element { itemsToOverflow: Array = []; itemsWidth = 0; minContentWidth = 0; + _lastFocusedItem?: HTMLElement; + _originalTabIndexes = new WeakMap(); ITEMS_WIDTH_MAP: Map = new Map(); @@ -302,8 +319,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._getFocusableItems(); + const lastItem = items.at(-1); + if (lastItem) { + this._lastFocusedItem = lastItem; + lastItem.focus(); + } } this.prePopulateAlwaysOverflowItems(); } @@ -315,6 +336,9 @@ class Toolbar extends UI5Element { this.items.forEach(item => { this.addItemsAdditionalProperties(item); }); + this._refreshOriginalTabIndexes(); + this._applyRovingTabIndex(); + this._restoreOverflowTabOrder(); } addItemsAdditionalProperties(item: ToolbarItemBase) { @@ -499,6 +523,7 @@ class Toolbar extends UI5Element { onOverflowPopoverOpened() { this.popoverOpen = true; + this._restoreOverflowTabOrder(); } onResize() { @@ -550,6 +575,354 @@ class Toolbar extends UI5Element { getCachedItemWidth(id: string) { return this.ITEMS_WIDTH_MAP.get(id); } + + /** + * Keyboard Navigation + */ + + _getFocusableItems(): Array { + const items: Array = []; + + 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.hasAttribute("disabled")) { + items.push(focusRef); + } + }); + + const overflowRef = this.overflowButtonDOM?.getFocusDomRef(); + if (!this.hideOverflowButton && overflowRef) { + items.push(overflowRef); + } + + return items; + } + + _getOverflowTabTargets(item: ToolbarItemBase): Array { + return item._getNavigationTargets(); + } + + _storeOriginalTabIndex(target: HTMLElement) { + if (!this._originalTabIndexes.has(target)) { + this._originalTabIndexes.set(target, target.getAttribute("tabindex")); + } + } + + _refreshOriginalTabIndexes() { + this._originalTabIndexes = new WeakMap(); + + this.standardItems + .filter(item => item.isInteractive && !item.hidden) + .forEach(item => { + const focusRef = item.getFocusDomRef(); + if (focusRef) { + this._storeOriginalTabIndex(focusRef); + } + + if (item.handlesOwnKeyboardNavigation) { + item._getNavigationTargets().forEach(target => this._storeOriginalTabIndex(target)); + } + }); + + this.overflowItems + .filter(item => item.isInteractive && !item.hidden) + .forEach(item => { + this._getOverflowTabTargets(item).forEach(target => this._storeOriginalTabIndex(target)); + }); + } + + _restoreOriginalTabIndex(target: HTMLElement) { + const originalTabIndex = this._originalTabIndexes.get(target); + + if (originalTabIndex === undefined || originalTabIndex === null) { + target.removeAttribute("tabindex"); + return; + } + + target.setAttribute("tabindex", originalTabIndex); + } + + _restoreOverflowTabOrder() { + this.overflowItems + .filter(item => item.isInteractive && !item.hidden) + .forEach(item => { + const isDisabled = "disabled" in item && !!(item as { disabled?: boolean }).disabled; + const targets = this._getOverflowTabTargets(item); + + targets.forEach(target => { + if (isDisabled) { + target.tabIndex = -1; + target.setAttribute("aria-disabled", "true"); + return; + } + + this._restoreOriginalTabIndex(target); + target.removeAttribute("aria-disabled"); + }); + }); + } + + _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) { + this._storeOriginalTabIndex(focusRef); + focusRef.tabIndex = -1; + focusRef.setAttribute("aria-disabled", "true"); + } + }); + } + + _findCurrentIndex(items: Array, e?: Event): number { + const active = getActiveElement() as HTMLElement | null; + if (!active) { + if (!e) { + return -1; + } + const path = e.composedPath(); + return items.findIndex(item => path.includes(item)); + } + + const activeIndex = items.findIndex(item => item === active || item.contains(active) + || (item.shadowRoot?.contains(active) ?? false)); + + if (activeIndex !== -1) { + return activeIndex; + } + + if (!e) { + return -1; + } + + const path = e.composedPath(); + return items.findIndex(item => path.includes(item)); + } + + _findToolbarItem(focusRef: HTMLElement, active?: HTMLElement | null): ToolbarItemBase | undefined { + return this.standardItems.find(item => { + const ref = item.getFocusDomRef(); + const focusMatches = ref === focusRef + || item === focusRef + || item.contains(focusRef) + || !!(ref && (ref.contains(focusRef) + || focusRef.contains(ref) + || ref.shadowRoot?.contains(focusRef))); + + if (focusMatches) { + return true; + } + + if (!active) { + return false; + } + + return item === active + || item.contains(active) + || !!(ref && (ref === active + || ref.contains(active) + || active.contains(ref) + || ref.shadowRoot?.contains(active))); + }); + } + + _applyRovingTabIndex(items = this._getFocusableItems()) { + // Reset all non-overflowed items first so no stale tabIndex=0 survives + // when an item becomes disabled while _lastFocusedItem is null or stale. + this.standardItems + .filter(item => !item.isOverflowed && !item.hidden) + .forEach(item => { + const ref = item.getFocusDomRef(); + if (ref) { + this._storeOriginalTabIndex(ref); + ref.tabIndex = -1; + } + }); + + if (!items.length) { + return; + } + + const current = (this._lastFocusedItem && items.includes(this._lastFocusedItem)) + ? this._lastFocusedItem + : items[0]; + + items.forEach(item => { + this._storeOriginalTabIndex(item); + item.tabIndex = item === current ? 0 : -1; + }); + + this._applySingleTabStopToGroups(current); + this._applyDisabledItemsAccessibility(); + } + + /** + * For ToolbarItem groups (handlesOwnKeyboardNavigation), ensures only + * the active child is tabbable so Tab exits the toolbar instead of + * moving between children within the same group. + */ + _applySingleTabStopToGroups(current: HTMLElement) { + this.standardItems + .filter(item => item.handlesOwnKeyboardNavigation && !item.isOverflowed && !item.hidden) + .forEach(item => { + const targets = item._getNavigationTargets(); + if (targets.length <= 1) { + return; + } + + targets.forEach(target => { + this._storeOriginalTabIndex(target); + }); + + // If this group's primary ref is not the current roving tab item, + // all its children should be untabbable + const primaryRef = item.getFocusDomRef(); + if (primaryRef !== current) { + targets.forEach(t => { t.tabIndex = -1; }); + return; + } + + // This is the active group - only the focused child should be tabbable + const activeEl = getActiveElement() as HTMLElement | null; + const activeTarget = activeEl + ? targets.find(t => t === activeEl || t.contains(activeEl) || !!t.shadowRoot?.contains(activeEl)) + : null; + + const focusedTarget = activeTarget || targets[0]; + + targets.forEach(t => { + t.tabIndex = t === focusedTarget ? 0 : -1; + }); + }); + } + + _onfocusin(e: FocusEvent) { + const items = this._getFocusableItems(); + if (!items.length) { + return; + } + + let idx = this._findCurrentIndex(items); + + if (idx === -1) { + const path = e.composedPath(); + const matchedItem = this.standardItems.find(item => !item.isOverflowed && !item.hidden && path.includes(item)); + if (matchedItem) { + const ref = matchedItem.getFocusDomRef(); + idx = ref ? items.indexOf(ref) : -1; + } + } + + if (idx !== -1) { + this._lastFocusedItem = items[idx]; + this._applyRovingTabIndex(items); + } + } + + _onkeydown(e: KeyboardEvent) { + const active = getActiveElement() as HTMLElement | null; + + if (!isLeft(e) && !isRight(e) && !isHome(e) && !isEnd(e)) { + return; + } + + const isRTL = this.effectiveDir === "rtl"; + + // Left/Right are reserved for toolbar navigation. + // Exception: inside an input or textarea, allow native caret movement + // but only exit to the next/prev toolbar item when the caret is already at the boundary. + if ((isLeft(e) || isRight(e)) && active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { + const input = active as HTMLInputElement | HTMLTextAreaElement; + const atStart = input.selectionStart === 0; + const atEnd = input.selectionStart === (input.value?.length ?? 0); + const textSelected = input.selectionStart !== input.selectionEnd; + const movingForward = (isRight(e) && !isRTL) || (isLeft(e) && isRTL); + + if (textSelected || (movingForward && !atEnd) || (!movingForward && !atStart)) { + return; + } + } + + const items = this._getFocusableItems(); + let currentIndex = this._findCurrentIndex(items, e); + if (currentIndex === -1) { + const path = e.composedPath(); + const toolbarItemFromPath = this.standardItems.find(item => path.includes(item)); + const pathFocusRef = toolbarItemFromPath?.getFocusDomRef(); + if (pathFocusRef && items.includes(pathFocusRef)) { + currentIndex = items.indexOf(pathFocusRef); + } + } + + if (currentIndex === -1) { + if (isHome(e)) { + currentIndex = 0; + } else if (isEnd(e)) { + currentIndex = items.length - 1; + } else if (this._lastFocusedItem && items.includes(this._lastFocusedItem)) { + currentIndex = items.indexOf(this._lastFocusedItem); + } else { + return; + } + } + + const toolbarItem = this._findToolbarItem(items[currentIndex], active); + const itemHandlesKey = toolbarItem?.shouldHandleOwnKeyboardNavigation(e) ?? false; + + // If a child already prevented default, only allow toolbar handling for items + // that explicitly opt into key-level delegation and currently do not own the key + // (e.g. ToolbarSelect with closed picker and Home/End). + if (e.defaultPrevented) { + if (!toolbarItem?.handlesOwnKeyboardNavigation || itemHandlesKey) { + return; + } + } + + // Let a toolbar item handle its own navigation for the keys it explicitly owns + // (e.g. ToolbarSelect claims Up/Down/Home/End via shouldHandleOwnKeyboardNavigation). + if (itemHandlesKey) { + return; + } + + let nextIndex = currentIndex; + const movingForward = (isRight(e) && !isRTL) || (isLeft(e) && isRTL); + const movingBackward = (isLeft(e) && !isRTL) || (isRight(e) && isRTL); + + if (movingForward) { + nextIndex = (currentIndex + 1) % items.length; + } else if (movingBackward) { + nextIndex = (currentIndex - 1 + items.length) % items.length; + } else if (isHome(e)) { + nextIndex = 0; + } else if (isEnd(e)) { + nextIndex = items.length - 1; + } + + // Always prevent default once the toolbar has committed to handling this key, + // so that Up/Down/Home/End do not scroll the page even when focus is already + // at the first or last item. + e.preventDefault(); + + if (isHome(e) || isEnd(e) || nextIndex !== currentIndex) { + const nextToolbarItem = this._findToolbarItem(items[nextIndex]); + const directionForEntry = movingForward || isHome(e); + + this._lastFocusedItem = items[nextIndex]; + this._applyRovingTabIndex(items); + + if (nextToolbarItem) { + nextToolbarItem.handleNavigationEntry(directionForEntry); + return; + } + + items[nextIndex].focus(); + } + } } Toolbar.define(); diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts index b0337805ff69..661232ee89b8 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -1,6 +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 { + isLeft, + isRight, +} from "@ui5/webcomponents-base/dist/Keys.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"; @@ -55,6 +60,10 @@ class ToolbarItem extends ToolbarItemBase { _wrapperChecked = false; fireCloseOverflowRef = this.fireCloseOverflow.bind(this); + get handlesOwnKeyboardNavigation(): boolean { + return true; + } + closeOverflowSet = { "ui5-button": ["click"], "ui5-select": ["change"], @@ -147,6 +156,209 @@ 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) { + 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); + } + + _getEventOriginIndex(e: KeyboardEvent, targets: HTMLElement[]): number { + // eslint-disable-next-line no-restricted-syntax + for (const node of e.composedPath()) { + if (node instanceof HTMLElement) { + const idx = targets.findIndex(target => this._matchesNavigationTarget(target, node)); + if (idx !== -1) { + return idx; + } + } + } + return -1; + } + + _isRadioGroupTargets(targets: HTMLElement[]) { + return targets.length > 1 && targets.every(target => this._isRadioButtonHost(this._resolveNavigationHost(target))); + } + + _restoreRadioBoundarySelection(targets: HTMLElement[], isForward: boolean) { + const edgeTarget = isForward ? targets[targets.length - 1] : targets[0]; + this._handleNavigationTarget(edgeTarget); + } + + handleNavigationEntry(forward: boolean) { + const target = this.getFocusDomRefForNavigation(forward); + if (!target) { + return; + } + + this._handleNavigationTarget(target); + } + + _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; + }); + } + + shouldHandleOwnKeyboardNavigation(e: KeyboardEvent): boolean { + const targets = this._getNavigationTargets(); + if (targets.length <= 1) { + if (!e.defaultPrevented) { + return false; + } + + const active = getActiveElement() as HTMLElement | null; + const origin = e.composedPath().find((node): node is HTMLElement => node instanceof HTMLElement); + const singleTarget = targets[0]; + + if (!active || !origin) { + return true; + } + + const activeInsideTarget = this._matchesNavigationTarget(singleTarget, active); + const originInsideTarget = this._matchesNavigationTarget(singleTarget, origin); + + if (activeInsideTarget && originInsideTarget) { + const activeHost = this._resolveNavigationHost(active); + const originHost = this._resolveNavigationHost(origin); + + // Single-child control kept focus on the same focusable part => boundary reached, + // let the toolbar continue navigation to the next/previous item. + if (activeHost === originHost) { + return false; + } + } + + return true; + } + + const active = getActiveElement() as HTMLElement | null; + if (!active) { + return false; + } + + const currentIndex = targets.findIndex(target => this._matchesNavigationTarget(target, active)); + + if (currentIndex === -1) { + return false; + } + + const isRTL = this.effectiveDir === "rtl"; + const isForward = (!isRTL && isRight(e)) || (isRTL && isLeft(e)); + const isBackward = (!isRTL && isLeft(e)) || (isRTL && isRight(e)); + + if (!isForward && !isBackward) { + return false; + } + + const isRadioGroup = this._isRadioGroupTargets(targets); + const nextIndex = isForward ? currentIndex + 1 : currentIndex - 1; + + if (isRadioGroup && e.defaultPrevented) { + const originIndex = this._getEventOriginIndex(e, targets); + const wrappedForward = originIndex === targets.length - 1 && currentIndex === 0; + const wrappedBackward = originIndex === 0 && currentIndex === targets.length - 1; + const isForwardBoundary = isForward && wrappedForward; + const isBackwardBoundary = isBackward && wrappedBackward; + const unknownOriginBoundary = originIndex === -1 && (nextIndex < 0 || nextIndex >= targets.length); + + if (isForwardBoundary || isBackwardBoundary || unknownOriginBoundary) { + this._restoreRadioBoundarySelection(targets, isForward); + return false; + } + + // RadioButton already handled in-group navigation for this arrow. + return true; + } + + if (nextIndex < 0 || nextIndex >= targets.length) { + return false; + } + + e.preventDefault(); + this._handleNavigationTarget(targets[nextIndex]); + return true; + } } export type { diff --git a/packages/main/src/ToolbarItemBase.ts b/packages/main/src/ToolbarItemBase.ts index 23f754c43820..7fb7750021c1 100644 --- a/packages/main/src/ToolbarItemBase.ts +++ b/packages/main/src/ToolbarItemBase.ts @@ -48,6 +48,40 @@ class ToolbarItemBase extends UI5Element { @property({ type: Boolean }) preventOverflowClosing = false; + /** + * Defines if the toolbar item should keep arrow key navigation for itself. + * + * When set to `true`, the toolbar does not process keys that are expected + * to be handled by the item itself. + * @default false + * @protected + * @since 2.22.0 + */ + get handlesOwnKeyboardNavigation(): boolean { + return false; + } + + /** + * Defines whether the toolbar item handles keyboard navigation for a given key. + * + * Override this method in complex toolbar items that need to preserve + * specific key handling. + * @public + * @since 2.22.0 + */ + shouldHandleOwnKeyboardNavigation(e: KeyboardEvent): boolean { // eslint-disable-line @typescript-eslint/no-unused-vars + return this.handlesOwnKeyboardNavigation; + } + + _getNavigationTargets(): HTMLElement[] { + const ref = this.getFocusDomRef(); + return ref ? [ref] : []; + } + + handleNavigationEntry(forward: boolean): void { // eslint-disable-line @typescript-eslint/no-unused-vars + this.getFocusDomRef()?.focus(); + } + _isOverflowed: boolean = false; get isOverflowed(): boolean { diff --git a/packages/main/src/ToolbarSelect.ts b/packages/main/src/ToolbarSelect.ts index 5885a16ade5f..2e265047c52c 100644 --- a/packages/main/src/ToolbarSelect.ts +++ b/packages/main/src/ToolbarSelect.ts @@ -72,6 +72,19 @@ class ToolbarSelect extends ToolbarItemBase { "click": ToolbarItemEventDetail; } + get handlesOwnKeyboardNavigation(): boolean { + return true; + } + + shouldHandleOwnKeyboardNavigation(e: KeyboardEvent): boolean { + // Home/End are only owned while the dropdown is open; otherwise the toolbar + // should use them to jump to the first/last item. + if (e.key === "Home" || e.key === "End") { + return !!(this.select?._isPickerOpen); + } + return false; + } + /** * Defines the width of the select. * From 3a65f4a9bdd8e702c09032ce7cc08867d66029bd Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Tue, 2 Jun 2026 11:39:21 +0300 Subject: [PATCH 2/9] fix(ui5-toolbar): simplify toolbar item keyboard navigation Refactor toolbar keyboard handling around a single toolbar-owned flow. - centralize arrow and tab navigation in Toolbar - add movement-info and roving-tabindex hooks to ToolbarItemBase - adapt grouped ToolbarItem content through shared internal target logic - restore caret-aware movement for Input and TextArea - apply forced tabindex to toolbar button/select templates - remove redundant select-owned keyboard handling - add Toolbar regressions for checkbox groups and overflow-button exit --- packages/main/cypress/specs/Toolbar.cy.tsx | 69 ++++ packages/main/src/Input.ts | 22 ++ packages/main/src/TextArea.ts | 23 ++ packages/main/src/Toolbar.ts | 402 +++++++++++++++----- packages/main/src/ToolbarButtonTemplate.tsx | 1 + packages/main/src/ToolbarItem.ts | 220 ++++++----- packages/main/src/ToolbarItemBase.ts | 78 +++- packages/main/src/ToolbarSelect.ts | 14 +- packages/main/src/ToolbarSelectTemplate.tsx | 1 + 9 files changed, 608 insertions(+), 222 deletions(-) diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index be5b32156db1..21ac0099b900 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,74 @@ 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("shouldn't have toolbar button as popover opener when there is spacer before last toolbar item", () => { cy.mount( 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/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 02d5d9edac72..b8c33cb95305 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -13,6 +13,8 @@ import { 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"; @@ -33,12 +35,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, @@ -185,10 +188,12 @@ class Toolbar extends UI5Element { _onResize!: ResizeObserverCallback; _onCloseOverflow!: EventListener; + _onFocusIn!: (e: FocusEvent) => void; + _onKeyDown!: (e: KeyboardEvent) => void; itemsToOverflow: Array = []; itemsWidth = 0; minContentWidth = 0; - _lastFocusedItem?: HTMLElement; + _lastFocusedItem?: ToolbarItemBase | HTMLElement; _originalTabIndexes = new WeakMap(); ITEMS_WIDTH_MAP: Map = new Map(); @@ -205,6 +210,8 @@ class Toolbar extends UI5Element { this._onResize = this.onResize.bind(this); this._onCloseOverflow = this.closeOverflow.bind(this); + this._onFocusIn = this._onfocusin.bind(this); + this._onKeyDown = this._onkeydown.bind(this); } /** @@ -319,11 +326,11 @@ class Toolbar extends UI5Element { this.detachListeners(); this.attachListeners(); if (getActiveElement() === this.overflowButtonDOM?.getFocusDomRef() && this.hideOverflowButton) { - const items = this._getFocusableItems(); + const items = this._getNavigableItems(); const lastItem = items.at(-1); if (lastItem) { this._lastFocusedItem = lastItem; - lastItem.focus(); + lastItem.focusForToolbarNavigation(false); } } this.prePopulateAlwaysOverflowItems(); @@ -538,10 +545,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() { @@ -731,33 +742,19 @@ class Toolbar extends UI5Element { }); } - _applyRovingTabIndex(items = this._getFocusableItems()) { - // Reset all non-overflowed items first so no stale tabIndex=0 survives - // when an item becomes disabled while _lastFocusedItem is null or stale. - this.standardItems - .filter(item => !item.isOverflowed && !item.hidden) - .forEach(item => { - const ref = item.getFocusDomRef(); - if (ref) { - this._storeOriginalTabIndex(ref); - ref.tabIndex = -1; - } - }); + _applyRovingTabIndex() { + const items = this._getNavigationChain(); if (!items.length) { return; } - const current = (this._lastFocusedItem && items.includes(this._lastFocusedItem)) - ? this._lastFocusedItem - : items[0]; + if (!this._lastFocusedItem || !items.includes(this._lastFocusedItem)) { + this._lastFocusedItem = items[0]; + } - items.forEach(item => { - this._storeOriginalTabIndex(item); - item.tabIndex = item === current ? 0 : -1; - }); + this._setCurrentItem(this._lastFocusedItem); - this._applySingleTabStopToGroups(current); this._applyDisabledItemsAccessibility(); } @@ -802,126 +799,321 @@ class Toolbar extends UI5Element { } _onfocusin(e: FocusEvent) { - const items = this._getFocusableItems(); - if (!items.length) { - return; - } + const currentTarget = this._findItemByPath(e.composedPath()) + || this._findOverflowButtonByPath(e.composedPath()) + || this._findCurrentTargetByActiveElement(); - let idx = this._findCurrentIndex(items); + if (currentTarget) { + this._setCurrentItem(currentTarget); + } + } - if (idx === -1) { - const path = e.composedPath(); - const matchedItem = this.standardItems.find(item => !item.isOverflowed && !item.hidden && path.includes(item)); - if (matchedItem) { - const ref = matchedItem.getFocusDomRef(); - idx = ref ? items.indexOf(ref) : -1; + _onkeydown(e: KeyboardEvent) { + if (isTabNext(e) || isTabPrevious(e)) { + const moved = this._focusAdjacentToolbarOrOutside(isTabPrevious(e), e.composedPath()); + if (moved) { + e.preventDefault(); } + return; } - if (idx !== -1) { - this._lastFocusedItem = items[idx]; - this._applyRovingTabIndex(items); - } - } + 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); - _onkeydown(e: KeyboardEvent) { - const active = getActiveElement() as HTMLElement | null; + if (!isForward && !isBackward && !isHomeKey && !isEndKey) { + return; + } - if (!isLeft(e) && !isRight(e) && !isHome(e) && !isEnd(e)) { + const currentTarget = this._findItemByPath(e.composedPath()) + || this._findOverflowButtonByPath(e.composedPath()) + || this._findCurrentTargetByActiveElement() + || this._lastFocusedItem; + if (!currentTarget) { return; } - const isRTL = this.effectiveDir === "rtl"; + 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; + } - // Left/Right are reserved for toolbar navigation. - // Exception: inside an input or textarea, allow native caret movement - // but only exit to the next/prev toolbar item when the caret is already at the boundary. - if ((isLeft(e) || isRight(e)) && active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { - const input = active as HTMLInputElement | HTMLTextAreaElement; - const atStart = input.selectionStart === 0; - const atEnd = input.selectionStart === (input.value?.length ?? 0); - const textSelected = input.selectionStart !== input.selectionEnd; - const movingForward = (isRight(e) && !isRTL) || (isLeft(e) && isRTL); + if (currentTarget.moveWithinToolbarItem(isForward)) { + e.preventDefault(); + e.stopPropagation(); + } - if (textSelected || (movingForward && !atEnd) || (!movingForward && !atStart)) { + // Not at boundary -> nested control (or fallback mover) handles traversal. return; } } - const items = this._getFocusableItems(); - let currentIndex = this._findCurrentIndex(items, e); - if (currentIndex === -1) { - const path = e.composedPath(); - const toolbarItemFromPath = this.standardItems.find(item => path.includes(item)); - const pathFocusRef = toolbarItemFromPath?.getFocusDomRef(); - if (pathFocusRef && items.includes(pathFocusRef)) { - currentIndex = items.indexOf(pathFocusRef); + 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; + } - if (currentIndex === -1) { - if (isHome(e)) { - currentIndex = 0; - } else if (isEnd(e)) { - currentIndex = items.length - 1; - } else if (this._lastFocusedItem && items.includes(this._lastFocusedItem)) { - currentIndex = items.indexOf(this._lastFocusedItem); + _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 { - return; + current = current.parentNode; } } - const toolbarItem = this._findToolbarItem(items[currentIndex], active); - const itemHandlesKey = toolbarItem?.shouldHandleOwnKeyboardNavigation(e) ?? false; + return false; + } - // If a child already prevented default, only allow toolbar handling for items - // that explicitly opt into key-level delegation and currently do not own the key - // (e.g. ToolbarSelect with closed picker and Home/End). - if (e.defaultPrevented) { - if (!toolbarItem?.handlesOwnKeyboardNavigation || itemHandlesKey) { - return; + _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); } - // Let a toolbar item handle its own navigation for the keys it explicitly owns - // (e.g. ToolbarSelect claims Up/Down/Home/End via shouldHandleOwnKeyboardNavigation). - if (itemHandlesKey) { + 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); - let nextIndex = currentIndex; - const movingForward = (isRight(e) && !isRTL) || (isLeft(e) && isRTL); - const movingBackward = (isLeft(e) && !isRTL) || (isRight(e) && isRTL); + if (nextItem instanceof ToolbarItemBase) { + nextItem.focusForToolbarNavigation(isForward); + } else { + nextItem.focus(); + } + } - if (movingForward) { - nextIndex = (currentIndex + 1) % items.length; - } else if (movingBackward) { - nextIndex = (currentIndex - 1 + items.length) % items.length; - } else if (isHome(e)) { - nextIndex = 0; - } else if (isEnd(e)) { - nextIndex = items.length - 1; + _focusToolbarEntry() { + const chain = this._getNavigationChain(); + if (!chain.length) { + return false; } - // Always prevent default once the toolbar has committed to handling this key, - // so that Up/Down/Home/End do not scroll the page even when focus is already - // at the first or last item. - e.preventDefault(); + const target = this._lastFocusedItem && chain.includes(this._lastFocusedItem) + ? this._lastFocusedItem + : chain[0]; - if (isHome(e) || isEnd(e) || nextIndex !== currentIndex) { - const nextToolbarItem = this._findToolbarItem(items[nextIndex]); - const directionForEntry = movingForward || isHome(e); + this._setCurrentItem(target); - this._lastFocusedItem = items[nextIndex]; - this._applyRovingTabIndex(items); + if (target instanceof ToolbarItemBase) { + const targets = target._getNavigationTargets(); + const focusTarget = targets.find(item => item.tabIndex === 0) + || targets[0] + || target.getFocusDomRef(); - if (nextToolbarItem) { - nextToolbarItem.handleNavigationEntry(directionForEntry); - return; + if (!focusTarget) { + return false; + } + + focusTarget.focus(); + return true; + } + + target.focus(); + return true; + } + + _focusAdjacentToolbarOrOutside(backward: boolean, path: Array) { + const toolbars = Array.from(document.querySelectorAll("ui5-toolbar")); + const currentToolbarIndex = toolbars.indexOf(this); + + if (currentToolbarIndex !== -1) { + const step = backward ? -1 : 1; + + for (let i = currentToolbarIndex + step; i >= 0 && i < toolbars.length; i += step) { + const toolbar = toolbars[i]; + const canFocusToolbar = toolbar instanceof Toolbar + && toolbar !== this + && toolbar.isConnected + && toolbar.offsetParent !== null; + + if (canFocusToolbar && toolbar._focusToolbarEntry()) { + return true; + } } + } + + return this._focusOutsideToolbar(backward, path); + } - items[nextIndex].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; + + 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 (!this.contains(candidate) && !this.shadowRoot?.contains(candidate)) { + candidate.focus(); + return true; + } + } + } + + const insideIndices = tabbables + .map((el, index) => ({ el, index })) + .filter(({ el }) => this.contains(el) || !!this.shadowRoot?.contains(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 (!this.contains(candidate) && !this.shadowRoot?.contains(candidate)) { + candidate.focus(); + return true; + } + } + + return false; } } diff --git a/packages/main/src/ToolbarButtonTemplate.tsx b/packages/main/src/ToolbarButtonTemplate.tsx index 4f31ed35a20f..5dc13df76f86 100644 --- a/packages/main/src/ToolbarButtonTemplate.tsx +++ b/packages/main/src/ToolbarButtonTemplate.tsx @@ -18,6 +18,7 @@ export default function ToolbarButtonTemplate(this: ToolbarButton) { design={this.design} disabled={this.disabled} hidden={this.hidden} + tabIndex={Number(this.forcedTabIndex)} data-ui5-external-action-item-id={this._id} data-ui5-stable={this.stableDomRef} onClick={(...args) => this.onClick(...args)} diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts index 661232ee89b8..80615fba9d74 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -1,14 +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 { - isLeft, - isRight, -} from "@ui5/webcomponents-base/dist/Keys.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"; /** @@ -22,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; } /** @@ -58,10 +63,18 @@ interface IToolbarItemContent extends HTMLElement { class ToolbarItem extends ToolbarItemBase { _maxWidth = 0; _wrapperChecked = false; + _lastFocusedNavigationTarget?: HTMLElement; fireCloseOverflowRef = this.fireCloseOverflow.bind(this); get handlesOwnKeyboardNavigation(): boolean { - return true; + const child = this.item[0] as IToolbarItemContent | undefined; + if (!child) { + return false; + } + + return this._supportsItemNavigationMovementInfo(child) + || typeof child.getToolbarMovementInfo === "function" + || this._hasOwnToolbarMovementInfo(); } closeOverflowSet = { @@ -184,6 +197,7 @@ class ToolbarItem extends ToolbarItemBase { } _handleNavigationTarget(target: HTMLElement) { + this._lastFocusedNavigationTarget = target; const hostTarget = this._resolveNavigationHost(target); if (this._isRadioButtonHost(hostTarget)) { @@ -237,37 +251,6 @@ class ToolbarItem extends ToolbarItemBase { return target === host || target.contains(host) || !!target.shadowRoot?.contains(host); } - _getEventOriginIndex(e: KeyboardEvent, targets: HTMLElement[]): number { - // eslint-disable-next-line no-restricted-syntax - for (const node of e.composedPath()) { - if (node instanceof HTMLElement) { - const idx = targets.findIndex(target => this._matchesNavigationTarget(target, node)); - if (idx !== -1) { - return idx; - } - } - } - return -1; - } - - _isRadioGroupTargets(targets: HTMLElement[]) { - return targets.length > 1 && targets.every(target => this._isRadioButtonHost(this._resolveNavigationHost(target))); - } - - _restoreRadioBoundarySelection(targets: HTMLElement[], isForward: boolean) { - const edgeTarget = isForward ? targets[targets.length - 1] : targets[0]; - this._handleNavigationTarget(edgeTarget); - } - - handleNavigationEntry(forward: boolean) { - const target = this.getFocusDomRefForNavigation(forward); - if (!target) { - return; - } - - this._handleNavigationTarget(target); - } - _getNavigationTargets(): HTMLElement[] { return this.item .filter(child => !("disabled" in child && !!(child as { disabled?: boolean }).disabled)) @@ -280,84 +263,145 @@ class ToolbarItem extends ToolbarItemBase { }); } - shouldHandleOwnKeyboardNavigation(e: KeyboardEvent): boolean { - const targets = this._getNavigationTargets(); - if (targets.length <= 1) { - if (!e.defaultPrevented) { - return false; - } + _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, + }; + } - const active = getActiveElement() as HTMLElement | null; - const origin = e.composedPath().find((node): node is HTMLElement => node instanceof HTMLElement); - const singleTarget = targets[0]; + _supportsItemNavigationMovementInfo(child: IToolbarItemContent): boolean { + const itemNavigationOwner = child as IItemNavigationOwner; + return typeof itemNavigationOwner._itemNavigation?._getCurrentItem === "function" + && typeof itemNavigationOwner._getFocusableItems === "function"; + } - if (!active || !origin) { - return true; - } + _getItemNavigationMovementInfo(child: IToolbarItemContent): ToolbarMovementInfo | undefined { + if (!this._supportsItemNavigationMovementInfo(child)) { + return undefined; + } - const activeInsideTarget = this._matchesNavigationTarget(singleTarget, active); - const originInsideTarget = this._matchesNavigationTarget(singleTarget, origin); + const itemNavigationOwner = child as IItemNavigationOwner; + const items = itemNavigationOwner._getFocusableItems!(); + const current = itemNavigationOwner._itemNavigation!._getCurrentItem(); + const currentIndex = current ? items.indexOf(current) : -1; - if (activeInsideTarget && originInsideTarget) { - const activeHost = this._resolveNavigationHost(active); - const originHost = this._resolveNavigationHost(origin); + if (currentIndex === -1) { + return undefined; + } - // Single-child control kept focus on the same focusable part => boundary reached, - // let the toolbar continue navigation to the next/previous item. - if (activeHost === originHost) { - return false; - } - } + return { + currentIndex, + itemCount: items.length, + }; + } - return true; + _hasOwnToolbarMovementInfo(): boolean { + return this._getNavigationTargets().length > 1; + } + + _getOwnToolbarMovementInfo(): ToolbarMovementInfo | undefined { + const { items, currentIndex } = this._getCurrentNavigationState(); + if (items.length <= 1) { + return undefined; } - const active = getActiveElement() as HTMLElement | null; - if (!active) { + if (currentIndex === -1) { + return undefined; + } + + return { + currentIndex, + itemCount: items.length, + }; + } + + _isUsingOwnFallbackMovementInfo(): boolean { + const child = this.item[0] as IToolbarItemContent | undefined; + if (!child) { return false; } - const currentIndex = targets.findIndex(target => this._matchesNavigationTarget(target, active)); + return !this._supportsItemNavigationMovementInfo(child) + && typeof child.getToolbarMovementInfo !== "function" + && this._hasOwnToolbarMovementInfo(); + } - if (currentIndex === -1) { + moveWithinToolbarItem(isForward: boolean): boolean { + if (!this._isUsingOwnFallbackMovementInfo()) { return false; } - const isRTL = this.effectiveDir === "rtl"; - const isForward = (!isRTL && isRight(e)) || (isRTL && isLeft(e)); - const isBackward = (!isRTL && isLeft(e)) || (isRTL && isRight(e)); + const { items, currentIndex } = this._getCurrentNavigationState(); - if (!isForward && !isBackward) { + if (currentIndex === -1) { return false; } - const isRadioGroup = this._isRadioGroupTargets(targets); const nextIndex = isForward ? currentIndex + 1 : currentIndex - 1; + if (nextIndex < 0 || nextIndex >= items.length) { + return false; + } - if (isRadioGroup && e.defaultPrevented) { - const originIndex = this._getEventOriginIndex(e, targets); - const wrappedForward = originIndex === targets.length - 1 && currentIndex === 0; - const wrappedBackward = originIndex === 0 && currentIndex === targets.length - 1; - const isForwardBoundary = isForward && wrappedForward; - const isBackwardBoundary = isBackward && wrappedBackward; - const unknownOriginBoundary = originIndex === -1 && (nextIndex < 0 || nextIndex >= targets.length); - - if (isForwardBoundary || isBackwardBoundary || unknownOriginBoundary) { - this._restoreRadioBoundarySelection(targets, isForward); - return false; - } + this._handleNavigationTarget(items[nextIndex]); + return true; + } - // RadioButton already handled in-group navigation for this arrow. - return true; + getToolbarMovementInfo(): ToolbarMovementInfo | undefined { + const child = this.item[0] as IToolbarItemContent | undefined; + if (!child) { + return undefined; } - if (nextIndex < 0 || nextIndex >= targets.length) { - return false; + const itemNavigationInfo = this._getItemNavigationMovementInfo(child); + if (itemNavigationInfo) { + return itemNavigationInfo; } - e.preventDefault(); - this._handleNavigationTarget(targets[nextIndex]); - return true; + if (typeof child.getToolbarMovementInfo === "function") { + return child.getToolbarMovementInfo(); + } + + return this._getOwnToolbarMovementInfo(); + } + + 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(); + } } } diff --git a/packages/main/src/ToolbarItemBase.ts b/packages/main/src/ToolbarItemBase.ts index 7fb7750021c1..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, }) @@ -49,10 +58,17 @@ class ToolbarItemBase extends UI5Element { preventOverflowClosing = false; /** - * Defines if the toolbar item should keep arrow key navigation for itself. + * Roving tabindex managed by toolbar for horizontal navigation. + * @private + */ + @property({ noAttribute: true }) + forcedTabIndex = "-1"; + + /** + * Defines whether the item exposes internal navigation semantics. * - * When set to `true`, the toolbar does not process keys that are expected - * to be handled by the item itself. + * 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 @@ -61,25 +77,51 @@ class ToolbarItemBase extends UI5Element { return false; } + _getNavigationTargets(): HTMLElement[] { + const ref = this.getFocusDomRef(); + return ref ? [ref] : []; + } + /** - * Defines whether the toolbar item handles keyboard navigation for a given key. - * - * Override this method in complex toolbar items that need to preserve - * specific key handling. - * @public - * @since 2.22.0 + * Called by toolbar to apply roving tabindex. + * Override in items that need custom tabindex handling. + * @private */ - shouldHandleOwnKeyboardNavigation(e: KeyboardEvent): boolean { // eslint-disable-line @typescript-eslint/no-unused-vars - return this.handlesOwnKeyboardNavigation; + setToolbarForcedTabIndex(tabIndex: string) { + this.forcedTabIndex = tabIndex; + const target = this.getToolbarFocusTarget(); + if (target) { + target.tabIndex = Number(tabIndex); + } } - _getNavigationTargets(): HTMLElement[] { - const ref = this.getFocusDomRef(); - return ref ? [ref] : []; + /** + * 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; } - handleNavigationEntry(forward: boolean): void { // eslint-disable-line @typescript-eslint/no-unused-vars - this.getFocusDomRef()?.focus(); + /** + * 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; @@ -138,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; } diff --git a/packages/main/src/ToolbarSelect.ts b/packages/main/src/ToolbarSelect.ts index 2e265047c52c..db50ecc54f6f 100644 --- a/packages/main/src/ToolbarSelect.ts +++ b/packages/main/src/ToolbarSelect.ts @@ -72,19 +72,6 @@ class ToolbarSelect extends ToolbarItemBase { "click": ToolbarItemEventDetail; } - get handlesOwnKeyboardNavigation(): boolean { - return true; - } - - shouldHandleOwnKeyboardNavigation(e: KeyboardEvent): boolean { - // Home/End are only owned while the dropdown is open; otherwise the toolbar - // should use them to jump to the first/last item. - if (e.key === "Home" || e.key === "End") { - return !!(this.select?._isPickerOpen); - } - return false; - } - /** * Defines the width of the select. * @@ -230,6 +217,7 @@ class ToolbarSelect extends ToolbarItemBase { get hasCustomLabel() { return !!this.label.length; } + } ToolbarSelect.define(); diff --git a/packages/main/src/ToolbarSelectTemplate.tsx b/packages/main/src/ToolbarSelectTemplate.tsx index 28e1f2c1dc3e..46d27a32118d 100644 --- a/packages/main/src/ToolbarSelectTemplate.tsx +++ b/packages/main/src/ToolbarSelectTemplate.tsx @@ -11,6 +11,7 @@ export default function ToolbarSelectTemplate(this: ToolbarSelect) { data-ui5-external-action-item-id={this._id} valueState={this.valueState} disabled={this.disabled} + tabIndex={Number(this.forcedTabIndex)} accessibleName={this.accessibleName} accessibleNameRef={this.accessibleNameRef} onClick={(...args) => this.onClick(...args)} From 433c6adfc4b450a4386989f880b332b721690e41 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Tue, 2 Jun 2026 12:38:00 +0300 Subject: [PATCH 3/9] - removed dead helpers --- packages/main/src/Toolbar.ts | 173 +---------------------------- packages/main/src/ToolbarSelect.ts | 1 - 2 files changed, 1 insertion(+), 173 deletions(-) diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index b8c33cb95305..22a81e30b566 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -207,21 +207,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") @@ -591,27 +587,6 @@ class Toolbar extends UI5Element { * Keyboard Navigation */ - _getFocusableItems(): Array { - const items: Array = []; - - 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.hasAttribute("disabled")) { - items.push(focusRef); - } - }); - - const overflowRef = this.overflowButtonDOM?.getFocusDomRef(); - if (!this.hideOverflowButton && overflowRef) { - items.push(overflowRef); - } - - return items; - } - _getOverflowTabTargets(item: ToolbarItemBase): Array { return item._getNavigationTargets(); } @@ -690,58 +665,6 @@ class Toolbar extends UI5Element { }); } - _findCurrentIndex(items: Array, e?: Event): number { - const active = getActiveElement() as HTMLElement | null; - if (!active) { - if (!e) { - return -1; - } - const path = e.composedPath(); - return items.findIndex(item => path.includes(item)); - } - - const activeIndex = items.findIndex(item => item === active || item.contains(active) - || (item.shadowRoot?.contains(active) ?? false)); - - if (activeIndex !== -1) { - return activeIndex; - } - - if (!e) { - return -1; - } - - const path = e.composedPath(); - return items.findIndex(item => path.includes(item)); - } - - _findToolbarItem(focusRef: HTMLElement, active?: HTMLElement | null): ToolbarItemBase | undefined { - return this.standardItems.find(item => { - const ref = item.getFocusDomRef(); - const focusMatches = ref === focusRef - || item === focusRef - || item.contains(focusRef) - || !!(ref && (ref.contains(focusRef) - || focusRef.contains(ref) - || ref.shadowRoot?.contains(focusRef))); - - if (focusMatches) { - return true; - } - - if (!active) { - return false; - } - - return item === active - || item.contains(active) - || !!(ref && (ref === active - || ref.contains(active) - || active.contains(ref) - || ref.shadowRoot?.contains(active))); - }); - } - _applyRovingTabIndex() { const items = this._getNavigationChain(); @@ -758,46 +681,6 @@ class Toolbar extends UI5Element { this._applyDisabledItemsAccessibility(); } - /** - * For ToolbarItem groups (handlesOwnKeyboardNavigation), ensures only - * the active child is tabbable so Tab exits the toolbar instead of - * moving between children within the same group. - */ - _applySingleTabStopToGroups(current: HTMLElement) { - this.standardItems - .filter(item => item.handlesOwnKeyboardNavigation && !item.isOverflowed && !item.hidden) - .forEach(item => { - const targets = item._getNavigationTargets(); - if (targets.length <= 1) { - return; - } - - targets.forEach(target => { - this._storeOriginalTabIndex(target); - }); - - // If this group's primary ref is not the current roving tab item, - // all its children should be untabbable - const primaryRef = item.getFocusDomRef(); - if (primaryRef !== current) { - targets.forEach(t => { t.tabIndex = -1; }); - return; - } - - // This is the active group - only the focused child should be tabbable - const activeEl = getActiveElement() as HTMLElement | null; - const activeTarget = activeEl - ? targets.find(t => t === activeEl || t.contains(activeEl) || !!t.shadowRoot?.contains(activeEl)) - : null; - - const focusedTarget = activeTarget || targets[0]; - - targets.forEach(t => { - t.tabIndex = t === focusedTarget ? 0 : -1; - }); - }); - } - _onfocusin(e: FocusEvent) { const currentTarget = this._findItemByPath(e.composedPath()) || this._findOverflowButtonByPath(e.composedPath()) @@ -810,7 +693,7 @@ class Toolbar extends UI5Element { _onkeydown(e: KeyboardEvent) { if (isTabNext(e) || isTabPrevious(e)) { - const moved = this._focusAdjacentToolbarOrOutside(isTabPrevious(e), e.composedPath()); + const moved = this._focusOutsideToolbar(isTabPrevious(e), e.composedPath()); if (moved) { e.preventDefault(); } @@ -1008,60 +891,6 @@ class Toolbar extends UI5Element { nextItem.focus(); } } - - _focusToolbarEntry() { - const chain = this._getNavigationChain(); - if (!chain.length) { - return false; - } - - const target = this._lastFocusedItem && chain.includes(this._lastFocusedItem) - ? this._lastFocusedItem - : chain[0]; - - this._setCurrentItem(target); - - if (target instanceof ToolbarItemBase) { - const targets = target._getNavigationTargets(); - const focusTarget = targets.find(item => item.tabIndex === 0) - || targets[0] - || target.getFocusDomRef(); - - if (!focusTarget) { - return false; - } - - focusTarget.focus(); - return true; - } - - target.focus(); - return true; - } - - _focusAdjacentToolbarOrOutside(backward: boolean, path: Array) { - const toolbars = Array.from(document.querySelectorAll("ui5-toolbar")); - const currentToolbarIndex = toolbars.indexOf(this); - - if (currentToolbarIndex !== -1) { - const step = backward ? -1 : 1; - - for (let i = currentToolbarIndex + step; i >= 0 && i < toolbars.length; i += step) { - const toolbar = toolbars[i]; - const canFocusToolbar = toolbar instanceof Toolbar - && toolbar !== this - && toolbar.isConnected - && toolbar.offsetParent !== null; - - if (canFocusToolbar && toolbar._focusToolbarEntry()) { - return true; - } - } - } - - return this._focusOutsideToolbar(backward, path); - } - _focusOutsideToolbar(backward: boolean, path: Array) { const active = getActiveElement() as HTMLElement | null; const tabbables = getTabbableElements(document.body); diff --git a/packages/main/src/ToolbarSelect.ts b/packages/main/src/ToolbarSelect.ts index db50ecc54f6f..5885a16ade5f 100644 --- a/packages/main/src/ToolbarSelect.ts +++ b/packages/main/src/ToolbarSelect.ts @@ -217,7 +217,6 @@ class ToolbarSelect extends ToolbarItemBase { get hasCustomLabel() { return !!this.label.length; } - } ToolbarSelect.define(); From 74a334ede0bc70cb9ee49bb40e2732ac15145db7 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Wed, 3 Jun 2026 13:16:42 +0300 Subject: [PATCH 4/9] fix(ui5-toolbar): isolate overflow popover from roving tabindex - Skip focusin/keydown handling when focus is inside the open overflow popover, preventing arrow-nav logic from firing inside the popover - Skip forcedTabIndex on overflowed ToolbarButton/ToolbarSelect so overflow items keep their natural tab order - Fix Tab-exit containment check to use shadow-DOM-aware walk (_isNodeInsideElement) instead of contains/shadowRoot.contains - Remove own-fallback movement info path from ToolbarItem; items without _itemNavigation or getToolbarMovementInfo are now treated as single tab stops - Drop dead WeakMap tab-index restoration machinery (no longer needed now that overflow items manage their own tab order) --- packages/main/src/Toolbar.ts | 99 ++++++--------------- packages/main/src/ToolbarButtonTemplate.tsx | 2 +- packages/main/src/ToolbarItem.ts | 56 +----------- packages/main/src/ToolbarSelectTemplate.tsx | 2 +- 4 files changed, 31 insertions(+), 128 deletions(-) diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 22a81e30b566..32badc6bfa1f 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -194,7 +194,6 @@ class Toolbar extends UI5Element { itemsWidth = 0; minContentWidth = 0; _lastFocusedItem?: ToolbarItemBase | HTMLElement; - _originalTabIndexes = new WeakMap(); ITEMS_WIDTH_MAP: Map = new Map(); @@ -339,9 +338,7 @@ class Toolbar extends UI5Element { this.items.forEach(item => { this.addItemsAdditionalProperties(item); }); - this._refreshOriginalTabIndexes(); this._applyRovingTabIndex(); - this._restoreOverflowTabOrder(); } addItemsAdditionalProperties(item: ToolbarItemBase) { @@ -526,7 +523,6 @@ class Toolbar extends UI5Element { onOverflowPopoverOpened() { this.popoverOpen = true; - this._restoreOverflowTabOrder(); } onResize() { @@ -587,70 +583,6 @@ class Toolbar extends UI5Element { * Keyboard Navigation */ - _getOverflowTabTargets(item: ToolbarItemBase): Array { - return item._getNavigationTargets(); - } - - _storeOriginalTabIndex(target: HTMLElement) { - if (!this._originalTabIndexes.has(target)) { - this._originalTabIndexes.set(target, target.getAttribute("tabindex")); - } - } - - _refreshOriginalTabIndexes() { - this._originalTabIndexes = new WeakMap(); - - this.standardItems - .filter(item => item.isInteractive && !item.hidden) - .forEach(item => { - const focusRef = item.getFocusDomRef(); - if (focusRef) { - this._storeOriginalTabIndex(focusRef); - } - - if (item.handlesOwnKeyboardNavigation) { - item._getNavigationTargets().forEach(target => this._storeOriginalTabIndex(target)); - } - }); - - this.overflowItems - .filter(item => item.isInteractive && !item.hidden) - .forEach(item => { - this._getOverflowTabTargets(item).forEach(target => this._storeOriginalTabIndex(target)); - }); - } - - _restoreOriginalTabIndex(target: HTMLElement) { - const originalTabIndex = this._originalTabIndexes.get(target); - - if (originalTabIndex === undefined || originalTabIndex === null) { - target.removeAttribute("tabindex"); - return; - } - - target.setAttribute("tabindex", originalTabIndex); - } - - _restoreOverflowTabOrder() { - this.overflowItems - .filter(item => item.isInteractive && !item.hidden) - .forEach(item => { - const isDisabled = "disabled" in item && !!(item as { disabled?: boolean }).disabled; - const targets = this._getOverflowTabTargets(item); - - targets.forEach(target => { - if (isDisabled) { - target.tabIndex = -1; - target.setAttribute("aria-disabled", "true"); - return; - } - - this._restoreOriginalTabIndex(target); - target.removeAttribute("aria-disabled"); - }); - }); - } - _applyDisabledItemsAccessibility() { this.standardItems .filter(item => item.isInteractive && !item.hidden && !item.isOverflowed @@ -658,7 +590,6 @@ class Toolbar extends UI5Element { .forEach(item => { const focusRef = item.getFocusDomRef(); if (focusRef) { - this._storeOriginalTabIndex(focusRef); focusRef.tabIndex = -1; focusRef.setAttribute("aria-disabled", "true"); } @@ -681,7 +612,25 @@ class Toolbar extends UI5Element { 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(); @@ -692,6 +641,10 @@ class Toolbar extends UI5Element { } _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) { @@ -909,11 +862,13 @@ class Toolbar extends UI5Element { 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 (!this.contains(candidate) && !this.shadowRoot?.contains(candidate)) { + if (!isInsideToolbar(candidate)) { candidate.focus(); return true; } @@ -922,7 +877,7 @@ class Toolbar extends UI5Element { const insideIndices = tabbables .map((el, index) => ({ el, index })) - .filter(({ el }) => this.contains(el) || !!this.shadowRoot?.contains(el)) + .filter(({ el }) => isInsideToolbar(el)) .map(({ index }) => index); if (!insideIndices.length) { @@ -936,7 +891,7 @@ class Toolbar extends UI5Element { for (let i = startIndex; i >= 0 && i < tabbables.length; i += step) { const candidate = tabbables[i]; - if (!this.contains(candidate) && !this.shadowRoot?.contains(candidate)) { + if (!isInsideToolbar(candidate)) { candidate.focus(); return true; } diff --git a/packages/main/src/ToolbarButtonTemplate.tsx b/packages/main/src/ToolbarButtonTemplate.tsx index 5dc13df76f86..3cf90a2cf20f 100644 --- a/packages/main/src/ToolbarButtonTemplate.tsx +++ b/packages/main/src/ToolbarButtonTemplate.tsx @@ -18,7 +18,7 @@ export default function ToolbarButtonTemplate(this: ToolbarButton) { design={this.design} disabled={this.disabled} hidden={this.hidden} - tabIndex={Number(this.forcedTabIndex)} + tabIndex={this.isOverflowed ? undefined : Number(this.forcedTabIndex)} data-ui5-external-action-item-id={this._id} data-ui5-stable={this.stableDomRef} onClick={(...args) => this.onClick(...args)} diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts index 80615fba9d74..7bf2dd6a9d2f 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -73,8 +73,7 @@ class ToolbarItem extends ToolbarItemBase { } return this._supportsItemNavigationMovementInfo(child) - || typeof child.getToolbarMovementInfo === "function" - || this._hasOwnToolbarMovementInfo(); + || typeof child.getToolbarMovementInfo === "function"; } closeOverflowSet = { @@ -304,57 +303,6 @@ class ToolbarItem extends ToolbarItemBase { }; } - _hasOwnToolbarMovementInfo(): boolean { - return this._getNavigationTargets().length > 1; - } - - _getOwnToolbarMovementInfo(): ToolbarMovementInfo | undefined { - const { items, currentIndex } = this._getCurrentNavigationState(); - if (items.length <= 1) { - return undefined; - } - - if (currentIndex === -1) { - return undefined; - } - - return { - currentIndex, - itemCount: items.length, - }; - } - - _isUsingOwnFallbackMovementInfo(): boolean { - const child = this.item[0] as IToolbarItemContent | undefined; - if (!child) { - return false; - } - - return !this._supportsItemNavigationMovementInfo(child) - && typeof child.getToolbarMovementInfo !== "function" - && this._hasOwnToolbarMovementInfo(); - } - - moveWithinToolbarItem(isForward: boolean): boolean { - if (!this._isUsingOwnFallbackMovementInfo()) { - 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; - } - getToolbarMovementInfo(): ToolbarMovementInfo | undefined { const child = this.item[0] as IToolbarItemContent | undefined; if (!child) { @@ -370,7 +318,7 @@ class ToolbarItem extends ToolbarItemBase { return child.getToolbarMovementInfo(); } - return this._getOwnToolbarMovementInfo(); + return undefined; } setToolbarForcedTabIndex(tabIndex: string) { diff --git a/packages/main/src/ToolbarSelectTemplate.tsx b/packages/main/src/ToolbarSelectTemplate.tsx index 46d27a32118d..20dba062e5b0 100644 --- a/packages/main/src/ToolbarSelectTemplate.tsx +++ b/packages/main/src/ToolbarSelectTemplate.tsx @@ -11,7 +11,7 @@ export default function ToolbarSelectTemplate(this: ToolbarSelect) { data-ui5-external-action-item-id={this._id} valueState={this.valueState} disabled={this.disabled} - tabIndex={Number(this.forcedTabIndex)} + tabIndex={this.isOverflowed ? undefined : Number(this.forcedTabIndex)} accessibleName={this.accessibleName} accessibleNameRef={this.accessibleNameRef} onClick={(...args) => this.onClick(...args)} From 916a81b7435704fd1cb979fb3237d03596fcef09 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Wed, 3 Jun 2026 18:34:27 +0300 Subject: [PATCH 5/9] feat(ui5-toolbar): implement WAI-ARIA toolbar keyboard navigation --- packages/main/cypress/specs/Toolbar.cy.tsx | 92 ++++++++++++++++++++-- packages/main/src/Toolbar.ts | 12 +++ packages/main/src/ToolbarItem.ts | 31 +++++++- 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index 21ac0099b900..ab13be780c11 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -115,7 +115,90 @@ describe("Toolbar general interaction", () => { }); }); - it("shouldn't have toolbar button as popover opener when there is spacer before last toolbar item", () => { + 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.viewport(200, 1080); + + cy.mount( + + + + + + ); + + cy.get("[ui5-toolbar]") + .shadow() + .find(".ui5-tb-overflow-btn") + .realClick(); + + cy.get("[ui5-toolbar]") + .shadow() + .find(".ui5-overflow-popover") + .should("have.attr", "open", "open"); + + cy.get("[ui5-toolbar-button]") + .first() + .should("be.focused"); + }); + + cy.mount( @@ -551,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/Toolbar.ts b/packages/main/src/Toolbar.ts index 32badc6bfa1f..af13877a7e9a 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -11,6 +11,8 @@ import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delega import { isLeft, isRight, + isUp, + isDown, isHome, isEnd, isTabNext, @@ -343,6 +345,9 @@ class Toolbar extends UI5Element { 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, @@ -523,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() { @@ -653,6 +660,11 @@ class Toolbar extends UI5Element { 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); diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts index 7bf2dd6a9d2f..6293bb7b66ea 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -73,7 +73,8 @@ class ToolbarItem extends ToolbarItemBase { } return this._supportsItemNavigationMovementInfo(child) - || typeof child.getToolbarMovementInfo === "function"; + || typeof child.getToolbarMovementInfo === "function" + || this.item.length > 1; } closeOverflowSet = { @@ -318,9 +319,37 @@ class ToolbarItem extends ToolbarItemBase { 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; From 28a6b396b5bc20bbddb37d29f83b6c8f6e80e8cf Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Wed, 3 Jun 2026 18:44:12 +0300 Subject: [PATCH 6/9] - fixed the arrow key navigation in toolbar to prevent vertical scrolling when up/down keys are pressed --- packages/main/cypress/specs/Toolbar.cy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index ab13be780c11..ecc2683120c0 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -198,7 +198,7 @@ describe("Toolbar general interaction", () => { .should("be.focused"); }); - + it("shouldn't have toolbar button as popover opener when there is spacer before last toolbar item", () => { cy.mount( From 44a72469338a3fdb0c985101c34e354ab6816d54 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Wed, 3 Jun 2026 18:49:40 +0300 Subject: [PATCH 7/9] - remove tabIndex from the overflowed items, as they should not be focusable --- packages/main/src/ToolbarButtonTemplate.tsx | 1 - packages/main/src/ToolbarSelectTemplate.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/main/src/ToolbarButtonTemplate.tsx b/packages/main/src/ToolbarButtonTemplate.tsx index 3cf90a2cf20f..4f31ed35a20f 100644 --- a/packages/main/src/ToolbarButtonTemplate.tsx +++ b/packages/main/src/ToolbarButtonTemplate.tsx @@ -18,7 +18,6 @@ export default function ToolbarButtonTemplate(this: ToolbarButton) { design={this.design} disabled={this.disabled} hidden={this.hidden} - tabIndex={this.isOverflowed ? undefined : Number(this.forcedTabIndex)} data-ui5-external-action-item-id={this._id} data-ui5-stable={this.stableDomRef} onClick={(...args) => this.onClick(...args)} diff --git a/packages/main/src/ToolbarSelectTemplate.tsx b/packages/main/src/ToolbarSelectTemplate.tsx index 20dba062e5b0..28e1f2c1dc3e 100644 --- a/packages/main/src/ToolbarSelectTemplate.tsx +++ b/packages/main/src/ToolbarSelectTemplate.tsx @@ -11,7 +11,6 @@ export default function ToolbarSelectTemplate(this: ToolbarSelect) { data-ui5-external-action-item-id={this._id} valueState={this.valueState} disabled={this.disabled} - tabIndex={this.isOverflowed ? undefined : Number(this.forcedTabIndex)} accessibleName={this.accessibleName} accessibleNameRef={this.accessibleNameRef} onClick={(...args) => this.onClick(...args)} From 5e4035764ba28c75b725429658c88d644ddd799c Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Wed, 3 Jun 2026 19:04:37 +0300 Subject: [PATCH 8/9] - fixed cypress test --- packages/main/cypress/specs/Toolbar.cy.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index ecc2683120c0..b35b4093985d 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -177,9 +177,9 @@ describe("Toolbar general interaction", () => { cy.mount( - - - + + + ); @@ -193,9 +193,8 @@ describe("Toolbar general interaction", () => { .find(".ui5-overflow-popover") .should("have.attr", "open", "open"); - cy.get("[ui5-toolbar-button]") - .first() - .should("be.focused"); + cy.focused() + .should("have.attr", "ui5-toolbar-button"); }); it("shouldn't have toolbar button as popover opener when there is spacer before last toolbar item", () => { From e9d1c91ed147207b8e545b55d0f817f8976c849b Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Wed, 3 Jun 2026 19:21:49 +0300 Subject: [PATCH 9/9] - fixed test --- packages/main/cypress/specs/Toolbar.cy.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index b35b4093985d..b0e8af1ed162 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -173,8 +173,6 @@ describe("Toolbar general interaction", () => { }); it("Should focus first overflow item when overflow popover opens", () => { - cy.viewport(200, 1080); - cy.mount( @@ -183,9 +181,12 @@ describe("Toolbar general interaction", () => { ); + 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]") @@ -193,8 +194,8 @@ describe("Toolbar general interaction", () => { .find(".ui5-overflow-popover") .should("have.attr", "open", "open"); - cy.focused() - .should("have.attr", "ui5-toolbar-button"); + 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", () => {