[ACS-10302] Add aria-live to selected aspects counter (#11593)

* [ACS-10302] Add aria-live to selected aspects counter

* [ACS-10302] Add the live announcer for amount of selected items in breadcrumb

* [ACS-10302] CR fixes
This commit is contained in:
Michal Kinas
2026-01-30 12:42:47 +01:00
committed by GitHub
parent 9cb8b962ae
commit 4987bbadef
5 changed files with 98 additions and 60 deletions

View File

@@ -5,7 +5,7 @@
<div class="adf-aspect-list-dialog-information">
<p id="aspect-list-dialog-over-table-message">{{overTableMessage | translate}}</p>
<p id="aspect-list-dialog-counter">{{counter}}
<p id="aspect-list-dialog-counter" aria-live="polite">{{counter}}
{{'ADF-ASPECT-LIST.DIALOG.SELECTED' | translate}}</p>
</div>
<mat-dialog-content class="adf-aspect-dialog-content">

View File

@@ -27,6 +27,8 @@ import { NodesApiService } from '../common/services/nodes-api.service';
import { By } from '@angular/platform-browser';
import { AspectListComponent } from './aspect-list.component';
import { provideApiTesting } from '../testing/providers';
import { UnitTestingUtils } from '@alfresco/adf-core';
import { DebugElement } from '@angular/core';
const aspectListMock: AspectEntry[] = [
{
@@ -99,11 +101,19 @@ describe('AspectListDialogComponent', () => {
let aspectListService: AspectListService;
let nodeService: NodesApiService;
let data: AspectListDialogComponentData;
let testingUtils: UnitTestingUtils;
const event = new KeyboardEvent('keydown', {
bubbles: true,
keyCode: 27
} as KeyboardEventInit);
const getResetButton = (): DebugElement => testingUtils.getByCSS('#aspect-list-dialog-actions-reset');
const getClearButton = (): DebugElement => testingUtils.getByCSS('#aspect-list-dialog-actions-clear');
const getCancelButton = (): DebugElement => testingUtils.getByCSS('#aspect-list-dialog-actions-cancel');
const getApplyButton = (): DebugElement => testingUtils.getByCSS('#aspect-list-dialog-actions-apply');
const getAspectCounter = (): string => testingUtils.getInnerTextByCSS('#aspect-list-dialog-counter');
const getAspectCheckbox = (index: number): HTMLInputElement => testingUtils.getByCSS(`#aspect-list-${index}-check-input`).nativeElement;
beforeEach(() => {
data = {
title: 'Title',
@@ -128,6 +138,7 @@ describe('AspectListDialogComponent', () => {
]
});
fixture = TestBed.createComponent(AspectListDialogComponent);
testingUtils = new UnitTestingUtils(fixture.debugElement);
});
describe('Without passing node id', () => {
@@ -144,91 +155,81 @@ describe('AspectListDialogComponent', () => {
});
it('should show 4 actions : CLEAR, RESET, CANCEL and APPLY', () => {
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-reset')).not.toBeNull();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-reset')).toBeDefined();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-clear')).not.toBeNull();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-clear')).toBeDefined();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-cancel')).not.toBeNull();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-cancel')).toBeDefined();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-apply')).not.toBeNull();
expect(fixture.nativeElement.querySelector('#aspect-list-dialog-actions-apply')).toBeDefined();
expect(getResetButton()).toBeDefined();
expect(getClearButton()).toBeDefined();
expect(getCancelButton()).toBeDefined();
expect(getApplyButton()).toBeDefined();
});
it('should show basic information for the dialog', () => {
const dialogTitle = fixture.nativeElement.querySelector('[data-automation-id="aspect-list-dialog-title"] .adf-aspect-list-dialog-title');
expect(dialogTitle).not.toBeNull();
expect(dialogTitle.innerText).toBe(data.title);
const dialogTitleText = testingUtils.getInnerTextByCSS('[data-automation-id="aspect-list-dialog-title"] .adf-aspect-list-dialog-title');
expect(dialogTitleText).toBe(data.title);
const dialogDescription = fixture.nativeElement.querySelector(
const dialogDescription = testingUtils.getInnerTextByCSS(
'[data-automation-id="aspect-list-dialog-title"] .adf-aspect-list-dialog-description'
);
expect(dialogDescription).not.toBeNull();
expect(dialogDescription.innerText).toBe(data.description);
expect(dialogDescription).toBe(data.description);
const overTableMessage = fixture.nativeElement.querySelector('#aspect-list-dialog-over-table-message');
expect(overTableMessage).not.toBeNull();
expect(overTableMessage.innerText).toBe(data.overTableMessage);
const selectionCounter = fixture.nativeElement.querySelector('#aspect-list-dialog-counter');
expect(selectionCounter).not.toBeNull();
expect(selectionCounter.innerText).toBe('0 ADF-ASPECT-LIST.DIALOG.SELECTED');
expect(testingUtils.getInnerTextByCSS('#aspect-list-dialog-over-table-message')).toBe(data.overTableMessage);
expect(getAspectCounter()).toBe('0 ADF-ASPECT-LIST.DIALOG.SELECTED');
});
it('should update the counter when an option is selcted and unselected', async () => {
const firstAspectCheckbox: HTMLInputElement = fixture.nativeElement.querySelector('#aspect-list-0-check-input');
it('should update the counter when an option is selected and unselected', async () => {
const firstAspectCheckbox = getAspectCheckbox(0);
expect(firstAspectCheckbox).toBeDefined();
expect(firstAspectCheckbox).not.toBeNull();
let selectionCounter = fixture.nativeElement.querySelector('#aspect-list-dialog-counter');
expect(selectionCounter).not.toBeNull();
expect(selectionCounter.innerText).toBe('0 ADF-ASPECT-LIST.DIALOG.SELECTED');
firstAspectCheckbox.click();
fixture.detectChanges();
await fixture.whenStable();
selectionCounter = fixture.nativeElement.querySelector('#aspect-list-dialog-counter');
expect(selectionCounter).not.toBeNull();
expect(selectionCounter.innerText).toBe('1 ADF-ASPECT-LIST.DIALOG.SELECTED');
expect(getAspectCounter()).toBe('0 ADF-ASPECT-LIST.DIALOG.SELECTED');
firstAspectCheckbox.click();
fixture.detectChanges();
await fixture.whenStable();
selectionCounter = fixture.nativeElement.querySelector('#aspect-list-dialog-counter');
expect(selectionCounter).not.toBeNull();
expect(selectionCounter.innerText).toBe('0 ADF-ASPECT-LIST.DIALOG.SELECTED');
expect(getAspectCounter()).toBe('1 ADF-ASPECT-LIST.DIALOG.SELECTED');
firstAspectCheckbox.click();
fixture.detectChanges();
await fixture.whenStable();
expect(getAspectCounter()).toBe('0 ADF-ASPECT-LIST.DIALOG.SELECTED');
});
it('should reset to the node values when Reset button is clicked', async () => {
let firstAspectCheckbox: HTMLInputElement = fixture.nativeElement.querySelector('#aspect-list-0-check-input');
let firstAspectCheckbox = getAspectCheckbox(0);
expect(firstAspectCheckbox).toBeDefined();
expect(firstAspectCheckbox).not.toBeNull();
firstAspectCheckbox.click();
fixture.detectChanges();
await fixture.whenStable();
const resetButton: HTMLButtonElement = fixture.nativeElement.querySelector('#aspect-list-dialog-actions-reset');
const resetButton: HTMLButtonElement = getResetButton().nativeElement;
expect(resetButton).toBeDefined();
expect(firstAspectCheckbox.checked).toBeTruthy();
resetButton.click();
fixture.detectChanges();
await fixture.whenStable();
firstAspectCheckbox = fixture.nativeElement.querySelector('#aspect-list-0-check-input');
firstAspectCheckbox = getAspectCheckbox(0);
expect(firstAspectCheckbox.checked).toBeFalsy();
});
it('should clear all the value when Clear button is clicked', async () => {
let firstAspectCheckbox: HTMLInputElement = fixture.nativeElement.querySelector('#aspect-list-0-check-input');
let firstAspectCheckbox = getAspectCheckbox(0);
expect(firstAspectCheckbox).toBeDefined();
expect(firstAspectCheckbox).not.toBeNull();
firstAspectCheckbox.click();
fixture.detectChanges();
await fixture.whenStable();
const clearButton: HTMLButtonElement = fixture.nativeElement.querySelector('#aspect-list-dialog-actions-clear');
const clearButton: HTMLButtonElement = getClearButton().nativeElement;
expect(clearButton).toBeDefined();
expect(firstAspectCheckbox.checked).toBeTruthy();
clearButton.click();
fixture.detectChanges();
await fixture.whenStable();
firstAspectCheckbox = fixture.nativeElement.querySelector('#aspect-list-0-check-input');
firstAspectCheckbox = getAspectCheckbox(0);
expect(firstAspectCheckbox.checked).toBeFalsy();
});
@@ -238,7 +239,7 @@ describe('AspectListDialogComponent', () => {
() => {},
() => done()
);
const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector('#aspect-list-dialog-actions-cancel');
const cancelButton: HTMLButtonElement = getCancelButton().nativeElement;
expect(cancelButton).toBeDefined();
cancelButton.click();
fixture.detectChanges();
@@ -272,17 +273,14 @@ describe('AspectListDialogComponent', () => {
await fixture.whenRenderingDone();
const firstAspectCheckbox: HTMLInputElement = fixture.nativeElement.querySelector('#aspect-list-0-check-input');
expect(firstAspectCheckbox).toBeDefined();
expect(firstAspectCheckbox).not.toBeNull();
expect(firstAspectCheckbox.checked).toBeTruthy();
const notCheckedAspect: HTMLInputElement = fixture.nativeElement.querySelector('#aspect-list-1-check-input');
expect(notCheckedAspect).toBeDefined();
expect(notCheckedAspect).not.toBeNull();
expect(notCheckedAspect.checked).toBeFalsy();
const customAspectCheckbox: HTMLInputElement = fixture.nativeElement.querySelector('#aspect-list-2-check-input');
expect(customAspectCheckbox).toBeDefined();
expect(customAspectCheckbox).not.toBeNull();
expect(customAspectCheckbox.checked).toBeTruthy();
});
@@ -298,14 +296,16 @@ describe('AspectListDialogComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
const applyButton = fixture.nativeElement.querySelector('#aspect-list-dialog-actions-apply');
fixture.nativeElement.querySelector('#aspect-list-dialog-actions-clear').click();
getClearButton().nativeElement.click();
fixture.detectChanges();
await fixture.whenStable();
expect(applyButton.disabled).toBe(false);
expect(getApplyButton().nativeElement.disabled).toBe(false);
});
it('should announce the amount of selected aspects', () => {
expect(testingUtils.getByCSS('#aspect-list-dialog-counter').nativeElement.getAttribute('aria-live')).toBe('polite');
});
});

View File

@@ -22,6 +22,9 @@ import { DocumentListComponent, DocumentListService } from '../document-list';
import { BreadcrumbComponent } from './breadcrumb.component';
import { of } from 'rxjs';
import { NoopAuthModule } from '@alfresco/adf-core';
import { SimpleChange } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { TranslateService } from '@ngx-translate/core';
describe('Breadcrumb', () => {
let component: BreadcrumbComponent;
@@ -31,6 +34,8 @@ describe('Breadcrumb', () => {
isCustomSourceService: false
});
let documentListComponent: DocumentListComponent;
let liveAnnouncer: LiveAnnouncer;
let translateService: TranslateService;
const getBreadcrumbActionText = (): string => fixture.debugElement.nativeElement.querySelector('.adf-breadcrumb-item-current').textContent.trim();
@@ -43,6 +48,8 @@ describe('Breadcrumb', () => {
component = fixture.componentInstance;
documentListComponent = TestBed.createComponent<DocumentListComponent>(DocumentListComponent).componentInstance;
documentListService = TestBed.inject(DocumentListService);
liveAnnouncer = TestBed.inject(LiveAnnouncer);
translateService = TestBed.inject(TranslateService);
});
afterEach(() => {
@@ -59,7 +66,7 @@ describe('Breadcrumb', () => {
it('should root be present as default node if the path is null', () => {
component.root = 'default';
component.folderNode = fakeNodeWithCreatePermission;
component.ngOnChanges();
component.ngOnChanges({});
expect(component.route[0].name).toBe('default');
});
@@ -313,7 +320,7 @@ describe('Breadcrumb', () => {
return transformNode;
};
component.folderNode = node;
component.ngOnChanges();
component.ngOnChanges({});
expect(component.route.length).toBe(4);
expect(component.route[3].id).toBe('test-id');
expect(component.route[3].name).toBe('test-name');
@@ -330,7 +337,7 @@ describe('Breadcrumb', () => {
path: { elements: [{ id: 'element-1-id', name: 'element-1-name' }] }
} as Node;
component.ngOnChanges();
component.ngOnChanges({});
fixture.detectChanges();
expect(getBreadcrumbActionText()).toEqual('test-name');
@@ -340,4 +347,15 @@ describe('Breadcrumb', () => {
expect(getBreadcrumbActionText()).toEqual('BREADCRUMB.HEADER.SELECTED');
});
it('should announce number of selected items when selectedRowItemsCount changes', () => {
const change = new SimpleChange(null, 10, true);
spyOn(liveAnnouncer, 'announce');
spyOn(translateService, 'instant').and.callThrough();
component.ngOnChanges({ selectedRowItemsCount: change });
expect(translateService.instant).toHaveBeenCalledWith('BREADCRUMB.HEADER.SELECTED', { count: 10 });
expect(liveAnnouncer.announce).toHaveBeenCalledWith('BREADCRUMB.HEADER.SELECTED');
});
});

View File

@@ -15,14 +15,27 @@
* limitations under the License.
*/
import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, ViewChild, ViewEncapsulation } from '@angular/core';
import {
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { MatSelect, MatSelectModule } from '@angular/material/select';
import { Node, PathElement } from '@alfresco/js-api';
import { DocumentListComponent } from '../document-list/components/document-list.component';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IconModule } from '@alfresco/adf-core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
@Component({
selector: 'adf-breadcrumb',
@@ -85,6 +98,8 @@ export class BreadcrumbComponent implements OnInit, OnChanges {
route: PathElement[] = [];
private readonly destroyRef = inject(DestroyRef);
private readonly liveAnnouncer = inject(LiveAnnouncer);
private readonly translationService = inject(TranslateService);
get hasRoot(): boolean {
return !!this.root;
@@ -109,8 +124,13 @@ export class BreadcrumbComponent implements OnInit, OnChanges {
}
}
ngOnChanges(): void {
ngOnChanges(changes: SimpleChanges): void {
this.recalculateNodes();
if (changes['selectedRowItemsCount'] && changes['selectedRowItemsCount'].currentValue > 0) {
const msg = this.translationService.instant('BREADCRUMB.HEADER.SELECTED', { count: changes['selectedRowItemsCount'].currentValue });
this.liveAnnouncer.announce(msg);
}
}
protected recalculateNodes(): void {

View File

@@ -54,7 +54,7 @@ describe('DropdownBreadcrumb', () => {
const triggerComponentChange = (fakeNodeData) => {
component.folderNode = fakeNodeData;
component.ngOnChanges();
component.ngOnChanges({});
fixture.detectChanges();
};