/** * Accessible modal dialog for the AccessibleDocs popup. * * - moves focus into the dialog on open and keeps it there (Tab / Shift+Tab) * - closes on Escape, the close button and a click outside the dialog * - returns focus to the element that opened it */ export type PopupState = 'in-process' | 'completed' | 'error'; const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'; export default class Popup { private readonly root: HTMLElement; private readonly dialog: HTMLElement; private readonly body: HTMLElement; private trigger: HTMLElement | null = null; constructor(root: HTMLElement) { this.root = root; this.dialog = root.querySelector('.js-accessible-docs-dialog')!; this.body = root.querySelector('.js-accessible-docs-popup')!; root.addEventListener('click', (event) => { const target = event.target as HTMLElement; // The close button, or a click on the dimmed area around the dialog (not inside it). if (target.closest('.accessible-docs-popup__close') || (target.classList.contains('js-popup-close') && !this.dialog.contains(target))) { event.preventDefault(); this.close(); } }); document.addEventListener('keydown', (event) => { if (!this.isOpen()) { return; } if (event.key === 'Escape') { event.preventDefault(); this.close(); } else if (event.key === 'Tab') { this.keepFocusInside(event); } }); } isOpen(): boolean { return this.root.classList.contains('is-open'); } open(pdfUrl: string, trigger: HTMLElement | null): void { this.reset(); this.trigger = trigger; const download = this.root.querySelector('.js-accessible-docs-link'); if (download) { download.href = pdfUrl; } this.root.classList.add('is-open'); this.root.style.display = 'block'; document.documentElement.classList.add('popup-opened'); document.body.classList.add('popup-opened'); this.root.querySelector('.js-accessible-docs-email')?.focus(); } close(): void { if (!this.isOpen()) { return; } this.root.classList.remove('is-open'); this.root.style.display = 'none'; document.documentElement.classList.remove('popup-opened'); document.body.classList.remove('popup-opened'); this.reset(); // Return focus to the link that opened the popup. if (this.trigger && document.contains(this.trigger)) { this.trigger.focus(); } this.trigger = null; } setLoading(loading: boolean): void { this.root.querySelector('.js-accessible-docs-popup-loader')?.classList.toggle('active', loading); const button = this.root.querySelector('.js-accessible-docs-submit'); if (button) { button.disabled = loading; } this.dialog.setAttribute('aria-busy', loading ? 'true' : 'false'); } /** Shows one of the result screens and moves focus to its title so screen readers announce it. */ setState(state: PopupState): void { this.body.classList.remove('in-process', 'completed', 'error'); this.body.classList.add(state); const screen = this.body.querySelector( { 'in-process': '.accessible-docs-popup__processed', completed: '.accessible-docs-popup__ready', error: '.accessible-docs-popup__error' }[state] ); screen?.querySelector('.js-accessible-docs-state-title')?.focus(); } private reset(): void { this.setLoading(false); this.body.classList.remove('in-process', 'completed', 'error'); const input = this.root.querySelector('.js-accessible-docs-email'); input?.removeAttribute('aria-invalid'); const error = this.root.querySelector('.js-accessible-docs-popup-error'); if (error) { error.style.display = 'none'; } } private keepFocusInside(event: KeyboardEvent): void { const focusable = Array.from(this.dialog.querySelectorAll(FOCUSABLE)).filter( (el) => el.offsetParent !== null || el === document.activeElement ); if (!focusable.length) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement as HTMLElement | null; if (!active || !this.dialog.contains(active)) { event.preventDefault(); first.focus(); } else if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } } }