Skip to content

Commit 8c09f6f

Browse files
committed
#3074 improved keyboard accessibility for editor params toolbars - Escape key now closes popups and Tab key navigates into open popups; #3083 fixed a bug that prevented the secondary toolbar from closing when clicking in the background
1 parent adc50c9 commit 8c09f6f

18 files changed

+371
-5
lines changed

projects/ngx-extended-pdf-viewer/changelog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,4 +694,4 @@
694694
- 25.6.0-rc.1 improved publishing automation; updated stable branch to pdf.js version 5.4.296
695695
- 25.6.0-rc.2 #3061 prevent annotation editor popup toolbars from flashing when programmatically adding annotations via `addHighlightToAnnotationLayer()`, `addImageToAnnotationLayer()`, or `addEditorAnnotation()`
696696
- 25.6.0-rc.3 #3065 fixed `getPageAsLines()` omitting the final text chunk when it has an end-of-line flag
697-
- 25.6.0-rc.4 #3074 improved keyboard accessibility for editor params toolbars - Escape key now closes popups and Tab key navigates into open popups
697+
- 25.6.0-rc.4 #3074 improved keyboard accessibility for editor params toolbars - Escape key now closes popups and Tab key navigates into open popups; #3083 fixed a bug that prevented the secondary toolbar from closing when clicking in the background
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable({
4+
providedIn: 'root',
5+
})
6+
export class FocusManagementService {
7+
private previousActiveElement: HTMLElement | null = null;
8+
private ariaLiveRegion: HTMLDivElement | null = null;
9+
private activeDialogId: string | null = null;
10+
private keydownHandler: ((event: KeyboardEvent) => void) | null = null;
11+
12+
constructor() {
13+
this.initializeAriaLiveRegion();
14+
}
15+
16+
/**
17+
* Initializes a hidden aria-live region for screen reader announcements
18+
*/
19+
private initializeAriaLiveRegion(): void {
20+
if (typeof document === 'undefined') {
21+
return; // SSR guard
22+
}
23+
24+
this.ariaLiveRegion = document.createElement('div');
25+
this.ariaLiveRegion.setAttribute('aria-live', 'polite');
26+
this.ariaLiveRegion.setAttribute('aria-atomic', 'true');
27+
this.ariaLiveRegion.setAttribute('class', 'sr-only');
28+
this.ariaLiveRegion.style.position = 'absolute';
29+
this.ariaLiveRegion.style.left = '-10000px';
30+
this.ariaLiveRegion.style.width = '1px';
31+
this.ariaLiveRegion.style.height = '1px';
32+
this.ariaLiveRegion.style.overflow = 'hidden';
33+
34+
if (document.body) {
35+
document.body.appendChild(this.ariaLiveRegion);
36+
} else {
37+
// If body is not ready yet, wait for DOMContentLoaded
38+
document.addEventListener('DOMContentLoaded', () => {
39+
if (this.ariaLiveRegion) {
40+
document.body.appendChild(this.ariaLiveRegion);
41+
}
42+
});
43+
}
44+
}
45+
46+
/**
47+
* Announces a message to screen readers via aria-live region
48+
* @param message The message to announce
49+
*/
50+
public announce(message: string): void {
51+
if (!this.ariaLiveRegion) {
52+
return;
53+
}
54+
55+
// Clear previous message
56+
this.ariaLiveRegion.textContent = '';
57+
58+
// Announce new message after a brief delay to ensure screen readers pick it up
59+
setTimeout(() => {
60+
if (this.ariaLiveRegion) {
61+
this.ariaLiveRegion.textContent = message;
62+
}
63+
}, 100);
64+
}
65+
66+
/**
67+
* Moves focus to the first focusable element within a dialog
68+
* @param dialogId The ID of the dialog element
69+
* @param announceMessage Optional message to announce when dialog opens
70+
* @param buttonId Optional ID of the button that triggered the dialog (for reliable focus return)
71+
*/
72+
public moveFocusToDialog(dialogId: string, announceMessage?: string, buttonId?: string): void {
73+
if (typeof document === 'undefined') {
74+
return; // SSR guard
75+
}
76+
77+
// Store the button element for reliable focus return
78+
// Use buttonId if provided, otherwise fall back to activeElement
79+
if (buttonId) {
80+
const button = document.getElementById(buttonId);
81+
if (button) {
82+
this.previousActiveElement = button;
83+
}
84+
} else {
85+
const activeElement = document.activeElement as HTMLElement;
86+
if (activeElement && activeElement !== document.body) {
87+
this.previousActiveElement = activeElement;
88+
}
89+
}
90+
91+
// Find dialog and first focusable element
92+
const dialog = document.getElementById(dialogId);
93+
if (!dialog) {
94+
console.warn(`Dialog with ID "${dialogId}" not found`);
95+
return;
96+
}
97+
98+
// Check if dialog is visible
99+
if (dialog.classList.contains('hidden') || dialog.style.display === 'none') {
100+
console.warn(`Dialog "${dialogId}" is not visible`);
101+
return;
102+
}
103+
104+
// Track active dialog and set up focus cycling
105+
this.activeDialogId = dialogId;
106+
this.setupFocusCycling(dialog);
107+
108+
const firstFocusable = this.findFirstFocusableElement(dialog);
109+
110+
if (firstFocusable) {
111+
// Small delay to ensure dialog is fully rendered
112+
setTimeout(() => {
113+
firstFocusable.focus();
114+
}, 50);
115+
}
116+
117+
// Announce dialog opening to screen readers
118+
if (announceMessage) {
119+
this.announce(announceMessage);
120+
}
121+
}
122+
123+
/**
124+
* Sets up focus cycling so that tabbing past the last element returns to the toolbar
125+
* @param dialog The dialog element
126+
*/
127+
private setupFocusCycling(dialog: HTMLElement): void {
128+
// Clean up any existing handler
129+
this.cleanupFocusCycling();
130+
131+
this.keydownHandler = (event: KeyboardEvent) => {
132+
if (event.key !== 'Tab') {
133+
return;
134+
}
135+
136+
const focusableElements = this.getAllFocusableElements(dialog);
137+
if (focusableElements.length === 0) {
138+
return;
139+
}
140+
141+
const firstElement = focusableElements[0];
142+
const lastElement = focusableElements[focusableElements.length - 1];
143+
const activeElement = document.activeElement;
144+
145+
// Tab on last element -> go to toolbar (previous element that opened the dialog)
146+
if (!event.shiftKey && activeElement === lastElement) {
147+
event.preventDefault();
148+
if (this.previousActiveElement) {
149+
this.previousActiveElement.focus();
150+
}
151+
}
152+
// Shift+Tab on first element -> go to last element in dialog
153+
else if (event.shiftKey && activeElement === firstElement) {
154+
event.preventDefault();
155+
lastElement.focus();
156+
}
157+
};
158+
159+
document.addEventListener('keydown', this.keydownHandler);
160+
}
161+
162+
/**
163+
* Cleans up focus cycling event listeners
164+
*/
165+
private cleanupFocusCycling(): void {
166+
if (this.keydownHandler) {
167+
document.removeEventListener('keydown', this.keydownHandler);
168+
this.keydownHandler = null;
169+
}
170+
this.activeDialogId = null;
171+
}
172+
173+
/**
174+
* Gets all focusable elements within a container
175+
* @param container The container element
176+
* @returns Array of focusable elements
177+
*/
178+
private getAllFocusableElements(container: HTMLElement): HTMLElement[] {
179+
const focusableSelectors = [
180+
'a[href]',
181+
'area[href]',
182+
'input:not([disabled]):not([type="hidden"])',
183+
'select:not([disabled])',
184+
'textarea:not([disabled])',
185+
'button:not([disabled])',
186+
'iframe',
187+
'object',
188+
'embed',
189+
'[contenteditable]',
190+
'[tabindex]:not([tabindex="-1"])',
191+
].join(',');
192+
193+
const elements = container.querySelectorAll<HTMLElement>(focusableSelectors);
194+
return Array.from(elements).filter((el) => this.isVisible(el));
195+
}
196+
197+
/**
198+
* Returns focus to the previously focused element (typically the button that opened the dialog)
199+
* @param announceMessage Optional message to announce when dialog closes
200+
*/
201+
public returnFocusToPrevious(announceMessage?: string): void {
202+
// Clean up focus cycling
203+
this.cleanupFocusCycling();
204+
205+
if (this.previousActiveElement) {
206+
this.previousActiveElement.focus();
207+
this.previousActiveElement = null;
208+
}
209+
210+
// Announce dialog closing to screen readers
211+
if (announceMessage) {
212+
this.announce(announceMessage);
213+
}
214+
}
215+
216+
/**
217+
* Finds the first focusable element within a container
218+
* @param container The container element to search within
219+
* @returns The first focusable element or null
220+
*/
221+
private findFirstFocusableElement(container: HTMLElement | null): HTMLElement | null {
222+
if (!container) {
223+
return null;
224+
}
225+
226+
const focusableSelectors = [
227+
'a[href]',
228+
'area[href]',
229+
'input:not([disabled]):not([type="hidden"])',
230+
'select:not([disabled])',
231+
'textarea:not([disabled])',
232+
'button:not([disabled])',
233+
'iframe',
234+
'object',
235+
'embed',
236+
'[contenteditable]',
237+
'[tabindex]:not([tabindex="-1"])',
238+
].join(',');
239+
240+
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelectors);
241+
242+
// Return first visible and focusable element
243+
for (const element of Array.from(focusableElements)) {
244+
if (this.isVisible(element)) {
245+
return element;
246+
}
247+
}
248+
249+
return null;
250+
}
251+
252+
/**
253+
* Checks if an element is visible
254+
* @param element The element to check
255+
* @returns True if the element is visible
256+
*/
257+
private isVisible(element: HTMLElement): boolean {
258+
const style = window.getComputedStyle(element);
259+
return style.display !== 'none' && style.visibility !== 'hidden' && element.offsetParent !== null;
260+
}
261+
}

projects/ngx-extended-pdf-viewer/src/lib/options/pdf-default-options.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ const _isIE11 = typeof window === 'undefined' ? false : !!(<any>globalThis).MSIn
44
const isEdge = typeof navigator === 'undefined' || /Edge\/\d./i.test(navigator.userAgent);
55
const needsES5 = typeof ReadableStream === 'undefined' || typeof Promise['allSettled'] === 'undefined';
66

7-
export const pdfjsVersion = '5.4.1095';
8-
export const pdfjsBleedingEdgeVersion = '5.4.1095';
7+
export const pdfjsVersion = '5.4.1096';
8+
export const pdfjsBleedingEdgeVersion = '5.4.1097';
99
export function getVersionSuffix(folder: string): string {
1010
if (folder?.includes('bleeding-edge')) {
1111
return pdfjsBleedingEdgeVersion;

projects/ngx-extended-pdf-viewer/src/lib/toolbar/pdf-comment-editor/pdf-comment-editor.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
[cssClass]="show | responsiveCSSClass : 'hiddenTinyView'"
55
l10nId="pdfjs-editor-comment-button"
66
l10nLabel="pdfjs-editor-comment-button-label"
7+
role="radio"
8+
ariaHasPopup="true"
9+
ariaControls="editorCommentParamsToolbar"
710
[order]="3900"
811
[action]="onClick"
912
[toggled]="isSelected"

projects/ngx-extended-pdf-viewer/src/lib/toolbar/pdf-comment-editor/pdf-comment-editor.component.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ChangeDetectorRef, Component, Input, effect } from '@angular/core';
22
import { PositioningService } from '../../dynamic-css/positioning.service';
33
import { AnnotationEditorEditorModeChangedEvent } from '../../events/annotation-editor-mode-changed-event';
4+
import { FocusManagementService } from '../../focus-management.service';
45
import { AnnotationEditorType } from '../../options/editor-annotations';
56
import { IPDFViewerApplication } from '../../options/pdf-viewer-application';
67
import { PDFNotificationService } from '../../pdf-notification-service';
@@ -21,6 +22,7 @@ export class PdfCommentEditorComponent {
2122
constructor(
2223
notificationService: PDFNotificationService,
2324
private cdr: ChangeDetectorRef,
25+
private focusManagement: FocusManagementService,
2426
) {
2527
effect(() => {
2628
this.PDFViewerApplication = notificationService.onPDFJSInitSignal();
@@ -33,7 +35,18 @@ export class PdfCommentEditorComponent {
3335
private onPdfJsInit() {
3436
this.PDFViewerApplication?.eventBus.on('annotationeditormodechanged', ({ mode }: AnnotationEditorEditorModeChangedEvent) => {
3537
setTimeout(() => {
38+
const wasSelected = this.isSelected;
3639
this.isSelected = mode === AnnotationEditorType.POPUP;
40+
41+
// Focus management
42+
if (!wasSelected && this.isSelected) {
43+
// Dialog just opened
44+
this.focusManagement.moveFocusToDialog('editorCommentParamsToolbar', 'Comment editor toolbar opened', 'editorCommentButton');
45+
} else if (wasSelected && !this.isSelected) {
46+
// Dialog just closed
47+
this.focusManagement.returnFocusToPrevious('Comment editor toolbar closed');
48+
}
49+
3750
this.cdr.detectChanges();
3851
});
3952
});

projects/ngx-extended-pdf-viewer/src/lib/toolbar/pdf-draw-editor/pdf-draw-editor.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
[cssClass]="show | responsiveCSSClass : 'hiddenTinyView'"
55
l10nId="pdfjs-editor-ink-button"
66
l10nLabel="pdfjs-editor-ink-button-label"
7+
role="radio"
8+
ariaHasPopup="true"
9+
ariaControls="editorInkParamsToolbar"
710
[order]="4050"
811
[action]="onClick"
912
[toggled]="isSelected"

projects/ngx-extended-pdf-viewer/src/lib/toolbar/pdf-draw-editor/pdf-draw-editor.component.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ChangeDetectorRef, Component, Input, effect } from '@angular/core';
22
import { PositioningService } from '../../dynamic-css/positioning.service';
33
import { AnnotationEditorEditorModeChangedEvent } from '../../events/annotation-editor-mode-changed-event';
4+
import { FocusManagementService } from '../../focus-management.service';
45
import { AnnotationEditorType } from '../../options/editor-annotations';
56
import { IPDFViewerApplication } from '../../options/pdf-viewer-application';
67
import { PDFNotificationService } from '../../pdf-notification-service';
@@ -22,6 +23,7 @@ export class PdfDrawEditorComponent {
2223
constructor(
2324
notificationService: PDFNotificationService,
2425
private cdr: ChangeDetectorRef,
26+
private focusManagement: FocusManagementService,
2527
) {
2628
effect(() => {
2729
this.PDFViewerApplication = notificationService.onPDFJSInitSignal();
@@ -34,7 +36,18 @@ export class PdfDrawEditorComponent {
3436
private onPdfJsInit() {
3537
this.PDFViewerApplication?.eventBus.on('annotationeditormodechanged', ({ mode }: AnnotationEditorEditorModeChangedEvent) => {
3638
setTimeout(() => {
39+
const wasSelected = this.isSelected;
3740
this.isSelected = mode === 15;
41+
42+
// Focus management
43+
if (!wasSelected && this.isSelected) {
44+
// Dialog just opened
45+
this.focusManagement.moveFocusToDialog('editorInkParamsToolbar', 'Draw editor toolbar opened', 'primaryEditorInk');
46+
} else if (wasSelected && !this.isSelected) {
47+
// Dialog just closed
48+
this.focusManagement.returnFocusToPrevious('Draw editor toolbar closed');
49+
}
50+
3851
this.cdr.detectChanges();
3952
});
4053
});

projects/ngx-extended-pdf-viewer/src/lib/toolbar/pdf-editor-signature/pdf-editor-signature.component.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
[cssClass]="show | responsiveCSSClass : 'hiddenTinyView'"
55
l10nId="pdfjs-editor-signature-button"
66
l10nLabel="pdfjs-editor-signature-button-label"
7+
role="radio"
8+
ariaHasPopup="true"
9+
ariaControls="editorSignatureParamsToolbar"
710
[order]="4000"
811
[action]="onClick"
912
[toggled]="isSelected"

0 commit comments

Comments
 (0)