UNPKG

van11y-accessible-modal-window-aria

Version:

ES2015 accessible modal window system, using ARIA (compatible IE9+ when transpiled)

458 lines (364 loc) 18.1 kB
/* * ES2015 accessible modal window system, using ARIA * Website: https://van11y.net/accessible-modal/ * License MIT: https://github.com/nico3333fr/van11y-accessible-modal-window-aria/blob/master/LICENSE */ (doc => { 'use strict'; const MODAL_JS_CLASS = 'js-modal'; const MODAL_ID_PREFIX = 'label_modal_'; const MODAL_CLASS_SUFFIX = 'modal'; const MODAL_DATA_BACKGROUND_ATTR = 'data-modal-background-click'; const MODAL_PREFIX_CLASS_ATTR = 'data-modal-prefix-class'; const MODAL_TEXT_ATTR = 'data-modal-text'; const MODAL_CONTENT_ID_ATTR = 'data-modal-content-id'; const MODAL_DESCRIBEDBY_ID_ATTR = 'data-modal-describedby-id'; const MODAL_TITLE_ATTR = 'data-modal-title'; const MODAL_FOCUS_TO_ATTR = 'data-modal-focus-toid'; const MODAL_CLOSE_TEXT_ATTR = 'data-modal-close-text'; const MODAL_CLOSE_TITLE_ATTR = 'data-modal-close-title'; const MODAL_CLOSE_IMG_ATTR = 'data-modal-close-img'; const MODAL_ROLE = 'dialog'; const MODAL_BUTTON_CLASS_SUFFIX = 'modal-close'; const MODAL_BUTTON_JS_ID = 'js-modal-close'; const MODAL_BUTTON_JS_CLASS = 'js-modal-close'; const MODAL_BUTTON_CONTENT_BACK_ID = 'data-content-back-id'; const MODAL_BUTTON_FOCUS_BACK_ID = 'data-focus-back'; const MODAL_WRAPPER_CLASS_SUFFIX = 'modal__wrapper'; const MODAL_CONTENT_CLASS_SUFFIX = 'modal__content'; const MODAL_CONTENT_JS_ID = 'js-modal-content'; const MODAL_CLOSE_IMG_CLASS_SUFFIX = 'modal__closeimg'; const MODAL_CLOSE_TEXT_CLASS_SUFFIX = 'modal-close__text'; const MODAL_TITLE_ID = 'modal-title'; const MODAL_TITLE_CLASS_SUFFIX = 'modal-title'; const FOCUSABLE_ELEMENTS_STRING = "a[href], area[href], input:not([type='hidden']):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]"; const WRAPPER_PAGE_JS = 'js-modal-page'; const MODAL_JS_ID = 'js-modal'; const MODAL_OVERLAY_ID = 'js-modal-overlay'; const MODAL_OVERLAY_CLASS_SUFFIX = 'modal-overlay'; const MODAL_OVERLAY_TXT = 'Close modal'; const MODAL_OVERLAY_BG_ENABLED_ATTR = 'data-background-click'; const VISUALLY_HIDDEN_CLASS = 'invisible'; const NO_SCROLL_CLASS = 'no-scroll'; const ATTR_ROLE = 'role'; const ATTR_OPEN = 'open'; const ATTR_LABELLEDBY = 'aria-labelledby'; const ATTR_DESCRIBEDBY = 'aria-describedby'; const ATTR_HIDDEN = 'aria-hidden'; //const ATTR_MODAL = 'aria-modal="true"'; const ATTR_HASPOPUP = 'aria-haspopup'; const ATTR_HASPOPUP_VALUE = 'dialog'; const findById = id => doc.getElementById(id); const addClass = (el, className) => { if (el.classList) { el.classList.add(className); // IE 10+ } else { el.className += ' ' + className; // IE 8+ } } const removeClass = (el, className) => { if (el.classList) { el.classList.remove(className); // IE 10+ } else { el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' '); // IE 8+ } } const hasClass = (el, className) => { if (el.classList) { return el.classList.contains(className); // IE 10+ } else { return new RegExp('(^| )' + className + '( |$)', 'gi').test(el.className); // IE 8+ ? } } /*const wrapInner = (el, wrapper_el) => { // doesn't work on IE/Edge, f… while (el.firstChild) wrapper_el.append(el.firstChild); el.append(wrapper_el); }*/ function wrapInner(parent, wrapper) { if (typeof wrapper === "string") wrapper = document.createElement(wrapper); parent.appendChild(wrapper); while (parent.firstChild !== wrapper) wrapper.appendChild(parent.firstChild); } function remove(el) { /* node.remove() is too modern for IE≤11 */ el.parentNode.removeChild(el); } /* gets an element el, search if it is child of parent class, returns id of the parent */ let searchParent = (el, parentClass) => { let found = false; let parentElement = el.parentNode; while (parentElement && found === false) { if (hasClass(parentElement, parentClass) === true) { found = true; } else { parentElement = parentElement.parentNode; } } if (found === true) { return parentElement.getAttribute('id'); } else { return ''; } } /** * Create the template for an overlay * @param {Object} config * @return {String} */ const createOverlay = config => { let id = MODAL_OVERLAY_ID; let overlayText = config.text || MODAL_OVERLAY_TXT; let overlayClass = config.prefixClass + MODAL_OVERLAY_CLASS_SUFFIX; let overlayBackgroundEnabled = config.backgroundEnabled === 'disabled' ? 'disabled' : 'enabled'; return `<span id="${id}" class="${overlayClass}" ${MODAL_OVERLAY_BG_ENABLED_ATTR}="${overlayBackgroundEnabled}" title="${overlayText}" > <span class="${VISUALLY_HIDDEN_CLASS}">${overlayText}</span> </span>`; }; /** * Create the template for a modal * @param {Object} config * @return {String} */ const createModal = config => { let id = MODAL_JS_ID; let modalClassName = config.modalPrefixClass + MODAL_CLASS_SUFFIX; let modalClassWrapper = config.modalPrefixClass + MODAL_WRAPPER_CLASS_SUFFIX; let buttonCloseClassName = config.modalPrefixClass + MODAL_BUTTON_CLASS_SUFFIX; let buttonCloseInner = config.modalCloseImgPath ? `<img src="${config.modalCloseImgPath}" alt="${config.modalCloseText}" class="${config.modalPrefixClass}${MODAL_CLOSE_IMG_CLASS_SUFFIX}" />` : `<span class="${config.modalPrefixClass}${MODAL_CLOSE_TEXT_CLASS_SUFFIX}"> ${config.modalCloseText} </span>`; let contentClassName = config.modalPrefixClass + MODAL_CONTENT_CLASS_SUFFIX; let titleClassName = config.modalPrefixClass + MODAL_TITLE_CLASS_SUFFIX; let title = config.modalTitle !== '' ? `<h1 id="${MODAL_TITLE_ID}" class="${titleClassName}"> ${config.modalTitle} </h1>` : ''; let button_close = `<button type="button" class="${MODAL_BUTTON_JS_CLASS} ${buttonCloseClassName}" id="${MODAL_BUTTON_JS_ID}" title="${config.modalCloseTitle}" ${MODAL_BUTTON_CONTENT_BACK_ID}="${config.modalContentId}" ${MODAL_BUTTON_FOCUS_BACK_ID}="${config.modalFocusBackId}"> ${buttonCloseInner} </button>`; let content = config.modalText; let describedById = config.modalDescribedById !== '' ? `${ATTR_DESCRIBEDBY}="${config.modalDescribedById}"` : ''; // If there is no content but an id we try to fetch content id if (content === '' && config.modalContentId) { let contentFromId = findById(config.modalContentId); if (contentFromId) { content = `<div id="${MODAL_CONTENT_JS_ID}"> ${contentFromId.innerHTML} </div`; // we remove content from its source to avoid id duplicates, etc. contentFromId.innerHTML = ''; } } return `<dialog id="${id}" class="${modalClassName}" ${ATTR_ROLE}="${MODAL_ROLE}" ${describedById} ${ATTR_OPEN} ${ATTR_LABELLEDBY}="${MODAL_TITLE_ID}"> <div role="document" class="${modalClassWrapper}"> ${button_close} <div class="${contentClassName}"> ${title} ${content} </div> </div> </dialog>`; }; const closeModal = config => { remove(config.modal); remove(config.overlay); if (config.contentBackId !== '') { let contentBack = findById(config.contentBackId); if (contentBack) { contentBack.innerHTML = config.modalContent; } } if (config.modalFocusBackId) { let contentFocus = findById(config.modalFocusBackId); if (contentFocus) { contentFocus.focus(); } } } /** Find all modals inside a container * @param {Node} node Default document * @return {Array} */ const $listModals = (node = doc) => [].slice.call(node.querySelectorAll('.' + MODAL_JS_CLASS)); /** * Build modals for a container * @param {Node} node */ const attach = (node, addListeners = true) => { $listModals(node) .forEach((modal_node) => { let iLisible = Math.random().toString(32).slice(2, 12); let wrapperBody = findById(WRAPPER_PAGE_JS); let body = doc.querySelector('body'); if(! modal_node.getAttribute('id')){ modal_node.setAttribute('id', MODAL_ID_PREFIX + iLisible); } modal_node.setAttribute(ATTR_HASPOPUP, ATTR_HASPOPUP_VALUE); if (wrapperBody === null || wrapperBody.length === 0) { let wrapper = doc.createElement('DIV'); wrapper.setAttribute('id', WRAPPER_PAGE_JS); wrapInner(body, wrapper); } }); if (addListeners) { /* listeners */ ['click', 'keydown'] .forEach(eventName => { doc.body .addEventListener(eventName, e => { // click on link modal let parentModalLauncher = searchParent(e.target, MODAL_JS_CLASS); if ((hasClass(e.target, MODAL_JS_CLASS) === true || parentModalLauncher !== '') && eventName === 'click') { let body = doc.querySelector('body'); let modalLauncher = parentModalLauncher !== '' ? findById(parentModalLauncher) : e.target; let modalPrefixClass = modalLauncher.hasAttribute(MODAL_PREFIX_CLASS_ATTR) === true ? modalLauncher.getAttribute(MODAL_PREFIX_CLASS_ATTR) + '-' : ''; let modalText = modalLauncher.hasAttribute(MODAL_TEXT_ATTR) === true ? modalLauncher.getAttribute(MODAL_TEXT_ATTR) : ''; let modalContentId = modalLauncher.hasAttribute(MODAL_CONTENT_ID_ATTR) === true ? modalLauncher.getAttribute(MODAL_CONTENT_ID_ATTR) : ''; let modalDescribedById = modalLauncher.hasAttribute(MODAL_DESCRIBEDBY_ID_ATTR) === true ? modalLauncher.getAttribute(MODAL_DESCRIBEDBY_ID_ATTR) : ''; let modalTitle = modalLauncher.hasAttribute(MODAL_TITLE_ATTR) === true ? modalLauncher.getAttribute(MODAL_TITLE_ATTR) : ''; let modalCloseText = modalLauncher.hasAttribute(MODAL_CLOSE_TEXT_ATTR) === true ? modalLauncher.getAttribute(MODAL_CLOSE_TEXT_ATTR) : MODAL_OVERLAY_TXT; let modalCloseTitle = modalLauncher.hasAttribute(MODAL_CLOSE_TITLE_ATTR) === true ? modalLauncher.getAttribute(MODAL_CLOSE_TITLE_ATTR) : modalCloseText; let modalCloseImgPath = modalLauncher.hasAttribute(MODAL_CLOSE_IMG_ATTR) === true ? modalLauncher.getAttribute(MODAL_CLOSE_IMG_ATTR) : ''; let backgroundEnabled = modalLauncher.hasAttribute(MODAL_DATA_BACKGROUND_ATTR) === true ? modalLauncher.getAttribute(MODAL_DATA_BACKGROUND_ATTR) : ''; let modalGiveFocusToId = modalLauncher.hasAttribute(MODAL_FOCUS_TO_ATTR) === true ? modalLauncher.getAttribute(MODAL_FOCUS_TO_ATTR) : ''; let wrapperBody = findById(WRAPPER_PAGE_JS); // insert overlay body.insertAdjacentHTML('beforeEnd', createOverlay({ text: modalCloseTitle, backgroundEnabled: backgroundEnabled, prefixClass: modalPrefixClass })); // insert modal body.insertAdjacentHTML('beforeEnd', createModal({ modalText: modalText, modalPrefixClass: modalPrefixClass, backgroundEnabled: modalContentId, modalTitle: modalTitle, modalCloseText: modalCloseText, modalCloseTitle: modalCloseTitle, modalCloseImgPath: modalCloseImgPath, modalContentId: modalContentId, modalDescribedById: modalDescribedById, modalFocusBackId: modalLauncher.getAttribute('id') })); // hide page wrapperBody.setAttribute(ATTR_HIDDEN, 'true'); // add class noscroll to body addClass(body, NO_SCROLL_CLASS); // give focus to close button or specified element let closeButton = findById(MODAL_BUTTON_JS_ID); if (modalGiveFocusToId !== '') { let focusTo = findById(modalGiveFocusToId); if (focusTo) { focusTo.focus(); } else { closeButton.focus(); } } else { closeButton.focus(); } e.preventDefault(); } // click on close button or on overlay not blocked let parentButton = searchParent(e.target, MODAL_BUTTON_JS_CLASS); if ( ( e.target.getAttribute('id') === MODAL_BUTTON_JS_ID || parentButton !== '' || e.target.getAttribute('id') === MODAL_OVERLAY_ID || hasClass(e.target, MODAL_BUTTON_JS_CLASS) === true ) && eventName === 'click' ) { let body = doc.querySelector('body'); let wrapperBody = findById(WRAPPER_PAGE_JS); let modal = findById(MODAL_JS_ID); let modalContent = findById(MODAL_CONTENT_JS_ID) ? findById(MODAL_CONTENT_JS_ID).innerHTML : ''; let overlay = findById(MODAL_OVERLAY_ID); let modalButtonClose = findById(MODAL_BUTTON_JS_ID); let modalFocusBackId = modalButtonClose.getAttribute(MODAL_BUTTON_FOCUS_BACK_ID); let contentBackId = modalButtonClose.getAttribute(MODAL_BUTTON_CONTENT_BACK_ID); let backgroundEnabled = overlay.getAttribute(MODAL_OVERLAY_BG_ENABLED_ATTR); if (!(e.target.getAttribute('id') === MODAL_OVERLAY_ID && backgroundEnabled === 'disabled')) { closeModal({ modal: modal, modalContent: modalContent, overlay: overlay, modalFocusBackId: modalFocusBackId, contentBackId: contentBackId, backgroundEnabled: backgroundEnabled, fromId: e.target.getAttribute('id') }); // show back page wrapperBody.removeAttribute(ATTR_HIDDEN); // remove class noscroll to body removeClass(body, NO_SCROLL_CLASS); } } // strike a key when modal opened if (findById(MODAL_JS_ID) && eventName === 'keydown') { let body = doc.querySelector('body'); let wrapperBody = findById(WRAPPER_PAGE_JS); let modal = findById(MODAL_JS_ID); let modalContent = findById(MODAL_CONTENT_JS_ID) ? findById(MODAL_CONTENT_JS_ID).innerHTML : ''; let overlay = findById(MODAL_OVERLAY_ID); let modalButtonClose = findById(MODAL_BUTTON_JS_ID); let modalFocusBackId = modalButtonClose.getAttribute(MODAL_BUTTON_FOCUS_BACK_ID); let contentBackId = modalButtonClose.getAttribute(MODAL_BUTTON_CONTENT_BACK_ID); let $listFocusables = [].slice.call(modal.querySelectorAll(FOCUSABLE_ELEMENTS_STRING)); // esc if (e.keyCode === 27) { closeModal({ modal: modal, modalContent: modalContent, overlay: overlay, modalFocusBackId: modalFocusBackId, contentBackId: contentBackId, }); // show back page wrapperBody.removeAttribute(ATTR_HIDDEN); // remove class noscroll to body removeClass(body, NO_SCROLL_CLASS); } // tab or Maj Tab in modal => capture focus if (e.keyCode === 9 && $listFocusables.indexOf(e.target) >= 0) { // maj-tab on first element focusable => focus on last if (e.shiftKey) { if (e.target === $listFocusables[0]) { $listFocusables[$listFocusables.length - 1].focus(); e.preventDefault(); } } else { // tab on last element focusable => focus on first if (e.target === $listFocusables[$listFocusables.length - 1]) { $listFocusables[0].focus(); e.preventDefault(); } } } // tab outside modal => put it in focus if (e.keyCode === 9 && $listFocusables.indexOf(e.target) === -1) { e.preventDefault(); $listFocusables[0].focus(); } } }, true); }); } }; const onLoad = () => { attach(); document.removeEventListener('DOMContentLoaded', onLoad); } document.addEventListener('DOMContentLoaded', onLoad); window.van11yAccessibleModalWindowAria = attach; })(document);