UNPKG

ctrly

Version:

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

643 lines (624 loc) 17.8 kB
/*! * ctrly v0.7.0 * Copyright (c) 2018-2019 Jan Sorgalla * License: MIT */ function activeElement() { try { var _document = document, _activeElement = _document.activeElement; return _activeElement && _activeElement.nodeName ? _activeElement : document.body; } catch (e) { return document.body; } } function parents(element) { var list = []; while (element && element.parentNode && element.parentNode.nodeType === 1) { element = element.parentNode; list.push(element); } return list; } function scrollPositionRestorer(element) { var positions = parents(element).map(function (parent) { return [parent, parent.scrollTop, parent.scrollLeft]; }); return function () { positions.forEach(function (cache) { cache[0].scrollTop = cache[1]; cache[0].scrollLeft = cache[2]; }); }; } function focus(element) { var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, restoreScrollPosition = _ref.restoreScrollPosition; var restorer; if (restoreScrollPosition) { restorer = scrollPositionRestorer(element); } try { element.focus(); } catch (e) { } if (restorer) { restorer(); } } function matches(element, selector) { if (!element) { return false; } var nativeMatches = element.matches || element.webkitMatchesSelector || element.msMatchesSelector; if (typeof nativeMatches !== 'function') { return false; } return nativeMatches.call(element, selector); } function closest(element, selector) { if (!element) { return null; } if (typeof element.closest === 'function') { return element.closest(selector); } do { if (matches(element, selector)) { return element; } element = element.parentNode; } while (element && element.nodeType === 1); return null; } function find(selector, element) { var context = arguments.length > 1 ? element : document; if (!context || typeof context.querySelectorAll !== 'function') { return []; } return [].slice.call(context.querySelectorAll(selector)); } var selector = ['a[href]', 'area[href]', 'input', 'select', 'textarea', 'button', 'iframe', 'object', 'audio[controls]', 'video[controls]', '[contenteditable]', '[tabindex]'].join(','); var inputNodeNameRegexp = /^(input|select|textarea|button|object)$/; function focusableFilter(element) { var nodeName = element.nodeName.toLowerCase(); if (nodeName === 'area') { return isValidArea(element); } if (element.disabled) { return false; } if (inputNodeNameRegexp.test(nodeName)) { var fieldset = closest(element, 'fieldset'); if (fieldset && fieldset.disabled) { return false; } } return visible(element); } function tabbableFilter(element) { var index = tabIndex(element); return focusableFilter(element) && index >= 0; } function compare(a, b) { var aIndex = tabIndex(a, true); var bIndex = tabIndex(b, true); if (aIndex === bIndex) { return a.compareDocumentPosition(b) & 2 ? 1 : -1; } return aIndex - bIndex; } function isValidArea(element) { var map = element.parentNode; var mapName = map.name; if (!element.href || !mapName || map.nodeName.toLowerCase() !== 'map') { return false; } var images = find("img[usemap=\"#".concat(mapName, "\"]")); return images.length > 0 && visible(images[0]); } function visible(element) { var style = getComputedStyle(element); return ( style.visibility !== 'hidden' && style.visibility !== 'collapse' && style.display !== 'none' && parents(element).every(function (el) { return getComputedStyle(el).display !== 'none'; }) ); } function tabIndex(element) { var positiveOnly = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; var index = parseInt(element.getAttribute('tabindex'), 10); return isNaN(index) ? 0 : positiveOnly && index < 0 ? 0 : index; } function isTabbable(element) { return matches(element, selector) && tabbableFilter(element); } var supportedOptions; function optionsSupport() { if (supportedOptions) { return supportedOptions; } supportedOptions = { capture: false, once: false, passive: false }; var options = { get capture() { supportedOptions.capture = true; return false; }, get once() { supportedOptions.once = true; return false; }, get passive() { supportedOptions.passive = true; return false; } }; window.addEventListener('test', options, options); window.removeEventListener('test', options, options); return supportedOptions; } function optionsArgument() { var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var _support = optionsSupport(), once = _support.once, passive = _support.passive, capture = _support.capture; if (!once && !passive && !capture) { return Boolean(options.capture); } if (!once) { delete options.once; } if (!passive) { delete options.passive; } if (!capture) { delete options.capture; } return options; } function off(target, type, listener) { var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : { capture: false }; if (!target || typeof target.removeEventListener !== 'function') { return; } target.removeEventListener(type, listener, optionsArgument(options)); } function on(target, type, listener) { var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : { capture: false }; if (!target || typeof target.addEventListener !== 'function') { return function () {}; } var callback = listener; var remove = function remove() { off(target, type, callback, options); }; if (options.once && !optionsSupport().once) { callback = function callback(event) { remove(); listener.call(target, event); }; } target.addEventListener(type, callback, optionsArgument(options)); return remove; } function delegate(target, type, selector, listener) { var options = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : { capture: false }; var once = options.once === true; delete options.once; var remove = on(target, type, function (event) { var delegateTarget = closest(event.target, selector); if (!delegateTarget) { return; } if (once) { remove(); } listener.call(delegateTarget, event, delegateTarget); }, options); return remove; } function dispatch(target, type) { var eventInit = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; if (!target || typeof target.dispatchEvent !== 'function') { return true; } eventInit.bubbles = eventInit.bubbles || false; eventInit.cancelable = eventInit.cancelable || false; eventInit.composed = eventInit.composed || false; eventInit.detail = eventInit.detail || null; var event; try { event = new CustomEvent(type, eventInit); } catch (err) { event = document.createEvent('CustomEvent'); event.initCustomEvent(type, eventInit.bubbles, eventInit.cancelable, eventInit.detail); } return target.dispatchEvent(event); } function ready(listener) { var state = document.readyState; if (state === 'complete' || state === 'interactive') { setTimeout(listener, 0); return; } document.addEventListener('DOMContentLoaded', function () { listener(); }, optionsArgument({ capture: true, once: true, passive: true })); } function tabbable(element) { return find(selector, arguments.length > 0 ? element : document).filter(tabbableFilter).sort(compare); } var 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) { var extended = {}; var args = [defaultOptions, opts]; args.forEach(function (arg) { for (var 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=\"".concat(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'); } var idCounter = 0; function ctrly() { var opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var options = settings(opts); var controlSelector = options.selector; var eventListener = options.on || {}; var 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:".concat(event), { bubbles: true, cancelable: true }) !== false; } function findParentTarget(control) { var element = control; while (element) { if (element.id && instances[element.id]) { return element; } element = element.parentElement; } } function close(target) { var returnFocus = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; if (!target) { return false; } if (!target.hasAttribute('data-ctrly-opened')) { return false; } if (!trigger(target, 'close')) { return false; } var currentActiveElement = activeElement(); var _ref = instances[target.id] || {}, lastActiveElement = _ref.lastActiveElement, destroy = _ref.destroy; delete instances[target.id]; if (destroy) { destroy(); } findControls(target).forEach(function (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(); if (returnFocus && lastActiveElement && target.contains(currentActiveElement)) { focus(lastActiveElement, { restoreScrollPosition: true }); } trigger(target, 'closed'); return target; } function closeOthers(target) { find(controlSelector, context(target)).forEach(function (control) { var other = findTarget(control); if (other && other.id !== target.id) { close(other, false); } }); } function addEventListeners(control, target) { var removeFuncs = []; if (options.closeOnBlur && !options.trapFocus) { removeFuncs.push(on(document, 'focusin', function (e) { if (!target.contains(e.target)) { setTimeout(function () { close(target, false); }, 0); } }, { capture: true, passive: true })); } if (options.closeOnEsc) { removeFuncs.push(on(document, 'keydown', function (e) { if (keyCode(e) === 27 && close(target)) { e.preventDefault(); } })); } if (options.closeOnOutsideClick) { removeFuncs.push(on(document, 'click', function (e) { if (keyCode(e) === 1 && !target.contains(e.target) && !closest(e.target, controlSelector)) { close(target); } }, { passive: true })); } if (options.closeOnScroll) { var active = false; var activate = function activate() { active = true; }; var deactivate = function 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', function () { if (!active) { close(target); } }, { passive: true })); } if (options.trapFocus) { removeFuncs.push(on(document, 'keydown', function (e) { if (keyCode(e) !== 9) { return; } var tabbableElements = tabbable(target); if (!tabbableElements[0]) { e.preventDefault(); focus(target); return; } var active = activeElement(); var firstTabStop = tabbableElements[0]; var 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 function () { while (removeFuncs.length) { removeFuncs.shift().call(); } }; } function open(control) { var 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(function (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) { var target = findTarget(control); if (!target) { 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); } target.scrollTop = 0; target.scrollLeft = 0; } } var removeControlClick; var removeControlKeydown; function init() { if (!removeControlClick) { removeControlClick = delegate(document, 'click', controlSelector, function (e, control) { if (keyCode(e) === 1 ) { toggle(e, control); } }); removeControlKeydown = delegate(document, 'keydown', controlSelector, function (e, control) { if (keyCode(e) === 13 || keyCode(e) === 32 ) { toggle(e, control); } }); } find(controlSelector).forEach(function (control) { var 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); var labelledBy = findControls(target).map(function (control) { if (!control.id) { control.setAttribute('id', 'ctrly-control-' + ++idCounter); } return control.id; }); var newLabelledBy = (target.getAttribute('aria-labelledby') || '').split(' ').concat(labelledBy).filter(function (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(function (control) { if (fullReset) { resetControl(control); } var target = findTarget(control); if (target) { close(target, false); if (fullReset) { target.removeAttribute('aria-hidden'); } } }); for (var 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: closeAll, destroy: destroy, init: init }; } export default ctrly;