toolshed-8.x-1.x-dev/assets/widgets/Accordion.es6.js
assets/widgets/Accordion.es6.js
(({ theme, t, Toolshed: ts }) => {
/**
* Function to create an HTMLElement to serve as the expand/collapse toggle
* for an AccordionItem.
*
* @return {HTMLElement}
* An HTMLElement to serve as the expand/collapse toggle for an
* AccordionItem.
*/
theme.accordionToggler = (expandText = 'Expand', collapseText = 'Collapse') => {
// Create top-level button element.
const btn = new ts.Element('button', { class: 'toggler'});
btn.innerHTML = `
<span class="toggler__collapse-text">${expandText}</span>
<span class="toggler__expand-text">${collapseText}</span>
`;
return btn;
};
let idCt = 1;
function uniqueId() {
// A unique ID for accordion items to use with "aria-controls" property.
return `ts-accordion-${idCt++}`;
}
/**
* Defines a collapsible accordion item.
*/
ts.AccordionItem = class AccordionItem extends ts.Element {
/**
* Class representing a single item of an Accordion which is expandable and
* collapsible. This item maintains its components and the state of the
* open and close states.
*
* @param {Element} el
* The whole accordion item.
* @param {Element} expander
* Element to use for toggling the body content.
* @param {Element} body
* The content area of the accordion item which is revealed or hidden
* when the accordion item is expanded or collapsed.
* @param {Accordion|AccordionItem} parent
* Parent accordion instance.
* @param {Object} settings
* Object of configurations to use to control the behavior of the
* accordion item.
*/
constructor(el, expander, body, parent = null, settings = {}) {
super(el, {class: ['accordion-item', 'item--collapsible']});
this.isExpanded = true;
this.parent = parent;
this.body = body;
this.expander = expander;
this.useAnimation = settings.animate || true;
this.animateTime = settings.animateTime || '0.3s';
this.children = [];
this.body.addClass('item__body');
this.expander.setAttrs({
'aria-expanded': this.isExpanded.toString(),
'aria-controls': body.id || (body.id = uniqueId()),
});
this.onToggleExpand = AccordionItem[parent && settings.exclusive ? 'onClickExclusive' : 'onClickToggle'].bind(this);
expander.on('click', this.onToggleExpand);
}
/**
* Add a child accordion item.
*
* @param {AccordionItem} child
* Child accordion item to add.
*/
addChild(child) {
this.children.push(child);
}
/**
* Expand the accordion item's content display.
*
* @param {bool} allowAnimate
* Animate expand animations if an animation is configured.
*/
expand(allowAnimate = true) {
this.addClass('item--expanded');
this.removeClass('item--collapsed');
this.expander.setAttr('aria-expanded', 'true');
this.isExpanded = true;
this.expander.addClass('toggler--expanded');
this.expander.removeClass('toggler--collapsed');
if (allowAnimate && this.useAnimation) {
this.body.expand(this.animateTime);
}
else {
this.body.style.display = '';
this.body.style.height = '';
}
}
/**
* Collapse the accordion item's content display.
*
* @param {bool} allowAnimate
* Should the collapse functionality expect to animate the transitions
* if the accordion item is setup to do animations.
*/
collapse(allowAnimate = true) {
this.addClass('item--collapsed');
this.removeClass('item--expanded');
this.expander.ariaExpanded = 'false';
this.isExpanded = false;
this.expander.addClass('toggler--collapsed');
this.expander.removeClass('toggler--expanded');
if (allowAnimate && this.useAnimation) {
this.body.collapse(this.animateTime);
}
else {
this.body.style.display = 'none';
}
// Recursively close all the open child accordions.
this.children.forEach((child) => {
if (child.isExpanded) child.collapse(false);
});
}
/**
* Expand and collapse of accordion items, without concern to the state of
* other accordion items. Multiple items can be open at once.
*
* @param {ClickEvent} e
* DOM click event object, containing information about the item being
* being clicked with the collapse functionality.
*/
static onClickToggle(e) {
e.preventDefault();
if (this.isExpanded) {
this.collapse();
}
else {
this.expand();
}
}
/**
* Expand and collapse of accordion items but ensure only one item is open
* at a time.
*
* @param {ClickEvent} e
* DOM click event object, containing information about the item being
* being clicked with the collapse functionality.
*/
static onClickExclusive(e) {
e.preventDefault();
if (this.isExpanded) {
this.collapse();
}
else {
// Recursively close all the open child accordions.
this.parent.children.forEach((child) => {
if (child !== this && child.isExpanded) child.collapse();
});
this.expand();
}
}
/**
* Teardown any events and alterations this accordion item made to the DOM.
*/
destroy() {
this.expander.removeAttrs(['aria-expanded', 'aria-controls']);
this.expander.removeClass([
'toggler--expanded',
'toggler--collapsed',
]);
this.removeClass([
'accordion-item',
'item--collapsed',
'item--expanded',
'item--collapsible',
]);
// Reset the accordion body display.
this.body.style.display = '';
this.body.style.height = '';
// Clear the expander events.
this.expander.destroy();
super.destroy();
}
}
/**
* Defines a non-collapsible accordion item.
*/
class NonAccordionItem extends ts.Element {
/**
* Class representing a single item of an Accordion which is not expandable
* or collapsible.
*
* @param {Element} el
* The whole accordion item.
* @param {Accordion|AccordionItem} parent
* Parent accordion instance.
*/
constructor(el, parent) {
super(el, {class: 'accordion-item'});
this.el = el;
this.parent = parent;
this.children = [];
this.isExpanded = false;
}
/**
* Add a child accordion item.
*
* @param {AccordionItem} child
* Child accordion item to add.
*/
addChild(child) {
this.isExpanded = true;
this.children.push(child);
}
/**
* This class implements an expand function in order to have the same
* interface as the AccordionItem class, but since this is a
* non-collapsible accordion item, it does nothing.
*/
expand() {} // eslint-disable-line class-methods-use-this
/**
* This class implements a collapse function in order to have the same
* interface as the AccordionItem class, but since this is a
* non-collapsible accordion item, it just collapses any children.
*/
collapse() {
// Recursively close all the open child accordions.
this.children.forEach((child) => {
if (child.isExpanded) child.collapse(false);
});
}
}
/**
* Accordion object definition.
*/
ts.Accordion = class Accordion extends ts.Element {
/**
* Create an accordion of expandable and collapsible items.
*
* @param {Element} el
* DOM element to turn into an accordion.
* @param {Object} configs
* A configuration options which determine the options:
* {
* initOpen: {bool|selector} [true],
* // Should accordion panels start of initially open.
* exclusive: {bool} [true],
* // Accordion only has one item open at a time.
* animate: {bool} [true],
* // Should the accordions expand and collapse animate.
* transitionDuration: {string} ['0.3s']
* // How long the expand/collapse transitions should last. Should be
* // a duration that a CSS transition can accept (such as '300ms' or
* // '0.3s').
* toggler: {DOMString|function|object}
* // The selector to find the element to toggle the collapse and expand, OR
* // A callback function to create the toggler element, OR
* // An object with `create` and `attacher` functions to create and
* // place the toggler into the DOM.
* itemSel: {DOMString}
* // The selector to use when locating each of the accordion items.
* bodySel: {DOMString}
* // The selector to use to find the item body within the item context.
* }
*/
constructor(el, configs) {
super(el, { class: 'accordion'});
this.items = new Map();
this.children = [];
this.configs = {
exclusive: true,
initOpen: true,
animate: true,
animateTime: '0.3s',
...configs,
};
const {
exclusive,
initOpen,
itemSel,
bodySel,
} = this.configs;
let toggleOpt = this.configs.toggler || theme.accordionToggler;
// If the expand option is just a selector, then the element is expected
// to already be in the document (otherwise not findable).
if (ts.isString(toggleOpt)) {
this.getToggler = (item) => new ts.Element(item.querySelector(toggleOpt)||'button');
}
else {
if (typeof toggleOpt === 'function') toggleOpt = { create: toggleOpt };
if (!toggleOpt.attach) {
toggleOpt.attach = (toggler, item) => item.insertBefore(toggler.el, item.querySelector(bodySel));
}
this.getToggler = (item) => {
let toggler = toggleOpt.create(item);
// If not already in the DOM, use the attach callback to insert it.
if (toggler && !toggler.parentNode) {
toggleOpt.attach(toggler, item);
}
return toggler;
};
}
// Build the accordion items allowing for a nested tree structure.
this.find(itemSel).forEach((el) => this.buildTreeItem(el));
if (!initOpen || exclusive) {
this.children.forEach((child, i) => {
if (!initOpen || i !== 0) child.collapse(false);
});
}
}
/**
* Add a child accordion item.
*
* @param {AccordionItem} child
* Child accordion item to add.
*/
addChild(child) {
this.children.push(child);
}
/**
* Find the parent accordion item for this element.
*
* @param {DOMElement} el
* The element to search for the closest matching selector for.
* @param {string} selector
* Selector to use when identifying an accordion item.
*
* @return {Element|null}
* If an appropriate parent matching the item selector is found within
* the confines of the accordion element, it is returned. Otherwise,
* return the Element wrapping the accordion.
*/
findParent(el, selector) {
let cur = el;
do {
cur = cur.parentElement;
} while (cur && cur !== this.el && !cur.matches(selector));
return cur;
}
/**
* Recursively add accordion items to the tree structure.
*
* @param {DOMElement} el
* The HTMLElement tos transform into an accordion item.
*
* @return {AccordionItem|NonAccordionItem}
* An accordion item or a NonAccordionItem (item that doesn't collapse).
*/
buildTreeItem(el) {
let a = this.items.get(el);
// This element has already been built, don't need to rebuild subtree.
if (a) return a;
const { itemSel, bodySel } = this.configs;
const parent = this.findParent(el, itemSel);
// Only create an accordion item if it has a parent item which exists
// within the Accordion scope.
if (!parent) throw new Error('Unable to find a parent buildTreeItem().');
const iParent = (parent === this.el) ? this : this.items.get(parent) || this.buildTreeItem(parent);
if (!iParent) throw new Error('No valid accordion parent available, or not a descendent of the accordion.');
// Only create a collapsible accordion item if it has a has a body to
// expand, and an expander (expand/collapse toggle). Otherwise create a
// non-collapsible accordion item.
let accord;
const body = el.querySelector(bodySel);
if (body) {
const expander = this.getToggler(el);
if (expander) {
// This accordion item should be made collapsible.
accord = new ts.AccordionItem(el, expander, new ts.Element(body), iParent, this.configs);
}
}
if (!accord) {
// This accordion item should be made non-collapsible.
accord = new NonAccordionItem(item, iParent);
}
// Add the accordion item to the accordion's 'items' list, and to its
// parent's list of children.
this.items.set(el, accord);
iParent.addChild(accord);
return accord;
}
/**
* Teardown any changes to the DOM as well as clearing AccordionItems.
*/
destroy() {
this.removeClass('accordion');
// Destroy all the accordion items.
this.items.forEach((item) => item.destroy());
delete this.items;
delete this.children;
delete this.configs;
super.destroy();
}
};
})(Drupal);
