Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/dropdown-roving-tabindex.md
Original file line number Diff line number Diff line change
@@ -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 `<button>`) can opt into this navigation by adding the `data-ix-roving-item` attribute; such native elements keep their own activation (<kbd>Enter</kbd> / <kbd>Space</kbd> fire a real click). In this mode pressing <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> closes the dropdown and moves focus to the next/previous element in the active focus scope, including an owning `ix-popover`. Dropdowns and popovers now share nested-overlay ownership so item-triggered popovers remain open; <kbd>Escape</kbd> closes a complete dropdown hierarchy before its owning popover, while nested popovers close one at a time. The default `active-descendant` mode keeps the existing behavior where DOM focus stays on the trigger and a visual focus indicator moves between items.
4 changes: 4 additions & 0 deletions packages/angular-standalone-test-app/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ export const routePaths: IxPreviewRoutes = {
import('../preview-examples/dropdown-icon').then((m) => m.default),
'preview/dropdown-quick-actions': () =>
import('../preview-examples/dropdown-quick-actions').then((m) => m.default),
'preview/dropdown-roving-tabindex': () =>
import('../preview-examples/dropdown-roving-tabindex').then(
(m) => m.default
),
'preview/dropdown-submenu': () =>
import('../preview-examples/dropdown-submenu').then((m) => m.default),
'preview/echarts': () =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 { Component } from '@angular/core';
import {
IxButton,
IxDropdown,
IxDropdownHeader,
IxDropdownItem,
IxDivider,
IxDropdownTriggerDirective,
} from '@siemens/ix-angular/standalone';

@Component({
selector: 'app-example',
imports: [
IxButton,
IxDropdown,
IxDropdownHeader,
IxDropdownItem,
IxDivider,
IxDropdownTriggerDirective,
],
template: `
<ix-button #trigger>Open</ix-button>
<ix-dropdown [ixDropdownTrigger]="trigger" navigationMode="roving-tabindex">
<ix-dropdown-header label="Category"></ix-dropdown-header>
<ix-dropdown-item label="Item 2"></ix-dropdown-item>
<ix-dropdown-item label="Item 3"></ix-dropdown-item>
<ix-dropdown-item label="Item 4"></ix-dropdown-item>
<ix-divider></ix-divider>
<ix-dropdown-item label="Item 5"></ix-dropdown-item>
</ix-dropdown>
`,
})
export default class DropdownRovingTabindex {}
5 changes: 5 additions & 0 deletions packages/angular-test-app/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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';
Expand Down Expand Up @@ -570,6 +571,10 @@ const routes: Routes = [
path: 'dropdown-quick-actions',
component: DropdownQuickActions,
},
{
path: 'dropdown-roving-tabindex',
component: DropdownRovingTabindex,
},
{
path: 'dropdown-submenu',
component: DropdownSubmenu,
Expand Down
2 changes: 2 additions & 0 deletions packages/angular-test-app/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,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';
Expand Down Expand Up @@ -347,6 +348,7 @@ import WorkflowVertical from '../preview-examples/workflow-vertical';
DropdownButton,
DropdownIcon,
DropdownQuickActions,
DropdownRovingTabindex,
DropdownSubmenu,
Dropdown,
Echarts,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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 { Component } from '@angular/core';

@Component({
standalone: false,
selector: 'app-example',
template: `
<ix-button #trigger>Open</ix-button>
<ix-dropdown [ixDropdownTrigger]="trigger" navigationMode="roving-tabindex">
<ix-dropdown-header label="Category"></ix-dropdown-header>
<ix-dropdown-item label="Item 2"></ix-dropdown-item>
<ix-dropdown-item label="Item 3"></ix-dropdown-item>
<ix-dropdown-item label="Item 4"></ix-dropdown-item>
<ix-divider></ix-divider>
<ix-dropdown-item label="Item 5"></ix-dropdown-item>
</ix-dropdown>
`,
})
export default class DropdownRovingTabindex {}
8 changes: 4 additions & 4 deletions packages/angular/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,15 +849,15 @@ export declare interface IxDivider extends Components.IxDivider {}


@ProxyCmp({
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'navigationMode', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
methods: ['updatePosition']
})
@Component({
selector: 'ix-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'navigationMode', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
outputs: ['showChange', 'showChanged'],
standalone: false
})
Expand Down Expand Up @@ -885,14 +885,14 @@ export declare interface IxDropdown extends Components.IxDropdown {


@ProxyCmp({
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'placement', 'variant']
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'navigationMode', 'placement', 'variant']
})
@Component({
selector: 'ix-dropdown-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'placement', 'variant'],
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'navigationMode', 'placement', 'variant'],
outputs: ['showChange', 'showChanged'],
standalone: false
})
Expand Down
8 changes: 4 additions & 4 deletions packages/angular/standalone/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,15 +954,15 @@ export declare interface IxDivider extends Components.IxDivider {}

@ProxyCmp({
defineCustomElementFn: defineIxDropdown,
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'navigationMode', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
methods: ['updatePosition']
})
@Component({
selector: 'ix-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
inputs: ['anchor', 'closeBehavior', 'disableFocusHandling', 'disableFocusTrap', 'enableTopLayer', 'focusCheckedItem', 'header', 'navigationMode', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'suppressTriggerVisibilityCheck', 'trigger'],
outputs: ['showChange', 'showChanged'],
})
export class IxDropdown {
Expand Down Expand Up @@ -990,14 +990,14 @@ export declare interface IxDropdown extends Components.IxDropdown {

@ProxyCmp({
defineCustomElementFn: defineIxDropdownButton,
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'placement', 'variant']
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'navigationMode', 'placement', 'variant']
})
@Component({
selector: 'ix-dropdown-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'placement', 'variant'],
inputs: ['ariaLabelDropdownButton', 'closeBehavior', 'disabled', 'enableTopLayer', 'focusCheckedItem', 'icon', 'label', 'navigationMode', 'placement', 'variant'],
outputs: ['showChange', 'showChanged'],
})
export class IxDropdownButton {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,12 @@ export namespace Components {
* @default ['Enter', ' ']
*/
"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 `<button>`) can opt into this navigation by adding the `data-ix-roving-item` attribute; such native elements keep their own activation (<kbd>Enter</kbd> / <kbd>Space</kbd> fire a real click).
* @since 5.2.0
* @default 'active-descendant'
*/
"navigationMode": 'active-descendant' | 'roving-tabindex';
/**
* Move dropdown along main axis of alignment
*/
Expand Down Expand Up @@ -1644,6 +1650,12 @@ export namespace Components {
* Set label
*/
"label"?: string | null;
/**
* Controls how keyboard navigation moves focus between dropdown items. - `active-descendant`: DOM focus stays on the dropdown button while a visual focus indicator moves between the items, exposed via `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 used because the focused item is announced directly.
* @since 5.2.0
* @default 'active-descendant'
*/
"navigationMode": 'active-descendant' | 'roving-tabindex';
/**
* Placement of the dropdown
*/
Expand Down Expand Up @@ -8171,6 +8183,12 @@ declare namespace LocalJSX {
* @default ['Enter', ' ']
*/
"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 `<button>`) can opt into this navigation by adding the `data-ix-roving-item` attribute; such native elements keep their own activation (<kbd>Enter</kbd> / <kbd>Space</kbd> fire a real click).
* @since 5.2.0
* @default 'active-descendant'
*/
"navigationMode"?: 'active-descendant' | 'roving-tabindex';
/**
* Move dropdown along main axis of alignment
*/
Expand Down Expand Up @@ -8269,6 +8287,12 @@ declare namespace LocalJSX {
* Set label
*/
"label"?: string | null;
/**
* Controls how keyboard navigation moves focus between dropdown items. - `active-descendant`: DOM focus stays on the dropdown button while a visual focus indicator moves between the items, exposed via `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 used because the focused item is announced directly.
* @since 5.2.0
* @default 'active-descendant'
*/
"navigationMode"?: 'active-descendant' | 'roving-tabindex';
/**
* Fire event before visibility of dropdown has changed, preventing event will cancel showing dropdown
*/
Expand Down Expand Up @@ -11879,6 +11903,7 @@ declare namespace LocalJSX {
"disableFocusTrap": boolean;
"enableTopLayer": boolean;
"focusCheckedItem": boolean;
"navigationMode": 'active-descendant' | 'roving-tabindex';
"discoverAllSubmenus": boolean;
"ignoreRelatedSubmenu": boolean;
"suppressOverflowBehavior": boolean;
Expand All @@ -11893,6 +11918,7 @@ declare namespace LocalJSX {
"placement": AlignedPlacement;
"ariaLabelDropdownButton": string;
"focusCheckedItem": boolean;
"navigationMode": 'active-descendant' | 'roving-tabindex';
"enableTopLayer": boolean;
"suppressAriaActiveDescendant": boolean;
}
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/components/dropdown-button/dropdown-button.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,111 @@ regressionTest(
await expect(dropdown).not.toBeVisible();
}
);

regressionTest(
'roving-tabindex navigation moves DOM focus without aria-activedescendant',
async ({ page, mount }) => {
await mount(`
<ix-dropdown-button label="Open" navigation-mode="roving-tabindex">
<ix-dropdown-item id="rov-1" label="Test1"></ix-dropdown-item>
<ix-dropdown-item id="rov-2" label="Test2"></ix-dropdown-item>
<ix-dropdown-item id="rov-3" label="Test3"></ix-dropdown-item>
</ix-dropdown-button>
`);
const button = page.locator('ix-dropdown-button');
const item1 = page.locator('#rov-1');
const item2 = page.locator('#rov-2');

const $onClickItem2 = item2.evaluateHandle(
(el) =>
new Promise<void>((resolve) => {
el.addEventListener('click', () => resolve());
})
);

await expect(button).toHaveClass(/hydrated/);

await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');

const dropdown = button.locator('ix-dropdown');
await expect(dropdown).toBeVisible();

await expect(item1).toBeFocused();
await expect(item1).toHaveAttribute('tabindex', '0');
await expect(button).not.toHaveAttribute('aria-activedescendant');

await page.keyboard.press('ArrowDown');
await expect(item2).toBeFocused();
await expect(item2).toHaveAttribute('tabindex', '0');
await expect(item1).toHaveAttribute('tabindex', '-1');

await page.keyboard.press('Enter');

await $onClickItem2;
await expect(dropdown).not.toBeVisible();
}
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

regressionTest(
'roving-tabindex closes on Tab and moves focus past the dropdown button',
async ({ page, mount }) => {
await mount(`
<button id="before">Before</button>
<ix-dropdown-button label="Open" navigation-mode="roving-tabindex">
<ix-dropdown-item id="tab-1" label="Test1"></ix-dropdown-item>
<ix-dropdown-item id="tab-2" label="Test2"></ix-dropdown-item>
</ix-dropdown-button>
<button id="after">After</button>
`);
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(`
<ix-dropdown-button label="Open">
<ix-dropdown-item id="dynamic-1" label="Test1"></ix-dropdown-item>
<ix-dropdown-item id="dynamic-2" label="Test2"></ix-dropdown-item>
</ix-dropdown-button>
`);
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');
}
);
Loading
Loading