Skip to content

Commit debc232

Browse files
committed
Merge branch 'live-region-8.0' into live-region-main
2 parents 779ff47 + 2249464 commit debc232

15 files changed

Lines changed: 422 additions & 3 deletions

config/config.example.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,16 @@ notifyMetrics:
503503
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
504504

505505

506-
507-
508-
506+
# Live Region configuration
507+
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
508+
# Live regions are perceivable regions of a web page that are typically updated as a
509+
# result of an external event when user focus may be elsewhere.
510+
#
511+
# The DSpace live region is a component present at the bottom of all pages that is invisible by default, but is useful
512+
# for screen readers. Any message pushed to the live region will be announced by the screen reader. These messages
513+
# usually contain information about changes on the page that might not be in focus.
514+
liveRegion:
515+
# The duration after which messages disappear from the live region in milliseconds
516+
messageTimeOutDurationMs: 30000
517+
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
518+
isVisible: false

src/app/root/root.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@
3131
<div class="ds-full-screen-loader" *ngIf="shouldShowFullscreenLoader">
3232
<ds-loading [showMessage]="false"></ds-loading>
3333
</div>
34+
35+
<ds-live-region></ds-live-region>

src/app/root/root.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { ThemedFooterComponent } from '../footer/themed-footer.component';
4141
import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component';
4242
import { slideSidebarPadding } from '../shared/animations/slide';
4343
import { HostWindowService } from '../shared/host-window.service';
44+
import { LiveRegionComponent } from '../shared/live-region/live-region.component';
4445
import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component';
4546
import { MenuService } from '../shared/menu/menu.service';
4647
import { MenuID } from '../shared/menu/menu-id.model';
@@ -67,6 +68,7 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
6768
ThemedFooterComponent,
6869
NotificationsBoardComponent,
6970
AsyncPipe,
71+
LiveRegionComponent,
7072
],
7173
})
7274
export class RootComponent implements OnInit {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div class="live-region" [ngClass]="{'visually-hidden': !isVisible }" aria-live="assertive" role="log" aria-relevant="additions" aria-atomic="true">
2+
<div class="live-region-message" *ngFor="let message of (messages$ | async)">{{ message }}</div>
3+
</div>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.live-region {
2+
position: fixed;
3+
bottom: 0;
4+
left: 0;
5+
right: 0;
6+
padding-left: 60px;
7+
height: 90px;
8+
line-height: 18px;
9+
color: var(--bs-white);
10+
background-color: var(--bs-dark);
11+
opacity: 0.94;
12+
z-index: var(--ds-live-region-z-index);
13+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
ComponentFixture,
3+
TestBed,
4+
waitForAsync,
5+
} from '@angular/core/testing';
6+
import { By } from '@angular/platform-browser';
7+
import { TranslateModule } from '@ngx-translate/core';
8+
import { of } from 'rxjs';
9+
10+
import { LiveRegionComponent } from './live-region.component';
11+
import { LiveRegionService } from './live-region.service';
12+
13+
describe('liveRegionComponent', () => {
14+
let fixture: ComponentFixture<LiveRegionComponent>;
15+
let liveRegionService: LiveRegionService;
16+
17+
beforeEach(waitForAsync(() => {
18+
liveRegionService = jasmine.createSpyObj('liveRegionService', {
19+
getMessages$: of(['message1', 'message2']),
20+
getLiveRegionVisibility: false,
21+
setLiveRegionVisibility: undefined,
22+
});
23+
24+
void TestBed.configureTestingModule({
25+
imports: [
26+
TranslateModule.forRoot(),
27+
LiveRegionComponent,
28+
],
29+
providers: [
30+
{ provide: LiveRegionService, useValue: liveRegionService },
31+
],
32+
}).compileComponents();
33+
}));
34+
35+
beforeEach(() => {
36+
fixture = TestBed.createComponent(LiveRegionComponent);
37+
fixture.detectChanges();
38+
});
39+
40+
it('should contain the current live region messages', () => {
41+
const messages = fixture.debugElement.queryAll(By.css('.live-region-message'));
42+
43+
expect(messages.length).toEqual(2);
44+
expect(messages[0].nativeElement.textContent).toEqual('message1');
45+
expect(messages[1].nativeElement.textContent).toEqual('message2');
46+
});
47+
48+
it('should respect the live region visibility', () => {
49+
const liveRegion = fixture.debugElement.query(By.css('.live-region'));
50+
expect(liveRegion).toBeDefined();
51+
52+
const liveRegionHidden = fixture.debugElement.query(By.css('.visually-hidden'));
53+
expect(liveRegionHidden).toBeDefined();
54+
55+
liveRegionService.getLiveRegionVisibility = jasmine.createSpy('getLiveRegionVisibility').and.returnValue(true);
56+
fixture = TestBed.createComponent(LiveRegionComponent);
57+
fixture.detectChanges();
58+
59+
const liveRegionVisible = fixture.debugElement.query(By.css('.visually-hidden'));
60+
expect(liveRegionVisible).toBeNull();
61+
});
62+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
AsyncPipe,
3+
NgClass,
4+
NgFor,
5+
} from '@angular/common';
6+
import {
7+
Component,
8+
OnInit,
9+
} from '@angular/core';
10+
import { Observable } from 'rxjs';
11+
12+
import { LiveRegionService } from './live-region.service';
13+
14+
@Component({
15+
selector: `ds-live-region`,
16+
templateUrl: './live-region.component.html',
17+
styleUrls: ['./live-region.component.scss'],
18+
standalone: true,
19+
imports: [NgClass, NgFor, AsyncPipe],
20+
})
21+
export class LiveRegionComponent implements OnInit {
22+
23+
protected isVisible: boolean;
24+
25+
protected messages$: Observable<string[]>;
26+
27+
constructor(
28+
protected liveRegionService: LiveRegionService,
29+
) {
30+
}
31+
32+
ngOnInit() {
33+
this.isVisible = this.liveRegionService.getLiveRegionVisibility();
34+
this.messages$ = this.liveRegionService.getMessages$();
35+
}
36+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Config } from '../../../config/config.interface';
2+
3+
/**
4+
* Configuration interface used by the LiveRegionService
5+
*/
6+
export class LiveRegionConfig implements Config {
7+
messageTimeOutDurationMs: number;
8+
isVisible: boolean;
9+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
fakeAsync,
3+
flush,
4+
tick,
5+
} from '@angular/core/testing';
6+
7+
import { LiveRegionService } from './live-region.service';
8+
9+
describe('liveRegionService', () => {
10+
let service: LiveRegionService;
11+
12+
13+
beforeEach(() => {
14+
service = new LiveRegionService();
15+
});
16+
17+
describe('addMessage', () => {
18+
it('should correctly add messages', () => {
19+
expect(service.getMessages().length).toEqual(0);
20+
21+
service.addMessage('Message One');
22+
expect(service.getMessages().length).toEqual(1);
23+
expect(service.getMessages()[0]).toEqual('Message One');
24+
25+
service.addMessage('Message Two');
26+
expect(service.getMessages().length).toEqual(2);
27+
expect(service.getMessages()[1]).toEqual('Message Two');
28+
});
29+
});
30+
31+
describe('clearMessages', () => {
32+
it('should clear the messages', () => {
33+
expect(service.getMessages().length).toEqual(0);
34+
35+
service.addMessage('Message One');
36+
service.addMessage('Message Two');
37+
expect(service.getMessages().length).toEqual(2);
38+
39+
service.clear();
40+
expect(service.getMessages().length).toEqual(0);
41+
});
42+
});
43+
44+
describe('messages$', () => {
45+
it('should emit when a message is added and when a message is removed after the timeOut', fakeAsync(() => {
46+
const results: string[][] = [];
47+
48+
service.getMessages$().subscribe((messages) => {
49+
results.push(messages);
50+
});
51+
52+
expect(results.length).toEqual(1);
53+
expect(results[0]).toEqual([]);
54+
55+
service.addMessage('message');
56+
57+
tick();
58+
59+
expect(results.length).toEqual(2);
60+
expect(results[1]).toEqual(['message']);
61+
62+
tick(service.getMessageTimeOutMs());
63+
64+
expect(results.length).toEqual(3);
65+
expect(results[2]).toEqual([]);
66+
}));
67+
68+
it('should only emit once when the messages are cleared', fakeAsync(() => {
69+
const results: string[][] = [];
70+
71+
service.getMessages$().subscribe((messages) => {
72+
results.push(messages);
73+
});
74+
75+
expect(results.length).toEqual(1);
76+
expect(results[0]).toEqual([]);
77+
78+
service.addMessage('Message One');
79+
service.addMessage('Message Two');
80+
81+
tick();
82+
83+
expect(results.length).toEqual(3);
84+
expect(results[2]).toEqual(['Message One', 'Message Two']);
85+
86+
service.clear();
87+
flush();
88+
89+
expect(results.length).toEqual(4);
90+
expect(results[3]).toEqual([]);
91+
}));
92+
93+
it('should respect configured timeOut', fakeAsync(() => {
94+
const results: string[][] = [];
95+
96+
service.getMessages$().subscribe((messages) => {
97+
results.push(messages);
98+
});
99+
100+
expect(results.length).toEqual(1);
101+
expect(results[0]).toEqual([]);
102+
103+
const timeOutMs = 500;
104+
service.setMessageTimeOutMs(timeOutMs);
105+
106+
service.addMessage('Message One');
107+
tick(timeOutMs - 1);
108+
109+
expect(results.length).toEqual(2);
110+
expect(results[1]).toEqual(['Message One']);
111+
112+
tick(1);
113+
114+
expect(results.length).toEqual(3);
115+
expect(results[2]).toEqual([]);
116+
117+
const timeOutMsTwo = 50000;
118+
service.setMessageTimeOutMs(timeOutMsTwo);
119+
120+
service.addMessage('Message Two');
121+
tick(timeOutMsTwo - 1);
122+
123+
expect(results.length).toEqual(4);
124+
expect(results[3]).toEqual(['Message Two']);
125+
126+
tick(1);
127+
128+
expect(results.length).toEqual(5);
129+
expect(results[4]).toEqual([]);
130+
}));
131+
});
132+
133+
describe('liveRegionVisibility', () => {
134+
it('should be false by default', () => {
135+
expect(service.getLiveRegionVisibility()).toBeFalse();
136+
});
137+
138+
it('should correctly update', () => {
139+
service.setLiveRegionVisibility(true);
140+
expect(service.getLiveRegionVisibility()).toBeTrue();
141+
service.setLiveRegionVisibility(false);
142+
expect(service.getLiveRegionVisibility()).toBeFalse();
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)