Merge branch 'live-region-7.6' into live-region-7_x

# Conflicts:
#	config/config.example.yml
#	src/app/shared/shared.module.ts
This commit is contained in:
Andreas Awouters
2024-09-30 09:09:44 +02:00
14 changed files with 450 additions and 2 deletions

View File

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

View 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>

View 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);
}

View 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();
});
});

View 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$();
}
}

View 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;
}

View 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();
});
});
});

View 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;
}
}

View File

@@ -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 = [

View File

@@ -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;
}
/**

View File

@@ -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,
};
}

View File

@@ -314,5 +314,10 @@ export const environment: BuildConfig = {
vocabulary: 'srsc',
enabled: true
}
]
],
liveRegion: {
messageTimeOutDurationMs: 30000,
isVisible: false,
},
};

View File

@@ -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;