diff --git a/packages/main/src/Select.ts b/packages/main/src/Select.ts index 71a6f13508d3..8fe587a07b78 100644 --- a/packages/main/src/Select.ts +++ b/packages/main/src/Select.ts @@ -1054,9 +1054,16 @@ class Select extends UI5Element implements IFormInputElement { } get _effectiveTabIndex() { - return this.disabled + if (this.disabled || (this.responsivePopover // Handles focus on Tab/Shift + Tab when the popover is opened - && this.responsivePopover.open) ? -1 : 0; + && this.responsivePopover.open)) { + return -1; + } + const tabindex = this.getAttribute("tabindex"); + if (tabindex) { + return Number.parseInt(tabindex); + } + return 0; } /** diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 5fff5e3880d0..63c54c53c444 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 ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; +import NavigationMode from "@ui5/webcomponents-base/dist/types/NavigationMode.js"; +import ItemNavigationBehavior from "@ui5/webcomponents-base/dist/types/ItemNavigationBehavior.js"; +import { + isLeft, + isRight, + isHome, + isEnd, +} from "@ui5/webcomponents-base/dist/Keys.js"; +import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.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"; @@ -32,7 +42,6 @@ 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"; type ToolbarMinWidthChangeEventDetail = { minWidth: number, @@ -170,6 +179,9 @@ class Toolbar extends UI5Element { _onResize!: ResizeObserverCallback; _onCloseOverflow!: EventListener; + _onKeydownCapture!: (e: KeyboardEvent) => void; + _onFocusin!: EventListener; + _itemNavigation!: ItemNavigation; itemsToOverflow: Array = []; itemsWidth = 0; minContentWidth = 0; @@ -188,6 +200,17 @@ class Toolbar extends UI5Element { this._onResize = this.onResize.bind(this); this._onCloseOverflow = this.closeOverflow.bind(this); + this._onKeydownCapture = this._handleKeydownCapture.bind(this); + this._onFocusin = this._handleFocusin.bind(this) as EventListener; + + // ItemNavigation manages roving tabindex and navigation logic. + // Its built-in _onkeydown won't fire (because _canNavigate fails across + // shadow boundaries), so we call its internal handlers from our capture listener. + this._itemNavigation = new ItemNavigation(this, { + getItemsCallback: () => this._getItemNavigationItems(), + navigationMode: NavigationMode.Horizontal, + behavior: ItemNavigationBehavior.Cyclic, + }); } /** @@ -283,10 +306,14 @@ class Toolbar extends UI5Element { */ onEnterDOM() { ResizeHandler.register(this, this._onResize); + this.addEventListener("keydown", this._onKeydownCapture, { capture: true }); + this.addEventListener("focusin", this._onFocusin); } onExitDOM() { ResizeHandler.deregister(this, this._onResize); + this.removeEventListener("keydown", this._onKeydownCapture, { capture: true }); + this.removeEventListener("focusin", this._onFocusin); } onInvalidation(changeInfo: ChangeInfo) { @@ -315,6 +342,7 @@ class Toolbar extends UI5Element { this.items.forEach(item => { this.addItemsAdditionalProperties(item); }); + this._syncOverflowButtonTabIndex(); } addItemsAdditionalProperties(item: ToolbarItemBase) { @@ -550,6 +578,121 @@ class Toolbar extends UI5Element { getCachedItemWidth(id: string) { return this.ITEMS_WIDTH_MAP.get(id); } + + // --- Keyboard Navigation (WAI-ARIA Toolbar Pattern) --- + // Capturing keydown intercepts arrows at toolbar boundaries only. + // Internal navigation within multi-target items is handled by the nested component's own ItemNavigation. + + _getNavigatableItems(): ToolbarItemBase[] { + return this.standardItems.filter(item => item.isInteractive && !item.hidden && !item.isOverflowed && !("disabled" in item && (item as { disabled?: boolean }).disabled)); + } + + _getItemNavigationItems(): Array<{ id: string; forcedTabIndex?: string }> { + const items: Array<{ id: string; forcedTabIndex?: string }> = [...this._getNavigatableItems()]; + if (!this.hideOverflowButton && this.overflowButtonDOM) { + items.push(this.overflowButtonDOM); + } + return items; + } + + _findCurrentFocusIndex(items: Array<{ id: string; forcedTabIndex?: string }>): number { + let el: Node | null = getActiveElement() as HTMLElement | null; + while (el) { + const idx = items.indexOf(el as unknown as { id: string; forcedTabIndex?: string }); + if (idx !== -1) { + return idx; + } + const root = el.getRootNode(); + el = root instanceof ShadowRoot ? root.host : (el as HTMLElement).parentElement; + } + return -1; + } + + _handleKeydownCapture(e: KeyboardEvent) { + if (!isLeft(e) && !isRight(e) && !isHome(e) && !isEnd(e)) { + return; + } + + const allItems = this._getItemNavigationItems(); + if (!allItems.length) { + return; + } + + const currentIdx = this._findCurrentFocusIndex(allItems); + if (currentIdx === -1) { + return; + } + + const toolbarItems = this._getNavigatableItems(); + const item = currentIdx < toolbarItems.length ? toolbarItems[currentIdx] : null; + + const isRTL = this.effectiveDir === "rtl"; + const movingForward = (isRight(e) && !isRTL) || (isLeft(e) && isRTL); + const movingBackward = (isLeft(e) && !isRTL) || (isRight(e) && isRTL); + + // Multi-target items: let the nested component handle unless at boundary + if ((isLeft(e) || isRight(e)) && item) { + const count = item.toolbarNavigationItemCount; + if (count > 1) { + const navIdx = item.toolbarNavigationCurrentIndex; + if (movingForward && navIdx < count - 1) { + return; // Nested component handles it + } + if (movingBackward && navIdx > 0) { + return; // Nested component handles it + } + } + } + + // Toolbar-level navigation + e.preventDefault(); + e.stopPropagation(); + + this._itemNavigation._currentIndex = currentIdx; + + if (movingForward) { + this._itemNavigation._handleRight(); + } else if (movingBackward) { + this._itemNavigation._handleLeft(); + } else if (isHome(e)) { + this._itemNavigation._handleHome(); + } else if (isEnd(e)) { + this._itemNavigation._handleEnd(); + } + + this._itemNavigation._applyTabIndex(); + + // Focus the new item + const newIdx = this._itemNavigation._currentIndex; + if (newIdx < toolbarItems.length) { + toolbarItems[newIdx].handleToolbarNavigationEntry(movingForward); + } else { + this._itemNavigation._focusCurrentItem(); + } + this._syncOverflowButtonTabIndex(); + } + + _handleFocusin() { + const allItems = this._getItemNavigationItems(); + if (!allItems.length) { + return; + } + + const idx = this._findCurrentFocusIndex(allItems); + if (idx !== -1) { + this._itemNavigation._currentIndex = idx; + this._itemNavigation._applyTabIndex(); + this._syncOverflowButtonTabIndex(); + } + } + + _syncOverflowButtonTabIndex() { + const overflowBtn = this.overflowButtonDOM; + if (!overflowBtn || this.hideOverflowButton) { + return; + } + overflowBtn.setAttribute("tabindex", overflowBtn.forcedTabIndex || "-1"); + } } Toolbar.define(); diff --git a/packages/main/src/ToolbarButtonTemplate.tsx b/packages/main/src/ToolbarButtonTemplate.tsx index 4f31ed35a20f..387dddf55ee9 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={parseInt(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 b0337805ff69..ca4afe8b2de4 100644 --- a/packages/main/src/ToolbarItem.ts +++ b/packages/main/src/ToolbarItem.ts @@ -19,6 +19,50 @@ interface IToolbarItemContent extends HTMLElement { hasOverflow?: boolean; } +/** + * Interface for components that support toolbar navigation without ItemNavigation. + * Implement this on your component to enable Up/Down navigation within a toolbar item. + * + * @public + * @since 2.22.0 + */ +interface IToolbarNavigatable { + /** + * Number of navigable items. Mirrors ItemNavigation's `_getItems().length`. + */ + toolbarNavigationItemCount: number; + /** + * 0-based index of currently focused item. Mirrors ItemNavigation's `_currentIndex`. + */ + toolbarNavigationCurrentIndex: number; + /** + * Focus the item at the given index. Mirrors `setCurrentItem` + `_focusCurrentItem`. + */ + focusToolbarNavigationItem(index: number): void; + /** + * Called when toolbar navigation enters this component from outside. + */ + handleToolbarNavigationEntry?(forward: boolean): void; +} + +/** + * Duck-typed interface for accessing a child component's ItemNavigation instance. + */ +interface IChildItemNavigation { + _getItems: () => Array<{ id: string; forcedTabIndex?: string }>; + _currentIndex: number; + setCurrentItem: (item: { id: string; forcedTabIndex?: string }) => void; + _focusCurrentItem: () => void; + _applyTabIndex: () => void; +} + +/** + * Duck-typed host that may have an _itemNavigation property. + */ +interface IToolbarNavigatableHost extends HTMLElement { + _itemNavigation?: Partial; +} + /** * @class * @@ -55,6 +99,7 @@ class ToolbarItem extends ToolbarItemBase { _wrapperChecked = false; fireCloseOverflowRef = this.fireCloseOverflow.bind(this); + closeOverflowSet = { "ui5-button": ["click"], "ui5-select": ["change"], @@ -72,6 +117,7 @@ class ToolbarItem extends ToolbarItemBase { onBeforeRendering(): void { this.checkForWrapper(); this.attachCloseOverflowHandlers(); + this._syncChildTabIndex(); } onExitDOM(): void { @@ -147,10 +193,154 @@ 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._getFirstFocusableChild(child) || child; + } + + return super.getFocusDomRef(); + } + + /** + * Returns the child's ItemNavigation instance if it has one (duck-typed). + * This enables auto-detection of components like SegmentedButton. + */ + _getChildItemNavigation(): IChildItemNavigation | null { + const child = this.item[0] as IToolbarNavigatableHost | undefined; + if (child + && child._itemNavigation + && typeof child._itemNavigation._getItems === "function" + && typeof child._itemNavigation.setCurrentItem === "function" + && "_currentIndex" in child._itemNavigation) { + return child._itemNavigation as IChildItemNavigation; + } + return null; + } + + /** + * Checks if the child implements the IToolbarNavigatable interface (duck-typed). + */ + _getChildNavigatable(): IToolbarNavigatable | null { + const child = this.item[0] as Partial | undefined; + if (child + && typeof child.toolbarNavigationItemCount === "number" + && typeof child.toolbarNavigationCurrentIndex === "number" + && typeof child.focusToolbarNavigationItem === "function") { + return child as IToolbarNavigatable; + } + return null; + } + + /** + * Syncs the child's internal tabindex state based on this item's forcedTabIndex. + */ + _syncChildTabIndex(): void { + const childNav = this._getChildItemNavigation(); + if (childNav) { + const items = childNav._getItems(); + if (this.forcedTabIndex === "-1") { + items.forEach(item => { item.forcedTabIndex = "-1"; }); + } else { + childNav._applyTabIndex(); + } + return; + } + + // Propagate forcedTabIndex to child if it supports it + const child = this.item[0] as { forcedTabIndex?: string } | undefined; + if (child && "forcedTabIndex" in child) { + child.forcedTabIndex = this.forcedTabIndex; + } + } + + // --- Navigation interface --- + + get toolbarNavigationItemCount(): number { + const childNav = this._getChildItemNavigation(); + if (childNav) { + return childNav._getItems().length; + } + + const navigatable = this._getChildNavigatable(); + if (navigatable) { + return navigatable.toolbarNavigationItemCount; + } + + return 1; + } + + get toolbarNavigationCurrentIndex(): number { + const childNav = this._getChildItemNavigation(); + if (childNav) { + return childNav._currentIndex; + } + + const navigatable = this._getChildNavigatable(); + if (navigatable) { + return navigatable.toolbarNavigationCurrentIndex; + } + + return 0; + } + + focusToolbarNavigationItem(index: number): void { + const childNav = this._getChildItemNavigation(); + if (childNav) { + const items = childNav._getItems(); + if (items[index]) { + childNav.setCurrentItem(items[index]); + childNav._focusCurrentItem(); + } + return; + } + + const navigatable = this._getChildNavigatable(); + if (navigatable) { + navigatable.focusToolbarNavigationItem(index); + return; + } + + this.getFocusDomRef()?.focus(); + } + + handleToolbarNavigationEntry(forward: boolean): void { + const childNav = this._getChildItemNavigation(); + if (childNav) { + childNav._focusCurrentItem(); + return; + } + + const navigatable = this._getChildNavigatable(); + if (navigatable) { + if (typeof navigatable.handleToolbarNavigationEntry === "function") { + navigatable.handleToolbarNavigationEntry(forward); + } else { + navigatable.focusToolbarNavigationItem( + forward ? 0 : navigatable.toolbarNavigationItemCount - 1, + ); + } + return; + } + + this.getFocusDomRef()?.focus(); + } + + _getFirstFocusableChild(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'])", + ); + } } export type { IToolbarItemContent, + IToolbarNavigatable, }; ToolbarItem.define(); diff --git a/packages/main/src/ToolbarItemBase.ts b/packages/main/src/ToolbarItemBase.ts index 23f754c43820..f46035e2be7b 100644 --- a/packages/main/src/ToolbarItemBase.ts +++ b/packages/main/src/ToolbarItemBase.ts @@ -143,6 +143,44 @@ class ToolbarItemBase extends UI5Element { get styles() { return {}; } + + /** + * ITabbable implementation for ItemNavigation roving tabindex. + */ + @property({ noAttribute: true }) + forcedTabIndex = "0"; + + /** + * Number of navigable sub-items. Override for multi-target items. + * @protected + */ + get toolbarNavigationItemCount(): number { + return 1; + } + + /** + * Index of currently focused sub-item. Override for multi-target items. + * @protected + */ + get toolbarNavigationCurrentIndex(): number { + return 0; + } + + /** + * Focus the sub-item at the given index. + * @protected + */ + focusToolbarNavigationItem(_index: number): void { // eslint-disable-line @typescript-eslint/no-unused-vars + this.getFocusDomRef()?.focus(); + } + + /** + * Called when toolbar navigation enters this item from outside. + * @protected + */ + handleToolbarNavigationEntry(_forward: boolean): void { // eslint-disable-line @typescript-eslint/no-unused-vars + this.getFocusDomRef()?.focus(); + } } export type { diff --git a/packages/main/src/ToolbarSelectTemplate.tsx b/packages/main/src/ToolbarSelectTemplate.tsx index 28e1f2c1dc3e..475825815ce3 100644 --- a/packages/main/src/ToolbarSelectTemplate.tsx +++ b/packages/main/src/ToolbarSelectTemplate.tsx @@ -13,6 +13,7 @@ export default function ToolbarSelectTemplate(this: ToolbarSelect) { disabled={this.disabled} accessibleName={this.accessibleName} accessibleNameRef={this.accessibleNameRef} + tabindex={parseInt(this.forcedTabIndex)} onClick={(...args) => this.onClick(...args)} onClose={(...args) => this.onClose(...args)} onOpen={(...args) => this.onOpen(...args)}