diff --git a/packages/core/src/components/split-button/split-button.tsx b/packages/core/src/components/split-button/split-button.tsx index 6d50c27311b..bc95fcae1c5 100644 --- a/packages/core/src/components/split-button/split-button.tsx +++ b/packages/core/src/components/split-button/split-button.tsx @@ -21,6 +21,7 @@ import { AlignedPlacement } from '../dropdown/placement'; import { iconContextMenu } from '@siemens/ix-icons/icons'; import { CloseBehavior } from '../dropdown/dropdown-controller'; import type { SplitButtonVariant } from './split-button.types'; +import { ArrowFocusController } from '../utils/focus'; @Component({ tag: 'ix-split-button', @@ -88,6 +89,7 @@ export class SplitButton { private triggerElement?: HTMLElement; private dropdownElement?: HTMLIxDropdownElement; + private arrowFocusController: ArrowFocusController | undefined; private linkTriggerRef() { if (this.triggerElement && this.dropdownElement) { @@ -95,6 +97,88 @@ export class SplitButton { } } + private get dropdownItems(): HTMLElement[] { + return Array.from(this.hostElement.querySelectorAll('ix-dropdown-item')); + } + + private get actionButton() { + return this.hostElement.shadowRoot?.querySelector( + 'ix-button, ix-icon-button:not(.anchor)' + ) as HTMLElement | null; + } + + private get anchorButton() { + return this.hostElement.shadowRoot?.querySelector( + 'ix-icon-button.anchor' + ) as HTMLElement | null; + } + + private onDropdownShowChanged(event: CustomEvent) { + if (event.detail) { + this.arrowFocusController = new ArrowFocusController( + this.dropdownItems, + this.dropdownElement!, + (index: number) => this.focusDropdownItem(index) + ); + this.arrowFocusController.wrap = true; + requestAnimationFrame(() => this.focusDropdownItem(0)); + this.dropdownElement?.addEventListener('keydown', this.handleKeyDown); + } else { + this.arrowFocusController?.disconnect(); + this.arrowFocusController = undefined; + this.dropdownElement?.removeEventListener('keydown', this.handleKeyDown); + } + } + private handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + this.dropdownItems.forEach((item) => item.setAttribute('tabindex', '-1')); + this.dropdownElement!.show = false; + if (!event.shiftKey) { + return; + } + + const isDisabled = this.actionButton?.classList.contains('disabled'); + + if (this.actionButton && !isDisabled) { + event.preventDefault(); + requestAnimationFrame(() => { + if (this.actionButton) { + const shadowBtn = + this.actionButton.shadowRoot?.querySelector('button'); + (shadowBtn ?? this.actionButton).focus(); + } + }); + } else if (this.anchorButton) { + this.anchorButton.setAttribute('tabindex', '-1'); + requestAnimationFrame(() => { + if (this.anchorButton) { + this.anchorButton.removeAttribute('tabindex'); + } + }); + } + }; + + private focusDropdownItem(index: number) { + if (index < 0) return; + const items = this.dropdownItems; + items.forEach((item, i) => + item.setAttribute('tabindex', i === index ? '0' : '-1') + ); + const item = items[index]; + if (item) { + requestAnimationFrame(() => { + const button = + item.shadowRoot?.querySelector('button') ?? + item.querySelector('button'); + if (button) { + button.focus(); + } + }); + } + } + componentDidLoad() { this.linkTriggerRef(); } @@ -129,7 +213,11 @@ export class SplitButton { )} (this.triggerElement = r)} + ref={(r) => { + if (r) { + this.triggerElement = r; + } + }} class={'anchor'} icon={this.splitIcon ?? iconContextMenu} aria-label={this.ariaLabelSplitIconButton} @@ -139,6 +227,7 @@ export class SplitButton { (this.dropdownElement = r)} closeBehavior={this.closeBehavior} + onShowChanged={(e) => this.onDropdownShowChanged(e)} > diff --git a/packages/core/src/components/split-button/test/split-button-keyboard.ct.ts b/packages/core/src/components/split-button/test/split-button-keyboard.ct.ts new file mode 100644 index 00000000000..902236ce78e --- /dev/null +++ b/packages/core/src/components/split-button/test/split-button-keyboard.ct.ts @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { expect, Locator } from '@playwright/test'; +import { regressionTest } from '@utils/test'; + +function splitButtonController(splitBtn: Locator) { + const mainButton = splitBtn.locator('ix-button').first(); + const chevronButton = splitBtn.locator('ix-icon-button.anchor'); + const dropdown = splitBtn.locator('ix-dropdown'); + + const dropdownVisible = async () => { + const element = await dropdown.elementHandle(); + if (!element) { + throw new Error('Dropdown has no open handle'); + } + await element.waitForElementState('stable'); + await expect(dropdown).toBeVisible(); + }; + + return { + async clickMainButton() { + await mainButton.click(); + }, + async clickChevron() { + await chevronButton.click(); + await dropdownVisible(); + }, + async arrowDown(skipDropdownCheck = false) { + if (!skipDropdownCheck) { + await dropdownVisible(); + } + await splitBtn.page().keyboard.press('ArrowDown', { delay: 50 }); + }, + async arrowUp(skipDropdownCheck = false) { + if (!skipDropdownCheck) { + await dropdownVisible(); + } + await splitBtn.page().keyboard.press('ArrowUp', { delay: 50 }); + }, + async pressEnter() { + await splitBtn.page().keyboard.press('Enter'); + }, + async getDropdownItemsLocator() { + await dropdownVisible(); + return splitBtn.locator('ix-dropdown-item').all(); + }, + async getFocusedItemLocator() { + await dropdownVisible(); + return splitBtn.locator('ix-dropdown-item:focus'); + }, + }; +} + +regressionTest( + 'ArrowDown cycles through dropdown items', + async ({ mount, page }) => { + await mount(` + + Action + Item 1 + Item 2 + Item 3 + + `); + + const splitBtn = page.locator('ix-split-button'); + const ctrl = splitButtonController(splitBtn); + + await ctrl.clickChevron(); + await ctrl.arrowDown(); + await ctrl.arrowDown(); + + const items = await ctrl.getDropdownItemsLocator(); + const focused = await ctrl.getFocusedItemLocator(); + + expect(items).toHaveLength(3); + await expect(focused).toHaveText('Item 3'); + } +); + +regressionTest('ArrowUp cycles backwards', async ({ mount, page }) => { + await mount(` + + Action + Item A + Item B + + `); + + const splitBtn = page.locator('ix-split-button'); + const ctrl = splitButtonController(splitBtn); + + await ctrl.clickChevron(); + await ctrl.arrowDown(); + await ctrl.arrowUp(); + + const focused = await ctrl.getFocusedItemLocator(); + await expect(focused).toHaveText('Item A'); +}); + +regressionTest( + 'Tab from dropdown item moves focus to next component', + async ({ mount, page }) => { + await mount(` + + Action + Item 1 + Item 2 + Item 3 + + Next Component + `); + + const splitBtn = page.locator('ix-split-button'); + const ctrl = splitButtonController(splitBtn); + + await ctrl.clickChevron(); + await ctrl.arrowDown(); + await ctrl.arrowDown(); + + await page.keyboard.press('Tab'); + + const nextButton = page.locator('#next'); + await expect(nextButton).toBeFocused(); + } +); diff --git a/packages/core/src/components/utils/focus.ts b/packages/core/src/components/utils/focus.ts index c33f771cba6..964e4c6ec37 100644 --- a/packages/core/src/components/utils/focus.ts +++ b/packages/core/src/components/utils/focus.ts @@ -34,12 +34,15 @@ export class ArrowFocusController { } private getActiveIndex() { - if (!document.activeElement) { + const activeElement = document.activeElement; + if (!activeElement) { return -1; } - return this.items.indexOf(document.activeElement); - } + return this.items.findIndex((item) => { + return item === activeElement || item.contains(activeElement); + }); + } private onKeyDown(e: KeyboardEvent) { const activeIndex = this.getActiveIndex();