export class FocusTrap { private preTrapOuter: HTMLElement | undefined private preTrap: HTMLElement | undefined private bodyPreTrap: HTMLElement | undefined private readonly FOCUS_HANDLER_BINDED = this.focusHandler.bind(this) constructor ( private readonly element: Element, private readonly containerElement: Element ) { } public activate (): void { const preTrapOuter = document.createElement('div') preTrapOuter.tabIndex = 0 // Makes it focusable preTrapOuter.style.position = 'fixed' // Prevents potential scrolling if the trap was to receive focus. const preTrap = document.createElement('div') preTrap.tabIndex = 0 preTrap.style.position = 'fixed' const bodyPreTrap = document.createElement('div') bodyPreTrap.tabIndex = 0 bodyPreTrap.style.position = 'fixed' this.containerElement.insertAdjacentElement('beforebegin', preTrapOuter) this.containerElement.insertAdjacentElement('beforebegin', preTrap) document.body.insertAdjacentElement('afterbegin', bodyPreTrap) this.blurIn() this.preTrapOuter = preTrapOuter this.preTrap = preTrap this.bodyPreTrap = bodyPreTrap document.addEventListener('focusin', this.FOCUS_HANDLER_BINDED) } public deactivate (): void { this.preTrap?.parentNode?.removeChild(this.preTrap) this.bodyPreTrap?.parentNode?.removeChild(this.bodyPreTrap) this.preTrapOuter?.parentNode?.removeChild(this.preTrapOuter) document.removeEventListener('focusin', this.FOCUS_HANDLER_BINDED) } /** * Similar to calling the regular blur() method on an active element. However, some browsers remember the last focused item even after the blur and move to the next element relative to the 'last focused' once 'tab' is pressed. This function prevents that in some browsers (Chrome, Firefox). In others, it acts like a regular blur method (Safari). */ private blurIn (): void { const tempTrap = document.createElement('div') tempTrap.tabIndex = 0 tempTrap.style.position = 'fixed' this.element.insertAdjacentElement('afterbegin', tempTrap) tempTrap.focus() this.element.removeChild(tempTrap) } /** * Handler for the focus event. If focus targets any of the traps, meaning it wants to escape, it is put back in the modal. * @param event */ private focusHandler (event: Event): void { if (event.target instanceof HTMLElement) { if (this.element.contains(event.target)) { return } if (event.target === this.preTrap) { this.focusLastFocusable() } else if (event.target === this.preTrapOuter) { this.focusFirstFocusable() } else if (event.target === this.bodyPreTrap) { this.focusFirstFocusable() } else { this.focusFirstFocusable() } } } /** * Finds and puts focus on the first focusable item inside an element. Tries to focus on each element to see if it becomes active. * @param element */ private focusFirstFocusable (): void { const elementsIn = this.element.querySelectorAll('*') for (let i = 0; i < elementsIn.length; i++) { const elementIn = elementsIn[i] if (elementIn instanceof HTMLElement) { elementIn.focus() if (document.activeElement === elementIn) { return } } } this.blurIn() } /** * Finds and puts focus on the last focusable item inside an element. Tries to focus on each element to see if it becomes active. * @param element */ private focusLastFocusable (): void { const elementsIn = this.element.querySelectorAll('*') for (let i = elementsIn.length - 1; i >= 0; i--) { const elementIn = elementsIn[i] if (elementIn instanceof HTMLElement) { elementIn.focus() if (document.activeElement === elementIn) { return } } } this.blurIn() } }