UNPKG

ctrly

Version:

Lightweight and dependency-free content toggling with a focus on accessibility.

498 lines (400 loc) 13.3 kB
import { activeElement, closest, delegate, dispatch, find, focus, isTabbable, on, ready, tabbable } from 'domestique'; const defaultOptions = { selector: '[data-ctrly]', context: null, focusTarget: true, closeOnBlur: true, closeOnEsc: true, closeOnOutsideClick: true, closeOnScroll: false, trapFocus: false, allowMultiple: false, on: null, autoInit: true }; function settings(opts) { const extended = {}; const args = [defaultOptions, opts]; args.forEach(arg => { for (const prop in arg) { if (Object.prototype.hasOwnProperty.call(arg, prop)) { extended[prop] = arg[prop]; } } }); return extended; } function keyCode(e) { return 'which' in e ? e.which : e.keyCode; } function findControls(target) { return find(`[aria-controls="${target.id}"]`); } function findTarget(control) { return document.getElementById( control.getAttribute('aria-controls') || control.getAttribute('data-ctrly') ); } function resetControl(control) { control.removeAttribute('aria-pressed'); control.removeAttribute('aria-controls'); control.removeAttribute('aria-expanded'); } let idCounter = 0; export default function ctrly(opts = {}) { const options = settings(opts); const controlSelector = options.selector; const eventListener = options.on || {}; const instances = {}; function context(control) { if (!options.context) { return document; } return closest(control, options.context); } function trigger(target, event) { if (typeof eventListener[event] === 'function') { if (eventListener[event](target) === false) { return false; } } return dispatch(target, `ctrly:${event}`, { bubbles: true, cancelable: true }) !== false; } function findParentTarget(control) { let element = control; while (element) { if (element.id && instances[element.id]) { return element; } element = element.parentElement; } } function close(target, returnFocus = true) { if (!target) { return false; } if (!target.hasAttribute('data-ctrly-opened')) { return false; } if (!trigger(target, 'close')) { return false; } // Store reference before we call target.blur() const currentActiveElement = activeElement(); const {lastActiveElement, destroy} = instances[target.id] || {}; delete instances[target.id]; if (destroy) { destroy(); } findControls(target).forEach(c => { if (c.tagName.toLowerCase() !== 'button') { c.setAttribute('aria-pressed', 'false'); } c.setAttribute('aria-expanded', 'false'); }); target.removeAttribute('data-ctrly-opened'); target.setAttribute('aria-hidden', 'true'); target.removeAttribute('tabindex'); target.blur(); // We return focus only if the current focus is inside this target if ( returnFocus && lastActiveElement && target.contains(currentActiveElement) ) { focus(lastActiveElement, { restoreScrollPosition: true }); } trigger(target, 'closed'); return target; } function closeOthers(target) { find(controlSelector, context(target)).forEach(control => { const other = findTarget(control); if (other && other.id !== target.id) { close(other, false); } }); } function addEventListeners(control, target) { const removeFuncs = []; if (options.closeOnBlur && !options.trapFocus) { removeFuncs.push( on(document, 'focusin', e => { if (!target.contains(e.target)) { // Delay closing target since the "focusin" event is // triggered before the control click handlers. // If focus is shifted to a control by clicking on it, // the click handler must have precedence. setTimeout(() => { close(target, false); }, 0); } }, {capture: true, passive: true}) ); } if (options.closeOnEsc) { removeFuncs.push( on(document, 'keydown', e => { if (keyCode(e) === 27 && close(target)) { e.preventDefault(); } }) ); } if (options.closeOnOutsideClick) { removeFuncs.push( on(document, 'click', e => { // Close only after click on document // - if it's a left button mouse click // - if currently not interacting with the target // - if the click did not originated from (within) a control if ( keyCode(e) === 1 && !target.contains(e.target) && !closest(e.target, controlSelector) ) { close(target); } }, {passive: true}) ); } if (options.closeOnScroll) { let active = false; const activate = () => { active = true; }; const deactivate = () => { active = false; }; removeFuncs.push( on(target, 'mouseenter', activate, {passive: true}) ); removeFuncs.push( on(target, 'mouseleave', deactivate, {passive: true}) ); removeFuncs.push( on(target, 'touchstart', activate, {passive: true}) ); removeFuncs.push( on(target, 'touchend', deactivate, {passive: true}) ); removeFuncs.push( on(window, 'scroll', () => { if (!active) { close(target); } }, {passive: true}) ); } if (options.trapFocus) { removeFuncs.push( on(document, 'keydown', e => { if (keyCode(e) !== 9) { return; } const tabbableElements = tabbable(target); if (!tabbableElements[0]) { e.preventDefault(); focus(target); return; } const active = activeElement(); const firstTabStop = tabbableElements[0]; const lastTabStop = tabbableElements[tabbableElements.length - 1]; if (e.shiftKey && active === firstTabStop) { e.preventDefault(); focus(lastTabStop); return; } if (!e.shiftKey && active === lastTabStop) { focus(firstTabStop); e.preventDefault(); } }) ); } return () => { while (removeFuncs.length) { removeFuncs.shift().call(); } }; } function open(control) { const target = findTarget(control); if (!target) { resetControl(control); return false; } if (target.hasAttribute('data-ctrly-opened')) { return false; } if (!trigger(target, 'open')) { return false; } instances[target.id] = { lastActiveElement: activeElement(), destroy: addEventListeners(control, target) }; findControls(target).forEach(c => { if (c.tagName.toLowerCase() !== 'button') { c.setAttribute('aria-pressed', 'true'); } c.setAttribute('aria-expanded', 'true'); }); target.setAttribute('data-ctrly-opened', ''); target.setAttribute('aria-hidden', 'false'); target.setAttribute('tabindex', '-1'); trigger(target, 'opened'); return target; } function toggle(e, control) { const target = findTarget(control); if (!target) { // Allow controls without a value for data-ctrly // to close a target if it's a child element if (close(findParentTarget(control))) { e.preventDefault(); } return; } if (control.getAttribute('aria-expanded') === 'true') { if (close(target)) { e.preventDefault(); } return; } if (!options.allowMultiple) { closeOthers(target); } open(control); if (target) { e.preventDefault(); if (options.focusTarget) { focus( tabbable(target)[0] || target ); } // Reset scrolling after focusing target.scrollTop = 0; target.scrollLeft = 0; } } let removeControlClick; let removeControlKeydown; function init() { if (!removeControlClick) { removeControlClick = delegate(document, 'click', controlSelector, (e, control) => { if (keyCode(e) === 1 /* Left mouse button */) { toggle(e, control); } }); removeControlKeydown = delegate(document, 'keydown', controlSelector, (e, control) => { if ( keyCode(e) === 13 /* Enter */ || keyCode(e) === 32 /* Space */ ) { toggle(e, control); } }); } find(controlSelector).forEach(control => { const target = findTarget(control); if (!target) { resetControl(control); return; } if (control.tagName.toLowerCase() !== 'button') { if (!control.hasAttribute('role')) { control.setAttribute('role', 'button'); } if (!isTabbable(control)) { control.setAttribute('tabindex', '0'); } } control.setAttribute('aria-controls', target.id); const labelledBy = findControls(target).map(control => { if (!control.id) { control.setAttribute('id', 'ctrly-control-' + ++idCounter); } return control.id; }); const newLabelledBy = (target.getAttribute('aria-labelledby') || '') .split(' ') .concat(labelledBy) .filter((id, pos, arr) => { return id !== '' && arr.indexOf(id) === pos; }); target.setAttribute('aria-labelledby', newLabelledBy.join(' ')); if ( control.getAttribute('aria-expanded') === 'true' || control.hasAttribute('data-ctrly-open') ) { open(control); return; } if (control.tagName.toLowerCase() !== 'button') { control.setAttribute('aria-pressed', 'false'); } control.setAttribute('aria-expanded', 'false'); target.setAttribute('aria-hidden', 'true'); target.removeAttribute('tabindex'); }); } function reset(fullReset) { if (fullReset && removeControlClick) { removeControlClick(); removeControlClick = null; removeControlKeydown(); removeControlKeydown = null; } find(controlSelector).forEach(control => { if (fullReset) { resetControl(control); } const target = findTarget(control); if (target) { close(target, false); if (fullReset) { target.removeAttribute('aria-hidden'); } } }); // Iterate leftover instances for (const id in instances) { if (Object.prototype.hasOwnProperty.call(instances, id)) { instances[id].destroy(); delete instances[id]; } } } function closeAll() { reset(false); } function destroy() { reset(true); } if (options.autoInit) { ready(init); } return { closeAll, destroy, init }; }