import { Modal } from './Modal' export class Srfc { private readonly ATTRIBUTE_CONTAINER = 'data-srfc' private readonly ATTRIBUTE_OPEN = 'data-srfc-open' private readonly ATTRIBUTE_CLOSE = 'data-srfc-close' private readonly GROUP_TYPE_ARROW = 'arrow' private readonly GROUP_TYPE_LINKED = 'linked' private readonly GROUP_TYPE_STRONG = 'strong' private readonly GROUP_TYPE_STRONG_BACKDROP = 'strong-backdrop' private readonly GROUP_TYPE_STRONG_ESC = 'strong-esc' private readonly modals: Map = new Map() private active: Modal | undefined private activeElementBeforeOpening: HTMLElement | null = null // Usually, I would not allow for undefined. // However, it would fail in IE if a polyfill for the Event class was not used. // Even basic functionality would fail that has nothing to do with events. private readonly eventOpen: Event | undefined private readonly eventClose: Event | undefined constructor () { try { this.eventOpen = new Event('open.srfc') } catch (error) { this.eventOpen = undefined } try { this.eventClose = new Event('close.srfc') } catch (error) { this.eventClose = undefined } } /** * Inititalize the Srfc, register necessary events. */ public init (): void { // Find every modal in the DOM and put references to them in the modal map. const containerElements = document.querySelectorAll(`[${this.ATTRIBUTE_CONTAINER}]`) containerElements.forEach(containerElement => { // Read modal types const containerAttribute = containerElement.getAttribute(this.ATTRIBUTE_CONTAINER) if (containerAttribute === null) return const types = containerAttribute.trim().split(/\s+/) const isLinked = types.includes(this.GROUP_TYPE_LINKED) const isArrowEnabled = types.includes(this.GROUP_TYPE_ARROW) const isClosableByBackdrop = !types.includes(this.GROUP_TYPE_STRONG) && !types.includes(this.GROUP_TYPE_STRONG_BACKDROP) const isClosableByEsc = !types.includes(this.GROUP_TYPE_STRONG) && !types.includes(this.GROUP_TYPE_STRONG_ESC) // Build modal objects and add them to the map Array.from(containerElement.children).forEach(element => { const id = element.getAttribute('id') if (id === null) return let onNext = (): void => {} let onPrev = (): void => {} const nextElementSibling = element.nextElementSibling if (nextElementSibling !== null) { const nextElementSiblingId = nextElementSibling.getAttribute('id') if (nextElementSiblingId !== null) { onNext = () => { this.open(nextElementSiblingId) } } } const previousElementSibling = element.previousElementSibling if (previousElementSibling !== null) { const previousElementSiblingId = previousElementSibling.getAttribute('id') if (previousElementSiblingId !== null) { onPrev = () => { this.open(previousElementSiblingId) } } } const onClose = (): void => { this.active = undefined this.activeElementBeforeOpening?.focus() } const modal = new Modal( element, containerElement, isLinked, isArrowEnabled, isClosableByBackdrop, isClosableByEsc, onNext, onPrev, onClose, this.eventOpen, this.eventClose ) this.modals.set(id, modal) }) }) // Adds click events on openers. const openers = document.querySelectorAll(`[${this.ATTRIBUTE_OPEN}]`) openers.forEach(opener => { const targetId = opener.getAttribute(this.ATTRIBUTE_OPEN) // In practice, this will not trigger. In this loop, all the openers have the opener attribute. if (targetId === null) return opener.addEventListener('click', event => { event.preventDefault() // In case the opener is a link, do not navigate to its location. This is useful for progressive enhancement. this.open(targetId) }) }) // Add click event on closer. const closers = document.querySelectorAll(`[${this.ATTRIBUTE_CLOSE}]`) closers.forEach(closer => { closer.addEventListener('click', () => { this.close() }) }) // Adds pop state event. Useful for linked modal types which react for example to the browser's redo buttons. window.addEventListener('popstate', event => { this.popstate() }) // Read the initial hash location of the url. If it points to a linked modal it gets opened. const hashLocation = window.location.hash.slice(1) const target = this.modals.get(hashLocation) if (target === undefined) return if (target.isLinked) { window.history.replaceState(null, '', location.pathname) window.history.pushState(null, '', '#' + hashLocation) target.open() this.active = target } } /** * If the hash location of the URL changes and it points to a linked modal, this will open it. */ private popstate (): void { // Read the hash location of the URL const hashLocation = window.location.hash.slice(1) // If the modal does not exist or is not of linked type, return const target = this.modals.get(hashLocation) if (target === undefined) return if (!target.isLinked) return // If some modal is already active, close it if (this.active != null) { this.active.close() } // Open the target modal target.open() this.active = target } /** * Opens a modal. * @param id the value of the 'id' attribute of the modal element you wish to open. */ public open (id: string): void { // Find the target modal. const target = this.modals.get(id) if (target === undefined) { throw new Error(`Target modal '${id}' not found.`) } // If any modal is currently active, close it. if (this.active != null) { this.active.close() if (this.active.isLinked && !target.isLinked) { window.history.back() } } else { // If no modal is currently active, remember the last active element to return the focus to. if (document.activeElement instanceof HTMLElement) { this.activeElementBeforeOpening = document.activeElement } } // If target is liked, hash location will need to be changed if (target.isLinked) { if (this.active?.isLinked === true) { window.history.replaceState(null, '', '#' + id) } else { window.history.pushState(null, '', '#' + id) } } // Finally, activate the target modal target.open() this.active = target } /** * Closes a modal. */ public close (): void { if (this.active === undefined) return this.active.close() if (this.active.isLinked) { window.history.back() } this.active = undefined this.activeElementBeforeOpening?.focus() } }