Skip to content

Commit 052b6a9

Browse files
authored
Merge pull request DSpace#3337 from atmire/live-region-main
Live Region
2 parents cbd681d + 0f5a50a commit 052b6a9

15 files changed

Lines changed: 472 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
/**
15+
* The Live Region Component is an accessibility tool for screenreaders. When a change occurs on a page when the changed
16+
* section is not in focus, a message should be displayed by this component so it can be announced by a screen reader.
17+
*
18+
* This component should not be used directly. Use the {@link LiveRegionService} to add messages.
19+
*/
20+
@Component({
21+
selector: `ds-live-region`,
22+
templateUrl: './live-region.component.html',
23+
styleUrls: ['./live-region.component.scss'],
24+
standalone: true,
25+
imports: [NgClass, NgFor, AsyncPipe],
26+
})
27+
export class LiveRegionComponent implements OnInit {
28+
29+
protected isVisible: boolean;
30+
31+
protected messages$: Observable<string[]>;
32+
33+
constructor(
34+
protected liveRegionService: LiveRegionService,
35+
) {
36+
}
37+
38+
ngOnInit() {
39+
this.isVisible = this.liveRegionService.getLiveRegionVisibility();
40+
this.messages$ = this.liveRegionService.getMessages$();
41+
}
42+
}
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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import {
2+
fakeAsync,
3+
flush,
4+
tick,
5+
} from '@angular/core/testing';
6+
7+
import { UUIDService } from '../../core/shared/uuid.service';
8+
import { LiveRegionService } from './live-region.service';
9+
10+
describe('liveRegionService', () => {
11+
let service: LiveRegionService;
12+
13+
beforeEach(() => {
14+
service = new LiveRegionService(
15+
new UUIDService(),
16+
);
17+
});
18+
19+
describe('addMessage', () => {
20+
it('should correctly add messages', () => {
21+
expect(service.getMessages().length).toEqual(0);
22+
23+
service.addMessage('Message One');
24+
expect(service.getMessages().length).toEqual(1);
25+
expect(service.getMessages()[0]).toEqual('Message One');
26+
27+
service.addMessage('Message Two');
28+
expect(service.getMessages().length).toEqual(2);
29+
expect(service.getMessages()[1]).toEqual('Message Two');
30+
});
31+
});
32+
33+
describe('clearMessages', () => {
34+
it('should clear the messages', () => {
35+
expect(service.getMessages().length).toEqual(0);
36+
37+
service.addMessage('Message One');
38+
service.addMessage('Message Two');
39+
expect(service.getMessages().length).toEqual(2);
40+
41+
service.clear();
42+
expect(service.getMessages().length).toEqual(0);
43+
});
44+
});
45+
46+
describe('messages$', () => {
47+
it('should emit when a message is added and when a message is removed after the timeOut', fakeAsync(() => {
48+
const results: string[][] = [];
49+
50+
service.getMessages$().subscribe((messages) => {
51+
results.push(messages);
52+
});
53+
54+
expect(results.length).toEqual(1);
55+
expect(results[0]).toEqual([]);
56+
57+
service.addMessage('message');
58+
59+
tick();
60+
61+
expect(results.length).toEqual(2);
62+
expect(results[1]).toEqual(['message']);
63+
64+
tick(service.getMessageTimeOutMs());
65+
66+
expect(results.length).toEqual(3);
67+
expect(results[2]).toEqual([]);
68+
}));
69+
70+
it('should only emit once when the messages are cleared', fakeAsync(() => {
71+
const results: string[][] = [];
72+
73+
service.getMessages$().subscribe((messages) => {
74+
results.push(messages);
75+
});
76+
77+
expect(results.length).toEqual(1);
78+
expect(results[0]).toEqual([]);
79+
80+
service.addMessage('Message One');
81+
service.addMessage('Message Two');
82+
83+
tick();
84+
85+
expect(results.length).toEqual(3);
86+
expect(results[2]).toEqual(['Message One', 'Message Two']);
87+
88+
service.clear();
89+
flush();
90+
91+
expect(results.length).toEqual(4);
92+
expect(results[3]).toEqual([]);
93+
}));
94+
95+
it('should not pop messages added after clearing within timeOut period', fakeAsync(() => {
96+
const results: string[][] = [];
97+
98+
service.getMessages$().subscribe((messages) => {
99+
results.push(messages);
100+
});
101+
102+
expect(results.length).toEqual(1);
103+
expect(results[0]).toEqual([]);
104+
105+
service.addMessage('Message One');
106+
tick(10000);
107+
service.clear();
108+
tick(15000);
109+
service.addMessage('Message Two');
110+
111+
// Message Two should not be cleared after 5 more seconds
112+
tick(5000);
113+
114+
expect(results.length).toEqual(4);
115+
expect(results[3]).toEqual(['Message Two']);
116+
117+
// But should be cleared 30 seconds after it was added
118+
tick(25000);
119+
expect(results.length).toEqual(5);
120+
expect(results[4]).toEqual([]);
121+
}));
122+
123+
it('should respect configured timeOut', fakeAsync(() => {
124+
const results: string[][] = [];
125+
126+
service.getMessages$().subscribe((messages) => {
127+
results.push(messages);
128+
});
129+
130+
expect(results.length).toEqual(1);
131+
expect(results[0]).toEqual([]);
132+
133+
const timeOutMs = 500;
134+
service.setMessageTimeOutMs(timeOutMs);
135+
136+
service.addMessage('Message One');
137+
tick(timeOutMs - 1);
138+
139+
expect(results.length).toEqual(2);
140+
expect(results[1]).toEqual(['Message One']);
141+
142+
tick(1);
143+
144+
expect(results.length).toEqual(3);
145+
expect(results[2]).toEqual([]);
146+
147+
const timeOutMsTwo = 50000;
148+
service.setMessageTimeOutMs(timeOutMsTwo);
149+
150+
service.addMessage('Message Two');
151+
tick(timeOutMsTwo - 1);
152+
153+
expect(results.length).toEqual(4);
154+
expect(results[3]).toEqual(['Message Two']);
155+
156+
tick(1);
157+
158+
expect(results.length).toEqual(5);
159+
expect(results[4]).toEqual([]);
160+
}));
161+
});
162+
163+
describe('liveRegionVisibility', () => {
164+
it('should be false by default', () => {
165+
expect(service.getLiveRegionVisibility()).toBeFalse();
166+
});
167+
168+
it('should correctly update', () => {
169+
service.setLiveRegionVisibility(true);
170+
expect(service.getLiveRegionVisibility()).toBeTrue();
171+
service.setLiveRegionVisibility(false);
172+
expect(service.getLiveRegionVisibility()).toBeFalse();
173+
});
174+
});
175+
});

0 commit comments

Comments
 (0)