From 83a44ba924fb20065f0bb62fc2a09a7aaec391bc Mon Sep 17 00:00:00 2001 From: Andreas Awouters Date: Mon, 16 Sep 2024 15:46:07 +0200 Subject: [PATCH 1/3] 118220: Add live-region service and component --- config/config.example.yml | 16 ++- .../live-region/live-region.component.html | 3 + .../live-region/live-region.component.scss | 13 ++ .../live-region/live-region.component.ts | 25 ++++ .../shared/live-region/live-region.config.ts | 9 ++ .../shared/live-region/live-region.service.ts | 118 ++++++++++++++++++ src/app/shared/shared.module.ts | 4 +- src/config/app-config.interface.ts | 2 + src/config/default-app-config.ts | 7 ++ src/environments/environment.test.ts | 7 +- src/styles/_custom_variables.scss | 1 + 11 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/app/shared/live-region/live-region.component.html create mode 100644 src/app/shared/live-region/live-region.component.scss create mode 100644 src/app/shared/live-region/live-region.component.ts create mode 100644 src/app/shared/live-region/live-region.config.ts create mode 100644 src/app/shared/live-region/live-region.service.ts diff --git a/config/config.example.yml b/config/config.example.yml index ea38303fa36..58eb6ff33d2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -379,4 +379,18 @@ vocabularies: # Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' - sortDirection: 'ASC' \ No newline at end of file + sortDirection: 'ASC' + +# 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/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.ts b/src/app/shared/live-region/live-region.component.ts new file mode 100644 index 00000000000..d7bd5eb806c --- /dev/null +++ b/src/app/shared/live-region/live-region.component.ts @@ -0,0 +1,25 @@ +import { Component, OnInit } from '@angular/core'; +import { LiveRegionService } from './live-region.service'; +import { Observable } from 'rxjs'; + +@Component({ + selector: `ds-live-region`, + templateUrl: './live-region.component.html', + styleUrls: ['./live-region.component.scss'], +}) +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.ts b/src/app/shared/live-region/live-region.service.ts new file mode 100644 index 00000000000..482d1ca1bbf --- /dev/null +++ b/src/app/shared/live-region/live-region.service.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class LiveRegionService { + + /** + * 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 + * @protected + */ + protected messages: 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() { + return [...this.messages]; + } + + /** + * Returns the BehaviorSubject emitting the array with messages every time the array updates + */ + getMessages$() { + return this.messages$; + } + + /** + * Adds a message to the live-region messages array + * @param message + */ + addMessage(message: string) { + this.messages.push(message); + this.emitCurrentMessages(); + + // Clear the message once the timeOut has passed + setTimeout(() => this.pop(), this.messageTimeOutDurationMs); + } + + /** + * Clears the live-region messages array + */ + clear() { + this.messages = []; + this.emitCurrentMessages(); + } + + /** + * Removes the longest living message from the array. + * @protected + */ + protected pop() { + if (this.messages.length > 0) { + this.messages.shift(); + 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/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 0f7871f7f9b..db5b7787228 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -284,6 +284,7 @@ import { } from '../item-page/simple/field-components/specific-field/title/themed-item-page-field.component'; import { BitstreamListItemComponent } from './object-list/bitstream-list-item/bitstream-list-item.component'; import { NgxPaginationModule } from 'ngx-pagination'; +import { LiveRegionComponent } from './live-region/live-region.component'; const MODULES = [ CommonModule, @@ -465,7 +466,8 @@ const ENTRY_COMPONENTS = [ AdvancedClaimedTaskActionRatingComponent, EpersonGroupListComponent, EpersonSearchBoxComponent, - GroupSearchBoxComponent + GroupSearchBoxComponent, + LiveRegionComponent, ]; const PROVIDERS = [ diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 84a30549a72..aa3033ecec3 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -22,6 +22,7 @@ import { HomeConfig } from './homepage-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { DiscoverySortConfig } from './discovery-sort.config'; +import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; interface AppConfig extends Config { ui: UIServerConfig; @@ -48,6 +49,7 @@ interface AppConfig extends Config { markdown: MarkdownConfig; vocabularies: FilterVocabularyConfig[]; comcolSelectionSort: DiscoverySortConfig; + liveRegion: LiveRegionConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index a6e9e092e46..1c0f88cf477 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -22,6 +22,7 @@ import { HomeConfig } from './homepage-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { DiscoverySortConfig } from './discovery-sort.config'; +import { LiveRegionConfig } from '../app/shared/live-region/live-region.config'; export class DefaultAppConfig implements AppConfig { production = false; @@ -432,4 +433,10 @@ export class DefaultAppConfig implements AppConfig { sortField:'dc.title', sortDirection:'ASC', }; + + // 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 cb9d2c71303..498799a454b 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -313,5 +313,10 @@ export const environment: BuildConfig = { vocabulary: 'srsc', enabled: true } - ] + ], + + liveRegion: { + messageTimeOutDurationMs: 30000, + isVisible: false, + }, }; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index ddf490c7a7a..09267d15aef 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; From e987c35450f2a03e73fe5a9951ec412fb9675e33 Mon Sep 17 00:00:00 2001 From: Andreas Awouters Date: Tue, 17 Sep 2024 09:07:02 +0200 Subject: [PATCH 2/3] 118220: Add liveRegionComponent & Service tests --- .../live-region/live-region.component.spec.ts | 57 +++++++ .../live-region/live-region.service.spec.ts | 140 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/app/shared/live-region/live-region.component.spec.ts create mode 100644 src/app/shared/live-region/live-region.service.spec.ts 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..2e9797f9c38 --- /dev/null +++ b/src/app/shared/live-region/live-region.component.spec.ts @@ -0,0 +1,57 @@ +import { LiveRegionComponent } from './live-region.component'; +import { ComponentFixture, waitForAsync, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { LiveRegionService } from './live-region.service'; +import { of } from 'rxjs'; +import { By } from '@angular/platform-browser'; + +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(), + ], + declarations: [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.service.spec.ts b/src/app/shared/live-region/live-region.service.spec.ts new file mode 100644 index 00000000000..fe5e8b8d8c5 --- /dev/null +++ b/src/app/shared/live-region/live-region.service.spec.ts @@ -0,0 +1,140 @@ +import { LiveRegionService } from './live-region.service'; +import { fakeAsync, tick, flush } from '@angular/core/testing'; + +describe('liveRegionService', () => { + let service: LiveRegionService; + + + beforeEach(() => { + service = new LiveRegionService(); + }); + + 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 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(); + }); + }); +}); From 35d29c84258e1656ed17c53e6e7054c69b7878d1 Mon Sep 17 00:00:00 2001 From: Andreas Awouters Date: Fri, 20 Sep 2024 09:38:31 +0200 Subject: [PATCH 3/3] 118220: Add LiveRegion to RootComponent --- src/app/root/root.component.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index bf49e507c0b..d59bea1db42 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -27,3 +27,5 @@
+ +