Merge pull request #3337 from atmire/live-region-main

Live Region
This commit is contained in:
Tim Donohue
2024-09-27 11:09:56 -05:00
committed by GitHub
15 changed files with 472 additions and 3 deletions

View File

@@ -503,6 +503,16 @@ notifyMetrics:
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
# Live Region configuration
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
# Live regions are perceivable regions of a web page that are typically updated as a
# result of an external event when user focus may be elsewhere.
#
# The DSpace live region is a component present at the bottom of all pages that is invisible by default, but is useful
# for screen readers. Any message pushed to the live region will be announced by the screen reader. These messages
# usually contain information about changes on the page that might not be in focus.
liveRegion:
# The duration after which messages disappear from the live region in milliseconds
messageTimeOutDurationMs: 30000
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
isVisible: false

View File

@@ -31,3 +31,5 @@
<div class="ds-full-screen-loader" *ngIf="shouldShowFullscreenLoader"> <div class="ds-full-screen-loader" *ngIf="shouldShowFullscreenLoader">
<ds-loading [showMessage]="false"></ds-loading> <ds-loading [showMessage]="false"></ds-loading>
</div> </div>
<ds-live-region></ds-live-region>

View File

@@ -41,6 +41,7 @@ import { ThemedFooterComponent } from '../footer/themed-footer.component';
import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component'; import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component';
import { slideSidebarPadding } from '../shared/animations/slide'; import { slideSidebarPadding } from '../shared/animations/slide';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
import { LiveRegionComponent } from '../shared/live-region/live-region.component';
import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component';
import { MenuService } from '../shared/menu/menu.service'; import { MenuService } from '../shared/menu/menu.service';
import { MenuID } from '../shared/menu/menu-id.model'; import { MenuID } from '../shared/menu/menu-id.model';
@@ -67,6 +68,7 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
ThemedFooterComponent, ThemedFooterComponent,
NotificationsBoardComponent, NotificationsBoardComponent,
AsyncPipe, AsyncPipe,
LiveRegionComponent,
], ],
}) })
export class RootComponent implements OnInit { export class RootComponent implements OnInit {

View File

@@ -0,0 +1,3 @@
<div class="live-region" [ngClass]="{'visually-hidden': !isVisible }" aria-live="assertive" role="log" aria-relevant="additions" aria-atomic="true">
<div class="live-region-message" *ngFor="let message of (messages$ | async)">{{ message }}</div>
</div>

View File

@@ -0,0 +1,13 @@
.live-region {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-left: 60px;
height: 90px;
line-height: 18px;
color: var(--bs-white);
background-color: var(--bs-dark);
opacity: 0.94;
z-index: var(--ds-live-region-z-index);
}

View File

@@ -0,0 +1,62 @@
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { LiveRegionComponent } from './live-region.component';
import { LiveRegionService } from './live-region.service';
describe('liveRegionComponent', () => {
let fixture: ComponentFixture<LiveRegionComponent>;
let liveRegionService: LiveRegionService;
beforeEach(waitForAsync(() => {
liveRegionService = jasmine.createSpyObj('liveRegionService', {
getMessages$: of(['message1', 'message2']),
getLiveRegionVisibility: false,
setLiveRegionVisibility: undefined,
});
void TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
LiveRegionComponent,
],
providers: [
{ provide: LiveRegionService, useValue: liveRegionService },
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LiveRegionComponent);
fixture.detectChanges();
});
it('should contain the current live region messages', () => {
const messages = fixture.debugElement.queryAll(By.css('.live-region-message'));
expect(messages.length).toEqual(2);
expect(messages[0].nativeElement.textContent).toEqual('message1');
expect(messages[1].nativeElement.textContent).toEqual('message2');
});
it('should respect the live region visibility', () => {
const liveRegion = fixture.debugElement.query(By.css('.live-region'));
expect(liveRegion).toBeDefined();
const liveRegionHidden = fixture.debugElement.query(By.css('.visually-hidden'));
expect(liveRegionHidden).toBeDefined();
liveRegionService.getLiveRegionVisibility = jasmine.createSpy('getLiveRegionVisibility').and.returnValue(true);
fixture = TestBed.createComponent(LiveRegionComponent);
fixture.detectChanges();
const liveRegionVisible = fixture.debugElement.query(By.css('.visually-hidden'));
expect(liveRegionVisible).toBeNull();
});
});

View File

@@ -0,0 +1,42 @@
import {
AsyncPipe,
NgClass,
NgFor,
} from '@angular/common';
import {
Component,
OnInit,
} from '@angular/core';
import { Observable } from 'rxjs';
import { LiveRegionService } from './live-region.service';
/**
* The Live Region Component is an accessibility tool for screenreaders. When a change occurs on a page when the changed
* section is not in focus, a message should be displayed by this component so it can be announced by a screen reader.
*
* This component should not be used directly. Use the {@link LiveRegionService} to add messages.
*/
@Component({
selector: `ds-live-region`,
templateUrl: './live-region.component.html',
styleUrls: ['./live-region.component.scss'],
standalone: true,
imports: [NgClass, NgFor, AsyncPipe],
})
export class LiveRegionComponent implements OnInit {
protected isVisible: boolean;
protected messages$: Observable<string[]>;
constructor(
protected liveRegionService: LiveRegionService,
) {
}
ngOnInit() {
this.isVisible = this.liveRegionService.getLiveRegionVisibility();
this.messages$ = this.liveRegionService.getMessages$();
}
}

View File

@@ -0,0 +1,9 @@
import { Config } from '../../../config/config.interface';
/**
* Configuration interface used by the LiveRegionService
*/
export class LiveRegionConfig implements Config {
messageTimeOutDurationMs: number;
isVisible: boolean;
}

View File

@@ -0,0 +1,175 @@
import {
fakeAsync,
flush,
tick,
} from '@angular/core/testing';
import { UUIDService } from '../../core/shared/uuid.service';
import { LiveRegionService } from './live-region.service';
describe('liveRegionService', () => {
let service: LiveRegionService;
beforeEach(() => {
service = new LiveRegionService(
new UUIDService(),
);
});
describe('addMessage', () => {
it('should correctly add messages', () => {
expect(service.getMessages().length).toEqual(0);
service.addMessage('Message One');
expect(service.getMessages().length).toEqual(1);
expect(service.getMessages()[0]).toEqual('Message One');
service.addMessage('Message Two');
expect(service.getMessages().length).toEqual(2);
expect(service.getMessages()[1]).toEqual('Message Two');
});
});
describe('clearMessages', () => {
it('should clear the messages', () => {
expect(service.getMessages().length).toEqual(0);
service.addMessage('Message One');
service.addMessage('Message Two');
expect(service.getMessages().length).toEqual(2);
service.clear();
expect(service.getMessages().length).toEqual(0);
});
});
describe('messages$', () => {
it('should emit when a message is added and when a message is removed after the timeOut', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
service.addMessage('message');
tick();
expect(results.length).toEqual(2);
expect(results[1]).toEqual(['message']);
tick(service.getMessageTimeOutMs());
expect(results.length).toEqual(3);
expect(results[2]).toEqual([]);
}));
it('should only emit once when the messages are cleared', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
service.addMessage('Message One');
service.addMessage('Message Two');
tick();
expect(results.length).toEqual(3);
expect(results[2]).toEqual(['Message One', 'Message Two']);
service.clear();
flush();
expect(results.length).toEqual(4);
expect(results[3]).toEqual([]);
}));
it('should not pop messages added after clearing within timeOut period', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
service.addMessage('Message One');
tick(10000);
service.clear();
tick(15000);
service.addMessage('Message Two');
// Message Two should not be cleared after 5 more seconds
tick(5000);
expect(results.length).toEqual(4);
expect(results[3]).toEqual(['Message Two']);
// But should be cleared 30 seconds after it was added
tick(25000);
expect(results.length).toEqual(5);
expect(results[4]).toEqual([]);
}));
it('should respect configured timeOut', fakeAsync(() => {
const results: string[][] = [];
service.getMessages$().subscribe((messages) => {
results.push(messages);
});
expect(results.length).toEqual(1);
expect(results[0]).toEqual([]);
const timeOutMs = 500;
service.setMessageTimeOutMs(timeOutMs);
service.addMessage('Message One');
tick(timeOutMs - 1);
expect(results.length).toEqual(2);
expect(results[1]).toEqual(['Message One']);
tick(1);
expect(results.length).toEqual(3);
expect(results[2]).toEqual([]);
const timeOutMsTwo = 50000;
service.setMessageTimeOutMs(timeOutMsTwo);
service.addMessage('Message Two');
tick(timeOutMsTwo - 1);
expect(results.length).toEqual(4);
expect(results[3]).toEqual(['Message Two']);
tick(1);
expect(results.length).toEqual(5);
expect(results[4]).toEqual([]);
}));
});
describe('liveRegionVisibility', () => {
it('should be false by default', () => {
expect(service.getLiveRegionVisibility()).toBeFalse();
});
it('should correctly update', () => {
service.setLiveRegionVisibility(true);
expect(service.getLiveRegionVisibility()).toBeTrue();
service.setLiveRegionVisibility(false);
expect(service.getLiveRegionVisibility()).toBeFalse();
});
});
});

View File

@@ -0,0 +1,133 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { environment } from '../../../environments/environment';
import { UUIDService } from '../../core/shared/uuid.service';
/**
* The LiveRegionService is responsible for handling the messages that are shown by the {@link LiveRegionComponent}.
* Use this service to add or remove messages to the Live Region.
*/
@Injectable({
providedIn: 'root',
})
export class LiveRegionService {
constructor(
protected uuidService: UUIDService,
) {
}
/**
* The duration after which the messages disappear in milliseconds
* @protected
*/
protected messageTimeOutDurationMs: number = environment.liveRegion.messageTimeOutDurationMs;
/**
* Array containing the messages that should be shown in the live region,
* together with a uuid, so they can be uniquely identified
* @protected
*/
protected messages: { message: string, uuid: string }[] = [];
/**
* BehaviorSubject emitting the array with messages every time the array updates
* @protected
*/
protected messages$: BehaviorSubject<string[]> = new BehaviorSubject([]);
/**
* Whether the live region should be visible
* @protected
*/
protected liveRegionIsVisible: boolean = environment.liveRegion.isVisible;
/**
* Returns a copy of the array with the current live region messages
*/
getMessages(): string[] {
return this.messages.map(messageObj => messageObj.message);
}
/**
* Returns the BehaviorSubject emitting the array with messages every time the array updates
*/
getMessages$(): BehaviorSubject<string[]> {
return this.messages$;
}
/**
* Adds a message to the live-region messages array
* @param message
* @return The uuid of the message
*/
addMessage(message: string): string {
const uuid = this.uuidService.generate();
this.messages.push({ message, uuid });
setTimeout(() => this.clearMessageByUUID(uuid), this.messageTimeOutDurationMs);
this.emitCurrentMessages();
return uuid;
}
/**
* Clears the live-region messages array
*/
clear() {
this.messages = [];
this.emitCurrentMessages();
}
/**
* Removes the message with the given UUID from the messages array
* @param uuid The uuid of the message to clear
*/
clearMessageByUUID(uuid: string) {
const index = this.messages.findIndex(messageObj => messageObj.uuid === uuid);
if (index !== -1) {
this.messages.splice(index, 1);
this.emitCurrentMessages();
}
}
/**
* Makes the messages$ BehaviorSubject emit the current messages array
* @protected
*/
protected emitCurrentMessages() {
this.messages$.next(this.getMessages());
}
/**
* Returns a boolean specifying whether the live region should be visible.
* Returns 'true' if the region should be visible and false otherwise.
*/
getLiveRegionVisibility(): boolean {
return this.liveRegionIsVisible;
}
/**
* Sets the visibility of the live region.
* Setting this to true will make the live region visible which is useful for debugging purposes.
* @param isVisible
*/
setLiveRegionVisibility(isVisible: boolean) {
this.liveRegionIsVisible = isVisible;
}
/**
* Gets the current message timeOut duration in milliseconds
*/
getMessageTimeOutMs(): number {
return this.messageTimeOutDurationMs;
}
/**
* Sets the message timeOut duration
* @param timeOutMs the message timeOut duration in milliseconds
*/
setMessageTimeOutMs(timeOutMs: number) {
this.messageTimeOutDurationMs = timeOutMs;
}
}

View File

@@ -6,6 +6,7 @@ import {
import { AdminNotifyMetricsRow } from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model'; import { AdminNotifyMetricsRow } from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model';
import { HALDataService } from '../app/core/data/base/hal-data-service.interface'; import { HALDataService } from '../app/core/data/base/hal-data-service.interface';
import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
import { ActuatorsConfig } from './actuators.config'; import { ActuatorsConfig } from './actuators.config';
import { AuthConfig } from './auth-config.interfaces'; import { AuthConfig } from './auth-config.interfaces';
import { BrowseByConfig } from './browse-by-config.interface'; import { BrowseByConfig } from './browse-by-config.interface';
@@ -33,6 +34,7 @@ import { SuggestionConfig } from './suggestion-config.interfaces';
import { ThemeConfig } from './theme.config'; import { ThemeConfig } from './theme.config';
import { UIServerConfig } from './ui-server-config.interface'; import { UIServerConfig } from './ui-server-config.interface';
interface AppConfig extends Config { interface AppConfig extends Config {
ui: UIServerConfig; ui: UIServerConfig;
rest: ServerConfig; rest: ServerConfig;
@@ -63,6 +65,7 @@ interface AppConfig extends Config {
qualityAssuranceConfig: QualityAssuranceConfig; qualityAssuranceConfig: QualityAssuranceConfig;
search: SearchConfig; search: SearchConfig;
notifyMetrics: AdminNotifyMetricsRow[]; notifyMetrics: AdminNotifyMetricsRow[];
liveRegion: LiveRegionConfig;
} }
/** /**

View File

@@ -1,5 +1,6 @@
import { AdminNotifyMetricsRow } from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model'; import { AdminNotifyMetricsRow } from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model';
import { RestRequestMethod } from '../app/core/data/rest-request-method'; import { RestRequestMethod } from '../app/core/data/rest-request-method';
import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type';
import { ActuatorsConfig } from './actuators.config'; import { ActuatorsConfig } from './actuators.config';
import { AppConfig } from './app-config.interface'; import { AppConfig } from './app-config.interface';
@@ -591,4 +592,10 @@ export class DefaultAppConfig implements AppConfig {
], ],
}, },
]; ];
// Live Region configuration, used by the LiveRegionService
liveRegion: LiveRegionConfig = {
messageTimeOutDurationMs: 30000,
isVisible: false,
};
} }

View File

@@ -422,4 +422,9 @@ export const environment: BuildConfig = {
], ],
}, },
], ],
liveRegion: {
messageTimeOutDurationMs: 30000,
isVisible: false,
},
}; };

View File

@@ -13,6 +13,7 @@
--ds-login-logo-width:72px; --ds-login-logo-width:72px;
--ds-submission-header-z-index: 1001; --ds-submission-header-z-index: 1001;
--ds-submission-footer-z-index: 999; --ds-submission-footer-z-index: 999;
--ds-live-region-z-index: 1030;
--ds-main-z-index: 1; --ds-main-z-index: 1;
--ds-nav-z-index: 10; --ds-nav-z-index: 10;

View File

@@ -13,6 +13,7 @@ import { ThemedFooterComponent } from '../../../../app/footer/themed-footer.comp
import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component'; import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component';
import { RootComponent as BaseComponent } from '../../../../app/root/root.component'; import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
import { slideSidebarPadding } from '../../../../app/shared/animations/slide'; import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
import { LiveRegionComponent } from '../../../../app/shared/live-region/live-region.component';
import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component';
import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component'; import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component';
import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-alert/alert-banner/system-wide-alert-banner.component'; import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-alert/alert-banner/system-wide-alert-banner.component';
@@ -38,6 +39,7 @@ import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-aler
ThemedFooterComponent, ThemedFooterComponent,
NotificationsBoardComponent, NotificationsBoardComponent,
AsyncPipe, AsyncPipe,
LiveRegionComponent,
], ],
}) })
export class RootComponent extends BaseComponent { export class RootComponent extends BaseComponent {