@stormid/toggle
Version:
Accessible DOM state toggling
239 lines (219 loc) • 9.22 kB
JavaScript
import { FOCUSABLE_ELEMENTS, ACCEPTED_TRIGGERS, EVENTS } from './constants';
/*
* Partially applied function
* Sets aria attributes and adds eventListener to each toggle button
*
* @param store, Object, model or state of the current instance
* @returns Function
*/
export const initUI = store => () => {
const { toggles, node, settings } = store.getState();
if (settings.useHidden) node.hidden = true;
toggles.forEach(toggle => {
const id = node.getAttribute('id');
if (toggle.tagName !== 'BUTTON') toggle.setAttribute('role', 'button');
if (!id) throw console.warn(`The toggle target should have an id.`);
toggle.setAttribute('aria-controls', id);
toggle.setAttribute('aria-expanded', 'false');
toggle.addEventListener('click', e => {
e.preventDefault();
startToggleLifecycle(store)();
});
});
};
/*
* Partially applied function
* Dispatches a toggle action to the store
*
* @param store, Object, model or state of the current instance
* @returns Function
*/
export const toggle = store => () => {
store.update({
...store.getState(),
isOpen: !store.getState().isOpen
},
[ toggleAttributes, manageFocus(store), closeProxy(store), broadcast(store) ]
);
};
/*
* Partially applied function that returns a function that begins the toggle lifecycle (prehook > toggle > callback)
*
* @param store, Object, model or state of the current instance
* @returns Function
*/
export const startToggleLifecycle = store => () => {
const { node, toggles, settings, isOpen, classTarget, animatingClass } = store.getState();
(settings.prehook && typeof settings.prehook === 'function') && settings.prehook({ node, toggles, isOpen });
classTarget.classList.add(animatingClass);
const fn = () => {
toggle(store)();
(!!settings.callback && typeof settings.callback === 'function') && settings.callback({ node, toggles, isOpen: store.getState().isOpen });
};
if (isOpen && +settings.delay > 0) window.setTimeout(fn, +settings.delay);
else fn();
};
/*
* Returns an Array of HTMLElements selected based on data-toggle attribute of a given node
*
* @param node, HTMLElement, node to be toggled
* @return Array of HTMLElements
*/
export const findToggles = node => {
const toggleSelector = node.getAttribute('data-toggle');
const composeSelector = classSelector => ACCEPTED_TRIGGERS.map(sel => `${sel}.${classSelector}`).join(', ');
const toggles = node.getAttribute('data-toggle') && [].slice.call(document.querySelectorAll(composeSelector(toggleSelector)));
if (!toggles) console.warn(`Toggle cannot be initialised, no buttons or anchors found for ${node}. Does it have a data-toggle attribute that identifies toggle buttons?`);
return toggles;
};
/*
* Returns an Array of HTMLElements selected from parentNode based on whitelist FOCUSABLE_ELEMENTS constant
*
* @param node, HTMLElement, node to be toggled
* @return Array of HTMLElements
*/
export const getFocusableChildren = node => [].slice.call(node.querySelectorAll(FOCUSABLE_ELEMENTS.join(',')));
/*
* Change toggle button attributes and node target classNames
*
* @param props, Object, composed of properties of current state required to accessibly change button and toggle attributes
*/
export const toggleAttributes = ({ toggles, isOpen, node, classTarget, animatingClass, statusClass, settings }) => {
toggles.forEach(toggle => toggle.setAttribute('aria-expanded', isOpen));
classTarget.classList.remove(animatingClass);
classTarget.classList[isOpen ? 'add' : 'remove'](statusClass);
if (settings.useHidden) node.hidden = !isOpen;
};
/*
* Partially applied function that returns a handler function for keydown events when toggle is open
*
* @param store, Object, model or store of the current instance
* @returns Function, keyboard event handler
*
* @param Event, document keydown event dispatched from document
*/
export const keyListener = store => e => {
switch (e.keyCode){
case 27:
e.preventDefault();
startToggleLifecycle(store);
break;
case 9:
trapTab(store, e);
break;
}
};
/*
* Checks activeElement and compares with array of focusable elements in target node
* If shift is held focus set on the last focusable element
* If last element, focus is set on the first element
*
* @param store, Object, model or store of the current instance
* @param e, Event, document keydown event passed down from keyListener
*/
const trapTab = (store, e) => {
const focusableChildren = store.getState().focusableChildren;
const focusedIndex = focusableChildren.indexOf(document.activeElement);
if (e.shiftKey && focusedIndex === 0) {
e.preventDefault();
focusableChildren[focusableChildren.length - 1].focus();
} else if (!e.shiftKey && focusedIndex === focusableChildren.length - 1) {
e.preventDefault();
focusableChildren[0].focus();
}
};
/*
* Checks if the event was dispatched from a toggle button
*
* @param toggles, Array of toggle HTMLElements
* @param target, event target
*
* @returns Boolean, true if event was dispatched from a toggle button
*
*/
const targetIsToggle = (toggles, target) => toggles.reduce((acc, toggle) => {
if (toggle === target|| toggle.contains(target)) acc = true;
return acc;
}, false);
/*
* Partially applied factory function that returns handlers for focusin events
* Returned function is added as an eventListener when closeOnBlur option is true
*
* @param store, Object, model or store of the current instance
* @returns Function, event handler
*
* @param Event, event dispatched from document
*/
export const focusInListener = store => e => {
const state = store.getState();
if (!state.node.contains(e.target) && !targetIsToggle(state.toggles, e.target)) toggle(store)();
};
/*
* Partially applied factory function that returns handlers for focusin and click events
* Returned function is added as an eventListener when closeOnClick options are true
*
* @param store, Object, model or store of the current instance
* @returns Function, event handler
*
* @param Event, event dispatched from document
*/
export const clickListener = store => e => {
const { node, toggles } = store.getState();
if (node.contains(e.target) || targetIsToggle(toggles, e.target)) return;
toggle(store)();
};
/*
* Partially applied function that returns a function that adds and removes the document proxyListeners
* Only added as an eventListener when closeOnBlur and/or closeOnClick options are true
*
* @param store, Object, model or state of the current instance
*/
export const closeProxy = store => () => {
const { settings, isOpen, focusInListener, clickListener } = store.getState();
if (settings.closeOnBlur) document[`${isOpen ? 'add' : 'remove'}EventListener`]('focusin', focusInListener);
if (settings.closeOnClick) document[`${isOpen ? 'add' : 'remove'}EventListener`]('click', clickListener);
};
/*
* Partially applied function that returns a function that sets up and pulls down focus event handlers based on toggle status and focus management options
*
* @param store, Object, model or state of the current instance
*/
export const manageFocus = store => () => {
const { isOpen, focusableChildren, settings, lastFocused, keyListener } = store.getState();
if ((!settings.focus && !settings.trapTab) || focusableChildren.length === 0) return;
if (isOpen){
store.update({ ...store.getState(), lastFocused: document.activeElement });
const focusFn = () => focusableChildren[0].focus();
if (settings.delay) window.setTimeout(focusFn, settings.delay);
else focusFn();
if (!settings.trapTab) return;
settings.trapTab && document.addEventListener('keydown', keyListener);
} else {
if (!settings.trapTab) return;
document.removeEventListener('keydown', keyListener);
const reFocusFn = () => {
lastFocused && lastFocused.focus();
store.update({ ...store.getState(), lastFocused: false });
};
if (settings.delay) window.setTimeout(reFocusFn, settings.delay);
else reFocusFn();
}
};
export const getStateFromDOM = (node, settings) => {
const classTarget = settings.local ? node.parentNode : document.documentElement;
const statusClass = settings.local ? 'is--active' : `on--${node.getAttribute('id')}`;
return {
classTarget,
statusClass,
shouldStartOpen: settings.startOpen || classTarget.classList.contains(statusClass)
};
};
export const broadcast = store => state => {
const event = new CustomEvent(EVENTS[state.isOpen ? 'OPEN' : 'CLOSE'], {
bubbles: true,
detail: {
getState: store.getState
}
});
state.node.dispatchEvent(event);
};