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