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(); + } + } } }