mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
[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:
@@ -627,6 +627,10 @@
|
|||||||
"LOAD-MORE-BUTTON": "Load more {{ name }}",
|
"LOAD-MORE-BUTTON": "Load more {{ name }}",
|
||||||
"ACTIONS": {
|
"ACTIONS": {
|
||||||
"TOOLTIP": "Open actions menu"
|
"TOOLTIP": "Open actions menu"
|
||||||
|
},
|
||||||
|
"ARIA": {
|
||||||
|
"SELECTED": "{{ name }} selected",
|
||||||
|
"DESELECTED": "{{ name }} deselected"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"LIBRARY": {
|
"LIBRARY": {
|
||||||
|
|||||||
@@ -42,18 +42,23 @@
|
|||||||
<mat-tree-node
|
<mat-tree-node
|
||||||
class="adf-tree-row"
|
class="adf-tree-row"
|
||||||
[attr.data-automation-id]="'node_' + node.id"
|
[attr.data-automation-id]="'node_' + node.id"
|
||||||
|
[attr.aria-label]="node.nodeName"
|
||||||
|
[attr.aria-selected]="treeNodesSelection.isSelected(node)"
|
||||||
*matTreeNodeDef="let node"
|
*matTreeNodeDef="let node"
|
||||||
matTreeNodePadding
|
matTreeNodePadding
|
||||||
[adf-context-menu]="contextMenuOptions"
|
[adf-context-menu]="contextMenuOptions"
|
||||||
[adf-context-menu-enabled]="!!contextMenuOptions"
|
[adf-context-menu-enabled]="!!contextMenuOptions"
|
||||||
(contextmenu)="contextMenuSource = node"
|
(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">
|
<div class="adf-tree-expand-collapse-container">
|
||||||
<button
|
<button
|
||||||
*ngIf="node.hasChildren"
|
*ngIf="node.hasChildren"
|
||||||
class="adf-tree-expand-collapse-button"
|
class="adf-tree-expand-collapse-button"
|
||||||
(click)="expandCollapseNode(node)"
|
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
|
matTreeNodeToggle
|
||||||
>
|
>
|
||||||
<mat-progress-spinner
|
<mat-progress-spinner
|
||||||
mode="indeterminate"
|
mode="indeterminate"
|
||||||
@@ -70,24 +75,26 @@
|
|||||||
[id]="node.id"
|
[id]="node.id"
|
||||||
[checked]="descendantsAllSelected(node)"
|
[checked]="descendantsAllSelected(node)"
|
||||||
[indeterminate]="descendantsPartiallySelected(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" />
|
data-automation-id="has-children-node-checkbox" />
|
||||||
<ng-template #noChildrenNodeCheckbox>
|
<ng-template #noChildrenNodeCheckbox>
|
||||||
<mat-checkbox
|
<mat-checkbox
|
||||||
[id]="node.id"
|
[id]="node.id"
|
||||||
[checked]="treeNodesSelection.isSelected(node)"
|
[checked]="treeNodesSelection.isSelected(node)"
|
||||||
(change)="onNodeSelected(node)"
|
(change)="onNodeSelected(node)"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
|
(keydown.space)="$event.stopPropagation()"
|
||||||
data-automation-id="no-children-node-checkbox" />
|
data-automation-id="no-children-node-checkbox" />
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div class="adf-tree-cell">
|
<div class="adf-tree-cell">
|
||||||
<span
|
<span
|
||||||
class="adf-tree-cell-value"
|
class="adf-tree-cell-value"
|
||||||
[class.adf-tree-clickable-cell-value]="node.hasChildren"
|
[class.adf-tree-clickable-cell-value]="node.hasChildren">
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
(keyup.enter)="expandCollapseNode(node)"
|
|
||||||
(click)="expandCollapseNode(node)">
|
|
||||||
{{ node.nodeName }}
|
{{ node.nodeName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +102,10 @@
|
|||||||
<button mat-icon-button
|
<button mat-icon-button
|
||||||
[matMenuTriggerFor]="menu"
|
[matMenuTriggerFor]="menu"
|
||||||
[attr.aria-label]="'ADF-TREE.ACTIONS.TOOLTIP' | translate"
|
[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" />
|
<mat-icon adf-icon="more_vert" />
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #menu="matMenu">
|
<mat-menu #menu="matMenu">
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ $tree-header-font-size: 12px !default;
|
|||||||
&:focus {
|
&:focus {
|
||||||
background-color: var(--mat-sys-surface-container);
|
background-color: var(--mat-sys-surface-container);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
outline: 1px solid var(--mat-sys-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.adf-tree-expand-collapse-button,
|
.adf-tree-expand-collapse-button,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { TreeComponent } from './tree.component';
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ContextMenuDirective, UnitTestingUtils, UserPreferencesService } from '@alfresco/adf-core';
|
import { ContextMenuDirective, UnitTestingUtils, UserPreferencesService } from '@alfresco/adf-core';
|
||||||
import { TreeNode, TreeNodeType } from '../models/tree-node.interface';
|
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 { singleNode, treeNodesChildrenMockExpanded, treeNodesMock, treeNodesMockExpanded, treeNodesNoChildrenMock } from '../mock/tree-node.mock';
|
||||||
import { of, Subject } from 'rxjs';
|
import { of, Subject } from 'rxjs';
|
||||||
import { TreeService } from '../services/tree.service';
|
import { TreeService } from '../services/tree.service';
|
||||||
@@ -29,6 +30,7 @@ import { DebugElement } from '@angular/core';
|
|||||||
import { HarnessLoader } from '@angular/cdk/testing';
|
import { HarnessLoader } from '@angular/cdk/testing';
|
||||||
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||||
import { MatCheckboxHarness } from '@angular/material/checkbox/testing';
|
import { MatCheckboxHarness } from '@angular/material/checkbox/testing';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
describe('TreeComponent', () => {
|
describe('TreeComponent', () => {
|
||||||
let fixture: ComponentFixture<TreeComponent<TreeNode>>;
|
let fixture: ComponentFixture<TreeComponent<TreeNode>>;
|
||||||
@@ -40,7 +42,8 @@ describe('TreeComponent', () => {
|
|||||||
const composeNodeSelector = (nodeId: string) => `[data-automation-id="node_${nodeId}"]`;
|
const composeNodeSelector = (nodeId: string) => `[data-automation-id="node_${nodeId}"]`;
|
||||||
const getNode = (nodeId: string) => testingUtils.getByCSS(composeNodeSelector(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`);
|
const getDisplayNameValue = (nodeId: string) => testingUtils.getInnerTextByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`);
|
||||||
|
|
||||||
@@ -181,6 +184,7 @@ describe('TreeComponent', () => {
|
|||||||
component.refreshTree();
|
component.refreshTree();
|
||||||
component.treeService.treeNodes[0].isLoading = false;
|
component.treeService.treeNodes[0].isLoading = false;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
|
||||||
const collapseSpy = spyOn(component.treeService, 'collapseNode');
|
const collapseSpy = spyOn(component.treeService, 'collapseNode');
|
||||||
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
|
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
|
||||||
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
|
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
|
||||||
@@ -201,15 +205,18 @@ describe('TreeComponent', () => {
|
|||||||
component.refreshTree();
|
component.refreshTree();
|
||||||
component.treeService.treeNodes[0].isLoading = false;
|
component.treeService.treeNodes[0].isLoading = false;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const collapseSpy = spyOn(component.treeService, 'expandNode');
|
component.treeService.treeNodes = Array.from(treeNodesMock);
|
||||||
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false);
|
component.treeService.treeNodes[0].isLoading = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
const expandSpy = spyOn(component.treeService, 'expandNode');
|
||||||
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
|
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', () => {
|
it('should call collapseNode on TreeService when collapsing node by clicking at node label and node has children', () => {
|
||||||
component.refreshTree();
|
component.refreshTree();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
component.treeService.treeControl.expand(component.treeService.treeNodes[0]);
|
||||||
spyOn(component.treeService, 'collapseNode');
|
spyOn(component.treeService, 'collapseNode');
|
||||||
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
|
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
|
||||||
clickDisplayNameElement(component.treeService.treeNodes[0].id);
|
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', () => {
|
it('should call expandNode on TreeService when expanding node by clicking at node label and node has children', () => {
|
||||||
component.refreshTree();
|
component.refreshTree();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
component.treeService.treeNodes = Array.from(treeNodesMock);
|
||||||
|
component.treeService.treeNodes[0].isLoading = false;
|
||||||
|
fixture.detectChanges();
|
||||||
spyOn(component.treeService, 'expandNode');
|
spyOn(component.treeService, 'expandNode');
|
||||||
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false);
|
|
||||||
clickDisplayNameElement(component.treeService.treeNodes[0].id);
|
clickDisplayNameElement(component.treeService.treeNodes[0].id);
|
||||||
expect(component.treeService.expandNode).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
|
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));
|
component.loadMoreSubnodes(component.treeService.treeNodes.find((node: TreeNode) => node.nodeType === TreeNodeType.LoadMoreNode));
|
||||||
fixture.detectChanges();
|
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', () => {
|
describe('Context menu', () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
HostBinding,
|
HostBinding,
|
||||||
Input,
|
Input,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
ViewEncapsulation,
|
ViewEncapsulation,
|
||||||
inject
|
inject
|
||||||
} from '@angular/core';
|
} 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 { TreeNode, TreeNodeType } from '../models/tree-node.interface';
|
||||||
import { TreeService } from '../services/tree.service';
|
import { TreeService } from '../services/tree.service';
|
||||||
import { ContextMenuDirective, IconModule, PaginationModel, UserPreferencesService } from '@alfresco/adf-core';
|
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 { TreeResponse } from '../models/tree-response.interface';
|
||||||
import { MatCheckbox, MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckbox, MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { TreeContextMenuResult } from '../models/tree-context-menu-result.interface';
|
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 { CommonModule } from '@angular/common';
|
||||||
import { TranslatePipe } from '@ngx-translate/core';
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||||
import { MatTreeModule } from '@angular/material/tree';
|
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||||
|
import { MatTreeModule, MatTreeNode } from '@angular/material/tree';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'adf-tree',
|
selector: 'adf-tree',
|
||||||
@@ -66,6 +69,9 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
||||||
treeService = inject<TreeService<T>>(TreeService);
|
treeService = inject<TreeService<T>>(TreeService);
|
||||||
private readonly userPreferenceService = inject(UserPreferencesService);
|
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 */
|
/** TemplateRef to provide empty template when no nodes are loaded */
|
||||||
@Input()
|
@Input()
|
||||||
@@ -107,6 +113,9 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
|||||||
@ViewChildren(MatCheckbox)
|
@ViewChildren(MatCheckbox)
|
||||||
public nodeCheckboxes: QueryList<MatCheckbox>;
|
public nodeCheckboxes: QueryList<MatCheckbox>;
|
||||||
|
|
||||||
|
@ViewChildren(MatTreeNode)
|
||||||
|
private readonly matTreeNodes: QueryList<MatTreeNode<T>>;
|
||||||
|
|
||||||
private readonly loadingRootSource = new BehaviorSubject<boolean>(false);
|
private readonly loadingRootSource = new BehaviorSubject<boolean>(false);
|
||||||
private _contextMenuSource: T;
|
private _contextMenuSource: T;
|
||||||
private _contextMenuOptions: any[];
|
private _contextMenuOptions: any[];
|
||||||
@@ -152,9 +161,13 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadingRoot$ = this.loadingRootSource.asObservable();
|
this.loadingRoot$ = this.loadingRootSource.asObservable();
|
||||||
this.refreshTree(0, this.userPreferenceService.paginationSize);
|
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.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() {
|
ngOnDestroy() {
|
||||||
@@ -207,6 +220,7 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
|||||||
this.treeNodesSelection.deselect(...response.entries);
|
this.treeNodesSelection.deselect(...response.entries);
|
||||||
this.paginationChanged.emit(response.pagination);
|
this.paginationChanged.emit(response.pagination);
|
||||||
this.loadingRootSource.next(false);
|
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 {
|
public expandCollapseNode(node: T): void {
|
||||||
if (node.hasChildren && !node.isLoading) {
|
if (node.hasChildren && !node.isLoading) {
|
||||||
if (this.treeService.treeControl.isExpanded(node)) {
|
this.treeService.treeControl.toggle(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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +265,9 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
|||||||
* @param node selected node
|
* @param node selected node
|
||||||
*/
|
*/
|
||||||
public onNodeSelected(node: T): void {
|
public onNodeSelected(node: T): void {
|
||||||
|
if (!this.selectableNodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.treeNodesSelection.toggle(node);
|
this.treeNodesSelection.toggle(node);
|
||||||
const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode);
|
const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode);
|
||||||
if (descendants.length > 0) {
|
if (descendants.length > 0) {
|
||||||
@@ -273,6 +276,13 @@ export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
|
|||||||
: this.treeNodesSelection.deselect(...descendants);
|
: this.treeNodesSelection.deselect(...descendants);
|
||||||
}
|
}
|
||||||
this.checkParentsSelection(node);
|
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 {
|
private checkParentsSelection(node: T): void {
|
||||||
let parent: T = this.treeService.getParentNode(node.parentId);
|
let parent: T = this.treeService.getParentNode(node.parentId);
|
||||||
while (parent) {
|
while (parent) {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('TreeService', () => {
|
|||||||
it('should collapse node containing children', () => {
|
it('should collapse node containing children', () => {
|
||||||
const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded);
|
const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded);
|
||||||
service.treeNodes = treeNodesMockExpandedCopy;
|
service.treeNodes = treeNodesMockExpandedCopy;
|
||||||
|
service.treeControl.expand(treeNodesMockExpandedCopy[0]);
|
||||||
const nodesSourceSpy = spyOn(service.treeNodesSource, 'next');
|
const nodesSourceSpy = spyOn(service.treeNodesSource, 'next');
|
||||||
const treeControlCollapseSpy = spyOn(service.treeControl, 'collapse');
|
const treeControlCollapseSpy = spyOn(service.treeControl, 'collapse');
|
||||||
service.collapseNode(treeNodesMockExpandedCopy[0]);
|
service.collapseNode(treeNodesMockExpandedCopy[0]);
|
||||||
@@ -66,6 +67,15 @@ describe('TreeService', () => {
|
|||||||
expect(service.treeNodes.length).toEqual(treeNodesMock.length);
|
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', () => {
|
it('should collapse node with more levels', () => {
|
||||||
service.treeNodes = Array.from(treeNodesChildrenMockExpanded);
|
service.treeNodes = Array.from(treeNodesChildrenMockExpanded);
|
||||||
const nodesSourceSpy = spyOn(service.treeNodesSource, 'next');
|
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 {
|
public collapseNode(nodeToCollapse: T): void {
|
||||||
if (nodeToCollapse?.hasChildren) {
|
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);
|
const children: T[] = this.treeNodes.filter((node: T) => nodeToCollapse.id === node.parentId);
|
||||||
children.forEach((child: T) => {
|
children.forEach((child: T) => {
|
||||||
this.collapseInnerNode(child);
|
this.collapseInnerNode(child);
|
||||||
|
|||||||
Reference in New Issue
Block a user