From f00d35deb41b040d03680bccb81f8d76ff9549c2 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Mon, 9 Mar 2026 18:59:25 +0200 Subject: [PATCH 1/3] feat(ui5-toolbar): implement WCAG-compliant keyboard navigation Add comprehensive keyboard navigation to toolbar following WCAG toolbar pattern: - Arrow keys (Left/Right/Up/Down) navigate between toolbar items - Home/End jump to first/last item - Tab/Shift+Tab navigate within toolbar, exit at edges - Roving tabindex pattern (single tab stop, toolbar is one stop) - Focus entry: remembers last focused item on re-entry, defaults to first item on initial entry - Proper handling of complex nested controls (ToolbarSelect) Resolves focus loss when pressing Shift+Tab on complex toolbar children. Ensures all toolbar items are reachable via keyboard navigation. Optimizations: - Extracted _focusItem() helper to reduce code duplication - Simplified Tab/Shift+Tab navigation logic Fixes: #12945 --- packages/main/cypress/specs/Toolbar.cy.tsx | 144 ++++++++++++ packages/main/src/Toolbar.ts | 242 ++++++++++++++++++++- packages/main/src/ToolbarTemplate.tsx | 2 + 3 files changed, 385 insertions(+), 3 deletions(-) diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index 3fe058f17a54..be4355f93097 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -175,6 +175,150 @@ describe("Toolbar general interaction", () => { .should("have.been.calledOnce"); }); + it("Should navigate items with arrows and Tab/Shift+Tab within toolbar", () => { + cy.mount( + <> + + + + One + Two + + + + + + ); + + cy.get("ui5-toolbar-button") + .first() + .shadow() + .find("ui5-button") + .realClick() + .should("be.focused"); + + // Arrow navigation from First to Select + cy.realPress("ArrowRight"); + cy.get("ui5-toolbar-select") + .shadow() + .find("ui5-select") + .should("be.focused"); + + // Tab from Select should navigate to Last button (not exit) + cy.get("ui5-toolbar-select") + .shadow() + .find("ui5-select") + .realPress("Tab"); + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .should("be.focused"); + + // Tab from Last button should exit to after input + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .realPress("Tab"); + cy.get("[data-testid='after']") + .should("be.focused"); + + // Shift+Tab from after input should go back to Last button + cy.get("[data-testid='after']") + .realPress(["Shift", "Tab"]); + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .should("be.focused"); + + // Shift+Tab from Last should navigate to Select + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .realPress(["Shift", "Tab"]); + cy.get("ui5-toolbar-select") + .shadow() + .find("ui5-select") + .should("be.focused"); + }); + + it("Should focus first item on entry and restore last focused on re-entry", () => { + cy.mount( + <> + + + + + One + Two + + + + + + ); + + // Tab into toolbar should focus first item + cy.get("[data-testid='before']") + .realClick(); + + cy.get("[data-testid='before']") + .realPress("Tab"); + cy.get("ui5-toolbar-button") + .first() + .shadow() + .find("ui5-button") + .should("be.focused"); + + // Navigate to Select + cy.realPress("ArrowRight"); + cy.get("ui5-toolbar-select") + .shadow() + .find("ui5-select") + .should("be.focused"); + + // Tab out of toolbar to 'after' input + cy.get("ui5-toolbar-select") + .shadow() + .find("ui5-select") + .realPress("Tab"); + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .should("be.focused"); + + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .realPress("Tab"); + cy.get("[data-testid='after']") + .should("be.focused"); + + // Shift+Tab back into toolbar should restore last focused item (Select) + cy.get("[data-testid='after']") + .realPress(["Shift", "Tab"]); + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .should("be.focused"); + + cy.get("ui5-toolbar-button") + .last() + .shadow() + .find("ui5-button") + .realPress(["Shift", "Tab"]); + cy.get("ui5-toolbar-select") + .shadow() + .find("ui5-select") + .should("be.focused"); + }); + it("Should move button with alwaysOverflow priority to overflow popover", () => { cy.mount( diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 0ce197f8c77c..3500f9cc7ed7 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -8,6 +8,16 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import { + isLeft, + isRight, + isUp, + isDown, + isHome, + isEnd, +} from "@ui5/webcomponents-base/dist/Keys.js"; +import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.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, @@ -162,6 +171,9 @@ class Toolbar extends UI5Element { itemsToOverflow: Array = []; itemsWidth = 0; minContentWidth = 0; + _lastFocusedItem?: HTMLElement; + _hasFocusHistory = false; + _isFocusRedirecting = false; ITEMS_WIDTH_MAP: Map = new Map(); @@ -291,8 +303,13 @@ class Toolbar extends UI5Element { this.detachListeners(); this.attachListeners(); if (getActiveElement() === this.overflowButtonDOM?.getFocusDomRef() && this.hideOverflowButton) { - const lastItem = this.interactiveItems.at(-1); - lastItem?.focus(); + const lastItem = this._getNavigationItems().at(-1); + if (lastItem) { + this._lastFocusedItem = lastItem; + this._hasFocusHistory = true; + this._updateRovingTabIndex(lastItem); + lastItem.focus(); + } } this.prePopulateAlwaysOverflowItems(); } @@ -304,6 +321,7 @@ class Toolbar extends UI5Element { this.items.forEach(item => { this.addItemsAdditionalProperties(item); }); + this._updateRovingTabIndex(this._lastFocusedItem); } addItemsAdditionalProperties(item: ToolbarItemBase) { @@ -539,6 +557,224 @@ class Toolbar extends UI5Element { getCachedItemWidth(id: string) { return this.ITEMS_WIDTH_MAP.get(id); } + + _getNavigationItems(): HTMLElement[] { + const itemFocusRefs = this.standardItems + .filter(item => { + if (!item.isInteractive || item.hidden || item.isOverflowed) { + return false; + } + return !("disabled" in item && (item as { disabled?: boolean }).disabled); + }) + .map(item => item.getFocusDomRef() as HTMLElement) + .filter((el): el is HTMLElement => !!el && this._isVisible(el)); + + if (!this.hideOverflowButton && this.overflowButtonDOM) { + const overflowButtonFocusRef = this.overflowButtonDOM.getFocusDomRef() as HTMLElement; + if (overflowButtonFocusRef && this._isVisible(overflowButtonFocusRef)) { + itemFocusRefs.push(overflowButtonFocusRef); + } + } + + return itemFocusRefs; + } + + _updateRovingTabIndex(currentItem?: HTMLElement) { + const items = this._getNavigationItems(); + if (!items.length) { + return; + } + const targetItem = currentItem && items.includes(currentItem) ? currentItem : items[0]; + items.forEach(item => { + item.tabIndex = item === targetItem ? 0 : -1; + }); + } + + _isVisible(element: HTMLElement): boolean { + const style = getComputedStyle(element); + return style.display !== "none" + && style.visibility !== "hidden" + && element.offsetWidth > 0 + && element.offsetHeight > 0; + } + + _shouldChildHandleNavigation(element: HTMLElement, e: KeyboardEvent): boolean { + // If element is an input or textarea, check cursor position + if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") { + const input = element as HTMLInputElement | HTMLTextAreaElement; + const cursorPos = input.selectionStart || 0; + const textLength = input.value.length; + + // Let input handle left arrow if cursor is not at start + if (isLeft(e) && cursorPos > 0) { + return true; + } + + // Let input handle right arrow if cursor is not at end + if (isRight(e) && cursorPos < textLength) { + return true; + } + } + + return false; + } + + _getCurrentItemIndex(items: HTMLElement[], activeElement: HTMLElement): number { + return items.findIndex(item => { + if (item === activeElement || item.contains(activeElement)) { + return true; + } + + if (item.shadowRoot && item.shadowRoot.contains(activeElement)) { + return true; + } + + return false; + }); + } + + _getCurrentItemFromEvent(items: HTMLElement[], e: FocusEvent): HTMLElement | undefined { + const path = e.composedPath().filter((node): node is HTMLElement => node instanceof HTMLElement); + return items.find(item => { + if (path.includes(item)) { + return true; + } + + if (item.shadowRoot && path.some(node => item.shadowRoot!.contains(node))) { + return true; + } + + return path.some(node => item.contains(node)); + }); + } + + _onfocusin(e: FocusEvent) { + const root = e.currentTarget as HTMLElement | null; + if (!root || this._isFocusRedirecting) { + this._isFocusRedirecting = false; + return; + } + + const relatedTarget = e.relatedTarget as HTMLElement | null; + const isEntering = !relatedTarget || !root.contains(relatedTarget); + + const items = this._getNavigationItems(); + if (!items.length) { + return; + } + + const target = e.target as HTMLElement; + const activeElement = (getActiveElement() || target) as HTMLElement; + const currentItem = this._getCurrentItemFromEvent(items, e) || items[this._getCurrentItemIndex(items, activeElement)]; + const currentIndex = currentItem ? items.indexOf(currentItem) : -1; + + if (currentIndex !== -1) { + this._lastFocusedItem = items[currentIndex]; + this._hasFocusHistory = true; + this._updateRovingTabIndex(this._lastFocusedItem); + } + + if (!isEntering || currentIndex !== -1) { + return; + } + + const desiredItem = (this._hasFocusHistory && this._lastFocusedItem && items.includes(this._lastFocusedItem)) + ? this._lastFocusedItem + : items[0]; + + if (desiredItem && desiredItem !== target && !desiredItem.contains(target)) { + this._isFocusRedirecting = true; + this._updateRovingTabIndex(desiredItem); + desiredItem.focus(); + } + } + + _isFromComplexToolbarChild(e: KeyboardEvent): boolean { + return e.composedPath().some(target => { + return target instanceof HTMLElement && target.tagName === "UI5-TOOLBAR-SELECT"; + }); + } + + _onkeydown(e: KeyboardEvent) { + const isNavigationKey = isLeft(e) || isRight(e) || isUp(e) || isDown(e) || isHome(e) || isEnd(e); + const isTabKey = e.key === "Tab"; + + // Only handle navigation keys or Tab + if (!isNavigationKey && !isTabKey) { + return; + } + + const activeElement = getActiveElement(); + if (!activeElement) { + return; + } + + const items = this._getNavigationItems(); + const currentIndex = this._getCurrentItemIndex(items, activeElement as HTMLElement); + + // Not on a toolbar item, don't handle + if (currentIndex === -1) { + return; + } + + // Complex controls like ToolbarSelect should keep their own arrow/home/end behavior. + if (isNavigationKey && this._isFromComplexToolbarChild(e)) { + return; + } + + // Check if the active element's child should handle navigation + if (isNavigationKey && this._shouldChildHandleNavigation(activeElement as HTMLElement, e)) { + return; + } + + if (isNavigationKey) { + e.preventDefault(); + this._navigateToItem(items, currentIndex, e); + } else if (isTabKey) { + // Handle Tab/Shift+Tab as toolbar navigation + const nextIndex = e.shiftKey ? currentIndex - 1 : currentIndex + 1; + const shouldNavigate = e.shiftKey ? currentIndex > 0 : currentIndex < items.length - 1; + + if (shouldNavigate) { + e.preventDefault(); + const nextItem = items[nextIndex]; + this._focusItem(nextItem); + } + // else: at edges, let browser handle Tab/Shift+Tab to exit + } + } + + _navigateToItem(items: HTMLElement[], currentIndex: number, e: KeyboardEvent): void { + if (isLeft(e) || isUp(e)) { + this._focusPrevious(items, currentIndex); + } else if (isRight(e) || isDown(e)) { + this._focusNext(items, currentIndex); + } else if (isHome(e)) { + this._updateRovingTabIndex(items[0]); + items[0]?.focus(); + } else if (isEnd(e)) { + const lastItem = items[items.length - 1]; + this._updateRovingTabIndex(lastItem); + lastItem?.focus(); + } + } + + _focusItem(item: HTMLElement): void { + this._updateRovingTabIndex(item); + item.focus(); + } + + _focusPrevious(items: HTMLElement[], currentIndex: number): void { + if (currentIndex > 0) { + this._focusItem(items[currentIndex - 1]); + } + } + + _focusNext(items: HTMLElement[], currentIndex: number): void { + if (currentIndex < items.length - 1) { + this._focusItem(items[currentIndex + 1]); + } + } } Toolbar.define(); diff --git a/packages/main/src/ToolbarTemplate.tsx b/packages/main/src/ToolbarTemplate.tsx index f40cc876e279..4ee539c520d0 100644 --- a/packages/main/src/ToolbarTemplate.tsx +++ b/packages/main/src/ToolbarTemplate.tsx @@ -10,6 +10,8 @@ export default function ToolbarTemplate(this: Toolbar) { "ui5-tb-items": true, "ui5-tb-items-full-width": this.hasFlexibleSpacers, }} + onKeyDown={this._onkeydown} + onFocusIn={this._onfocusin} role={this.accInfo.root.role} aria-label={this.accInfo.root.accessibleName} > From 6ff615b281d778d337a6e40ad219a960f0490724 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Mon, 9 Mar 2026 19:25:29 +0200 Subject: [PATCH 2/3] - fixed lint errors in Toolbar --- packages/main/src/Toolbar.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 3500f9cc7ed7..7926951e9c44 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -16,7 +16,6 @@ import { isHome, isEnd, } from "@ui5/webcomponents-base/dist/Keys.js"; -import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.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"; From f57e97682bbe96110a4d364777c24da286105749 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Tue, 10 Mar 2026 15:16:04 +0200 Subject: [PATCH 3/3] - prevented arrow keys from scrolling the page when focus is inside the overflow popover --- packages/main/src/Toolbar.ts | 7 +++++++ packages/main/src/ToolbarTemplate.tsx | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 7926951e9c44..284ab9f09e3f 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -688,6 +688,13 @@ class Toolbar extends UI5Element { } } + _onoverflowkeydown(e: KeyboardEvent) { + // Prevent arrow keys from scrolling the page while focus is inside the overflow popover + if (isLeft(e) || isRight(e) || isUp(e) || isDown(e) || isHome(e) || isEnd(e)) { + e.preventDefault(); + } + } + _isFromComplexToolbarChild(e: KeyboardEvent): boolean { return e.composedPath().some(target => { return target instanceof HTMLElement && target.tagName === "UI5-TOOLBAR-SELECT"; diff --git a/packages/main/src/ToolbarTemplate.tsx b/packages/main/src/ToolbarTemplate.tsx index 4ee539c520d0..f3f7a27a6401 100644 --- a/packages/main/src/ToolbarTemplate.tsx +++ b/packages/main/src/ToolbarTemplate.tsx @@ -53,7 +53,9 @@ export default function ToolbarTemplate(this: Toolbar) { >
+ }} + onKeyDown={this._onoverflowkeydown} + > {this.overflowItems.map(item => { return (