From a2a7d26a01777749ee333b68b45c26b2c0639d81 Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Fri, 3 Jul 2026 15:19:12 +0200 Subject: [PATCH 1/6] feat(core/dropdown): support roving tabindex keyboard navigation --- .changeset/dropdown-roving-tabindex.md | 5 + .../src/app/app.routes.ts | 4 + .../dropdown-roving-tabindex.ts | 42 +++ .../src/app/app-routing.module.ts | 5 + .../angular-test-app/src/app/app.module.ts | 2 + .../dropdown-roving-tabindex.ts | 27 ++ packages/angular/src/components.ts | 8 +- packages/angular/standalone/src/components.ts | 8 +- packages/core/src/components.d.ts | 39 +++ .../dropdown-button/dropdown-button.ct.ts | 45 +++ .../dropdown-button/dropdown-button.tsx | 22 +- .../src/components/dropdown/dropdown-focus.ts | 167 +++++++++- .../core/src/components/dropdown/dropdown.tsx | 124 +++++++- .../components/dropdown/test/dropdown.ct.ts | 289 ++++++++++++++++++ .../ix-playground/ix-playground.tsx | 26 +- .../dropdown-roving-tabindex.html | 29 ++ packages/react-test-app/src/main.tsx | 2 + .../dropdown-roving-tabindex.tsx | 32 ++ .../react/src/components/components.server.ts | 2 + packages/vue-test-app/src/Root.vue | 2 + .../dropdown-roving-tabindex.vue | 30 ++ .../vue/src/components/ix-dropdown-button.ts | 1 + packages/vue/src/components/ix-dropdown.ts | 1 + 23 files changed, 888 insertions(+), 24 deletions(-) create mode 100644 .changeset/dropdown-roving-tabindex.md create mode 100644 packages/angular-standalone-test-app/src/preview-examples/dropdown-roving-tabindex.ts create mode 100644 packages/angular-test-app/src/preview-examples/dropdown-roving-tabindex.ts create mode 100644 packages/html-test-app/src/preview-examples/dropdown-roving-tabindex.html create mode 100644 packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx create mode 100644 packages/vue-test-app/src/preview-examples/dropdown-roving-tabindex.vue diff --git a/.changeset/dropdown-roving-tabindex.md b/.changeset/dropdown-roving-tabindex.md new file mode 100644 index 00000000000..bf448c3abad --- /dev/null +++ b/.changeset/dropdown-roving-tabindex.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': minor +--- + +Added a `navigationMode` property to `ix-dropdown` (and passed through by `ix-dropdown-button`) that selects the keyboard navigation strategy. The new `roving-tabindex` mode moves real DOM focus between items via a roving `tabindex` (`0` for the active item, `-1` for the rest), so items are actually focused and no `aria-activedescendant` is required. Besides the built-in item components, arbitrary focusable elements (e.g. a native ` + `); + await expect(page.locator('#trigger')).toHaveClass(/hydrated/); + await expect(page.locator('ix-dropdown')).toHaveClass(/hydrated/); + }); + + regressionTest( + 'closes on Tab and moves focus to the next element outside the dropdown', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const dropdown = page.locator('ix-dropdown'); + const firstItem = page.locator('ix-dropdown-item').first(); + const after = page.locator('#after'); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(firstItem).toBeFocused(); + + await page.keyboard.press('Tab'); + + await expect(dropdown).not.toHaveClass(/show/); + await expect(after).toBeFocused(); + } + ); + + regressionTest( + 'closes on Shift+Tab and returns focus to the trigger', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const dropdown = page.locator('ix-dropdown'); + const firstItem = page.locator('ix-dropdown-item').first(); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(firstItem).toBeFocused(); + + await page.keyboard.press('Shift+Tab'); + + await expect(dropdown).not.toHaveClass(/show/); + await expect(trigger).toBeFocused(); + } + ); + }); + + regressionTest.describe('Roving tabindex with native elements', () => { + regressionTest.beforeEach(async ({ mount, page }) => { + await mount(` + Open + + + + + + + `); + await expect(page.locator('#trigger')).toHaveClass(/hydrated/); + await expect(page.locator('ix-dropdown')).toHaveClass(/hydrated/); + }); + + regressionTest( + 'moves DOM focus between marked native buttons with roving tabindex', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const first = page.locator('#btn-1'); + const second = page.locator('#btn-2'); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(first).toBeFocused(); + await expect(first).toHaveAttribute('tabindex', '0'); + + await page.keyboard.press('ArrowDown'); + await expect(second).toBeFocused(); + await expect(second).toHaveAttribute('tabindex', '0'); + await expect(first).toHaveAttribute('tabindex', '-1'); + } + ); + + regressionTest( + 'activates a marked native button exactly once with Enter', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const second = page.locator('#btn-2'); + + await second.evaluate((item) => { + (window as unknown as { __clicks: number }).__clicks = 0; + item.addEventListener('click', () => { + (window as unknown as { __clicks: number }).__clicks++; + }); + }); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('#btn-1')).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(second).toBeFocused(); + + await page.keyboard.press('Enter'); + + await expect + .poll(() => + page.evaluate( + () => (window as unknown as { __clicks: number }).__clicks + ) + ) + .toBe(1); + } + ); + + regressionTest( + 'activates a marked native button exactly once with Space', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const second = page.locator('#btn-2'); + + await second.evaluate((item) => { + (window as unknown as { __clicks: number }).__clicks = 0; + item.addEventListener('click', () => { + (window as unknown as { __clicks: number }).__clicks++; + }); + }); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('#btn-1')).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(second).toBeFocused(); + + await page.keyboard.press(' '); + + await expect + .poll(() => + page.evaluate( + () => (window as unknown as { __clicks: number }).__clicks + ) + ) + .toBe(1); + } + ); + + regressionTest( + 'resets tabindex on marked native buttons after closing', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const first = page.locator('#btn-1'); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(first).toHaveAttribute('tabindex', '0'); + + await page.keyboard.press('Escape'); + await expect(page.locator('ix-dropdown')).not.toHaveClass(/show/); + await expect(first).not.toHaveAttribute('tabindex'); + } + ); + }); }); regressionTest('Dropdown works in floating-ui', async ({ mount, page }) => { diff --git a/packages/core/src/components/ix-playground/ix-playground.tsx b/packages/core/src/components/ix-playground/ix-playground.tsx index 1e5d33a3ac4..b7779ae1aa4 100644 --- a/packages/core/src/components/ix-playground/ix-playground.tsx +++ b/packages/core/src/components/ix-playground/ix-playground.tsx @@ -7,6 +7,7 @@ * LICENSE file in the root directory of this source tree. */ import { Component, Host, h } from '@stencil/core'; +import { makeRef } from '../utils/make-ref'; @Component({ tag: 'ix-playground', @@ -14,7 +15,30 @@ import { Component, Host, h } from '@stencil/core'; shadow: false, }) export class IxPlayground { + buttonRef = makeRef(); + render() { - return ; + return ( + + Button + + Test + Test2 + Test3 + + + + + + + + + ); } } diff --git a/packages/html-test-app/src/preview-examples/dropdown-roving-tabindex.html b/packages/html-test-app/src/preview-examples/dropdown-roving-tabindex.html new file mode 100644 index 00000000000..4acb75539cb --- /dev/null +++ b/packages/html-test-app/src/preview-examples/dropdown-roving-tabindex.html @@ -0,0 +1,29 @@ + + + + + + + + Dropdown roving tabindex example + + + Open + + + + + + + + + + + diff --git a/packages/react-test-app/src/main.tsx b/packages/react-test-app/src/main.tsx index 1604ab6f46e..f3a15d4a6c9 100644 --- a/packages/react-test-app/src/main.tsx +++ b/packages/react-test-app/src/main.tsx @@ -89,6 +89,7 @@ import DropdownButton from './preview-examples/dropdown-button'; import DropdownButtonIcon from './preview-examples/dropdown-button-icon'; import DropdownIcon from './preview-examples/dropdown-icon'; import DropdownQuickActions from './preview-examples/dropdown-quick-actions'; +import DropdownRovingTabindex from './preview-examples/dropdown-roving-tabindex'; import DropdownSubmenu from './preview-examples/dropdown-submenu'; import Echarts from './preview-examples/echarts'; import EchartsBarHorizontalStacked from './preview-examples/echarts-bar-horizontal-stacked'; @@ -333,6 +334,7 @@ const routes: IxPreviewRoutes = { '/preview/dropdown-button': DropdownButton, '/preview/dropdown-icon': DropdownIcon, '/preview/dropdown-quick-actions': DropdownQuickActions, + '/preview/dropdown-roving-tabindex': DropdownRovingTabindex, '/preview/dropdown-submenu': DropdownSubmenu, '/preview/dropdown': Dropdown, '/preview/echarts': Echarts, diff --git a/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx b/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx new file mode 100644 index 00000000000..ad37ba7ef88 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 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 { + IxButton, + IxDivider, + IxDropdown, + IxDropdownHeader, + IxDropdownItem, +} from '@siemens/ix-react'; + +export default () => { + return ( + <> + Open + + + + + + + + + + ); +}; diff --git a/packages/react/src/components/components.server.ts b/packages/react/src/components/components.server.ts index 94ba51710ff..8b12ce512df 100644 --- a/packages/react/src/components/components.server.ts +++ b/packages/react/src/components/components.server.ts @@ -717,6 +717,7 @@ export const IxDropdown: StencilReactComponent + + + + diff --git a/packages/vue/src/components/ix-dropdown-button.ts b/packages/vue/src/components/ix-dropdown-button.ts index a5a856ebee3..73999c08250 100644 --- a/packages/vue/src/components/ix-dropdown-button.ts +++ b/packages/vue/src/components/ix-dropdown-button.ts @@ -15,6 +15,7 @@ export const IxDropdownButton: StencilVueComponent = /*@__ 'placement', 'ariaLabelDropdownButton', 'focusCheckedItem', + 'navigationMode', 'enableTopLayer', 'suppressAriaActiveDescendant', 'showChange', diff --git a/packages/vue/src/components/ix-dropdown.ts b/packages/vue/src/components/ix-dropdown.ts index 875054416c5..77993d3b49f 100644 --- a/packages/vue/src/components/ix-dropdown.ts +++ b/packages/vue/src/components/ix-dropdown.ts @@ -20,6 +20,7 @@ export const IxDropdown: StencilVueComponent = /*@__PURE__*/ def 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', + 'navigationMode', 'keyboardActivationKeys', 'keyboardItemTriggerKeys', 'offset', From 68d8f073bc20d1927002c2b4d1218c8b56890bec Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Fri, 17 Jul 2026 12:17:49 +0200 Subject: [PATCH 2/6] fix restore tabindex --- packages/core/src/components.d.ts | 21 +- .../dropdown-button/dropdown-button.tsx | 2 +- .../src/components/dropdown/dropdown-focus.ts | 51 ++- .../core/src/components/dropdown/dropdown.tsx | 278 +++++++++++--- .../components/dropdown/test/dropdown.ct.ts | 353 ++++++++++++++++-- .../ix-playground/ix-playground.tsx | 26 +- .../src/stories/dropdown-button.stories.ts | 14 + .../framework-tests/tests/generated/axe.ts | 1 + .../dropdown-roving-tabindex-axe.spec.ts | 26 ++ .../dropdown-roving-tabindex.spec.ts | 26 ++ .../framework-tests/tests/generated/index.ts | 2 + .../tests/generated/test-ids.autogenerated.ts | 1 + 12 files changed, 666 insertions(+), 135 deletions(-) create mode 100644 testing/framework-tests/tests/generated/dropdown-roving-tabindex-axe.spec.ts create mode 100644 testing/framework-tests/tests/generated/dropdown-roving-tabindex.spec.ts diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index f6983cb8bea..2e330d4ba78 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -1557,7 +1557,7 @@ export namespace Components { "keyboardItemTriggerKeys": string[]; /** * Controls how keyboard navigation moves focus between dropdown items. - `active-descendant`: DOM focus stays on the trigger/anchor element while a visual focus indicator moves between the items. Consumers can expose the active item through `aria-activedescendant`. - `roving-tabindex`: real DOM focus is moved to each item using a roving `tabindex` (`0` for the active item, `-1` for the others). No `aria-activedescendant` is required because the focused item is announced directly. Besides the built-in item components, arbitrary focusable elements (e.g. a native ` + `); + + const trigger = page.locator('#trigger'); + const parentDropdown = page.locator('#parent-dropdown'); + const submenuTrigger = page.locator('#submenu-trigger'); + const submenu = page.locator('#submenu'); + const subitem = page.locator('#subitem'); + const after = page.locator('#after'); + + await expect(submenuTrigger).toHaveAttribute('data-ix-dropdown-trigger'); + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(submenuTrigger).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(subitem).toBeFocused(); + + await page.keyboard.press('Tab'); + + await expect(parentDropdown).not.toHaveClass(/show/); + await expect(submenu).not.toHaveClass(/show/); + await expect(after).toBeFocused(); + } + ); + regressionTest.describe('Roving tabindex with native elements', () => { regressionTest.beforeEach(async ({ mount, page }) => { await mount(` @@ -949,6 +1170,7 @@ regressionTest.describe('A11y', () => { + `); await expect(page.locator('#trigger')).toHaveClass(/hydrated/); @@ -974,6 +1196,43 @@ regressionTest.describe('A11y', () => { } ); + regressionTest( + 'closes on Tab from the trigger without focusing a native dropdown item', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const dropdown = page.locator('ix-dropdown'); + const after = page.locator('#after'); + + await trigger.click(); + await expect(dropdown).toHaveClass(/show/); + await expect(trigger).toBeFocused(); + + await page.keyboard.press('Tab'); + + await expect(dropdown).not.toHaveClass(/show/); + await expect(after).toBeFocused(); + } + ); + + regressionTest( + 'closes on Tab from a native roving item and moves focus outside', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const dropdown = page.locator('ix-dropdown'); + const first = page.locator('#btn-1'); + const after = page.locator('#after'); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(first).toBeFocused(); + + await page.keyboard.press('Tab'); + + await expect(dropdown).not.toHaveClass(/show/); + await expect(after).toBeFocused(); + } + ); + regressionTest( 'activates a marked native button exactly once with Enter', async ({ page }) => { @@ -1051,6 +1310,28 @@ regressionTest.describe('A11y', () => { await expect(first).not.toHaveAttribute('tabindex'); } ); + + regressionTest( + 'restores consumer-provided tabindex values after closing', + async ({ page }) => { + const trigger = page.locator('#trigger'); + const first = page.locator('#btn-1'); + const second = page.locator('#btn-2'); + const third = page.locator('#btn-3'); + + await first.evaluate((item) => item.setAttribute('tabindex', '5')); + await second.evaluate((item) => item.setAttribute('tabindex', '-1')); + + await trigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(first).toBeFocused(); + await page.keyboard.press('Escape'); + + await expect(first).toHaveAttribute('tabindex', '5'); + await expect(second).toHaveAttribute('tabindex', '-1'); + await expect(third).not.toHaveAttribute('tabindex'); + } + ); }); }); diff --git a/packages/core/src/components/ix-playground/ix-playground.tsx b/packages/core/src/components/ix-playground/ix-playground.tsx index b7779ae1aa4..1e5d33a3ac4 100644 --- a/packages/core/src/components/ix-playground/ix-playground.tsx +++ b/packages/core/src/components/ix-playground/ix-playground.tsx @@ -7,7 +7,6 @@ * LICENSE file in the root directory of this source tree. */ import { Component, Host, h } from '@stencil/core'; -import { makeRef } from '../utils/make-ref'; @Component({ tag: 'ix-playground', @@ -15,30 +14,7 @@ import { makeRef } from '../utils/make-ref'; shadow: false, }) export class IxPlayground { - buttonRef = makeRef(); - render() { - return ( - - Button - - Test - Test2 - Test3 - - - - - - - - - ); + return ; } } diff --git a/packages/storybook-docs/src/stories/dropdown-button.stories.ts b/packages/storybook-docs/src/stories/dropdown-button.stories.ts index 05fbd1c102b..9c29b327912 100644 --- a/packages/storybook-docs/src/stories/dropdown-button.stories.ts +++ b/packages/storybook-docs/src/stories/dropdown-button.stories.ts @@ -8,6 +8,7 @@ */ import type { ArgTypes, Meta, StoryObj } from '@storybook/web-components-vite'; import type { Components } from '@siemens/ix/components'; +import { html } from 'lit'; import { genericRender, makeArgTypes } from './utils/generic-render'; type Element = Components.IxDropdownButton; @@ -32,6 +33,19 @@ export const Primary: Story = { }, }; +export const RovingTabindex: Story = { + render: () => html` + + + + + + `, +}; + export const Secondary: Story = { args: { label: 'Button', diff --git a/testing/framework-tests/tests/generated/axe.ts b/testing/framework-tests/tests/generated/axe.ts index f13ecc6ae46..ed513c1265c 100644 --- a/testing/framework-tests/tests/generated/axe.ts +++ b/testing/framework-tests/tests/generated/axe.ts @@ -71,6 +71,7 @@ import './dropdown-button-axe.spec.ts'; import './dropdown-button-icon-axe.spec.ts'; import './dropdown-icon-axe.spec.ts'; import './dropdown-quick-actions-axe.spec.ts'; +import './dropdown-roving-tabindex-axe.spec.ts'; import './dropdown-submenu-axe.spec.ts'; import './empty-state-axe.spec.ts'; import './empty-state-compact-axe.spec.ts'; diff --git a/testing/framework-tests/tests/generated/dropdown-roving-tabindex-axe.spec.ts b/testing/framework-tests/tests/generated/dropdown-roving-tabindex-axe.spec.ts new file mode 100644 index 00000000000..117770e1416 --- /dev/null +++ b/testing/framework-tests/tests/generated/dropdown-roving-tabindex-axe.spec.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness, createAxeBuilder } from '../utils'; + +test('dropdown-roving-tabindex - accessibility check', async ({ page }) => { + await page.goto('/preview/dropdown-roving-tabindex'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + const accessibilityScanResults = await createAxeBuilder(page).disableRules(['page-has-heading-one']).analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); +}); diff --git a/testing/framework-tests/tests/generated/dropdown-roving-tabindex.spec.ts b/testing/framework-tests/tests/generated/dropdown-roving-tabindex.spec.ts new file mode 100644 index 00000000000..f14fa319432 --- /dev/null +++ b/testing/framework-tests/tests/generated/dropdown-roving-tabindex.spec.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * This test was autogenerated by 'testing/framework-tests/src/main.ts' + * Do not edit this file directly. + */ +import { test, expect } from '@playwright/test'; +import { waitForReadiness } from '../utils'; + +test('dropdown-roving-tabindex', async ({ page }) => { + await page.goto('/preview/dropdown-roving-tabindex'); + + // Ugly and not the reliable way to wait for Stencil to be ready + await waitForReadiness(page); + + await expect(page.locator('body')).toMatchAriaSnapshot({ + name: 'dropdown-roving-tabindex.aria-snapshot.yaml', + }); +}); diff --git a/testing/framework-tests/tests/generated/index.ts b/testing/framework-tests/tests/generated/index.ts index 7425263881f..9f4042a822b 100644 --- a/testing/framework-tests/tests/generated/index.ts +++ b/testing/framework-tests/tests/generated/index.ts @@ -143,6 +143,8 @@ import './dropdown-icon-axe.spec.ts'; import './dropdown-icon.spec.ts'; import './dropdown-quick-actions-axe.spec.ts'; import './dropdown-quick-actions.spec.ts'; +import './dropdown-roving-tabindex-axe.spec.ts'; +import './dropdown-roving-tabindex.spec.ts'; import './dropdown-submenu-axe.spec.ts'; import './dropdown-submenu.spec.ts'; import './dropdown.spec.ts'; diff --git a/testing/framework-tests/tests/generated/test-ids.autogenerated.ts b/testing/framework-tests/tests/generated/test-ids.autogenerated.ts index 68075c65b78..e0a3db0249d 100644 --- a/testing/framework-tests/tests/generated/test-ids.autogenerated.ts +++ b/testing/framework-tests/tests/generated/test-ids.autogenerated.ts @@ -174,6 +174,7 @@ export const testIds = [ "echarts-bar-horizontal-stacked", "dropdown", "dropdown-submenu", + "dropdown-roving-tabindex", "dropdown-quick-actions", "dropdown-icon", "dropdown-button", From 1d5c69b72b6bb1363e9d17544268cc23dbb23c18 Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Fri, 17 Jul 2026 14:04:30 +0200 Subject: [PATCH 3/6] simplify controller setup --- .changeset/dropdown-roving-tabindex.md | 2 +- .../dropdown/dropdown-controller.ts | 121 ++++--- .../core/src/components/dropdown/dropdown.tsx | 23 ++ .../components/dropdown/test/dropdown.ct.ts | 26 ++ .../components/popover/popover-controller.ts | 167 ++++------ .../core/src/components/popover/popover.tsx | 21 ++ .../popover/test/popover-controller.spec.ts | 13 +- .../src/components/popover/test/popover.ct.ts | 143 +++++++++ .../src/components/utils/focus/focus-trap.ts | 47 ++- .../components/utils/nested-overlay/index.ts | 14 +- ...ay-stack.ts => nested-overlay-registry.ts} | 7 +- .../nested-overlay/overlay-coordinator.ts | 294 ++++++++++++++++++ .../test/nested-overlay.spec.ts | 40 +-- .../test/overlay-coordinator.spec.ts | 196 ++++++++++++ packages/core/src/components/utils/overlay.ts | 54 ---- 15 files changed, 931 insertions(+), 237 deletions(-) rename packages/core/src/components/utils/nested-overlay/{nested-overlay-stack.ts => nested-overlay-registry.ts} (94%) create mode 100644 packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts create mode 100644 packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts delete mode 100644 packages/core/src/components/utils/overlay.ts diff --git a/.changeset/dropdown-roving-tabindex.md b/.changeset/dropdown-roving-tabindex.md index bf448c3abad..934ca2087e5 100644 --- a/.changeset/dropdown-roving-tabindex.md +++ b/.changeset/dropdown-roving-tabindex.md @@ -2,4 +2,4 @@ '@siemens/ix': minor --- -Added a `navigationMode` property to `ix-dropdown` (and passed through by `ix-dropdown-button`) that selects the keyboard navigation strategy. The new `roving-tabindex` mode moves real DOM focus between items via a roving `tabindex` (`0` for the active item, `-1` for the rest), so items are actually focused and no `aria-activedescendant` is required. Besides the built-in item components, arbitrary focusable elements (e.g. a native ` + + + `); + + const menuTrigger = page.locator('#menu-trigger'); + const dropdown = page.locator('#dropdown'); + const detailsTrigger = page.locator('#details-trigger'); + const popover = page.locator('#details-popover'); + const popoverAction = page.locator('#popover-action'); + + await expect(detailsTrigger).toHaveAttribute('data-ix-popover-trigger'); + await menuTrigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(detailsTrigger).toBeFocused(); + await page.keyboard.press('Enter'); + + await expect(dropdown).toHaveClass(/show/); + await expect(popover).toHaveAttribute('show'); + await expect(popoverAction).toBeFocused(); + + await popoverAction.click(); + await expect(dropdown).toHaveClass(/show/); + await expect(popover).toHaveAttribute('show'); + + await page.keyboard.press('Escape'); + await expect(popover).not.toHaveAttribute('show'); + await expect(dropdown).toHaveClass(/show/); + + await page.keyboard.press('Escape'); + await expect(dropdown).not.toHaveClass(/show/); + } + ); + + for (const enableTopLayer of [false, true]) { + regressionTest( + `keeps Tab inside a popover when a ${ + enableTopLayer ? 'top-layer' : 'regular' + } roving dropdown closes`, + async ({ mount, page }) => { + await mount(html` + Open settings + + + + Choose action + + + + + + + + `); + + const popoverTrigger = page.locator('#popover-trigger'); + const popover = page.locator('#settings-popover'); + const dropdownTrigger = page.locator('#dropdown-trigger'); + const dropdown = page.locator('#dropdown'); + const firstItem = dropdown.locator('ix-dropdown-item').first(); + const firstControl = page.locator('#first-control'); + const outside = page.locator('#outside'); + + await popoverTrigger.click(); + await expect(popover).toHaveAttribute('show'); + + await dropdownTrigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(firstItem).toBeFocused(); + await page.keyboard.press('Tab'); + + await expect(dropdown).not.toHaveClass(/show/); + await expect(popover).toHaveAttribute('show'); + await expect(firstControl).toBeFocused(); + await expect(outside).not.toBeFocused(); + } + ); + } + + regressionTest( + 'Escape closes a dropdown before its parent popover', + async ({ mount, page }) => { + await mount(html` + Open settings + + + Choose action + + + + + + `); + + const popoverTrigger = page.locator('#popover-trigger'); + const popover = page.locator('#settings-popover'); + const dropdownTrigger = page.locator('#dropdown-trigger'); + const dropdown = page.locator('#dropdown'); + + await popoverTrigger.click(); + await dropdownTrigger.focus(); + await page.keyboard.press('ArrowDown'); + await expect(dropdown.locator('ix-dropdown-item')).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(dropdown).not.toHaveClass(/show/); + await expect(popover).toHaveAttribute('show'); + + await page.keyboard.press('Escape'); + await expect(popover).not.toHaveAttribute('show'); + } + ); }); regressionTest.describe('sub-components', () => { diff --git a/packages/core/src/components/utils/focus/focus-trap.ts b/packages/core/src/components/utils/focus/focus-trap.ts index 227fd9383c2..944a352deeb 100644 --- a/packages/core/src/components/utils/focus/focus-trap.ts +++ b/packages/core/src/components/utils/focus/focus-trap.ts @@ -68,6 +68,19 @@ const mergeUniqueInDocumentOrder = ( }); }; +const sortByTabOrder = (elements: HTMLElement[]) => + elements + .map((element, documentOrder) => ({ documentOrder, element })) + .sort((a, b) => { + const aOrder = + a.element.tabIndex > 0 ? a.element.tabIndex : Number.MAX_SAFE_INTEGER; + const bOrder = + b.element.tabIndex > 0 ? b.element.tabIndex : Number.MAX_SAFE_INTEGER; + + return aOrder - bOrder || a.documentOrder - b.documentOrder; + }) + .map(({ element }) => element); + export const getDeepActiveElement = (): Element | null => { let active: Element | null = document.activeElement; @@ -346,13 +359,37 @@ export function getFocusTrapFocusables( (element) => !isHiddenFromTabOrder(element) ); - if (!options?.excludeElements) { - return filtered; - } + const included = options?.excludeElements + ? filtered.filter( + (element) => !element.hasAttribute(TRAP_FOCUS_EXCLUDE_ATTRIBUTE) + ) + : filtered; - return filtered.filter( - (element) => !element.hasAttribute(TRAP_FOCUS_EXCLUDE_ATTRIBUTE) + return sortByTabOrder(included); +} + +export function getAdjacentFocusTrapElement( + ref: HTMLElement, + current: HTMLElement, + backwards: boolean, + options?: FocusTrapOptions, + excludedHost?: HTMLElement +): HTMLElement | undefined { + const focusableElements = getFocusTrapFocusables(ref, options).filter( + (element) => + excludedHost === undefined || + !isFocusWithinTrapHost(element, excludedHost) ); + const currentIndex = findActiveFocusableIndex(current, focusableElements); + if (currentIndex === -1 || focusableElements.length === 0) { + return undefined; + } + + const nextIndex = backwards + ? (currentIndex - 1 + focusableElements.length) % focusableElements.length + : (currentIndex + 1) % focusableElements.length; + + return focusableElements[nextIndex]; } function isHeaderFocusable( diff --git a/packages/core/src/components/utils/nested-overlay/index.ts b/packages/core/src/components/utils/nested-overlay/index.ts index a00ec3f2e99..f1691c9dbc6 100644 --- a/packages/core/src/components/utils/nested-overlay/index.ts +++ b/packages/core/src/components/utils/nested-overlay/index.ts @@ -14,7 +14,17 @@ export { removeIdFromHierarchy, } from './hierarchy'; export type { ChildIdsByParent } from './hierarchy'; -export { NestedOverlayStack } from './nested-overlay-stack'; -export type { OverlayInstanceBase } from './nested-overlay-stack'; +export { NestedOverlayRegistry } from './nested-overlay-registry'; +export type { OverlayInstanceBase } from './nested-overlay-registry'; +export { + getOverlayKey, + OverlayCoordinator, + overlayCoordinator, +} from './overlay-coordinator'; +export type { + CoordinatedOverlay, + OverlayDismissReason, + OverlayKind, +} from './overlay-coordinator'; export { pathIncludesTrigger } from './path-utils'; export type { DismissAllOptions, OverlayDismissPolicy } from './types'; diff --git a/packages/core/src/components/utils/nested-overlay/nested-overlay-stack.ts b/packages/core/src/components/utils/nested-overlay/nested-overlay-registry.ts similarity index 94% rename from packages/core/src/components/utils/nested-overlay/nested-overlay-stack.ts rename to packages/core/src/components/utils/nested-overlay/nested-overlay-registry.ts index 4ae67559bc5..ac8c7093b84 100644 --- a/packages/core/src/components/utils/nested-overlay/nested-overlay-stack.ts +++ b/packages/core/src/components/utils/nested-overlay/nested-overlay-registry.ts @@ -11,6 +11,7 @@ import { buildComposedPath, buildPathIncluding, ChildIdsByParent, + getParentId as findParentId, removeIdFromHierarchy, } from './hierarchy'; import { DismissAllOptions, OverlayDismissPolicy } from './types'; @@ -19,7 +20,7 @@ export interface OverlayInstanceBase { getId(): string; } -export class NestedOverlayStack { +export class NestedOverlayRegistry { private readonly instances = new Map(); private readonly childIdsByParent: ChildIdsByParent = {}; @@ -66,8 +67,8 @@ export class NestedOverlayStack { return this.childIdsByParent[parentId] || []; } - getChildIdsByParent(): ChildIdsByParent { - return this.childIdsByParent; + getParentId(childId: string): string | undefined { + return findParentId(childId, this.childIdsByParent); } removeFromHierarchy(id: string): void { diff --git a/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts b/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts new file mode 100644 index 00000000000..c524f3dc7c9 --- /dev/null +++ b/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts @@ -0,0 +1,294 @@ +/* + * SPDX-FileCopyrightText: 2026 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. + */ + +export type OverlayKind = 'dropdown' | 'popover'; +export type OverlayDismissReason = 'escape' | 'outside' | 'parent-close'; + +export interface CoordinatedOverlay { + key: string; + kind: OverlayKind; + hostElement: HTMLElement; + getTriggerElement(): HTMLElement | undefined; + isPresent(): boolean; + dismissOnOutside(): boolean; + dismiss(reason: OverlayDismissReason): void; + getAdjacentFocusElement?( + current: HTMLElement, + backwards: boolean, + excludedHost?: HTMLElement + ): HTMLElement | undefined; +} + +export const getOverlayKey = (kind: OverlayKind, id: string) => `${kind}:${id}`; + +const getComposedDistance = (host: Node, target: Node): number | undefined => { + let current: Node | null = target; + let distance = 0; + + while (current) { + if (current === host) { + return distance; + } + + if (current.parentNode) { + current = current.parentNode; + distance++; + continue; + } + + const root = current.getRootNode(); + current = root instanceof ShadowRoot ? root.host : null; + distance++; + } + + return undefined; +}; + +const composedContains = (host: Node, target: Node): boolean => + getComposedDistance(host, target) !== undefined; + +export class OverlayCoordinator { + private readonly entries = new Map(); + private readonly presentationOrder: string[] = []; + private isListening = false; + private readonly onWindowClick = (event: MouseEvent) => { + this.dismissOutside(event.composedPath()); + }; + private readonly onWindowKeydown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + this.getTopmost()?.dismiss('escape'); + }; + + connect(entry: CoordinatedOverlay) { + this.entries.set(entry.key, entry); + this.addListeners(); + } + + disconnect(key: string) { + this.dismissed(key); + this.entries.delete(key); + if (this.entries.size === 0) { + this.removeListeners(); + } + } + + dispose() { + this.entries.clear(); + this.presentationOrder.length = 0; + this.removeListeners(); + } + + presented(key: string) { + this.dismissed(key); + this.presentationOrder.push(key); + } + + dismissed(key: string) { + const index = this.presentationOrder.indexOf(key); + if (index !== -1) { + this.presentationOrder.splice(index, 1); + } + } + + isTopmostHost(host: HTMLElement) { + return this.getTopmost()?.hostElement === host; + } + + getParentFocusExitTarget( + childKey: string, + current: HTMLElement, + backwards: boolean + ) { + const parentKey = this.getParentKey(childKey); + const child = this.entries.get(childKey); + return parentKey + ? this.entries + .get(parentKey) + ?.getAdjacentFocusElement?.(current, backwards, child?.hostElement) + : undefined; + } + + pathIncludesChildTrigger(parentKey: string, path: EventTarget[]) { + for (const entry of this.entries.values()) { + if ( + entry.key === parentKey || + this.getParentKey(entry.key) !== parentKey + ) { + continue; + } + + const trigger = entry.getTriggerElement(); + if (trigger && this.pathIncludesNode(path, trigger)) { + return true; + } + } + + return false; + } + + pathIncludesDescendant(parentKey: string, path: EventTarget[]) { + for (const entry of this.entries.values()) { + if ( + entry.key !== parentKey && + entry.isPresent() && + this.isDescendantOf(entry.key, parentKey) && + this.pathIncludesEntry(path, entry) + ) { + return true; + } + } + + return false; + } + + dismissCrossTypeChildren( + parentKey: string, + parentKind: OverlayKind, + reason: OverlayDismissReason = 'parent-close' + ) { + const children = this.getPresentedEntries().filter( + (entry) => + entry.kind !== parentKind && this.getParentKey(entry.key) === parentKey + ); + + children.reverse().forEach((entry) => entry.dismiss(reason)); + } + + private getParentKey(childKey: string): string | undefined { + const child = this.entries.get(childKey); + const trigger = child?.getTriggerElement(); + if (!child || !trigger) { + return undefined; + } + + let parentKey: string | undefined; + let parentDistance = Number.POSITIVE_INFINITY; + + for (const candidate of this.getPresentedEntries()) { + if (candidate.key === childKey || !candidate.isPresent()) { + continue; + } + + const distance = getComposedDistance(candidate.hostElement, trigger); + if (distance !== undefined && distance <= parentDistance) { + parentKey = candidate.key; + parentDistance = distance; + } + } + + return parentKey; + } + + private isDescendantOf(childKey: string, parentKey: string) { + let current = this.getParentKey(childKey); + + while (current) { + if (current === parentKey) { + return true; + } + current = this.getParentKey(current); + } + + return false; + } + + private pathIncludesNode(path: EventTarget[], node: Node) { + return path.some( + (target) => target instanceof Node && composedContains(node, target) + ); + } + + private pathIncludesEntry(path: EventTarget[], entry: CoordinatedOverlay) { + const trigger = entry.getTriggerElement(); + return ( + this.pathIncludesNode(path, entry.hostElement) || + (trigger !== undefined && this.pathIncludesNode(path, trigger)) + ); + } + + private pathIncludesHierarchy(rootKey: string, path: EventTarget[]) { + const root = this.entries.get(rootKey); + if (root && this.pathIncludesEntry(path, root)) { + return true; + } + + return this.pathIncludesDescendant(rootKey, path); + } + + private getPresentedEntries() { + const entries: CoordinatedOverlay[] = []; + const seen = new Set(); + + for (const key of this.presentationOrder) { + const entry = this.entries.get(key); + if (entry) { + entries.push(entry); + seen.add(key); + } + } + + for (const entry of this.entries.values()) { + if (!seen.has(entry.key) && entry.isPresent()) { + entries.push(entry); + } + } + + return entries; + } + + private getTopmost() { + const entries = this.getPresentedEntries(); + for (let index = entries.length - 1; index >= 0; index--) { + if (entries[index].isPresent()) { + return entries[index]; + } + } + + return undefined; + } + + private dismissOutside(path: EventTarget[]) { + const entries = this.getPresentedEntries().reverse(); + + for (const entry of entries) { + if ( + entry.isPresent() && + entry.dismissOnOutside() && + !this.pathIncludesHierarchy(entry.key, path) + ) { + entry.dismiss('outside'); + } + } + } + + private addListeners() { + if (this.isListening) { + return; + } + this.isListening = true; + + window.addEventListener('click', this.onWindowClick); + window.addEventListener('keydown', this.onWindowKeydown); + } + + private removeListeners() { + if (!this.isListening) { + return; + } + this.isListening = false; + + window.removeEventListener('click', this.onWindowClick); + window.removeEventListener('keydown', this.onWindowKeydown); + } +} + +export const overlayCoordinator = new OverlayCoordinator(); diff --git a/packages/core/src/components/utils/nested-overlay/test/nested-overlay.spec.ts b/packages/core/src/components/utils/nested-overlay/test/nested-overlay.spec.ts index da281f9a990..d43464ee128 100644 --- a/packages/core/src/components/utils/nested-overlay/test/nested-overlay.spec.ts +++ b/packages/core/src/components/utils/nested-overlay/test/nested-overlay.spec.ts @@ -14,7 +14,7 @@ import { getParentId, removeIdFromHierarchy, } from '../hierarchy'; -import { NestedOverlayStack } from '../nested-overlay-stack'; +import { NestedOverlayRegistry } from '../nested-overlay-registry'; describe('nested-overlay hierarchy', () => { const childIdsByParent = { @@ -55,24 +55,24 @@ describe('nested-overlay hierarchy', () => { }); }); -describe('NestedOverlayStack', () => { +describe('NestedOverlayRegistry', () => { type Instance = { id: string; persistent: boolean; open: boolean }; type TestInstance = Instance & { getId(): string }; - function createStack( + function createRegistry( dismissSpy = vi.fn<(instance: TestInstance) => void>() ): { - stack: NestedOverlayStack; + registry: NestedOverlayRegistry; dismissSpy: typeof dismissSpy; } { - const stack = new NestedOverlayStack( + const registry = new NestedOverlayRegistry( { blocksOutsideDismiss: (instance) => instance.persistent, }, dismissSpy ); - return { stack, dismissSpy }; + return { registry, dismissSpy }; } function instance( @@ -88,43 +88,45 @@ describe('NestedOverlayStack', () => { } it('dismissOthers skips instances on the active hierarchy path', () => { - const { stack, dismissSpy } = createStack(); + const { registry, dismissSpy } = createRegistry(); const parent = instance('parent'); const child = instance('child'); const unrelated = instance('unrelated'); - stack.connect(parent); - stack.connect(child); - stack.connect(unrelated); - stack.setChildIds('parent', ['child']); + registry.connect(parent); + registry.connect(child); + registry.connect(unrelated); + registry.setChildIds('parent', ['child']); - stack.dismissOthers('child'); + expect(registry.getParentId('child')).toBe('parent'); + + registry.dismissOthers('child'); expect(dismissSpy).toHaveBeenCalledTimes(1); expect(dismissSpy).toHaveBeenCalledWith(unrelated); }); it('dismissAll respects blocksOutsideDismiss unless policy is ignored', () => { - const { stack, dismissSpy } = createStack(); + const { registry, dismissSpy } = createRegistry(); const dismissible = instance('dismissible'); const persistent = instance('persistent', { persistent: true }); - stack.connect(dismissible); - stack.connect(persistent); + registry.connect(dismissible); + registry.connect(persistent); - stack.dismissAll(); + registry.dismissAll(); expect(dismissSpy).toHaveBeenCalledTimes(1); expect(dismissSpy).toHaveBeenCalledWith(dismissible); }); it('dismissAll with ignorePolicyForIds dismisses persistent instances', () => { - const { stack, dismissSpy } = createStack(); + const { registry, dismissSpy } = createRegistry(); const persistent = instance('persistent', { persistent: true }); - stack.connect(persistent); + registry.connect(persistent); - stack.dismissAll({ ignorePolicyForIds: ['persistent'] }); + registry.dismissAll({ ignorePolicyForIds: ['persistent'] }); expect(dismissSpy).toHaveBeenCalledOnce(); }); diff --git a/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts b/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts new file mode 100644 index 00000000000..0bcf617ac3a --- /dev/null +++ b/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2026 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 { fireEvent } from '@testing-library/dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CoordinatedOverlay, + OverlayCoordinator, + OverlayKind, +} from '../overlay-coordinator'; + +type OverlayOptions = { + kind?: OverlayKind; + hostElement?: HTMLElement; + triggerElement?: HTMLElement; + isPresent?: boolean; + dismissOnOutside?: boolean; + adjacentFocusElement?: HTMLElement; +}; + +function createOverlay(key: string, options: OverlayOptions = {}) { + let present = options.isPresent ?? true; + const dismiss = vi.fn(() => { + present = false; + }); + const getAdjacentFocusElement = options.adjacentFocusElement + ? vi.fn(() => options.adjacentFocusElement) + : undefined; + const entry: CoordinatedOverlay = { + key, + kind: options.kind ?? 'popover', + hostElement: options.hostElement ?? document.createElement('div'), + getTriggerElement: () => options.triggerElement, + isPresent: () => present, + dismissOnOutside: () => options.dismissOnOutside ?? true, + dismiss, + getAdjacentFocusElement, + }; + + return { dismiss, entry, getAdjacentFocusElement }; +} + +describe('OverlayCoordinator', () => { + const coordinators: OverlayCoordinator[] = []; + + function createCoordinator() { + const coordinator = new OverlayCoordinator(); + coordinators.push(coordinator); + return coordinator; + } + + afterEach(() => { + coordinators.forEach((coordinator) => coordinator.dispose()); + coordinators.length = 0; + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it('removes global listeners after the last overlay disconnects', () => { + const addListener = vi.spyOn(window, 'addEventListener'); + const removeListener = vi.spyOn(window, 'removeEventListener'); + const coordinator = createCoordinator(); + const { entry } = createOverlay('popover:one'); + + coordinator.connect(entry); + coordinator.disconnect(entry.key); + + expect(addListener).toHaveBeenCalledWith('click', expect.any(Function)); + expect(addListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + expect(removeListener).toHaveBeenCalledWith('click', expect.any(Function)); + expect(removeListener).toHaveBeenCalledWith( + 'keydown', + expect.any(Function) + ); + }); + + it('dismisses the most recently presented overlay on Escape', () => { + const coordinator = createCoordinator(); + const first = createOverlay('popover:first'); + const second = createOverlay('popover:second'); + coordinator.connect(first.entry); + coordinator.connect(second.entry); + coordinator.presented(first.entry.key); + coordinator.presented(second.entry.key); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(first.dismiss).not.toHaveBeenCalled(); + expect(second.dismiss).toHaveBeenCalledWith('escape'); + }); + + it('uses the nearest composed ancestor as the parent focus scope', () => { + const coordinator = createCoordinator(); + const grandparentHost = document.createElement('div'); + const parentHost = document.createElement('div'); + const parentShadow = parentHost.attachShadow({ mode: 'open' }); + const trigger = document.createElement('button'); + const childHost = document.createElement('div'); + const current = document.createElement('button'); + const grandparentTarget = document.createElement('button'); + const parentTarget = document.createElement('button'); + grandparentHost.append(parentHost); + parentShadow.append(trigger); + document.body.append(grandparentHost, childHost); + + const parent = createOverlay('popover:parent', { + hostElement: parentHost, + adjacentFocusElement: parentTarget, + }); + const grandparent = createOverlay('popover:grandparent', { + hostElement: grandparentHost, + adjacentFocusElement: grandparentTarget, + }); + const child = createOverlay('dropdown:child', { + kind: 'dropdown', + hostElement: childHost, + triggerElement: trigger, + }); + coordinator.connect(parent.entry); + coordinator.connect(grandparent.entry); + coordinator.connect(child.entry); + coordinator.presented(parent.entry.key); + coordinator.presented(grandparent.entry.key); + coordinator.presented(child.entry.key); + + expect( + coordinator.getParentFocusExitTarget(child.entry.key, current, false) + ).toBe(parentTarget); + expect(parent.getAdjacentFocusElement).toHaveBeenCalledWith( + current, + false, + childHost + ); + expect(grandparent.getAdjacentFocusElement).not.toHaveBeenCalled(); + }); + + it('keeps a parent open when clicking inside a presented child overlay', () => { + const coordinator = createCoordinator(); + const parentHost = document.createElement('div'); + const childTrigger = document.createElement('button'); + const childHost = document.createElement('div'); + parentHost.append(childTrigger); + document.body.append(parentHost, childHost); + + const parent = createOverlay('dropdown:parent', { + kind: 'dropdown', + hostElement: parentHost, + }); + const child = createOverlay('popover:child', { + hostElement: childHost, + triggerElement: childTrigger, + }); + coordinator.connect(parent.entry); + coordinator.connect(child.entry); + coordinator.presented(parent.entry.key); + coordinator.presented(child.entry.key); + + fireEvent.click(childHost); + + expect(parent.dismiss).not.toHaveBeenCalled(); + expect(child.dismiss).not.toHaveBeenCalled(); + }); + + it('dismisses a child when clicking its parent outside the child', () => { + const coordinator = createCoordinator(); + const parentHost = document.createElement('div'); + const childTrigger = document.createElement('button'); + const childHost = document.createElement('div'); + parentHost.append(childTrigger); + document.body.append(parentHost, childHost); + + const parent = createOverlay('dropdown:parent', { + kind: 'dropdown', + hostElement: parentHost, + }); + const child = createOverlay('popover:child', { + hostElement: childHost, + triggerElement: childTrigger, + }); + coordinator.connect(parent.entry); + coordinator.connect(child.entry); + coordinator.presented(parent.entry.key); + coordinator.presented(child.entry.key); + + fireEvent.click(parentHost); + + expect(child.dismiss).toHaveBeenCalledWith('outside'); + expect(parent.dismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/utils/overlay.ts b/packages/core/src/components/utils/overlay.ts deleted file mode 100644 index 185b16f72a1..00000000000 --- a/packages/core/src/components/utils/overlay.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 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 { IxComponentInterface } from '../utils/internal'; - -export interface IxOverlayComponent extends IxComponentInterface { - isPresent(): boolean; - - willPresent?(): boolean; - willDismiss?(): boolean; - - present(): void; - dismiss(): void; -} - -export class OverlayController { - overlays: Set = new Set(); - - connected(instance: IxOverlayComponent): void { - this.overlays.add(instance); - } - - disconnected(instance: IxOverlayComponent): void { - this.overlays.delete(instance); - } - - present(instance: IxOverlayComponent): void { - if (instance.willPresent && !instance.willPresent()) { - return; - } - this.dismissOthers(instance); - instance.present(); - } - - dismiss(instance: IxOverlayComponent): void { - if (instance.willDismiss && !instance.willDismiss()) { - return; - } - instance.dismiss(); - } - - private dismissOthers(instance: IxOverlayComponent): void { - this.overlays.forEach((overlay) => { - if (overlay !== instance) { - this.dismiss(overlay); - } - }); - } -} From c1ab47197cfe134a05c134c36a3fbc81520552b9 Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Mon, 20 Jul 2026 12:49:07 +0200 Subject: [PATCH 4/6] add roving support to dropdown-button --- .../dropdown-button/dropdown-button.ct.ts | 63 +++++++++++++++++++ .../core/src/components/dropdown/dropdown.tsx | 6 +- .../core/src/components/utils/shadow-dom.ts | 8 +-- .../components/utils/test/shadow-dom.spec.ts | 32 +++++++++- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/packages/core/src/components/dropdown-button/dropdown-button.ct.ts b/packages/core/src/components/dropdown-button/dropdown-button.ct.ts index cdbe0963f03..60e3294d992 100644 --- a/packages/core/src/components/dropdown-button/dropdown-button.ct.ts +++ b/packages/core/src/components/dropdown-button/dropdown-button.ct.ts @@ -215,3 +215,66 @@ regressionTest( await expect(dropdown).not.toBeVisible(); } ); + +regressionTest( + 'roving-tabindex closes on Tab and moves focus past the dropdown button', + async ({ page, mount }) => { + await mount(` + + + + + + + `); + const button = page.locator('ix-dropdown-button'); + const dropdown = button.locator('ix-dropdown'); + const item1 = page.locator('#tab-1'); + const after = page.locator('#after'); + + await expect(button).toHaveClass(/hydrated/); + await button.focus(); + await page.keyboard.press('ArrowDown'); + await expect(item1).toBeFocused(); + + await page.keyboard.press('Tab'); + + await expect(dropdown).not.toBeVisible(); + await expect(after).toBeFocused(); + } +); + +regressionTest( + 'updates the open dropdown when navigation mode changes', + async ({ page, mount }) => { + await mount(` + + + + + `); + const button = page.locator('ix-dropdown-button'); + const item1 = page.locator('#dynamic-1'); + + await expect(button).toHaveClass(/hydrated/); + await button.focus(); + await page.keyboard.press('ArrowDown'); + await expect(button).toHaveAttribute('aria-activedescendant', 'dynamic-1'); + + await button.evaluate( + (element: HTMLIxDropdownButtonElement) => + (element.navigationMode = 'roving-tabindex') + ); + + await expect(item1).toBeFocused(); + await expect(button).not.toHaveAttribute('aria-activedescendant'); + + await button.evaluate( + (element: HTMLIxDropdownButtonElement) => + (element.navigationMode = 'active-descendant') + ); + + await expect(button).toBeFocused(); + await expect(button).toHaveAttribute('aria-activedescendant', 'dynamic-1'); + } +); diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index 319a713cee3..b1d9861e2ae 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -57,6 +57,7 @@ import { } from '../utils/internal/mixins/setup.mixin'; import { makeRef } from '../utils/make-ref'; import { requestAnimationFrameNoNgZone } from '../utils/requestAnimationFrame'; +import { closestPassShadow } from '../utils/shadow-dom'; import { CloseBehavior, dropdownController, @@ -955,7 +956,8 @@ export class Dropdown const trigger = (this.triggerElement ?? this.anchorElement) as HTMLElement; const eventTarget = event.target as Element | null; const leavesFocusedItem = - eventTarget !== null && eventTarget.closest('ix-dropdown') !== null; + eventTarget !== null && + closestPassShadow(eventTarget, 'ix-dropdown') !== null; if (event.shiftKey && leavesFocusedItem) { return trigger; @@ -975,7 +977,7 @@ export class Dropdown focusableQueryString ).filter( (element) => - element.closest('ix-dropdown') === null && + closestPassShadow(element, 'ix-dropdown') === null && element.getClientRects().length > 0 ); const tabOrder = focusableElements diff --git a/packages/core/src/components/utils/shadow-dom.ts b/packages/core/src/components/utils/shadow-dom.ts index 6db037596cc..ea36320c30f 100644 --- a/packages/core/src/components/utils/shadow-dom.ts +++ b/packages/core/src/components/utils/shadow-dom.ts @@ -55,7 +55,7 @@ export function containsElement(target: Element, element: Element) { return target.contains(element); } -export function closestPassShadow(node: Node, selector: string) { +export function closestPassShadow(node: Node | null, selector: string) { if (!node) { return null; } @@ -67,12 +67,12 @@ export function closestPassShadow(node: Node, selector: string) { if (node instanceof HTMLElement) { if (node.matches(selector)) { return node; - } else { - return closestPassShadow(node.parentNode!, selector); } + + return closestPassShadow(node.assignedSlot ?? node.parentNode, selector); } - return closestPassShadow(node.parentNode!, selector); + return closestPassShadow(node.parentNode, selector); } /** diff --git a/packages/core/src/components/utils/test/shadow-dom.spec.ts b/packages/core/src/components/utils/test/shadow-dom.spec.ts index 8b91c6420cf..5c44adb9db9 100644 --- a/packages/core/src/components/utils/test/shadow-dom.spec.ts +++ b/packages/core/src/components/utils/test/shadow-dom.spec.ts @@ -8,7 +8,11 @@ */ import { afterEach, describe, expect, it } from 'vitest'; -import { hasSlottedContent, hasSlottedElements } from '../shadow-dom'; +import { + closestPassShadow, + hasSlottedContent, + hasSlottedElements, +} from '../shadow-dom'; function createDefaultSlot(...assignedNodes: Node[]) { const host = document.createElement('div'); @@ -87,4 +91,30 @@ describe('shadow-dom slot helpers', () => { ).toBe(true); }); }); + + describe('closestPassShadow', () => { + it('finds an ancestor across a shadow root', () => { + const ancestor = document.createElement('section'); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const child = document.createElement('button'); + ancestor.append(host); + shadowRoot.append(child); + + expect(closestPassShadow(child, 'section')).toBe(ancestor); + }); + + it('finds an ancestor through an assigned slot', () => { + const ancestor = document.createElement('section'); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const slot = document.createElement('slot'); + const child = document.createElement('button'); + ancestor.append(host); + shadowRoot.append(slot); + host.append(child); + + expect(closestPassShadow(child, 'section')).toBe(ancestor); + }); + }); }); From 76fca3f59391d53ca69b608d8b1c74dfb916a46f Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Tue, 21 Jul 2026 08:43:54 +0200 Subject: [PATCH 5/6] fix(dropdown): prevent default action for child dropdown triggers --- .../core/src/components/dropdown/dropdown.tsx | 9 ++++++ .../nested-overlay/overlay-coordinator.ts | 6 ++++ .../test/overlay-coordinator.spec.ts | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index b1d9861e2ae..971870a772c 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -1177,6 +1177,15 @@ export class Dropdown if ( dropdownController.pathIncludesChildOverlay(this, event.composedPath()) ) { + const childDropdownTrigger = dropdownController.pathIncludesTrigger( + event.composedPath() + ); + if ( + childDropdownTrigger && + childDropdownTrigger !== this.triggerElement + ) { + event.preventDefault(); + } return; } diff --git a/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts b/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts index c524f3dc7c9..70403e6a97a 100644 --- a/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts +++ b/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts @@ -36,6 +36,12 @@ const getComposedDistance = (host: Node, target: Node): number | undefined => { return distance; } + if (current instanceof Element && current.assignedSlot) { + current = current.assignedSlot; + distance++; + continue; + } + if (current.parentNode) { current = current.parentNode; distance++; diff --git a/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts b/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts index 0bcf617ac3a..771680cfedb 100644 --- a/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts +++ b/packages/core/src/components/utils/nested-overlay/test/overlay-coordinator.spec.ts @@ -140,6 +140,37 @@ describe('OverlayCoordinator', () => { expect(grandparent.getAdjacentFocusElement).not.toHaveBeenCalled(); }); + it('recognizes a child trigger assigned to a slot inside its parent', () => { + const coordinator = createCoordinator(); + const wrapper = document.createElement('div'); + const wrapperShadow = wrapper.attachShadow({ mode: 'open' }); + const parentHost = document.createElement('div'); + const slot = document.createElement('slot'); + const childTrigger = document.createElement('button'); + const childHost = document.createElement('div'); + parentHost.append(slot); + wrapperShadow.append(parentHost); + wrapper.append(childTrigger); + document.body.append(wrapper, childHost); + + const parent = createOverlay('dropdown:parent', { + kind: 'dropdown', + hostElement: parentHost, + }); + const child = createOverlay('dropdown:child', { + kind: 'dropdown', + hostElement: childHost, + triggerElement: childTrigger, + }); + coordinator.connect(parent.entry); + coordinator.connect(child.entry); + coordinator.presented(parent.entry.key); + + expect( + coordinator.pathIncludesChildTrigger(parent.entry.key, [childTrigger]) + ).toBe(true); + }); + it('keeps a parent open when clicking inside a presented child overlay', () => { const coordinator = createCoordinator(); const parentHost = document.createElement('div'); From 4bbd4050530e934a204d954928e672ac5ba460ee Mon Sep 17 00:00:00 2001 From: Daniel Leroux Date: Tue, 21 Jul 2026 08:55:36 +0200 Subject: [PATCH 6/6] review comments --- .../dropdown-button/dropdown-button.ct.ts | 14 ++++++-- .../dropdown-item/dropdown-item.tsx | 1 + .../core/src/components/dropdown/dropdown.tsx | 12 ++----- .../components/dropdown/test/dropdown.ct.ts | 32 +++++++++++++------ .../src/components/utils/focus/focus-trap.ts | 5 +-- .../nested-overlay/overlay-coordinator.ts | 3 +- .../dropdown-roving-tabindex.tsx | 4 +-- 7 files changed, 44 insertions(+), 27 deletions(-) diff --git a/packages/core/src/components/dropdown-button/dropdown-button.ct.ts b/packages/core/src/components/dropdown-button/dropdown-button.ct.ts index 60e3294d992..ee2f5c5ae71 100644 --- a/packages/core/src/components/dropdown-button/dropdown-button.ct.ts +++ b/packages/core/src/components/dropdown-button/dropdown-button.ct.ts @@ -173,7 +173,7 @@ regressionTest( regressionTest( 'roving-tabindex navigation moves DOM focus without aria-activedescendant', - async ({ page, mount }) => { + async ({ page, mount, makeAxeBuilder }) => { await mount(` @@ -204,6 +204,11 @@ regressionTest( await expect(item1).toHaveAttribute('tabindex', '0'); await expect(button).not.toHaveAttribute('aria-activedescendant'); + const accessibilityScanResults = await makeAxeBuilder() + .include('ix-dropdown-item') + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + await page.keyboard.press('ArrowDown'); await expect(item2).toBeFocused(); await expect(item2).toHaveAttribute('tabindex', '0'); @@ -218,7 +223,7 @@ regressionTest( regressionTest( 'roving-tabindex closes on Tab and moves focus past the dropdown button', - async ({ page, mount }) => { + async ({ page, mount, makeAxeBuilder }) => { await mount(` @@ -237,6 +242,11 @@ regressionTest( await page.keyboard.press('ArrowDown'); await expect(item1).toBeFocused(); + const accessibilityScanResults = await makeAxeBuilder() + .include('ix-dropdown-item') + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + await page.keyboard.press('Tab'); await expect(dropdown).not.toBeVisible(); diff --git a/packages/core/src/components/dropdown-item/dropdown-item.tsx b/packages/core/src/components/dropdown-item/dropdown-item.tsx index 3a4c103c6f2..d87ab3632f8 100644 --- a/packages/core/src/components/dropdown-item/dropdown-item.tsx +++ b/packages/core/src/components/dropdown-item/dropdown-item.tsx @@ -143,6 +143,7 @@ export class DropdownItem 0 ); - const tabOrder = focusableElements - .map((element, documentOrder) => ({ documentOrder, element })) - .sort((a, b) => { - const aTabIndex = a.element.tabIndex; - const bTabIndex = b.element.tabIndex; - const aOrder = aTabIndex > 0 ? aTabIndex : Number.MAX_SAFE_INTEGER; - const bOrder = bTabIndex > 0 ? bTabIndex : Number.MAX_SAFE_INTEGER; - return aOrder - bOrder || a.documentOrder - b.documentOrder; - }) - .map(({ element }) => element); + const tabOrder = sortByTabOrder(focusableElements); const triggerIndex = tabOrder.indexOf(trigger); if (triggerIndex === -1) { return undefined; diff --git a/packages/core/src/components/dropdown/test/dropdown.ct.ts b/packages/core/src/components/dropdown/test/dropdown.ct.ts index a0100f2321c..a43a447f1da 100644 --- a/packages/core/src/components/dropdown/test/dropdown.ct.ts +++ b/packages/core/src/components/dropdown/test/dropdown.ct.ts @@ -795,8 +795,12 @@ regressionTest.describe('A11y', () => { await mount( ` Open - - + + @@ -812,16 +816,24 @@ regressionTest.describe('A11y', () => { ); }); - regressionTest('opens and focuses the first item', async ({ page }) => { - const trigger = page.locator('#trigger'); - const firstItem = page.locator('ix-dropdown-item').first(); + regressionTest( + 'opens and focuses the first item', + async ({ page, makeAxeBuilder }) => { + const trigger = page.locator('#trigger'); + const firstItem = page.locator('ix-dropdown-item').first(); - await trigger.focus(); - await page.keyboard.press('ArrowDown'); + await trigger.focus(); + await page.keyboard.press('ArrowDown'); - await expect(firstItem).toBeFocused(); - await expect(firstItem).toHaveAttribute('tabindex', '0'); - }); + await expect(firstItem).toBeFocused(); + await expect(firstItem).toHaveAttribute('tabindex', '0'); + + const accessibilityScanResults = await makeAxeBuilder() + .include('ix-dropdown-item') + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + } + ); regressionTest( 'opens and focuses the last item on ArrowUp', diff --git a/packages/core/src/components/utils/focus/focus-trap.ts b/packages/core/src/components/utils/focus/focus-trap.ts index 944a352deeb..76226926bb8 100644 --- a/packages/core/src/components/utils/focus/focus-trap.ts +++ b/packages/core/src/components/utils/focus/focus-trap.ts @@ -68,8 +68,8 @@ const mergeUniqueInDocumentOrder = ( }); }; -const sortByTabOrder = (elements: HTMLElement[]) => - elements +export const sortByTabOrder = (elements: HTMLElement[]) => { + return elements .map((element, documentOrder) => ({ documentOrder, element })) .sort((a, b) => { const aOrder = @@ -80,6 +80,7 @@ const sortByTabOrder = (elements: HTMLElement[]) => return aOrder - bOrder || a.documentOrder - b.documentOrder; }) .map(({ element }) => element); +}; export const getDeepActiveElement = (): Element | null => { let active: Element | null = document.activeElement; diff --git a/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts b/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts index 70403e6a97a..23825d0a52a 100644 --- a/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts +++ b/packages/core/src/components/utils/nested-overlay/overlay-coordinator.ts @@ -166,7 +166,8 @@ export class OverlayCoordinator { entry.kind !== parentKind && this.getParentKey(entry.key) === parentKey ); - children.reverse().forEach((entry) => entry.dismiss(reason)); + children.reverse(); + children.forEach((entry) => entry.dismiss(reason)); } private getParentKey(childKey: string): string | undefined { diff --git a/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx b/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx index ad37ba7ef88..d260ce7aa4f 100644 --- a/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx +++ b/packages/react-test-app/src/preview-examples/dropdown-roving-tabindex.tsx @@ -15,7 +15,7 @@ import { IxDropdownItem, } from '@siemens/ix-react'; -export default () => { +export default function DropdownRovingTabindex() { return ( <> Open @@ -29,4 +29,4 @@ export default () => { ); -}; +}