[ACA-3899] Viewer thumbnails cannot be accessed by keyboard (#6150)

* accessibility

* add thumb focus

* close thumbs list event

* fix eventbus listener

* rename parameter

* track previous focused element

* implement FocusKeyManager

* fix getGlobalEventBus is deprecated

* keyboard navigation

* set tabindex

* setActiveItem on list change

* update  test
This commit is contained in:
Cilibiu Bogdan
2020-09-23 07:57:46 +03:00
committed by GitHub
parent 472e112b71
commit 3da5196b2d
10 changed files with 181 additions and 35 deletions

View File

@@ -353,7 +353,8 @@
"ROTATE_LEFT": "Rotate left", "ROTATE_LEFT": "Rotate left",
"ROTATE_RIGHT": "Rotate right", "ROTATE_RIGHT": "Rotate right",
"RESET": "Reset", "RESET": "Reset",
"THUMBNAILS": "Document thumbnails" "THUMBNAILS": "Document thumbnails",
"THUMBNAILS_PANLEL_CLOSE": "Close thumbnails panel"
}, },
"PAGE_LABEL": { "PAGE_LABEL": {
"SHOWING": "Showing", "SHOWING": "Showing",

View File

@@ -1,4 +1,5 @@
<ng-container *ngIf="image$ | async as image"> <ng-container *ngIf="image$ | async as image">
<img [src]="image" [alt]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }" <img [src]="image" [alt]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }"
title="{{ 'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id } }}"> title="{{ 'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id } }}"
[attr.aria-label]="'ADF_VIEWER.SIDEBAR.THUMBNAILS.PAGE' | translate: { pageNum: page.id }">
</ng-container> </ng-container>

View File

@@ -66,4 +66,12 @@ describe('PdfThumbComponent', () => {
done(); done();
}); });
}); });
it('should focus element', () => {
component.page = page;
fixture.detectChanges();
component.focus();
expect(fixture.debugElement.nativeElement.id).toBe(document.activeElement.id);
});
}); });

View File

@@ -15,28 +15,34 @@
* limitations under the License. * limitations under the License.
*/ */
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; import { Component, ElementRef, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { FocusableOption } from '@angular/cdk/a11y';
@Component({ @Component({
selector: 'adf-pdf-thumb', selector: 'adf-pdf-thumb',
templateUrl: './pdf-viewer-thumb.component.html', templateUrl: './pdf-viewer-thumb.component.html',
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None,
host: { tabindex: '0'}
}) })
export class PdfThumbComponent implements OnInit { export class PdfThumbComponent implements OnInit, FocusableOption {
@Input() @Input()
page: any = null; page: any = null;
image$: Promise<string>; image$: Promise<string>;
constructor(private sanitizer: DomSanitizer) { constructor(private sanitizer: DomSanitizer, private element: ElementRef) {
} }
ngOnInit() { ngOnInit() {
this.image$ = this.page.getPage().then((page) => this.getThumb(page)); this.image$ = this.page.getPage().then((page) => this.getThumb(page));
} }
focus() {
this.element.nativeElement.focus();
}
private getThumb(page): Promise<string> { private getThumb(page): Promise<string> {
const viewport = page.getViewport({ scale: 1 }); const viewport = page.getViewport({ scale: 1 });

View File

@@ -4,6 +4,7 @@
[style.transform]="'translate(-50%, ' + translateY + 'px)'"> [style.transform]="'translate(-50%, ' + translateY + 'px)'">
<adf-pdf-thumb *ngFor="let page of renderItems; trackBy: trackByFn" <adf-pdf-thumb *ngFor="let page of renderItems; trackBy: trackByFn"
class="adf-pdf-thumbnails__thumb" class="adf-pdf-thumbnails__thumb"
[id]="page.id"
[ngClass]="{'adf-pdf-thumbnails__thumb--selected' : isSelected(page.id)}" [ngClass]="{'adf-pdf-thumbnails__thumb--selected' : isSelected(page.id)}"
[page]="page" [page]="page"
(click)="goTo(page.id)"> (click)="goTo(page.id)">

View File

@@ -28,9 +28,5 @@
&__thumb:hover { &__thumb:hover {
box-shadow: 0 0 5px 0 $black-87-opacity; box-shadow: 0 0 5px 0 $black-87-opacity;
} }
&__thumb--selected:not(:hover) {
box-shadow: 0 0 5px 0 $black-87-opacity;
}
} }
} }

View File

@@ -21,6 +21,7 @@ import { PdfThumbListComponent } from './pdf-viewer-thumbnails.component';
import { setupTestBed } from '../../testing/setup-test-bed'; import { setupTestBed } from '../../testing/setup-test-bed';
import { CoreTestingModule } from '../../testing/core.testing.module'; import { CoreTestingModule } from '../../testing/core.testing.module';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { DOWN_ARROW, UP_ARROW, ESCAPE } from '@angular/cdk/keycodes';
declare const pdfjsViewer: any; declare const pdfjsViewer: any;
@@ -41,7 +42,7 @@ describe('PdfThumbListComponent', () => {
set currentPageNumber(pageNum) { set currentPageNumber(pageNum) {
this._currentPageNumber = pageNum; this._currentPageNumber = pageNum;
/* cspell:disable-next-line */ /* cspell:disable-next-line */
this.eventBus.dispatch('pagechange', { pageNumber: pageNum }); this.eventBus.dispatch('pagechanging', { pageNumber: pageNum });
}, },
get currentPageNumber() { get currentPageNumber() {
return this._currentPageNumber; return this._currentPageNumber;
@@ -125,7 +126,7 @@ describe('PdfThumbListComponent', () => {
expect(renderedIds).toContain(12); expect(renderedIds).toContain(12);
/* cspell:disable-next-line */ /* cspell:disable-next-line */
viewerMock.eventBus.dispatch('pagechange', { pageNumber: 12 }); viewerMock.eventBus.dispatch('pagechanging', { pageNumber: 12 });
const newRenderedIds = component.renderItems.map((item) => item.id); const newRenderedIds = component.renderItems.map((item) => item.id);
@@ -139,10 +140,17 @@ describe('PdfThumbListComponent', () => {
expect(component.renderItems[component.renderItems.length - 1].id).toBe(6); expect(component.renderItems[component.renderItems.length - 1].id).toBe(6);
expect(fixture.debugElement.nativeElement.scrollTop).toBe(0); expect(fixture.debugElement.nativeElement.scrollTop).toBe(0);
viewerMock.currentPageNumber = 6; component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 6 });
expect(component.scrollInto).not.toHaveBeenCalled(); expect(component.scrollInto).not.toHaveBeenCalled();
expect(fixture.debugElement.nativeElement.scrollTop).toBe(129); expect(fixture.debugElement.nativeElement.scrollTop).toBe(0);
});
it('should set active current page on onPageChange event', () => {
fixture.detectChanges();
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 6 });
expect(document.activeElement.id).toBe('6');
}); });
it('should return current viewed page as selected', () => { it('should return current viewed page as selected', () => {
@@ -161,4 +169,58 @@ describe('PdfThumbListComponent', () => {
expect(viewerMock.currentPageNumber).toBe(12); expect(viewerMock.currentPageNumber).toBe(12);
}); });
describe('Keyboard events', () => {
it('should select next page in the list on DOWN_ARROW event', () => {
const event = new KeyboardEvent('keydown', {'keyCode': DOWN_ARROW} as KeyboardEventInit);
fixture.detectChanges();
component.goTo(1);
expect(document.activeElement.id).toBe('1');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('2');
});
it('should select previous page in the list on UP_ARROW event', () => {
const event = new KeyboardEvent('keydown', {'keyCode': UP_ARROW} as KeyboardEventInit);
fixture.detectChanges();
component.goTo(2);
expect(document.activeElement.id).toBe('2');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('1');
});
it('should not select previous page if it is the first page', () => {
const event = new KeyboardEvent('keydown', {'keyCode': UP_ARROW} as KeyboardEventInit);
fixture.detectChanges();
component.goTo(1);
expect(document.activeElement.id).toBe('1');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('1');
});
it('should not select next item if it is the last page', () => {
const event = new KeyboardEvent('keydown', {'keyCode': DOWN_ARROW} as KeyboardEventInit);
fixture.detectChanges();
component.scrollInto(16);
fixture.detectChanges();
component.pdfViewer.eventBus.dispatch('pagechanging', { pageNumber: 16 });
expect(document.activeElement.id).toBe('16');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.id).toBe('16');
});
it('should emit on ESCAPE event', () => {
const event = new KeyboardEvent('keydown', {'keyCode': ESCAPE} as KeyboardEventInit);
spyOn(component.close, 'emit');
fixture.detectChanges();
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(component.close.emit).toHaveBeenCalled();
});
});
}); });

View File

@@ -17,19 +17,27 @@
import { import {
Component, Input, ContentChild, TemplateRef, HostListener, OnInit, Component, Input, ContentChild, TemplateRef, HostListener, OnInit,
AfterViewInit, ElementRef, OnDestroy, ViewEncapsulation AfterViewInit, ElementRef, OnDestroy, ViewEncapsulation, EventEmitter, Output, Inject, ViewChildren, QueryList
} from '@angular/core'; } from '@angular/core';
import { ESCAPE, UP_ARROW, DOWN_ARROW } from '@angular/cdk/keycodes';
import { DOCUMENT } from '@angular/common';
import { FocusKeyManager } from '@angular/cdk/a11y';
import { PdfThumbComponent } from './pdf-viewer-thumb.component';
import { delay } from 'rxjs/operators';
@Component({ @Component({
selector: 'adf-pdf-thumbnails', selector: 'adf-pdf-thumbnails',
templateUrl: './pdf-viewer-thumbnails.component.html', templateUrl: './pdf-viewer-thumbnails.component.html',
styleUrls: ['./pdf-viewer-thumbnails.component.scss'], styleUrls: ['./pdf-viewer-thumbnails.component.scss'],
host: { 'class': 'adf-pdf-thumbnails' }, host: { class: 'adf-pdf-thumbnails' },
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class PdfThumbListComponent implements OnInit, AfterViewInit, OnDestroy { export class PdfThumbListComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() pdfViewer: any; @Input() pdfViewer: any;
@Output()
close: EventEmitter<any> = new EventEmitter<void>();
virtualHeight: number = 0; virtualHeight: number = 0;
translateY: number = 0; translateY: number = 0;
renderItems = []; renderItems = [];
@@ -39,56 +47,96 @@ export class PdfThumbListComponent implements OnInit, AfterViewInit, OnDestroy {
private items = []; private items = [];
private margin: number = 15; private margin: number = 15;
private itemHeight: number = 114 + this.margin; private itemHeight: number = 114 + this.margin;
private previouslyFocusedElement: HTMLElement | null = null;
private keyManager: FocusKeyManager<PdfThumbComponent>;
@ContentChild(TemplateRef) @ContentChild(TemplateRef)
template: any; template: any;
@ViewChildren(PdfThumbComponent)
thumbsList: QueryList<PdfThumbComponent>;
@HostListener('keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
const keyCode = event.keyCode;
if (keyCode === UP_ARROW && this.canSelectPreviousItem()) {
this.pdfViewer.currentPageNumber -= 1;
}
if (keyCode === DOWN_ARROW && this.canSelectNextItem()) {
this.pdfViewer.currentPageNumber += 1;
}
if (keyCode === ESCAPE) {
this.close.emit();
}
this.keyManager.setFocusOrigin('keyboard');
event.preventDefault();
}
@HostListener('window:resize') @HostListener('window:resize')
onResize() { onResize() {
this.calculateItems(); this.calculateItems();
} }
constructor(private element: ElementRef) { constructor(private element: ElementRef, @Inject(DOCUMENT) private document: any) {
this.calculateItems = this.calculateItems.bind(this); this.calculateItems = this.calculateItems.bind(this);
this.onPageChange = this.onPageChange.bind(this); this.onPageChange = this.onPageChange.bind(this);
} }
ngOnInit() { ngOnInit() {
/* cspell:disable-next-line */ /* cspell:disable-next-line */
this.pdfViewer.eventBus.on('pagechange', this.onPageChange); this.pdfViewer.eventBus.on('pagechanging', this.onPageChange);
this.element.nativeElement.addEventListener('scroll', this.calculateItems, true); this.element.nativeElement.addEventListener('scroll', this.calculateItems, true);
this.setHeight(this.pdfViewer.currentPageNumber); this.setHeight(this.pdfViewer.currentPageNumber);
this.items = this.getPages(); this.items = this.getPages();
this.calculateItems(); this.calculateItems();
this.previouslyFocusedElement = this.document.activeElement as HTMLElement;
} }
ngAfterViewInit() { ngAfterViewInit() {
setTimeout(() => this.scrollInto(this.pdfViewer.currentPageNumber), 0); this.keyManager = new FocusKeyManager(this.thumbsList);
this.thumbsList.changes
.pipe(delay(0))
.subscribe(() => this.keyManager.setActiveItem(this.getPageIndex(this.pdfViewer.currentPageNumber)));
setTimeout(() => {
this.scrollInto(this.pdfViewer.currentPageNumber);
this.keyManager.setActiveItem(this.getPageIndex(this.pdfViewer.currentPageNumber));
}, 0);
} }
ngOnDestroy() { ngOnDestroy() {
this.element.nativeElement.removeEventListener('scroll', this.calculateItems, true); this.element.nativeElement.removeEventListener('scroll', this.calculateItems, true);
/* cspell:disable-next-line */ /* cspell:disable-next-line */
this.pdfViewer.eventBus.off('pagechange', this.onPageChange); this.pdfViewer.eventBus.on('pagechanging', this.onPageChange);
if (this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus();
this.previouslyFocusedElement = null;
}
} }
trackByFn(_: number, item: any): number { trackByFn(_: number, item: any): number {
return item.id; return item.id;
} }
isSelected(pageNum: number) { isSelected(pageNumber: number) {
return this.pdfViewer.currentPageNumber === pageNum; return this.pdfViewer.currentPageNumber === pageNumber;
} }
goTo(pageNum: number) { goTo(pageNumber: number) {
this.pdfViewer.currentPageNumber = pageNum; this.pdfViewer.currentPageNumber = pageNumber;
} }
scrollInto(item: any) { scrollInto(pageNumber: number) {
if (this.items.length) { if (this.items.length) {
const index: number = this.items.findIndex((element) => element.id === item); const index: number = this.items.findIndex((element) => element.id === pageNumber);
if (index < 0 || index >= this.items.length) { if (index < 0 || index >= this.items.length) {
return; return;
@@ -164,5 +212,20 @@ export class PdfThumbListComponent implements OnInit, AfterViewInit, OnDestroy {
if (index >= this.renderItems.length - 1) { if (index >= this.renderItems.length - 1) {
this.element.nativeElement.scrollTop += this.itemHeight; this.element.nativeElement.scrollTop += this.itemHeight;
} }
this.keyManager.setActiveItem(this.getPageIndex(event.pageNumber));
}
private getPageIndex(pageNumber: number): number {
const thumbsListArray = this.thumbsList.toArray();
return thumbsListArray.findIndex(el => el.page.id === pageNumber);
}
private canSelectNextItem(): boolean {
return this.pdfViewer.currentPageNumber !== this.pdfViewer.pagesCount;
}
private canSelectPreviousItem(): boolean {
return this.pdfViewer.currentPageNumber !== 1;
} }
} }

View File

@@ -3,7 +3,12 @@
<div class="adf-pdf-viewer__thumbnails"> <div class="adf-pdf-viewer__thumbnails">
<div class="adf-thumbnails-template__container"> <div class="adf-thumbnails-template__container">
<div class="adf-thumbnails-template__buttons"> <div class="adf-thumbnails-template__buttons">
<button mat-icon-button data-automation-id='adf-thumbnails-close' (click)="toggleThumbnails()"> <button
mat-icon-button
data-automation-id='adf-thumbnails-close'
(click)="toggleThumbnails()"
[attr.aria-label]="'ADF_VIEWER.ARIA.THUMBNAILS_PANLEL_CLOSE' | translate"
title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}">
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
</div> </div>
@@ -13,7 +18,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="!thumbnailsTemplate"> <ng-container *ngIf="!thumbnailsTemplate">
<adf-pdf-thumbnails [pdfViewer]="pdfViewer"></adf-pdf-thumbnails> <adf-pdf-thumbnails (close)="toggleThumbnails()" [pdfViewer]="pdfViewer"></adf-pdf-thumbnails>
</ng-container> </ng-container>
</div> </div>
</div> </div>

View File

@@ -100,6 +100,8 @@ export class PdfViewerComponent implements OnChanges, OnDestroy {
return Math.round(this.currentScale * 100) + '%'; return Math.round(this.currentScale * 100) + '%';
} }
private eventBus = new pdfjsViewer.EventBus();
constructor( constructor(
private dialog: MatDialog, private dialog: MatDialog,
private renderingQueueServices: RenderingQueueServices, private renderingQueueServices: RenderingQueueServices,
@@ -200,15 +202,16 @@ export class PdfViewerComponent implements OnChanges, OnDestroy {
this.pdfViewer = new pdfjsViewer.PDFViewer({ this.pdfViewer = new pdfjsViewer.PDFViewer({
container: container, container: container,
viewer: viewer, viewer: viewer,
renderingQueue: this.renderingQueueServices renderingQueue: this.renderingQueueServices,
eventBus: this.eventBus
}); });
// cspell: disable-next // cspell: disable-next
this.pdfViewer.eventBus.on('pagechanging', this.onPageChange); this.eventBus.on('pagechanging', this.onPageChange);
// cspell: disable-next // cspell: disable-next
this.pdfViewer.eventBus.on('pagesloaded', this.onPagesLoaded); this.eventBus.on('pagesloaded', this.onPagesLoaded);
// cspell: disable-next // cspell: disable-next
this.pdfViewer.eventBus.on('textlayerrendered', this.onPageRendered); this.eventBus.on('textlayerrendered', this.onPageRendered);
this.renderingQueueServices.setViewer(this.pdfViewer); this.renderingQueueServices.setViewer(this.pdfViewer);
this.pdfViewer.setDocument(pdfDocument); this.pdfViewer.setDocument(pdfDocument);
@@ -219,11 +222,11 @@ export class PdfViewerComponent implements OnChanges, OnDestroy {
ngOnDestroy() { ngOnDestroy() {
if (this.pdfViewer) { if (this.pdfViewer) {
// cspell: disable-next // cspell: disable-next
this.pdfViewer.eventBus.off('pagechanging'); this.eventBus.off('pagechanging');
// cspell: disable-next // cspell: disable-next
this.pdfViewer.eventBus.off('pagesloaded'); this.eventBus.off('pagesloaded');
// cspell: disable-next // cspell: disable-next
this.pdfViewer.eventBus.off('textlayerrendered'); this.eventBus.off('textlayerrendered');
} }
if (this.loadingTask) { if (this.loadingTask) {