mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge pull request #3371 from atmire/live-region-7_x
[Port dspace-7_x] Live region
This commit is contained in:
@@ -396,3 +396,17 @@ vocabularies:
|
||||
comcolSelectionSort:
|
||||
sortField: 'dc.title'
|
||||
sortDirection: 'ASC'
|
||||
|
||||
# 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
|
||||
|
@@ -31,3 +31,5 @@
|
||||
<div class="ds-full-screen-loader" *ngIf="shouldShowFullscreenLoader">
|
||||
<ds-themed-loading [showMessage]="false"></ds-themed-loading>
|
||||
</div>
|
||||
|
||||
<ds-live-region></ds-live-region>
|
||||
|
3
src/app/shared/live-region/live-region.component.html
Normal file
3
src/app/shared/live-region/live-region.component.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="live-region" [ngClass]="{'visually-hidden': !isVisible }" aria-live="assertive" role="log" aria-relevant="additions" aria-atomic="true">
|
||||
<div class="live-region-message" *ngFor="let message of (messages$ | async)">{{ message }}</div>
|
||||
</div>
|
13
src/app/shared/live-region/live-region.component.scss
Normal file
13
src/app/shared/live-region/live-region.component.scss
Normal file
@@ -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);
|
||||
}
|
57
src/app/shared/live-region/live-region.component.spec.ts
Normal file
57
src/app/shared/live-region/live-region.component.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { LiveRegionComponent } from './live-region.component';
|
||||
import { ComponentFixture, waitForAsync, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { LiveRegionService } from './live-region.service';
|
||||
import { of } from 'rxjs';
|
||||
import { By } from '@angular/platform-browser';
|
||||
|
||||
describe('liveRegionComponent', () => {
|
||||
let fixture: ComponentFixture<LiveRegionComponent>;
|
||||
let liveRegionService: LiveRegionService;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
liveRegionService = jasmine.createSpyObj('liveRegionService', {
|
||||
getMessages$: of(['message1', 'message2']),
|
||||
getLiveRegionVisibility: false,
|
||||
setLiveRegionVisibility: undefined,
|
||||
});
|
||||
|
||||
void TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
declarations: [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();
|
||||
});
|
||||
});
|
31
src/app/shared/live-region/live-region.component.ts
Normal file
31
src/app/shared/live-region/live-region.component.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { LiveRegionService } from './live-region.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
})
|
||||
export class LiveRegionComponent implements OnInit {
|
||||
|
||||
protected isVisible: boolean;
|
||||
|
||||
protected messages$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
protected liveRegionService: LiveRegionService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.isVisible = this.liveRegionService.getLiveRegionVisibility();
|
||||
this.messages$ = this.liveRegionService.getMessages$();
|
||||
}
|
||||
}
|
9
src/app/shared/live-region/live-region.config.ts
Normal file
9
src/app/shared/live-region/live-region.config.ts
Normal file
@@ -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;
|
||||
}
|
170
src/app/shared/live-region/live-region.service.spec.ts
Normal file
170
src/app/shared/live-region/live-region.service.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { LiveRegionService } from './live-region.service';
|
||||
import { fakeAsync, tick, flush } from '@angular/core/testing';
|
||||
import { UUIDService } from '../../core/shared/uuid.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();
|
||||
});
|
||||
});
|
||||
});
|
132
src/app/shared/live-region/live-region.service.ts
Normal file
132
src/app/shared/live-region/live-region.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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<string[]> = 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<string[]> {
|
||||
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;
|
||||
}
|
||||
}
|
@@ -284,6 +284,7 @@ import { BitstreamListItemComponent } from './object-list/bitstream-list-item/bi
|
||||
import { NgxPaginationModule } from 'ngx-pagination';
|
||||
import { ThemedLangSwitchComponent } from './lang-switch/themed-lang-switch.component';
|
||||
import {ThemedUserMenuComponent} from './auth-nav-menu/user-menu/themed-user-menu.component';
|
||||
import { LiveRegionComponent } from './live-region/live-region.component';
|
||||
|
||||
const MODULES = [
|
||||
CommonModule,
|
||||
@@ -467,7 +468,8 @@ const ENTRY_COMPONENTS = [
|
||||
AdvancedClaimedTaskActionRatingComponent,
|
||||
EpersonGroupListComponent,
|
||||
EpersonSearchBoxComponent,
|
||||
GroupSearchBoxComponent
|
||||
GroupSearchBoxComponent,
|
||||
LiveRegionComponent,
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
|
@@ -22,6 +22,7 @@ import { HomeConfig } from './homepage-config.interface';
|
||||
import { MarkdownConfig } from './markdown-config.interface';
|
||||
import { FilterVocabularyConfig } from './filter-vocabulary-config';
|
||||
import { DiscoverySortConfig } from './discovery-sort.config';
|
||||
import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
|
||||
|
||||
interface AppConfig extends Config {
|
||||
ui: UIServerConfig;
|
||||
@@ -48,6 +49,7 @@ interface AppConfig extends Config {
|
||||
markdown: MarkdownConfig;
|
||||
vocabularies: FilterVocabularyConfig[];
|
||||
comcolSelectionSort: DiscoverySortConfig;
|
||||
liveRegion: LiveRegionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -22,6 +22,7 @@ import { HomeConfig } from './homepage-config.interface';
|
||||
import { MarkdownConfig } from './markdown-config.interface';
|
||||
import { FilterVocabularyConfig } from './filter-vocabulary-config';
|
||||
import { DiscoverySortConfig } from './discovery-sort.config';
|
||||
import { LiveRegionConfig } from '../app/shared/live-region/live-region.config';
|
||||
|
||||
export class DefaultAppConfig implements AppConfig {
|
||||
production = false;
|
||||
@@ -435,4 +436,10 @@ export class DefaultAppConfig implements AppConfig {
|
||||
sortField:'dc.title',
|
||||
sortDirection:'ASC',
|
||||
};
|
||||
|
||||
// Live Region configuration, used by the LiveRegionService
|
||||
liveRegion: LiveRegionConfig = {
|
||||
messageTimeOutDurationMs: 30000,
|
||||
isVisible: false,
|
||||
};
|
||||
}
|
||||
|
@@ -314,5 +314,10 @@ export const environment: BuildConfig = {
|
||||
vocabulary: 'srsc',
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
liveRegion: {
|
||||
messageTimeOutDurationMs: 30000,
|
||||
isVisible: false,
|
||||
},
|
||||
};
|
||||
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user