/* c8 ignore start */ import { activeElements, getDeepestActiveElement } from './active-elements.js'; import { getTabbableElements } from './tabbable.js'; let activeModals: HTMLElement[] = []; export default class Modal { element: HTMLElement; isExternalActivated: boolean = false; tabDirection: 'forward' | 'backward' = 'forward'; currentFocus: HTMLElement | null = null; previousFocus: HTMLElement | null = null; elementsWithTabbableControls: string[]; constructor(element: HTMLElement) { this.element = element; this.elementsWithTabbableControls = ['iframe']; } /** Activates focus trapping. */ activate() { activeModals.push(this.element); document.addEventListener('focusin', this.handleFocusIn); document.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keyup', this.handleKeyUp); } /** Deactivates focus trapping. */ deactivate() { activeModals = activeModals.filter((modal) => modal !== this.element); this.currentFocus = null; document.removeEventListener('focusin', this.handleFocusIn); document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keyup', this.handleKeyUp); } /** Determines if this modal element is currently active or not. */ isActive() { // The "active" modal is always the most recent one shown return activeModals[activeModals.length - 1] === this.element; } /** Activates external modal behavior and temporarily disables focus trapping. */ activateExternal() { this.isExternalActivated = true; } /** Deactivates external modal behavior and re-enables focus trapping. */ deactivateExternal() { this.isExternalActivated = false; } private checkFocus() { if (this.isActive() && !this.isExternalActivated) { const tabbableElements = getTabbableElements(this.element); if (!this.element.matches(':focus-within')) { const start = tabbableElements[0]; const end = tabbableElements[tabbableElements.length - 1]; const target = this.tabDirection === 'forward' ? start : end; if (typeof target?.focus === 'function') { this.currentFocus = target; target.focus({ preventScroll: false }); } } } } private handleFocusIn = () => { if (!this.isActive()) return; this.checkFocus(); }; private possiblyHasTabbableChildren(element: HTMLElement) { return ( this.elementsWithTabbableControls.includes( element.tagName.toLowerCase(), ) || element.hasAttribute('controls') // Should we add a data-attribute for people to set just in case they have an element where we don't know if it has possibly tabbable elements? ); } private handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Tab' || this.isExternalActivated) return; if (!this.isActive()) return; // Because sometimes focus can actually be taken over from outside sources, // we don't want to rely on `this.currentFocus`. Instead we check the actual `activeElement` and // recurse through shadowRoots. const currentActiveElement = getDeepestActiveElement(); this.previousFocus = currentActiveElement as HTMLElement | null; if ( this.previousFocus && this.possiblyHasTabbableChildren(this.previousFocus) ) { return; } if (event.shiftKey) { this.tabDirection = 'backward'; } else { this.tabDirection = 'forward'; } const tabbableElements = getTabbableElements(this.element); let currentFocusIndex = tabbableElements.findIndex( (el) => el === currentActiveElement, ); this.previousFocus = this.currentFocus; const addition = this.tabDirection === 'forward' ? 1 : -1; while (true) { if (currentFocusIndex + addition >= tabbableElements.length) { currentFocusIndex = 0; } else if (currentFocusIndex + addition < 0) { currentFocusIndex = tabbableElements.length - 1; } else { currentFocusIndex += addition; } this.previousFocus = this.currentFocus; const nextFocus = /** @type {HTMLElement} */ tabbableElements[currentFocusIndex]; // This is a special case. We need to make sure we're not calling .focus() if we're already focused on an element // that possibly has "controls" if (this.tabDirection === 'backward') { if ( this.previousFocus && this.possiblyHasTabbableChildren(this.previousFocus) ) { return; } } if (nextFocus && this.possiblyHasTabbableChildren(nextFocus)) { return; } event.preventDefault(); this.currentFocus = nextFocus; this.currentFocus?.focus({ preventScroll: false }); // Check to make sure focus actually changed. It may not always be the next focus, we just don't want it to be the previousFocus. const allActiveElements = [...activeElements()]; if ( allActiveElements.includes(this.currentFocus) || !allActiveElements.includes(this.previousFocus!) ) { break; } } setTimeout(() => this.checkFocus()); }; private handleKeyUp = () => { this.tabDirection = 'forward'; }; } /* c8 ignore end */