Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions packages/main/cypress/specs/Toolbar.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,166 @@ describe("Toolbar general interaction", () => {
});
});

describe("Keyboard Navigation", () => {
it("Should navigate between toolbar items using arrow keys", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1" data-testid="btn1"></ToolbarButton>
<ToolbarButton text="Button 2" data-testid="btn2"></ToolbarButton>
<ToolbarButton text="Button 3" data-testid="btn3"></ToolbarButton>
<ToolbarSeparator></ToolbarSeparator>
<ToolbarSelect data-testid="select1">
<ToolbarSelectOption>1</ToolbarSelectOption>
<ToolbarSelectOption>2</ToolbarSelectOption>
</ToolbarSelect>
</Toolbar>
);

// Focus the first button (go through both shadow roots)
cy.get("[data-testid='btn1']").shadow().find("[ui5-button]").shadow().find("button").focus();

// Press Arrow Right to move to second button
cy.realPress("ArrowRight");
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");

// Press Arrow Right to move to third button
cy.realPress("ArrowRight");
cy.get("[data-testid='btn3']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");

// Press Arrow Right to move to select (skip separator)
cy.realPress("ArrowRight");
cy.get("[data-testid='select1']").shadow().find("[ui5-select]").shadow().find("[data-sap-focus-ref]").should("be.focused");

// Press Arrow Left to move back to third button
cy.realPress("ArrowLeft");
cy.get("[data-testid='btn3']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");
});

it("Should navigate using Home and End keys", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="First" data-testid="first"></ToolbarButton>
<ToolbarButton text="Middle" data-testid="middle"></ToolbarButton>
<ToolbarButton text="Last" data-testid="last"></ToolbarButton>
</Toolbar>
);

// Focus the middle button
cy.get("[data-testid='middle']").shadow().find("[ui5-button]").shadow().find("button").focus();

// Press End to move to last button
cy.realPress("End");
cy.get("[data-testid='last']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");

// Press Home to move to first button
cy.realPress("Home");
cy.get("[data-testid='first']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");
});

it("Should skip disabled items during arrow navigation", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1" data-testid="btn1"></ToolbarButton>
<ToolbarButton text="Button 2" disabled data-testid="btn2"></ToolbarButton>
<ToolbarButton text="Button 3" data-testid="btn3"></ToolbarButton>
</Toolbar>
);

// Focus the first button
cy.get("[data-testid='btn1']").shadow().find("[ui5-button]").shadow().find("button").focus();

// Press Arrow Right - should skip disabled button and go to button 3
cy.realPress("ArrowRight");
cy.get("[data-testid='btn3']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");
});

it("Should skip non-interactive items during arrow navigation", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1" data-testid="btn1"></ToolbarButton>
<ToolbarSeparator data-testid="sep1"></ToolbarSeparator>
<ToolbarSpacer data-testid="spacer1"></ToolbarSpacer>
<ToolbarButton text="Button 2" data-testid="btn2"></ToolbarButton>
</Toolbar>
);

// Focus the first button
cy.get("[data-testid='btn1']").shadow().find("[ui5-button]").shadow().find("button").focus();

// Press Arrow Right - should skip separator and spacer
cy.realPress("ArrowRight");
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");
});

it("Should have correct tabindex values for toolbar items", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1" data-testid="btn1"></ToolbarButton>
<ToolbarButton text="Button 2" data-testid="btn2"></ToolbarButton>
<ToolbarButton text="Button 3" data-testid="btn3"></ToolbarButton>
</Toolbar>
);

// First button should have tabindex="0", others should have tabindex="-1"
cy.get("[data-testid='btn1']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "0");
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "-1");
cy.get("[data-testid='btn3']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "-1");

// Focus second button and check tabindex updates
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").focus();
cy.get("[data-testid='btn1']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "-1");
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "0");
cy.get("[data-testid='btn3']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "-1");
});

it("Should maintain focus when clicking on toolbar items", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1" data-testid="btn1"></ToolbarButton>
<ToolbarButton text="Button 2" data-testid="btn2"></ToolbarButton>
<ToolbarButton text="Button 3" data-testid="btn3"></ToolbarButton>
</Toolbar>
);

// Click on second button
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").realClick();

// Check that second button has tabindex="0" and can navigate from there
cy.get("[data-testid='btn2']").shadow().find("[ui5-button]").shadow().find("button").should("have.attr", "tabindex", "0");

// Navigate with arrow key
cy.realPress("ArrowRight");
cy.get("[data-testid='btn3']").shadow().find("[ui5-button]").shadow().find("button").should("be.focused");
});

it("Should have role='toolbar' when there are multiple interactive items", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1"></ToolbarButton>
<ToolbarButton text="Button 2"></ToolbarButton>
</Toolbar>
);

cy.get("[ui5-toolbar]")
.shadow()
.find(".ui5-tb-items")
.should("have.attr", "role", "toolbar");
});

it("Should not have role='toolbar' when there is only one interactive item", () => {
cy.mount(
<Toolbar>
<ToolbarButton text="Button 1"></ToolbarButton>
</Toolbar>
);

cy.get("[ui5-toolbar]")
.shadow()
.find(".ui5-tb-items")
.should("not.have.attr", "role");
});
});

describe("Accessibility", () => {
it("Should apply accessibile-name to the popover", () => {
cy.mount(
Expand Down
16 changes: 14 additions & 2 deletions packages/main/src/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,14 @@ class Select extends UI5Element implements IFormInputElement {
@property({ type: Boolean })
focused = false;

/**
* Defines the tabindex of the component.
* @private
* @since 2.16.0
*/
@property()
forcedTabIndex?: string;

_selectedIndexBeforeOpen = -1;
_escapePressed = false;
_lastSelectedOption: IOption | null = null;;
Expand Down Expand Up @@ -1045,9 +1053,13 @@ 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;
}

return this.forcedTabIndex !== undefined ? parseInt(this.forcedTabIndex) : 0;
}

/**
Expand Down
74 changes: 72 additions & 2 deletions packages/main/src/Toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import type { UI5CustomEvent } from "@ui5/webcomponents-base";
import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js";
import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import { isUp, isDown } from "@ui5/webcomponents-base/dist/Keys.js";
import "@ui5/webcomponents-icons/dist/overflow.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
Expand Down Expand Up @@ -59,8 +61,10 @@ 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
* - [Tab] - focuses the toolbar or the next tabbable element after the toolbar
* - [Arrow Left] / [Arrow Right] - navigate between toolbar items
* - [Home] - navigate to the first toolbar item
* - [End] - navigate to the last toolbar item
*
* ### ES6 Module Import
* `import "@ui5/webcomponents/dist/Toolbar.js";`
Expand Down Expand Up @@ -164,6 +168,8 @@ class Toolbar extends UI5Element {
itemsToOverflow: Array<ToolbarItem> = [];
itemsWidth = 0;
minContentWidth = 0;
_itemNavigation: ItemNavigation;
hasPreviouslyFocusedItem = false;

ITEMS_WIDTH_MAP: Map<string, number> = new Map();

Expand All @@ -179,6 +185,9 @@ class Toolbar extends UI5Element {

this._onResize = this.onResize.bind(this);
this._onCloseOverflow = this.closeOverflow.bind(this);
this._itemNavigation = new ItemNavigation(this, {
getItemsCallback: () => this.navigatableItems,
});
}

/**
Expand Down Expand Up @@ -221,6 +230,67 @@ class Toolbar extends UI5Element {
return this.items.filter((item: ToolbarItem) => item.isInteractive);
}

get navigatableItems() {
return this.items.filter((item: ToolbarItem) => item.isInteractive && !item.disabled);
}

/**
* Keyboard Navigation Handlers
*/

_onkeydown(e: KeyboardEvent) {
// If a Select is focused and Up/Down is pressed, don't let ItemNavigation handle it
// so the Select can handle its own options navigation
const path = e.composedPath();
const toolbarItem = path.find(el => this.items.includes(el as ToolbarItem)) as ToolbarItem | undefined;

if (toolbarItem && toolbarItem.matches && toolbarItem.matches("[ui5-toolbar-select]")) {
if (isUp(e) || isDown(e)) {
e.stopPropagation();
}
}
}

_onfocusin(e: FocusEvent) {
// Find the toolbar item - it could be the target itself or a parent
const target = e.target as HTMLElement;
let toolbarItem: ToolbarItem | undefined;

// Check if target is a toolbar item
if (this.items.includes(target as ToolbarItem)) {
toolbarItem = target as ToolbarItem;
} else {
// Find the toolbar item parent by checking composed path
const path = e.composedPath();
toolbarItem = path.find(el => this.items.includes(el as ToolbarItem)) as ToolbarItem | undefined;
}

if (!toolbarItem || !toolbarItem.isInteractive) {
return;
}

// Update ItemNavigation to sync tabindex values
if (this.hasPreviouslyFocusedItem) {
this._itemNavigation.setCurrentItem(toolbarItem);
return;
}

// On first focus, set the focused item as current
this._itemNavigation.setCurrentItem(toolbarItem);
this.hasPreviouslyFocusedItem = true;
}

_onmousedown(e: MouseEvent) {
// Find the toolbar item from the event path
const path = e.composedPath();
const toolbarItem = path.find(el => this.items.includes(el as ToolbarItem)) as ToolbarItem | undefined;

if (toolbarItem && toolbarItem.isInteractive && !toolbarItem.disabled) {
this._itemNavigation.setCurrentItem(toolbarItem);
this.hasPreviouslyFocusedItem = true;
}
}

/**
* Accessibility
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/main/src/ToolbarButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js";
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import type Button from "./Button.js";
import type { ButtonAccessibilityAttributes } from "./Button.js";
import type ButtonDesign from "./types/ButtonDesign.js";

Expand Down Expand Up @@ -213,6 +214,11 @@ class ToolbarButton extends ToolbarItem {
},
};
}

getFocusDomRef(): HTMLElement | undefined {
const button = this.shadowRoot?.querySelector("[ui5-button]") as Button | null;
return button?.getFocusDomRef();
}
}

ToolbarButton.define();
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/ToolbarButtonTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default function ToolbarButtonTemplate(this: ToolbarButton) {
design={this.design}
disabled={this.disabled}
hidden={this.hidden}
forcedTabIndex={this.forcedTabIndex}
data-ui5-external-action-item-id={this._id}
data-ui5-stable={this.stableDomRef}
onClick={(...args) => this.onClick(...args)}
Expand Down
14 changes: 14 additions & 0 deletions packages/main/src/ToolbarItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ class ToolbarItem extends UI5Element {
@property({ type: Boolean })
isOverflowed: boolean = false;

/**
* Defines the tabindex of the toolbar item.
* Used by ItemNavigation for keyboard navigation.
* @private
*/
@property()
forcedTabIndex?: string;

_isRendering = true;

onAfterRendering(): void {
Expand Down Expand Up @@ -92,6 +100,12 @@ class ToolbarItem extends UI5Element {
return true;
}

/**
* Returns if the item is disabled.
* @protected
*/
disabled = false;

/**
* Returns if the item is separator.
* @protected
Expand Down
7 changes: 6 additions & 1 deletion packages/main/src/ToolbarSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js";
import type ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import ToolbarSelectCss from "./generated/themes/ToolbarSelect.css.js";
import type Select from "./Select.js";
import type { SelectChangeEventDetail } from "./Select.js";

// Templates
import ToolbarSelectTemplate from "./ToolbarSelectTemplate.js";
import ToolbarItem from "./ToolbarItem.js";
import type { ToolbarItemEventDetail } from "./ToolbarItem.js";
import type ToolbarSelectOption from "./ToolbarSelectOption.js";
import type { SelectChangeEventDetail } from "./Select.js";

type ToolbarSelectChangeEventDetail = ToolbarItemEventDetail & SelectChangeEventDetail;

Expand Down Expand Up @@ -214,6 +214,11 @@ class ToolbarSelect extends ToolbarItem {
get hasCustomLabel() {
return !!this.label.length;
}

getFocusDomRef(): HTMLElement | undefined {
const select = this.shadowRoot?.querySelector("[ui5-select]") as Select | null;
return select?.getFocusDomRef();
}
}

ToolbarSelect.define();
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/ToolbarSelectTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function ToolbarSelectTemplate(this: ToolbarSelect) {
disabled={this.disabled}
accessibleName={this.accessibleName}
accessibleNameRef={this.accessibleNameRef}
forcedTabIndex={this.forcedTabIndex}
onClick={(...args) => this.onClick(...args)}
onClose={(...args) => this.onClose(...args)}
onOpen={(...args) => this.onOpen(...args)}
Expand Down
Loading
Loading