UNPKG

pure-modal

Version:

A lightweight, accessible, vanilla JavaScript modal component

276 lines (215 loc) 7.06 kB
const ClassName = { BACKDROP: 'modal-backdrop', OPEN: 'is-open', IN: 'in', OUT: 'out', DIALOG: 'modal-dialog', } const KeyCodes = { ESC: 27, TAB: 9, } const DefaultConfig = { transition: true, } const throwError = message => window.console.error(`PureModal: ${message}`); const reflow = element => element.offsetHeight; class PureModal { constructor(id, config) { this.id = id; this.config = Object.assign({}, DefaultConfig, config); this.modal = PureModal.findModal(this.id); this.dialog = this.modal.querySelector(`.${ClassName.DIALOG}`); this.backdrop = null; this.isOpen = false; this.isInit = false; this.isTransitioning = false; this.triggers = PureModal.findTriggers(this.id); this.closeEls = null; this.focusableEls = null; this.focusedElBeforeOpen = null; this.firstFocusableEl = null; this.lastFocusableEl = null; this.open = this.open.bind(this); this.close = this.close.bind(this); this.onDismiss = this.onDismiss.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); } init() { if (this.isInit) { throwError('Event listeners already added.'); return; } this.triggers.forEach(trigger => trigger.addEventListener('click', this.open)); this.isInit = true; } destroy() { if (!this.isInit) { throwError('Event listeners already removed.'); return; } this.triggers.forEach(trigger => trigger.removeEventListener('click', this.open)); this.isInit = false; } open(e) { if (this.isTransitioning) { return; } e.preventDefault(); if (typeof this.config.beforeOpen === 'function') { this.config.beforeOpen(); } this.focusedElBeforeOpen = document.activeElement; this.focusableEls = Array.from( this.dialog.querySelectorAll('a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]') ); this.firstFocusableEl = this.focusableEls[0]; this.lastFocusableEl = this.focusableEls[this.focusableEls.length - 1]; this.showBackdrop(); this.modal.style.display = 'block'; if (this.config.transition) { this.isTransitioning = true; this.modal.classList.add(ClassName.OPEN); this.modal.classList.add(ClassName.IN); this.openWithTransition(); } else { this.firstFocusableEl.focus(); } document.addEventListener('mousedown', this.onDismiss); document.addEventListener('keydown', this.handleKeyDown); this.closeEls = Array.from( this.modal.querySelectorAll('[data-dismiss="modal"]') ); this.closeEls.forEach(button => button.addEventListener('click', this.close)); this.isOpen = true; if (typeof this.config.onOpen === 'function') { this.config.onOpen(); } } openWithTransition() { const openTransitionHandler = () => { this.dialog.removeEventListener('animationend', openTransitionHandler); this.firstFocusableEl.focus(); if (typeof this.config.onOpen === 'function') { this.config.onOpen(); } this.isTransitioning = false; }; this.dialog.addEventListener('animationend', openTransitionHandler); } close() { if (this.isTransitioning) { return; } if (typeof this.config.beforeClose === 'function') { this.config.beforeClose(); } if (this.config.transition) { this.isTransitioning = true; this.modal.classList.remove(ClassName.IN); this.modal.classList.remove(ClassName.OPEN); this.modal.classList.add(ClassName.OUT); this.closeWithTransition(); this.closeBackdrop(); } else { this.modal.style.display = 'none'; this.modal.classList.remove(ClassName.OPEN); this.closeBackdrop(); if (typeof this.config.onClose === 'function') { this.config.onClose(); } } document.removeEventListener('mousedown', this.onDismiss); document.removeEventListener('keydown', this.handleKeyDown); this.closeEls.forEach(button => button.removeEventListener('click', this.close)); this.isOpen = false; this.focusedElBeforeOpen.focus(); } closeWithTransition() { const closeTransitionHandler = () => { this.dialog.removeEventListener('animationend', closeTransitionHandler); this.modal.style.display = 'none'; this.modal.classList.remove(ClassName.OUT); if (typeof this.config.onClose === 'function') { this.config.onClose(); } this.isTransitioning = false; }; this.dialog.addEventListener('animationend', closeTransitionHandler); } onDismiss(e) { if (!this.dialog.contains(e.target) && this.isOpen) { this.close(); } } showBackdrop() { this.backdrop = document.createElement('div'); this.backdrop.tabIndex = -1; this.backdrop.classList.add(ClassName.BACKDROP); if (this.config.transition) { this.backdrop.classList.add(ClassName.IN); } document.body.appendChild(this.backdrop); // force reflow reflow(this.backdrop); this.backdrop.classList.add(ClassName.OPEN); } closeBackdrop() { if (this.config.transition) { this.backdrop.classList.remove(ClassName.IN); this.backdrop.classList.remove(ClassName.OPEN); this.backdrop.classList.add(ClassName.OUT); this.closeBackdropWithTransition(); } else { this.backdrop.remove(); } } closeBackdropWithTransition() { const removeTransitionHandler = () => { this.backdrop.removeEventListener('transitionend', removeTransitionHandler); this.backdrop.remove(); } this.backdrop.addEventListener('transitionend', removeTransitionHandler); } handleKeyDown(e) { const handleBackwardTab = () => { if (document.activeElement === this.firstFocusableEl) { e.preventDefault(); this.lastFocusableEl.focus(); } } const handleForwardTab = () => { if (document.activeElement === this.lastFocusableEl) { e.preventDefault(); this.firstFocusableEl.focus(); } } switch(e.keyCode) { case KeyCodes.TAB: if (this.focusableEls.length === 1) { e.preventDefault(); break; } if (e.shiftKey) { handleBackwardTab(); } else { handleForwardTab(); } break; case KeyCodes.ESC: this.close(); break; default: break; } }; static findTriggers(id) { return Array.from( document.querySelectorAll(`[data-toggle="modal"][data-target="${id}"]`) ); } static findModal(id) { return document.getElementById(id); } } export default PureModal;