import { FocusTrap } from './FocusTrap' import { fit, resetSrc } from './embedUtils' export class Modal { private readonly CLASS_ACTIVE = 'srfc-active' private readonly ATTRIBUTE_EMBED = 'data-srfc-embed' private readonly ATTRIBUTE_AUTOFOCUS = 'data-srfc-autofocus' private readonly EMBED_TYPE_RESET = 'reset' private readonly EMBED_TYPE_FIT = 'fit' private readonly ESC_KEY_HANDLER_BINDED = this.escKeyHandler.bind(this) private readonly ARROW_KEYS_HANDLER_BINDED = this.arrowKeysHandler.bind(this) private readonly BACKDROP_CLICK_HANDLER_BINDED = this.backdropClickHandler.bind(this) private readonly POPSTATE_HANDLER_BINDED = this.popstateHandler.bind(this) private readonly FIT_ON_RESIZE_HANDLER_BINDED = this.resizeHandler.bind(this) private readonly AUTOFOCUS_TRANSITIONEND_HANDLER_BINDED = this.autofocusTransitionEndHandler.bind(this) private readonly embedsToReset: Element[] = [] private readonly embedsToFit: Element[] = [] private readonly focusTrap: FocusTrap private autofocusElement: HTMLElement | undefined constructor ( private readonly element: Element, private readonly containerElement: Element, public readonly isLinked: boolean, private readonly isArrowEnabled: boolean, private readonly isClosableByBackdrop: boolean, private readonly isClosableByEsc: boolean, private readonly onNext: () => void, private readonly onPrev: () => void, private readonly onClose: () => void, private readonly eventOpen: Event | undefined, private readonly eventClose: Event | undefined ) { this.focusTrap = new FocusTrap(element, containerElement) } private popstateHandler (): void { this.popstate() } private backdropClickHandler (event: Event): void { // Only capture click on the backdrop, not its children. if (this.containerElement !== event.target) { return } this.close() if (this.isLinked) { window.history.back() } this.onClose() } /** * Handler for the keyboard event. Handles 'Escape'. * @param event */ private escKeyHandler (event: KeyboardEvent): void { if (event.key === 'Escape' || event.key === 'Esc') { // 'Esc' is for IE 11 this.close() if (this.isLinked) { window.history.back() } this.onClose() } } /** * Handler for the keyboard event. Handles 'ArrowRight' and 'ArrowLeft'. * @param event */ private arrowKeysHandler (event: KeyboardEvent): void { if (event.key === 'ArrowRight' || event.key === 'Right') { // 'Right' is for IE 11 this.onNext() } else if (event.key === 'ArrowLeft' || event.key === 'Left') { this.onPrev() } } private popstate (): void { this.close() this.onClose() } private resizeHandler (): void { this.embedsToFit.forEach(fit) } public autofocusTransitionEndHandler (): void { this.autofocusElement?.focus() } /** * Interacts with the DOM to add 'active' classes, adds events. */ public open (): void { this.element.classList.add(this.CLASS_ACTIVE) this.containerElement.classList.add(this.CLASS_ACTIVE) if (this.isClosableByBackdrop) { this.containerElement.addEventListener('click', this.BACKDROP_CLICK_HANDLER_BINDED) } if (this.isClosableByEsc) { document.addEventListener('keydown', this.ESC_KEY_HANDLER_BINDED) } if (this.isArrowEnabled) { document.addEventListener('keydown', this.ARROW_KEYS_HANDLER_BINDED) } if (this.isLinked) { window.addEventListener('popstate', this.POPSTATE_HANDLER_BINDED) } window.addEventListener('resize', this.FIT_ON_RESIZE_HANDLER_BINDED) const embeds = this.element.querySelectorAll(`[${this.ATTRIBUTE_EMBED}]`) embeds.forEach(embed => { const embedAttrribute = embed.getAttribute(this.ATTRIBUTE_EMBED) if (embedAttrribute === null) { return } const embedAttrributeValues = embedAttrribute.trim().split(/\s+/) if (embedAttrributeValues.includes(this.EMBED_TYPE_RESET)) { this.embedsToReset.push(embed) } if (embedAttrributeValues.includes(this.EMBED_TYPE_FIT)) { this.embedsToFit.push(embed) } }) this.embedsToFit.forEach(fit) this.focusTrap.activate() // body scroll document.body.classList.add(this.CLASS_ACTIVE) // autofocus const autofocusElement = this.element.querySelector(`[${this.ATTRIBUTE_AUTOFOCUS}]`) if (autofocusElement instanceof HTMLElement) { this.autofocusElement = autofocusElement this.autofocusElement.focus() // If animation/transition is used on the modal, the autofocus element might not get focused. // I do not know if it is some style attribute being in transition that is the cause of failure... // ... but it gets fixed if the element is focused again on the transitionEnd. if (this.autofocusElement !== document.activeElement) { this.element.addEventListener('transitionend', this.AUTOFOCUS_TRANSITIONEND_HANDLER_BINDED, { once: true }) } } if (this.eventOpen != null) { this.element.dispatchEvent(this.eventOpen) } } /** * Interacts with the DOM to remove 'active' classes, removes events. */ public close (): void { this.element.classList.remove(this.CLASS_ACTIVE) this.containerElement.classList.remove(this.CLASS_ACTIVE) if (this.isClosableByBackdrop) { this.containerElement.removeEventListener('click', this.BACKDROP_CLICK_HANDLER_BINDED) } if (this.isClosableByEsc) { document.removeEventListener('keydown', this.ESC_KEY_HANDLER_BINDED) } if (this.isArrowEnabled) { document.removeEventListener('keydown', this.ARROW_KEYS_HANDLER_BINDED) } if (this.isLinked) { window.removeEventListener('popstate', this.POPSTATE_HANDLER_BINDED) } window.removeEventListener('resize', this.FIT_ON_RESIZE_HANDLER_BINDED) this.embedsToReset.forEach(resetSrc) this.focusTrap.deactivate() // body scroll document.body.classList.remove(this.CLASS_ACTIVE) this.element.removeEventListener('transitionend', this.AUTOFOCUS_TRANSITIONEND_HANDLER_BINDED) if (this.eventClose != null) { this.element.dispatchEvent(this.eventClose) } } }