accessible-toggle
Version:
Accessible and responsive toggling of an element's visibility
411 lines (358 loc) • 10.6 kB
JavaScript
import throttle from 'raf-throttle';
/**
* Defaults for the user-configurable options
*
* @type {Object}
*/
const defaultOptions = {
trapFocus: true,
assignFocus: true,
closeOnEsc: true,
closeOnClickOutside: false,
mediaQuery: false,
onShow: () => {},
onHide: () => {},
onEnable: () => {},
onDisable: () => {},
};
/**
* Elements that can receive tab focus
* (cribbed from https://github.com/edenspiekermann/a11y-dialog)
*/
const 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])',
];
const 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) {
const elementList = (context || document).querySelectorAll(selector);
return Array.prototype.slice.call(elementList);
}
export default class AccessibleToggle {
/**
* 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
*/
constructor(element, 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 = throttle(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
*/
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
*/
destroy() {
window.removeEventListener('resize', this.throttledMediaQueryTest);
this.disable();
}
/**
* Adds ARIA roles to all the elements and attaches event handler
*/
enable() {
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((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
const event = new Event('toggle-enable');
this.content.dispatchEvent(event);
this.active = true;
}
}
/**
* Removes all ARIA roles
*/
disable() {
if (this.active) {
document.removeEventListener('click', this.boundClickHandler);
document.removeEventListener('keyup', this.boundKeyupHandler);
// Button properties
this.buttons.forEach((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((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
const event = new Event('toggle-disable');
this.content.dispatchEvent(event);
this.active = false;
}
}
/**
* Toggles the script on and off based on a media query
*/
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
*/
isOpen() {
return this.content.getAttribute('aria-hidden') !== 'true';
}
/**
* Show the content
*
* @return {class} The accessible-toggle class
*/
show() {
// Set ARIA attributes
this.content.setAttribute('aria-hidden', 'false');
this.buttons.forEach((button) => {
button.setAttribute('aria-expanded', 'true');
});
// Allow child elements to receive focus
this.focusableChildren.forEach((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) {
const 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
const event = new Event('toggle-show');
this.content.dispatchEvent(event);
return this;
}
/**
* Hide the content
*
* @return {class} The accessible-toggle class
*/
hide() {
// Set ARIA attributes
this.content.setAttribute('aria-hidden', 'true');
this.buttons.forEach((button) => {
button.setAttribute('aria-expanded', 'false');
});
// Remove child elements from the tab order
this.focusableChildren.forEach((element) => {
const 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
const 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
*/
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
*/
clickHandler(event) {
// If the click was on one of the control buttons, or a
// child element of a control button, toggle visibility
let 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
*/
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
*/
getFocusableChildElements() {
return $$(focusable.join(','), this.content).filter((child) => {
return Boolean(
child.offsetWidth || child.offsetHeight || child.getClientRects().length
);
});
}
/**
* Trap tab focus inside the given element
*
* @param {Event} event The focus event
*/
trapFocus(event) {
if (this.focusableChildren.length > 0) {
const 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();
}
}
}
}