diff --git a/.changeset/keyboard-tree-item-a11y.md b/.changeset/keyboard-tree-item-a11y.md
new file mode 100644
index 00000000000..e140a6f00ed
--- /dev/null
+++ b/.changeset/keyboard-tree-item-a11y.md
@@ -0,0 +1,10 @@
+---
+"@siemens/ix": patch
+---
+
+fix(core/tree-item): improve keyboard accessibility for tree item interactions
+
+Tree item chevron and node container are now properly activatable via Enter and
+Space keys. Focus is preserved across tree refreshes. Added `aria-expanded` and
+`aria-disabled` attributes to interactive controls for correct screen-reader
+announcement.
diff --git a/packages/core/src/components/tree-item/tree-item.scss b/packages/core/src/components/tree-item/tree-item.scss
index dc92d3e8adf..4370437be19 100644
--- a/packages/core/src/components/tree-item/tree-item.scss
+++ b/packages/core/src/components/tree-item/tree-item.scss
@@ -10,6 +10,7 @@
@use 'misc/common-variables' as vars;
@use 'mixins/text-truncation';
@use 'mixins/shadow-dom/component';
+@use 'mixins/shadow-dom/focus-visible';
:host {
display: flex;
@@ -28,9 +29,14 @@
height: vars.$x-large-space;
flex-grow: 1;
align-items: center;
+ outline: none;
@include text-truncation.ellipsis;
+ &:focus-visible {
+ @include focus-visible.ix-focus-visible;
+ }
+
.tree-node-text {
@include text-truncation.ellipsis;
}
@@ -46,10 +52,15 @@
ix-icon {
transition: transform var(--theme-default-time) ease-in-out;
+ outline: none;
&.icon-toggle-down {
transform: rotate(90deg);
}
+
+ &:focus-visible {
+ @include focus-visible.ix-focus-visible;
+ }
}
}
}
diff --git a/packages/core/src/components/tree-item/tree-item.tsx b/packages/core/src/components/tree-item/tree-item.tsx
index 8118878f36c..9dec70a00b1 100644
--- a/packages/core/src/components/tree-item/tree-item.tsx
+++ b/packages/core/src/components/tree-item/tree-item.tsx
@@ -7,7 +7,15 @@
* LICENSE file in the root directory of this source tree.
*/
-import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core';
+import {
+ Component,
+ Element,
+ Event,
+ EventEmitter,
+ h,
+ Host,
+ Prop,
+} from '@stencil/core';
import { TreeItemContext } from '../tree/tree-model';
import { iconChevronRightSmall } from '@siemens/ix-icons/icons';
@@ -20,6 +28,8 @@ import { iconChevronRightSmall } from '@siemens/ix-icons/icons';
shadow: true,
})
export class TreeItem {
+ @Element() hostElement!: HTMLIxTreeItemElement;
+
/**
* Text
*/
@@ -76,6 +86,16 @@ export class TreeItem {
['icon-toggle-down']: !!this.context?.isExpanded,
}}
color="color-std-text"
+ tabIndex={isDisabled ? -1 : 0}
+ role="button"
+ aria-expanded={!!this.context?.isExpanded}
+ aria-disabled={isDisabled ?? false}
+ aria-label={
+ this.ariaLabelChevronIcon ??
+ (this.context?.isExpanded
+ ? 'Collapse tree item'
+ : 'Expand tree item')
+ }
onClick={(e: Event) => {
if (isDisabled) {
return;
@@ -84,17 +104,34 @@ export class TreeItem {
e.stopPropagation();
this.toggle.emit();
}}
- aria-label={
- this.ariaLabelChevronIcon ??
- (this.context?.isExpanded
- ? 'Collapse tree item'
- : 'Expand tree item')
- }
+ onKeyDown={(e: KeyboardEvent) => {
+ if (isDisabled) {
+ return;
+ }
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.toggle.emit();
+ }
+ }}
/>
) : null}
{
+ if (isDisabled) {
+ return;
+ }
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ (e.currentTarget as HTMLElement).click();
+ }
+ }}
onClick={() => {
if (isDisabled) {
return;
diff --git a/packages/core/src/components/tree/test/tree.ct.ts b/packages/core/src/components/tree/test/tree.ct.ts
index f940e08148c..3f5b32f2022 100644
--- a/packages/core/src/components/tree/test/tree.ct.ts
+++ b/packages/core/src/components/tree/test/tree.ct.ts
@@ -92,6 +92,27 @@ const updateModel = async (tree: Locator, updatedModel: any) => {
);
};
+regressionTest.describe('accessibility', () => {
+ regressionTest('collapsed', async ({ mount, page, makeAxeBuilder }) => {
+ await initializeTree(mount, page);
+
+ const accessibilityScanResults = await makeAxeBuilder().analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+
+ regressionTest('expanded', async ({ mount, page, makeAxeBuilder }) => {
+ const tree = await initializeTree(mount, page);
+
+ await tree
+ .locator('ix-tree-item', { hasText: 'Sample', hasNotText: 'Child' })
+ .locator('ix-icon')
+ .click();
+
+ const accessibilityScanResults = await makeAxeBuilder().analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+});
+
regressionTest('renders', async ({ mount, page }) => {
const tree = await initializeTree(mount, page);
const item = tree.locator('ix-tree-item').nth(0);
@@ -749,3 +770,161 @@ regressionTest(
);
}
);
+
+regressionTest(
+ 'should expand item when Enter is pressed on chevron',
+ async ({ mount, page }) => {
+ const tree = await initializeTree(mount, page);
+
+ const sampleItem = tree.locator('ix-tree-item', {
+ hasText: 'Sample',
+ hasNotText: 'Child',
+ });
+ const chevron = sampleItem.locator('ix-icon');
+
+ await chevron.focus();
+ await page.keyboard.press('Enter');
+
+ const children = tree.locator('ix-tree-item', {
+ hasText: 'Sample Child ',
+ });
+ await expect(children.nth(0)).toBeVisible();
+ }
+);
+
+regressionTest(
+ 'should expand item when Space is pressed on chevron',
+ async ({ mount, page }) => {
+ const tree = await initializeTree(mount, page);
+
+ const sampleItem = tree.locator('ix-tree-item', {
+ hasText: 'Sample',
+ hasNotText: 'Child',
+ });
+ const chevron = sampleItem.locator('ix-icon');
+
+ await chevron.focus();
+ await page.keyboard.press('Space');
+
+ const children = tree.locator('ix-tree-item', {
+ hasText: 'Sample Child ',
+ });
+ await expect(children.nth(0)).toBeVisible();
+ }
+);
+
+regressionTest(
+ 'should select item when Enter is pressed on tree-node-container',
+ async ({ mount, page }) => {
+ const tree = await initializeTree(mount, page);
+
+ const sampleItem = tree.locator('ix-tree-item', {
+ hasText: 'Sample',
+ hasNotText: 'Child',
+ });
+ const container = sampleItem.locator('.tree-node-container');
+
+ await container.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(sampleItem).toHaveClass(/selected/);
+ }
+);
+
+regressionTest(
+ 'should select item when Space is pressed on tree-node-container',
+ async ({ mount, page }) => {
+ const tree = await initializeTree(mount, page);
+
+ const sampleItem = tree.locator('ix-tree-item', {
+ hasText: 'Sample',
+ hasNotText: 'Child',
+ });
+ const container = sampleItem.locator('.tree-node-container');
+
+ await container.focus();
+ await page.keyboard.press('Space');
+
+ await expect(sampleItem).toHaveClass(/selected/);
+ }
+);
+
+regressionTest(
+ 'disabled item should not respond to keyboard activation',
+ async ({ mount, page }) => {
+ await mount(`
+
+
+
+ `);
+
+ const tree = page.locator('ix-tree');
+ await tree.evaluate(
+ (element: HTMLIxTreeElement, args) => {
+ element.model = args.model;
+ element.context = args.context;
+ },
+ {
+ model: {
+ root: {
+ id: 'root',
+ data: { name: '' },
+ hasChildren: true,
+ children: ['parent'],
+ },
+ parent: {
+ id: 'parent',
+ data: { name: 'Disabled Parent' },
+ hasChildren: true,
+ children: ['child'],
+ disabled: true,
+ },
+ child: {
+ id: 'child',
+ data: { name: 'Child' },
+ hasChildren: false,
+ children: [],
+ },
+ } as TreeModel
,
+ context: {
+ root: { isExpanded: true, isSelected: false },
+ parent: { isExpanded: false, isSelected: false },
+ child: { isExpanded: false, isSelected: false },
+ } as TreeContext,
+ }
+ );
+
+ await expect(tree).toHaveClass(/hydrated/);
+
+ const parent = tree.locator('ix-tree-item', { hasText: 'Disabled Parent' });
+ const container = parent.locator('.tree-node-container');
+
+ await container.focus();
+ await page.keyboard.press('Enter');
+
+ await expect(parent).not.toHaveClass(/selected/);
+ await expect(
+ tree.locator('ix-tree-item', { hasText: 'Child' })
+ ).not.toBeVisible();
+ }
+);
+
+regressionTest(
+ 'should preserve focus on tree-node-container after refreshTree',
+ async ({ mount, page }) => {
+ const tree = await initializeTree(mount, page);
+
+ const sampleItem = tree.locator('ix-tree-item', {
+ hasText: 'Sample',
+ hasNotText: 'Child',
+ });
+ const container = sampleItem.locator('.tree-node-container');
+
+ await container.focus();
+ await expect(container).toBeFocused();
+
+ await tree.evaluate((el: HTMLIxTreeElement) => el.refreshTree());
+
+ await expect(container).toBeFocused();
+ }
+);
diff --git a/packages/core/src/components/tree/tree.tsx b/packages/core/src/components/tree/tree.tsx
index dead31c8d8c..89c40fe0950 100644
--- a/packages/core/src/components/tree/tree.tsx
+++ b/packages/core/src/components/tree/tree.tsx
@@ -314,6 +314,35 @@ export class Tree {
);
}
+ private getFocusedTreeNodeInfo(): {
+ nodeId: string;
+ target: 'chevron' | 'item';
+ } | null {
+ const activeEl = document.activeElement;
+ if (!(activeEl instanceof HTMLElement)) {
+ return null;
+ }
+
+ const nodeId = this.getTreeNodeId(activeEl);
+ if (!nodeId) {
+ return null;
+ }
+
+ const shadowActive = activeEl.shadowRoot?.activeElement;
+ if (shadowActive?.tagName === 'IX-ICON') {
+ return { nodeId, target: 'chevron' };
+ }
+
+ if (
+ shadowActive instanceof HTMLElement &&
+ shadowActive.classList.contains('tree-node-container')
+ ) {
+ return { nodeId, target: 'item' };
+ }
+
+ return null;
+ }
+
/**
* Refresh the list.
* This will re-render the list with the current model and context.
@@ -321,10 +350,33 @@ export class Tree {
@Method()
async refreshTree(options: RefreshTreeOptions = defaultRefreshTreeOptions) {
if (this.hyperlist) {
+ const focusInfo = this.getFocusedTreeNodeInfo();
+
this.hyperlist.refresh(
this.hostElement,
this.getVirtualizerOptions(options)
);
+
+ if (focusInfo) {
+ const treeItem = this.hostElement.querySelector(
+ `[data-tree-node-id="${CSS.escape(focusInfo.nodeId)}"]`
+ );
+ if (focusInfo.target === 'chevron') {
+ const chevron =
+ treeItem?.shadowRoot?.querySelector('ix-icon');
+ if (chevron) {
+ chevron.focus();
+ } else {
+ treeItem?.shadowRoot
+ ?.querySelector('.tree-node-container')
+ ?.focus();
+ }
+ } else {
+ treeItem?.shadowRoot
+ ?.querySelector('.tree-node-container')
+ ?.focus();
+ }
+ }
}
}