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 @@
+
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 {