diff --git a/config/config.example.yml b/config/config.example.yml index 86f608e5a3e..bb8f3de58cf 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -503,6 +503,16 @@ notifyMetrics: 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 diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index b5b753cdb9d..1fb9f786b43 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -31,3 +31,5 @@
+ + diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index c683899211c..b5c1c9be48e 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -41,6 +41,7 @@ import { ThemedFooterComponent } from '../footer/themed-footer.component'; import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component'; import { slideSidebarPadding } from '../shared/animations/slide'; 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 { MenuService } from '../shared/menu/menu.service'; import { MenuID } from '../shared/menu/menu-id.model'; @@ -67,6 +68,7 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne ThemedFooterComponent, NotificationsBoardComponent, AsyncPipe, + LiveRegionComponent, ], }) export class RootComponent implements OnInit { diff --git a/src/app/shared/live-region/live-region.component.html b/src/app/shared/live-region/live-region.component.html new file mode 100644 index 00000000000..a48f3ad52e5 --- /dev/null +++ b/src/app/shared/live-region/live-region.component.html @@ -0,0 +1,3 @@ +
+
{{ message }}
+
diff --git a/src/app/shared/live-region/live-region.component.scss b/src/app/shared/live-region/live-region.component.scss new file mode 100644 index 00000000000..69844a93e1e --- /dev/null +++ b/src/app/shared/live-region/live-region.component.scss @@ -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); +} diff --git a/src/app/shared/live-region/live-region.component.spec.ts b/src/app/shared/live-region/live-region.component.spec.ts new file mode 100644 index 00000000000..86cf24f49b2 --- /dev/null +++ b/src/app/shared/live-region/live-region.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/shared/live-region/live-region.component.ts b/src/app/shared/live-region/live-region.component.ts new file mode 100644 index 00000000000..7fb4a8b52bc --- /dev/null +++ b/src/app/shared/live-region/live-region.component.ts @@ -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; + + constructor( + protected liveRegionService: LiveRegionService, + ) { + } + + ngOnInit() { + this.isVisible = this.liveRegionService.getLiveRegionVisibility(); + this.messages$ = this.liveRegionService.getMessages$(); + } +} diff --git a/src/app/shared/live-region/live-region.config.ts b/src/app/shared/live-region/live-region.config.ts new file mode 100644 index 00000000000..e545bfd2543 --- /dev/null +++ b/src/app/shared/live-region/live-region.config.ts @@ -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; +} diff --git a/src/app/shared/live-region/live-region.service.spec.ts b/src/app/shared/live-region/live-region.service.spec.ts new file mode 100644 index 00000000000..375c0d0d699 --- /dev/null +++ b/src/app/shared/live-region/live-region.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/src/app/shared/live-region/live-region.service.ts b/src/app/shared/live-region/live-region.service.ts new file mode 100644 index 00000000000..79470240919 --- /dev/null +++ b/src/app/shared/live-region/live-region.service.ts @@ -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 = 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 { + 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; + } +} diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 9a4d56bee0f..7f5f0199582 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -6,6 +6,7 @@ import { 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 { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; import { ActuatorsConfig } from './actuators.config'; import { AuthConfig } from './auth-config.interfaces'; import { BrowseByConfig } from './browse-by-config.interface'; @@ -33,6 +34,7 @@ import { SuggestionConfig } from './suggestion-config.interfaces'; import { ThemeConfig } from './theme.config'; import { UIServerConfig } from './ui-server-config.interface'; + interface AppConfig extends Config { ui: UIServerConfig; rest: ServerConfig; @@ -63,6 +65,7 @@ interface AppConfig extends Config { qualityAssuranceConfig: QualityAssuranceConfig; search: SearchConfig; notifyMetrics: AdminNotifyMetricsRow[]; + liveRegion: LiveRegionConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 3682d095cdd..3455ef5f92a 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -1,5 +1,6 @@ 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 { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; import { ActuatorsConfig } from './actuators.config'; 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, + }; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index cd02e35fb19..c7146dfb078 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -422,4 +422,9 @@ export const environment: BuildConfig = { ], }, ], + + liveRegion: { + messageTimeOutDurationMs: 30000, + isVisible: false, + }, }; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 2117a6144dc..682306c636a 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -13,6 +13,7 @@ --ds-login-logo-width:72px; --ds-submission-header-z-index: 1001; --ds-submission-footer-z-index: 999; + --ds-live-region-z-index: 1030; --ds-main-z-index: 1; --ds-nav-z-index: 10; diff --git a/src/themes/custom/app/root/root.component.ts b/src/themes/custom/app/root/root.component.ts index bac434eeb3b..4894746e52d 100644 --- a/src/themes/custom/app/root/root.component.ts +++ b/src/themes/custom/app/root/root.component.ts @@ -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 { RootComponent as BaseComponent } from '../../../../app/root/root.component'; 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 { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.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, NotificationsBoardComponent, AsyncPipe, + LiveRegionComponent, ], }) export class RootComponent extends BaseComponent {