diff --git a/config/config.example.yml b/config/config.example.yml index 93386274e6..6c3c900f6a 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 b5b753cdb9..1fb9f786b4 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 c683899211..b5c1c9be48 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 0000000000..a48f3ad52e --- /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 0000000000..69844a93e1 --- /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 0000000000..86cf24f49b --- /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 0000000000..2106050a27 --- /dev/null +++ b/src/app/shared/live-region/live-region.component.ts @@ -0,0 +1,36 @@ +import { + AsyncPipe, + NgClass, + NgFor, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { Observable } from 'rxjs'; + +import { LiveRegionService } from './live-region.service'; + +@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 0000000000..e545bfd254 --- /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 0000000000..8e38e2ad7b --- /dev/null +++ b/src/app/shared/live-region/live-region.service.spec.ts @@ -0,0 +1,145 @@ +import { + fakeAsync, + flush, + tick, +} from '@angular/core/testing'; + +import { LiveRegionService } from './live-region.service'; + +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(); + }); + }); +}); 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 0000000000..f5553cb4dd --- /dev/null +++ b/src/app/shared/live-region/live-region.service.ts @@ -0,0 +1,119 @@ +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/config/app-config.interface.ts b/src/config/app-config.interface.ts index 9a4d56bee0..7f5f019958 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 3682d095cd..3455ef5f92 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 cd02e35fb1..c7146dfb07 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 f261f0f400..7df6d87456 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 bac434eeb3..4894746e52 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 {