Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
10 changes: 10 additions & 0 deletions .changeset/keyboard-tree-item-a11y.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/core/src/components/tree-item/tree-item.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Comment thread
1307-Dev marked this conversation as resolved.
}
}
}
Expand Down
51 changes: 44 additions & 7 deletions packages/core/src/components/tree-item/tree-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,6 +28,8 @@
shadow: true,
})
export class TreeItem {
@Element() hostElement!: HTMLIxTreeItemElement;

/**
* Text
*/
Expand Down Expand Up @@ -76,6 +86,16 @@
['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')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onClick={(e: Event) => {
if (isDisabled) {
return;
Expand All @@ -84,24 +104,41 @@
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}
</div>
<div
class="tree-node-container"
role="button"
tabIndex={isDisabled ? -1 : 0}
Comment thread
1307-Dev marked this conversation as resolved.
Comment thread
1307-Dev marked this conversation as resolved.
aria-disabled={isDisabled ?? false}
onKeyDown={(e: KeyboardEvent) => {
if (isDisabled) {
return;
}
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).click();
}
Comment thread
1307-Dev marked this conversation as resolved.
}}
Comment thread
1307-Dev marked this conversation as resolved.
onClick={() => {
if (isDisabled) {
return;
}
this.itemClick.emit();
}}
>

Check warning on line 141 in packages/core/src/components/tree-item/tree-item.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <input type="button">, <input type="image">, <input type="reset">, <input type="submit">, or <button> instead of the "button" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=siemens_ix&issues=AZ-NFj5PgOV5hraTpHKa&open=AZ-NFj5PgOV5hraTpHKa&pullRequest=2665
<div class="tree-node-text">{this.text}</div>
<slot></slot>
</div>
Expand Down
179 changes: 179 additions & 0 deletions packages/core/src/components/tree/test/tree.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,27 @@ const updateModel = async (tree: Locator, updatedModel: any) => {
);
};

regressionTest.describe('accessibility', () => {
regressionTest.only('collapsed', async ({ mount, page, makeAxeBuilder }) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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);
Expand Down Expand Up @@ -749,3 +770,161 @@ regressionTest(
);
}
);

regressionTest.only(
'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.only(
'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.only(
'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.only(
'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.only(
'disabled item should not respond to keyboard activation',
async ({ mount, page }) => {
await mount(`
<div style="height: 20rem; width: 100%;">
<ix-tree root="root"></ix-tree>
</div>
`);

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<unknown>,
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.only(
'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();
}
);
52 changes: 52 additions & 0 deletions packages/core/src/components/tree/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,17 +314,69 @@ 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') {
Comment thread
1307-Dev marked this conversation as resolved.
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.
*/
@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<HTMLElement>(
`[data-tree-node-id="${CSS.escape(focusInfo.nodeId)}"]`
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (focusInfo.target === 'chevron') {
const chevron =
treeItem?.shadowRoot?.querySelector<HTMLElement>('ix-icon');
if (chevron) {
chevron.focus();
} else {
treeItem?.shadowRoot
?.querySelector<HTMLElement>('.tree-node-container')
?.focus();
}
} else {
treeItem?.shadowRoot
?.querySelector<HTMLElement>('.tree-node-container')
?.focus();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
}

Expand Down
Loading