accessible-toggle
Version:
Accessible and responsive toggling of an element's visibility
391 lines (325 loc) • 11.2 kB
JavaScript
"use strict";
exports.__esModule = true;
exports["default"] = void 0;
var _rafThrottle = _interopRequireDefault(require("raf-throttle"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
/**
* Defaults for the user-configurable options
*
* @type {Object}
*/
var defaultOptions = {
trapFocus: true,
assignFocus: true,
closeOnEsc: true,
closeOnClickOutside: false,
mediaQuery: false,
onShow: function onShow() {},
onHide: function onHide() {},
onEnable: function onEnable() {},
onDisable: function onDisable() {}
};
/**
* Elements that can receive tab focus
* (cribbed from https://github.com/edenspiekermann/a11y-dialog)
*/
var focusable = ['a[href]:not([tabindex^="-"]):not([inert])', 'area[href]:not([tabindex^="-"]):not([inert])', 'input:not([disabled]):not([inert])', 'select:not([disabled]):not([inert])', 'textarea:not([disabled]):not([inert])', 'button:not([disabled]):not([inert])', 'iframe:not([tabindex^="-"]):not([inert])', '[contenteditable]:not([tabindex^="-"]):not([inert])', '[tabindex]:not([tabindex^="-"]):not([inert])'];
var keyCodes = {
tab: 9,
esc: 27
};
/**
* Helper that converts the result of querySelectorAll to a plain array
*
* @param {string} selector CSS string to search for
* @param {element} context Parent to search within
* @return {array} Array of elements
*/
function $$(selector, context) {
var elementList = (context || document).querySelectorAll(selector);
return Array.prototype.slice.call(elementList);
}
var AccessibleToggle = /*#__PURE__*/function () {
/**
* Constructor – stores references to all the DOM elements
* and runs the "setup" function
*
* @param {Element} element The toggleable content element
* @param {Object} options Configurable options
*/
function AccessibleToggle(element, options) {
if (options === void 0) {
options = {};
}
if (!element || !(element instanceof HTMLElement)) {
console.warn('Toggle: first parameter must by an HTML element.');
return;
}
this.content = element;
this.id = element.id;
this.buttons = $$("[data-toggle='" + this.id + "']");
this.focusableChildren = this.getFocusableChildElements();
this.options = Object.assign({}, defaultOptions, options);
this.throttledMediaQueryTest = (0, _rafThrottle["default"])(this.testMediaQuery.bind(this));
if (this.buttons.length === 0) {
console.warn('Toggle: there are no buttons that control the toggleable element.');
return;
}
this.setup();
}
/**
* Add event listeners and mount the control
*/
var _proto = AccessibleToggle.prototype;
_proto.setup = function setup() {
// Start things off
if (this.options.mediaQuery === false) {
// No media query – go ahead and run everything as normal
this.enable();
} else {
// Check if it should be setup now, and again every time the window is resized
this.testMediaQuery();
window.addEventListener('resize', this.throttledMediaQueryTest);
}
}
/**
* Remove event listeners and disable the component
*/
;
_proto.destroy = function destroy() {
window.removeEventListener('resize', this.throttledMediaQueryTest);
this.disable();
}
/**
* Adds ARIA roles to all the elements and attaches event handler
*/
;
_proto.enable = function enable() {
var _this = this;
if (!this.active) {
this.boundClickHandler = this.clickHandler.bind(this);
this.boundKeypressHandler = this.keypressHandler.bind(this);
document.addEventListener('keydown', this.boundKeypressHandler);
document.addEventListener('click', this.boundClickHandler); // Toggleable content properties
this.content.setAttribute('aria-labelledby', this.id + "-control-0"); // Button properties
this.buttons.forEach(function (button, index) {
button.setAttribute('aria-controls', _this.id);
button.setAttribute('id', _this.id + "-control-" + index);
});
if (this.content.hasAttribute("data-toggle-open")) {
this.show();
} else {
this.hide();
} // Trigger callback
if (typeof this.options.onEnable === 'function') {
this.options.onEnable();
} // Fire custom event
var event = new Event('toggle-enable');
this.content.dispatchEvent(event);
this.active = true;
}
}
/**
* Removes all ARIA roles
*/
;
_proto.disable = function disable() {
if (this.active) {
document.removeEventListener('click', this.boundClickHandler);
document.removeEventListener('keyup', this.boundKeyupHandler); // Button properties
this.buttons.forEach(function (button) {
button.removeAttribute('aria-label');
button.removeAttribute('aria-expanded');
button.removeAttribute('aria-controls');
button.removeAttribute('id');
}); // Toggleable content properties
this.content.removeAttribute('aria-hidden');
this.content.removeAttribute('aria-labelledby'); // Reset child element tabindexes
this.focusableChildren.forEach(function (element) {
if (element.hasAttribute('data-toggle-tabindex')) {
element.setAttribute('tabindex', element.getAttribute('data-toggle-tabindex'));
element.removeAttribute('data-toggle-tabindex');
} else {
element.removeAttribute('tabindex');
}
}); // Trigger callback
if (typeof this.options.onDisable === 'function') {
this.options.onDisable();
} // Fire custom event
var event = new Event('toggle-disable');
this.content.dispatchEvent(event);
this.active = false;
}
}
/**
* Toggles the script on and off based on a media query
*/
;
_proto.testMediaQuery = function testMediaQuery() {
if (this.options.mediaQuery && window.matchMedia(this.options.mediaQuery).matches) {
this.enable();
} else {
this.disable();
}
}
/**
* Test if the content panel is currently visible
*
* @return {bool} Whether the panel is visible
*/
;
_proto.isOpen = function isOpen() {
return this.content.getAttribute('aria-hidden') !== 'true';
}
/**
* Show the content
*
* @return {class} The accessible-toggle class
*/
;
_proto.show = function show() {
// Set ARIA attributes
this.content.setAttribute('aria-hidden', 'false');
this.buttons.forEach(function (button) {
button.setAttribute('aria-expanded', 'true');
}); // Allow child elements to receive focus
this.focusableChildren.forEach(function (element) {
if (element.hasAttribute('data-toggle-tabindex')) {
element.setAttribute('tabindex', element.getAttribute('data-toggle-tabindex'));
} else {
element.removeAttribute('tabindex');
}
}); // Set focus on first focusable item
if (this.options.assignFocus) {
var toFocus = this.content.querySelector('[autofocus]') || this.focusableChildren[0];
if (toFocus) {
toFocus.focus();
}
} // Trigger callback
if (typeof this.options.onShow === 'function') {
this.options.onShow();
} // Fire custom event
var event = new Event('toggle-show');
this.content.dispatchEvent(event);
return this;
}
/**
* Hide the content
*
* @return {class} The accessible-toggle class
*/
;
_proto.hide = function hide() {
// Set ARIA attributes
this.content.setAttribute('aria-hidden', 'true');
this.buttons.forEach(function (button) {
button.setAttribute('aria-expanded', 'false');
}); // Remove child elements from the tab order
this.focusableChildren.forEach(function (element) {
var oldTabIndex = element.getAttribute('tabindex');
if (oldTabIndex) {
element.dataset.toggleTabindex = oldTabIndex;
}
element.setAttribute('tabindex', '-1');
}); // Trigger callback
if (typeof this.options.onShow === 'function') {
this.options.onHide();
} // Fire custom event
var event = new Event('toggle-hide');
this.content.dispatchEvent(event);
return this;
}
/**
* Toggles visibility and ARIA roles
*
* @param {bool} display True to show the content, false to hide it
* @return {class} The accessible-toggle class
*/
;
_proto.toggle = function toggle(display) {
if (typeof display === 'undefined') {
display = !this.isOpen();
}
if (display) {
this.show();
} else {
this.hide();
}
return this;
}
/**
* Handle clicks
*
* @param {event} event The click event
*/
;
_proto.clickHandler = function clickHandler(event) {
// If the click was on one of the control buttons, or a
// child element of a control button, toggle visibility
var element = event.target;
while (element && element.nodeType === 1) {
if (this.buttons.includes(element)) {
event.preventDefault();
this.toggle();
return;
}
element = element.parentNode;
} // If the content is visible and the user clicks outside
// of it, close the content
if (this.options.closeOnClickOutside && this.isOpen() && this.content !== event.target && !this.content.contains(event.target)) {
event.preventDefault();
this.hide();
}
}
/**
* Handle keypresses
*
* @param {event} event The keypress event
*/
;
_proto.keypressHandler = function keypressHandler(event) {
// Is ESC key?
if (this.options.closeOnEsc && this.isOpen() && event.which === keyCodes.esc) {
event.preventDefault();
this.hide();
this.buttons[0].focus();
} // Tab key?
if (this.options.trapFocus && event.which === keyCodes.tab) {
this.trapFocus(event);
}
}
/**
* Get all focusable child elements of the given element
*
* @return {aray} Array of focusable elements
*/
;
_proto.getFocusableChildElements = function getFocusableChildElements() {
return $$(focusable.join(','), this.content).filter(function (child) {
return Boolean(child.offsetWidth || child.offsetHeight || child.getClientRects().length);
});
}
/**
* Trap tab focus inside the given element
*
* @param {Event} event The focus event
*/
;
_proto.trapFocus = function trapFocus(event) {
if (this.focusableChildren.length > 0) {
var focusedItemIndex = this.focusableChildren.indexOf(document.activeElement) || 0; // If we're on the last focusable item, loop back to the first
if (!event.shiftKey && focusedItemIndex === this.focusableChildren.length - 1) {
this.focusableChildren[0].focus();
event.preventDefault();
} // If we're on the first focusable item and shift-tab
// (moving backward), wrap to the last item
if (event.shiftKey && focusedItemIndex === 0) {
this.focusableChildren[this.focusableChildren.length - 1].focus();
event.preventDefault();
}
}
};
return AccessibleToggle;
}();
exports["default"] = AccessibleToggle;
module.exports = exports.default;