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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
118 changes: 118 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,121 @@ regressionTest(
await expect(dropdown).not.toBeVisible();
}
);

regressionTest(
'roving-tabindex navigation moves DOM focus without aria-activedescendant',
async ({ page, mount, makeAxeBuilder }) => {
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');

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');
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, makeAxeBuilder }) => {
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();

const accessibilityScanResults = await makeAxeBuilder()
.include('ix-dropdown-item')
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);

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