[ACS-11325] Add keyboard navigation and a11y support for adf-tree component (#11973)

* [ACS-11325] Add keyboard navigation and a11y support for adf-tree component

* [ACS-11325] clean up comments

* [ACS-11325] cr fixes

* [ACS-11325] cr fixes

* [ACS-11325] cr fix

* [ACS-11325] sonar issue

* [ACS-11325] fix lint
This commit is contained in:
Mykyta Maliarchuk
2026-06-22 12:08:41 +02:00
committed by GitHub
parent 49a3f1e104
commit 80eef35e1d
7 changed files with 195 additions and 35 deletions
@@ -627,6 +627,10 @@
"LOAD-MORE-BUTTON": "Load more {{ name }}",
"ACTIONS": {
"TOOLTIP": "Open actions menu"
},
"ARIA": {
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -42,18 +42,23 @@
<mat-tree-node
class="adf-tree-row"
[attr.data-automation-id]="'node_' + node.id"
[attr.aria-label]="node.nodeName"
[attr.aria-selected]="treeNodesSelection.isSelected(node)"
*matTreeNodeDef="let node"
matTreeNodePadding
[adf-context-menu]="contextMenuOptions"
[adf-context-menu-enabled]="!!contextMenuOptions"
(contextmenu)="contextMenuSource = node"
(click)="expandCollapseNode(node)"
(keydown.enter)="expandCollapseNode(node); $event.stopPropagation()"
(keydown.space)="onNodeSelected(node); $event.preventDefault(); $event.stopPropagation()"
>
<div class="adf-tree-expand-collapse-container">
<button
*ngIf="node.hasChildren"
class="adf-tree-expand-collapse-button"
(click)="expandCollapseNode(node)"
mat-icon-button
matTreeNodeToggle
>
<mat-progress-spinner
mode="indeterminate"
@@ -70,24 +75,26 @@
[id]="node.id"
[checked]="descendantsAllSelected(node)"
[indeterminate]="descendantsPartiallySelected(node)"
(change)="onNodeSelected(node)"
(change)="onNodeSelected(node);"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
data-automation-id="has-children-node-checkbox" />
<ng-template #noChildrenNodeCheckbox>
<mat-checkbox
[id]="node.id"
[checked]="treeNodesSelection.isSelected(node)"
(change)="onNodeSelected(node)"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
data-automation-id="no-children-node-checkbox" />
</ng-template>
</ng-container>
<div class="adf-tree-cell">
<span
class="adf-tree-cell-value"
[class.adf-tree-clickable-cell-value]="node.hasChildren"
tabindex="0"
role="button"
(keyup.enter)="expandCollapseNode(node)"
(click)="expandCollapseNode(node)">
[class.adf-tree-clickable-cell-value]="node.hasChildren">
{{ node.nodeName }}
</span>
</div>
@@ -95,7 +102,10 @@
<button mat-icon-button
[matMenuTriggerFor]="menu"
[attr.aria-label]="'ADF-TREE.ACTIONS.TOOLTIP' | translate"
[attr.id]="'action_menu_right_' + node.id">
[attr.id]="'action_menu_right_' + node.id"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()">
<mat-icon adf-icon="more_vert" />
</button>
<mat-menu #menu="matMenu">
@@ -43,7 +43,6 @@ $tree-header-font-size: 12px !default;
&:focus {
background-color: var(--mat-sys-surface-container);
outline-offset: -1px;
outline: 1px solid var(--mat-sys-secondary);
}
.adf-tree-expand-collapse-button,
@@ -19,6 +19,7 @@ import { TreeComponent } from './tree.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContextMenuDirective, UnitTestingUtils, UserPreferencesService } from '@alfresco/adf-core';
import { TreeNode, TreeNodeType } from '../models/tree-node.interface';
import { TreeResponse } from '../models/tree-response.interface';
import { singleNode, treeNodesChildrenMockExpanded, treeNodesMock, treeNodesMockExpanded, treeNodesNoChildrenMock } from '../mock/tree-node.mock';
import { of, Subject } from 'rxjs';
import { TreeService } from '../services/tree.service';
@@ -29,6 +30,7 @@ import { DebugElement } from '@angular/core';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatCheckboxHarness } from '@angular/material/checkbox/testing';
import { TranslateService } from '@ngx-translate/core';
describe('TreeComponent', () => {
let fixture: ComponentFixture<TreeComponent<TreeNode>>;
@@ -40,7 +42,8 @@ describe('TreeComponent', () => {
const composeNodeSelector = (nodeId: string) => `[data-automation-id="node_${nodeId}"]`;
const getNode = (nodeId: string) => testingUtils.getByCSS(composeNodeSelector(nodeId));
const clickDisplayNameElement = (nodeId: string) => testingUtils.clickByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`);
const clickDisplayNameElement = (nodeId: string) =>
testingUtils.getByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`).nativeElement.click();
const getDisplayNameValue = (nodeId: string) => testingUtils.getInnerTextByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`);
@@ -181,6 +184,7 @@ describe('TreeComponent', () => {
component.refreshTree();
component.treeService.treeNodes[0].isLoading = false;
fixture.detectChanges();
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
const collapseSpy = spyOn(component.treeService, 'collapseNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
@@ -201,15 +205,18 @@ describe('TreeComponent', () => {
component.refreshTree();
component.treeService.treeNodes[0].isLoading = false;
fixture.detectChanges();
const collapseSpy = spyOn(component.treeService, 'expandNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false);
component.treeService.treeNodes = Array.from(treeNodesMock);
component.treeService.treeNodes[0].isLoading = false;
fixture.detectChanges();
const expandSpy = spyOn(component.treeService, 'expandNode');
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
expect(expandSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
});
it('should call collapseNode on TreeService when collapsing node by clicking at node label and node has children', () => {
component.refreshTree();
fixture.detectChanges();
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
spyOn(component.treeService, 'collapseNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
clickDisplayNameElement(component.treeService.treeNodes[0].id);
@@ -219,8 +226,10 @@ describe('TreeComponent', () => {
it('should call expandNode on TreeService when expanding node by clicking at node label and node has children', () => {
component.refreshTree();
fixture.detectChanges();
component.treeService.treeNodes = Array.from(treeNodesMock);
component.treeService.treeNodes[0].isLoading = false;
fixture.detectChanges();
spyOn(component.treeService, 'expandNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false);
clickDisplayNameElement(component.treeService.treeNodes[0].id);
expect(component.treeService.expandNode).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
});
@@ -356,6 +365,86 @@ describe('TreeComponent', () => {
component.loadMoreSubnodes(component.treeService.treeNodes.find((node: TreeNode) => node.nodeType === TreeNodeType.LoadMoreNode));
fixture.detectChanges();
});
describe('announcement', () => {
let translateSpy: jasmine.Spy<TranslateService['instant']>;
beforeEach(() => {
fixture.detectChanges();
translateSpy = spyOn(TestBed.inject(TranslateService), 'instant');
});
it('should use selected key when node is selected', () => {
component.onNodeSelected(component.treeService.treeNodes[0]);
expect(translateSpy).toHaveBeenCalledWith('ADF-TREE.ARIA.SELECTED', { name: 'testName1' });
});
it('should use deselected key when node is deselected', () => {
component.treeNodesSelection.select(component.treeService.treeNodes[0]);
component.onNodeSelected(component.treeService.treeNodes[0]);
expect(translateSpy).toHaveBeenCalledWith('ADF-TREE.ARIA.DESELECTED', { name: 'testName1' });
});
});
});
describe('expansionModel.changed subscription', () => {
it('should load and expand node when expansionModel fires added event', () => {
fixture.detectChanges();
component.treeService.treeNodes = Array.from(treeNodesMock);
component.treeService.treeNodes[0].isLoading = false;
const expandSpy = spyOn(component.treeService, 'expandNode');
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
expect(expandSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
});
it('should collapse node when expansionModel fires removed event', () => {
fixture.detectChanges();
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
const collapseSpy = spyOn(component.treeService, 'collapseNode');
component.treeService.treeControl.collapse(component.treeService.treeNodes[0]);
expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0]);
});
it('should not expand node when expansionModel fires added event and node is already loading', () => {
fixture.detectChanges();
component.treeService.treeNodes[0].isLoading = true;
const expandSpy = spyOn(component.treeService, 'expandNode');
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
expect(expandSpy).not.toHaveBeenCalled();
});
it('should not collapse node when expansionModel fires removed event and node is loading', () => {
fixture.detectChanges();
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
component.treeService.treeNodes[0].isLoading = true;
const collapseSpy = spyOn(component.treeService, 'collapseNode');
component.treeService.treeControl.collapse(component.treeService.treeNodes[0]);
expect(collapseSpy).not.toHaveBeenCalled();
});
it('should not load children when node is expanded but children are already loaded', () => {
fixture.detectChanges();
const expandSpy = spyOn(component.treeService, 'expandNode');
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
expect(expandSpy).not.toHaveBeenCalled();
});
it('should reset isLoading and collapse node when getSubNodes fails during expansion', () => {
fixture.detectChanges();
component.treeService.treeNodes = Array.from(treeNodesMock);
component.treeService.treeNodes[0].isLoading = false;
const subject = new Subject<TreeResponse<TreeNode>>();
spyOn(component.treeService, 'getSubNodes').and.returnValue(subject.asObservable());
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
expect(component.treeService.treeNodes[0].isLoading).toBeTrue();
subject.error(new Error('error'));
expect(component.treeService.treeNodes[0].isLoading).toBeFalse();
expect(component.treeService.treeControl.isExpanded(component.treeService.treeNodes[0])).toBeFalse();
});
});
describe('Context menu', () => {
@@ -17,6 +17,7 @@
import {
Component,
DestroyRef,
EventEmitter,
HostBinding,
Input,
@@ -29,7 +30,7 @@ import {
ViewEncapsulation,
inject
} from '@angular/core';
import { BehaviorSubject, merge, Observable, Subject } from 'rxjs';
import { BehaviorSubject, merge, Observable, Subject, EMPTY } from 'rxjs';
import { TreeNode, TreeNodeType } from '../models/tree-node.interface';
import { TreeService } from '../services/tree.service';
import { ContextMenuDirective, IconModule, PaginationModel, UserPreferencesService } from '@alfresco/adf-core';
@@ -37,13 +38,15 @@ import { SelectionChange, SelectionModel } from '@angular/cdk/collections';
import { TreeResponse } from '../models/tree-response.interface';
import { MatCheckbox, MatCheckboxModule } from '@angular/material/checkbox';
import { TreeContextMenuResult } from '../models/tree-context-menu-result.interface';
import { takeUntil } from 'rxjs/operators';
import { takeUntil, catchError } from 'rxjs/operators';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { MatTreeModule } from '@angular/material/tree';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { MatTreeModule, MatTreeNode } from '@angular/material/tree';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'adf-tree',
@@ -66,6 +69,9 @@ import { MatMenuModule } from '@angular/material/menu';
export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
treeService = inject<TreeService<T>>(TreeService);
private readonly userPreferenceService = inject(UserPreferencesService);
private readonly destroyRef = inject(DestroyRef);
private readonly translateService = inject(TranslateService);
private readonly liveAnnouncer = inject(LiveAnnouncer);
/** TemplateRef to provide empty template when no nodes are loaded */
@Input()
@@ -107,6 +113,9 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
@ViewChildren(MatCheckbox)
public nodeCheckboxes: QueryList<MatCheckbox>;
@ViewChildren(MatTreeNode)
private readonly matTreeNodes: QueryList<MatTreeNode<T>>;
private readonly loadingRootSource = new BehaviorSubject<boolean>(false);
private _contextMenuSource: T;
private _contextMenuOptions: any[];
@@ -152,9 +161,13 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
ngOnInit(): void {
this.loadingRoot$ = this.loadingRootSource.asObservable();
this.refreshTree(0, this.userPreferenceService.paginationSize);
this.treeNodesSelection.changed.subscribe((selectionChange: SelectionChange<T>) => {
this.treeNodesSelection.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((selectionChange: SelectionChange<T>) => {
this.onTreeSelectionChange(selectionChange);
});
this.treeService.treeControl.expansionModel.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((change: SelectionChange<T>) => {
change.added.forEach((node: T) => this.handleNodeExpanded(node));
change.removed.forEach((node: T) => this.handleNodeCollapsed(node));
});
}
ngOnDestroy() {
@@ -207,6 +220,7 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
this.treeNodesSelection.deselect(...response.entries);
this.paginationChanged.emit(response.pagination);
this.loadingRootSource.next(false);
setTimeout(() => this.matTreeNodes?.first?.makeFocusable());
});
}
@@ -217,21 +231,7 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
*/
public expandCollapseNode(node: T): void {
if (node.hasChildren && !node.isLoading) {
if (this.treeService.treeControl.isExpanded(node)) {
this.treeService.collapseNode(node);
} else {
node.isLoading = true;
this.treeService.getSubNodes(node.id, 0, this.userPreferenceService.paginationSize).subscribe((response: TreeResponse<T>) => {
this.treeService.expandNode(node, response.entries);
node.isLoading = false;
if (this.treeNodesSelection.isSelected(node)) {
//timeout used to update nodeCheckboxes query list after new nodes are added so they can be selected
setTimeout(() => {
this.treeNodesSelection.select(...response.entries);
});
}
});
}
this.treeService.treeControl.toggle(node);
}
}
@@ -265,6 +265,9 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
* @param node selected node
*/
public onNodeSelected(node: T): void {
if (!this.selectableNodes) {
return;
}
this.treeNodesSelection.toggle(node);
const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode);
if (descendants.length > 0) {
@@ -273,6 +276,13 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
: this.treeNodesSelection.deselect(...descendants);
}
this.checkParentsSelection(node);
this.liveAnnouncer.announce(
this.translateService.instant(this.treeNodesSelection.isSelected(node) ? 'ADF-TREE.ARIA.SELECTED' : 'ADF-TREE.ARIA.DESELECTED', {
name: node.nodeName
}),
'assertive'
);
}
/**
@@ -301,6 +311,42 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
);
}
private handleNodeExpanded(node: T): void {
if (!node.hasChildren || node.isLoading || this.treeService.getChildren(node).length > 0) {
return;
}
node.isLoading = true;
this.treeService
.getSubNodes(node.id, 0, this.userPreferenceService.paginationSize)
.pipe(
catchError(() => {
node.isLoading = false;
this.treeService.treeControl.collapse(node);
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((response: TreeResponse<T>) => {
if (!this.treeService.treeControl.isExpanded(node)) {
node.isLoading = false;
return;
}
this.treeService.expandNode(node, response.entries);
if (this.treeNodesSelection.isSelected(node)) {
// timeout used to update nodeCheckboxes query list after new nodes are added so they can be selected
setTimeout(() => {
this.treeNodesSelection.select(...response.entries);
});
}
});
}
private handleNodeCollapsed(node: T): void {
if (!node.isLoading) {
this.treeService.collapseNode(node);
}
}
private checkParentsSelection(node: T): void {
let parent: T = this.treeService.getParentNode(node.parentId);
while (parent) {
@@ -58,6 +58,7 @@ describe('TreeService', () => {
it('should collapse node containing children', () => {
const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded);
service.treeNodes = treeNodesMockExpandedCopy;
service.treeControl.expand(treeNodesMockExpandedCopy[0]);
const nodesSourceSpy = spyOn(service.treeNodesSource, 'next');
const treeControlCollapseSpy = spyOn(service.treeControl, 'collapse');
service.collapseNode(treeNodesMockExpandedCopy[0]);
@@ -66,6 +67,15 @@ describe('TreeService', () => {
expect(service.treeNodes.length).toEqual(treeNodesMock.length);
});
it('should not call treeControl.collapse when node is not in expansionModel (re-entrant safety)', () => {
const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded);
service.treeNodes = treeNodesMockExpandedCopy;
const treeControlCollapseSpy = spyOn(service.treeControl, 'collapse');
service.collapseNode(treeNodesMockExpandedCopy[0]);
expect(treeControlCollapseSpy).not.toHaveBeenCalled();
expect(service.treeNodes.length).toEqual(treeNodesMock.length);
});
it('should collapse node with more levels', () => {
service.treeNodes = Array.from(treeNodesChildrenMockExpanded);
const nodesSourceSpy = spyOn(service.treeNodesSource, 'next');
@@ -70,7 +70,9 @@ export abstract class TreeService<T extends TreeNode> extends DataSource<T> {
*/
public collapseNode(nodeToCollapse: T): void {
if (nodeToCollapse?.hasChildren) {
this.treeControl.collapse(nodeToCollapse);
if (this.treeControl.isExpanded(nodeToCollapse)) {
this.treeControl.collapse(nodeToCollapse);
}
const children: T[] = this.treeNodes.filter((node: T) => nodeToCollapse.id === node.parentId);
children.forEach((child: T) => {
this.collapseInnerNode(child);