fr-dialogmodal
Version:
Frend's accessible, modern modal component.
227 lines (198 loc) • 7.01 kB
JavaScript
;
/**
* @param {object} options Object containing configuration overrides
*/
const Frdialogmodal = function ({
selector: selector = '.js-fr-dialogmodal',
modalSelector: modalSelector = '.js-fr-dialogmodal-modal',
openSelector: openSelector = '.js-fr-dialogmodal-open',
closeSelector: closeSelector = '.js-fr-dialogmodal-close',
isAlert: isAlert = false,
readyClass: readyClass = 'fr-dialogmodal--is-ready',
activeClass: activeClass = 'fr-dialogmodal--is-active'
} = {}) {
// CONSTANTS
const doc = document;
const docEl = doc.documentElement;
const _q = (el, ctx = doc) => [].slice.call(ctx.querySelectorAll(el));
// SUPPORTS
if (!('querySelector' in doc) || !('addEventListener' in window) || !docEl.classList) return;
// SETUP
// set dialog modal element NodeLists
const containers = _q(selector);
const focusableSelectors = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'iframe', 'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex^="-"])'];
// TEMP
let currButtonOpen = null;
let currModal = null;
// elements within modal
let focusableElements = null;
// UTILS
function _defer (fn) {
// wrapped in setTimeout to delay binding until previous rendering has completed
if (typeof fn === 'function') setTimeout(fn, 0);
}
// A11Y
function _addA11y (container) {
let modal = _q(modalSelector, container)[0];
let role = isAlert ? 'alertdialog' : 'dialog';
// add relevant roles and properties
container.setAttribute('aria-hidden', true);
modal.setAttribute('role', role);
}
function _removeA11y (container) {
let modal = _q(modalSelector, container)[0];
// add relevant roles and properties
container.removeAttribute('aria-hidden');
modal.removeAttribute('role');
}
// ACTIONS
function _showModal (container, modal) {
// show container and focus the modal
container.setAttribute('aria-hidden', false);
modal.setAttribute('tabindex', -1);
// set first/last focusable elements
focusableElements = _q(focusableSelectors.join(), modal);
// focus first element if exists, otherwise focus modal element
if (focusableElements.length) focusableElements[0].focus();
else modal.focus();
// update bound events
_defer(_bindDocKey);
_defer(_bindClosePointer);
// if contents are not interactive, bind click off
if (!isAlert) _defer(_bindContainerPointer);
// reset scroll
modal.scrollTop = 0;
// update style hook
container.classList.add(activeClass);
}
function _hideModal (modal, returnfocus = true) {
// get container element
let container = modal.parentElement;
// show container and focus the modal
container.setAttribute('aria-hidden', true);
modal.removeAttribute('tabindex');
// update bound events
_unbindDocKey();
_unbindClosePointer();
// if contents are not interactive, unbind click off
if (!isAlert) _unbindContainerPointer();
// update style hook
container.classList.remove(activeClass);
// return focus to button that opened the modal and reset the reference
if (returnfocus) {
currButtonOpen.focus();
currButtonOpen = null;
}
}
function _handleTabEvent (e) {
// get the index of the current active element within the modal
let focusedIndex = focusableElements.indexOf(doc.activeElement);
// handle TAB event if need to skip
// if first element is focused and shiftkey is in use
if (e.shiftKey && (focusedIndex === 0 || focusedIndex === -1)) {
// focus last item within modal
focusableElements[focusableElements.length - 1].focus();
e.preventDefault();
// if last element is focused and shiftkey is not in use
} else if (!e.shiftKey && focusedIndex === focusableElements.length - 1) {
// focus first item within modal
focusableElements[0].focus();
e.preventDefault();
}
}
// EVENTS
function _eventOpenPointer (e) {
// get related elements
let button = e.currentTarget;
let container = doc.getElementById(button.getAttribute('aria-controls'));
let modal = _q(modalSelector, container)[0];
// save element references
currButtonOpen = button;
currModal = modal;
// show modal
_showModal(container, modal);
}
function _eventClosePointer () {
_hideModal(currModal);
}
function _eventContainerPointer (e) {
let container = currModal.parentElement;
// check if target is modal container (but not modal)
if (e.target === container) _hideModal(currModal);
}
function _eventDocKey (e) {
// ESC key
if (e.keyCode === 27) _hideModal(currModal);
// TAB key
if (e.keyCode === 9) _handleTabEvent(e);
}
// BIND EVENTS
function _bindOpenPointers (container) {
let id = container.getAttribute('id');
let buttons = _q(`${openSelector}[aria-controls="${id}"]`);
buttons.forEach(button => button.addEventListener('click', _eventOpenPointer));
}
function _bindClosePointer (modal = currModal) {
let button = _q(closeSelector, modal)[0];
button.addEventListener('click', _eventClosePointer);
}
function _bindContainerPointer (modal = currModal) {
let container = modal.parentElement;
container.addEventListener('click', _eventContainerPointer);
}
function _bindDocKey () {
doc.addEventListener('keydown', _eventDocKey);
}
// UNBIND EVENTS
function _unbindOpenPointers (container) {
let id = container.getAttribute('id');
let buttons = doc.querySelectorAll(`${openSelector}[aria-controls="${id}"]`);
buttons.forEach(button => button.removeEventListener('click', _eventOpenPointer));
}
function _unbindClosePointer (modal = currModal) {
let button = _q(closeSelector, modal)[0];
button.removeEventListener('click', _eventClosePointer);
}
function _unbindContainerPointer () {
let container = currModal.parentElement;
container.removeEventListener('click', _eventContainerPointer);
}
function _unbindDocKey () {
doc.removeEventListener('keydown', _eventDocKey);
}
// DESTROY
function destroy () {
// loop through available modals
containers.forEach(container => {
let modal = _q(modalSelector, container)[0];
modal.removeAttribute('tabindex');
_removeA11y(container);
_unbindOpenPointers(container);
_unbindClosePointer(modal);
_unbindContainerPointer(modal);
// remove ready, active style hooks
container.classList.remove(readyClass, activeClass);
});
_unbindDocKey();
}
// INIT
function init () {
// cancel if no modals found
if (!containers.length) return;
// loop through available modals
containers.forEach(container => {
_addA11y(container);
_bindOpenPointers(container);
// set ready style hook
container.classList.add(readyClass);
});
}
init();
// REVEAL API
return {
init,
destroy
}
}
// module exports
export default Frdialogmodal;