119176: Announce notification content in live region

This commit is contained in:
Andreas Awouters
2024-10-14 11:30:48 +02:00
parent 8d93f22767
commit 5bb6f6d34c
3 changed files with 97 additions and 24 deletions

View File

@@ -4,19 +4,25 @@ export interface INotificationOptions {
timeOut: number; timeOut: number;
clickToClose: boolean; clickToClose: boolean;
animate: NotificationAnimationsType | string; animate: NotificationAnimationsType | string;
announceContentInLiveRegion: boolean;
} }
export class NotificationOptions implements INotificationOptions { export class NotificationOptions implements INotificationOptions {
public timeOut: number; public timeOut: number;
public clickToClose: boolean; public clickToClose: boolean;
public animate: any; public animate: any;
public announceContentInLiveRegion: boolean;
constructor(timeOut = 5000,
clickToClose = true,
animate: NotificationAnimationsType | string = NotificationAnimationsType.Scale) {
constructor(
timeOut = 5000,
clickToClose = true,
animate: NotificationAnimationsType | string = NotificationAnimationsType.Scale,
announceContentInLiveRegion: boolean = true,
) {
this.timeOut = timeOut; this.timeOut = timeOut;
this.clickToClose = clickToClose; this.clickToClose = clickToClose;
this.animate = animate; this.animate = animate;
this.announceContentInLiveRegion = announceContentInLiveRegion;
} }
} }

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef } from '@angular/core'; import { ChangeDetectorRef } from '@angular/core';
@@ -15,14 +15,20 @@ import uniqueId from 'lodash/uniqueId';
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
import { cold } from 'jasmine-marbles'; import { cold } from 'jasmine-marbles';
import { LiveRegionService } from '../../live-region/live-region.service';
import { LiveRegionServiceStub } from '../../live-region/live-region.service.stub';
import { NotificationOptions } from '../models/notification-options.model';
export const bools = { f: false, t: true }; export const bools = { f: false, t: true };
describe('NotificationsBoardComponent', () => { describe('NotificationsBoardComponent', () => {
let comp: NotificationsBoardComponent; let comp: NotificationsBoardComponent;
let fixture: ComponentFixture<NotificationsBoardComponent>; let fixture: ComponentFixture<NotificationsBoardComponent>;
let liveRegionService: LiveRegionServiceStub;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
liveRegionService = new LiveRegionServiceStub();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
BrowserModule, BrowserModule,
@@ -36,7 +42,9 @@ describe('NotificationsBoardComponent', () => {
declarations: [NotificationsBoardComponent, NotificationComponent], // declare the test component declarations: [NotificationsBoardComponent, NotificationComponent], // declare the test component
providers: [ providers: [
{ provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: NotificationsService, useClass: NotificationsServiceStub },
ChangeDetectorRef] { provide: LiveRegionService, useValue: liveRegionService },
ChangeDetectorRef,
]
}).compileComponents(); // compile template and css }).compileComponents(); // compile template and css
})); }));
@@ -106,5 +114,42 @@ describe('NotificationsBoardComponent', () => {
}); });
}); });
describe('add', () => {
beforeEach(() => {
liveRegionService.addMessage.calls.reset();
});
it('should announce content to the live region', fakeAsync(() => {
const notification = new Notification('id', NotificationType.Info, 'title', 'content');
comp.add(notification);
flush();
expect(liveRegionService.addMessage).toHaveBeenCalledWith('content');
}));
it('should not announce anything if there is no content', fakeAsync(() => {
const notification = new Notification('id', NotificationType.Info, 'title');
comp.add(notification);
flush();
expect(liveRegionService.addMessage).not.toHaveBeenCalled();
}));
it('should not announce the content if disabled', fakeAsync(() => {
const options = new NotificationOptions();
options.announceContentInLiveRegion = false;
const notification = new Notification('id', NotificationType.Info, 'title', 'content');
notification.options = options;
comp.add(notification);
flush();
expect(liveRegionService.addMessage).not.toHaveBeenCalled();
}));
});
}) })
; ;

View File

@@ -9,7 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { BehaviorSubject, Subscription } from 'rxjs'; import { BehaviorSubject, Subscription, of as observableOf } from 'rxjs';
import difference from 'lodash/difference'; import difference from 'lodash/difference';
import { NotificationsService } from '../notifications.service'; import { NotificationsService } from '../notifications.service';
@@ -18,6 +18,9 @@ import { notificationsStateSelector } from '../selectors';
import { INotification } from '../models/notification.model'; import { INotification } from '../models/notification.model';
import { NotificationsState } from '../notifications.reducers'; import { NotificationsState } from '../notifications.reducers';
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
import { LiveRegionService } from '../../live-region/live-region.service';
import { hasNoValue, isNotEmptyOperator } from '../../empty.util';
import { take } from 'rxjs/operators';
@Component({ @Component({
selector: 'ds-notifications-board', selector: 'ds-notifications-board',
@@ -49,9 +52,12 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
*/ */
public isPaused$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); public isPaused$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(private service: NotificationsService, constructor(
private store: Store<AppState>, private service: NotificationsService,
private cdr: ChangeDetectorRef) { private store: Store<AppState>,
private cdr: ChangeDetectorRef,
protected liveRegionService: LiveRegionService,
) {
} }
ngOnInit(): void { ngOnInit(): void {
@@ -85,6 +91,7 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
this.notifications.splice(this.notifications.length - 1, 1); this.notifications.splice(this.notifications.length - 1, 1);
} }
this.notifications.splice(0, 0, item); this.notifications.splice(0, 0, item);
this.addContentToLiveRegion(item);
} else { } else {
// Remove the notification from the store // Remove the notification from the store
// This notification was in the store, but not in this.notifications // This notification was in the store, but not in this.notifications
@@ -93,29 +100,44 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
} }
} }
/**
* Adds the content of the notification (if any) to the live region, so it can be announced by screen readers.
*/
private addContentToLiveRegion(item: INotification) {
let content = item.content;
if (!item.options.announceContentInLiveRegion || hasNoValue(content)) {
return;
}
if (typeof content === 'string') {
content = observableOf(content);
}
content.pipe(
isNotEmptyOperator(),
take(1),
).subscribe(contentStr => this.liveRegionService.addMessage(contentStr));
}
/**
* Whether to block the provided item because a duplicate notification with the exact same information already
* exists within the notifications array.
* @param item The item to check
* @return true if the notifications array already contains a notification with the exact same information as the
* provided item. false otherwise.
* @private
*/
private block(item: INotification): boolean { private block(item: INotification): boolean {
const toCheck = item.html ? this.checkHtml : this.checkStandard; const toCheck = item.html ? this.checkHtml : this.checkStandard;
this.notifications.forEach((notification) => { this.notifications.forEach((notification) => {
if (toCheck(notification, item)) { if (toCheck(notification, item)) {
return true; return true;
} }
}); });
if (this.notifications.length > 0) { return false;
this.notifications.forEach((notification) => {
if (toCheck(notification, item)) {
return true;
}
});
}
let comp: INotification;
if (this.notifications.length > 0) {
comp = this.notifications[0];
} else {
return false;
}
return toCheck(comp, item);
} }
private checkStandard(checker: INotification, item: INotification): boolean { private checkStandard(checker: INotification, item: INotification): boolean {