UNPKG

accordion

Version:

Silky-smooth accordion widgets with no external dependencies.

422 lines (339 loc) 11.5 kB
"use strict"; import {transitionEnd, setToken, debounce} from "./helpers.mjs"; import {default as Fold, folds} from "./fold.mjs"; const accordions = []; let activeAccordions = 0; let lastResizeRate; /** * Represents a column of collapsible content regions. * @class */ export default class Accordion { /** * Instantiate a new Accordion instance. * * @param {HTMLElement} el - Container wrapped around each immediate fold * @param {Object} options - Optional hash of settings * @param {String} options.openClass - CSS class controlling each fold's "open" state * @param {String} options.closeClass - CSS class used to mark a fold as closed * @param {String} options.edgeClass - CSS class toggled based on whether the bottom-edge is visible * @param {String} options.snapClass - CSS class for disabling transitions between window resizes * @param {String} options.enabledClass - CSS class marking an accordion as enabled * @param {String} options.disabledClass - CSS class marking an accordion as disabled * @param {Boolean} options.disabled - Whether to disable the accordion on creation * @param {Boolean} options.modal - Whether to close the current fold when opening another * @param {Boolean} options.noAria - Disable the addition and management of ARIA attributes * @param {Boolean} options.noKeys - Disable keyboard navigation * @param {Boolean} options.noTransforms - Disable CSS transforms; positioning will be used instead * @param {Number} options.heightOffset - Distance to offset each fold by * @param {Boolean} options.useBorders - Consider borders when calculating fold heights * @param {Function} options.onToggle - Callback executed when opening or closing a fold * @constructor */ constructor(el, options){ this.index = accordions.push(this) - 1; // Parse options options = options || {}; this.openClass = options.openClass || "open"; this.closeClass = options.closeClass || "closed"; this.edgeClass = (undefined === options.edgeClass ? "edge-visible" : options.edgeClass); this.snapClass = (undefined === options.snapClass ? "snap" : options.snapClass); this.enabledClass = (undefined === options.enabledClass ? "accordion" : options.enabledClass); this.disabledClass = options.disabledClass; this.modal = !!options.modal; this.noAria = !!options.noAria; this.noKeys = !!options.noKeys; this.noTransforms = !!options.noTransforms; this.heightOffset = +options.heightOffset || 0; this.useBorders = undefined === options.useBorders ? "auto" : options.useBorders; this.onToggle = options.onToggle; // Create a fold for each immediate descendant of the Accordion's container let folds = []; for(let i of Array.from(el.children)){ let fold = new Fold(this, i); folds.push(fold); // Connect the fold to its previous sibling, if it's not the first to be added let prev = folds[folds.length - 2]; if(prev){ prev.nextFold = fold; fold.previousFold = prev; } } el.accordion = this.index; this.noAria || el.setAttribute("role", "tablist"); this.el = el; this.folds = folds; // Add .enabledClass early - it might affect the heights of each fold if(!options.disabled && this.enabledClass) el.classList.add(this.enabledClass); this.update(); // Find out if this accordion's nested inside another let next = el; while((next = next.parentNode) && 1 === next.nodeType){ let fold = Accordion.getFold(next); if(fold){ let accordion = fold.accordion; this.parent = accordion; this.parentFold = fold; this.edgeClass && el.classList.remove(this.edgeClass); (accordion.childAccordions = accordion.childAccordions || []).push(this); (fold.childAccordions = fold.childAccordions || []).push(this); // Adjust the height of the containing fold's element if(fold.open){ let scrollHeight = fold.el.scrollHeight; let distance = (fold.headingHeight + fold.content.scrollHeight) - scrollHeight || (scrollHeight - fold.el.clientHeight); accordion.updateFold(fold, distance); } break; } } this.edgeClass && this.el.addEventListener(transitionEnd, this.onTransitionEnd = e => { if(!this.parent && e.target === el && "height" === e.propertyName && el.getBoundingClientRect().bottom > window.innerHeight) el.classList.remove(this.edgeClass); }); this.disabled = !!options.disabled; } /** * Get or set the accordion enclosing this one. * * @property * @type {Accordion} */ set parent(input){ this._parent = input; } get parent(){ let result = this._parent; if(!result) return null; // Search for the first ancestor that *isn't* disabled while(result){ if(!result.disabled) return result; result = result.parent; } return null; } /** * Get or set the fold of the accordion enclosing this one. * * @property * @type {Fold} */ set parentFold(input){ this._parentFold = input; } get parentFold(){ let fold = this._parentFold; if(!fold) return null; let accordion = fold.accordion; // Search for the first ancestor that *isn't* disabled while(fold && accordion){ if(!accordion.disabled) return fold; if(accordion = accordion.parent) fold = accordion.parentFold; } return null; } /** * Whether the accordion's been deactivated. * * @property * @type {Boolean} */ get disabled(){ return this._disabled; } set disabled(input){ if((input = !!input) !== this._disabled){ const el = this.el; const style = el.style; const classes = el.classList; this.enabledClass && setToken(classes, this.enabledClass, !input); this.disabledClass && setToken(classes, this.disabledClass, input); // Deactivating if(this._disabled = input){ style.height = null; this.snapClass && classes.remove(this.snapClass); if(this.edgeClass){ el.removeEventListener(transitionEnd, this.onTransitionEnd); classes.remove(this.edgeClass); } for(let i of this.folds) i.disabled = true; this.noAria || el.removeAttribute("role"); --activeAccordions; } // Reactivating else{ for(let i of this.folds) i.disabled = false; this.noAria || el.setAttribute("role", "tablist"); ++activeAccordions; this.update(); } // If there're no more active accordions, disable the onResize handler if(activeAccordions <= 0){ activeAccordions = 0; Accordion.setResizeRate(false); } // Otherwise, reactivate the onResize handler, assuming it was previously active else if(lastResizeRate) Accordion.setResizeRate(lastResizeRate); } } /** * Height of the accordion's container element. * * @property * @type {Number} */ get height(){ return this._height; } set height(input){ if(input && (input = +input) !== this._height){ this.el.style.height = input + "px"; this._height = input; } } /** * Internal method to check if an accordion's bottom-edge is visible to the user (or about to be). * * @param {Number} offset * @private */ edgeCheck(offset){ let edgeClass = this.edgeClass; if(edgeClass){ let box = this.el.getBoundingClientRect(); let windowEdge = window.innerHeight; let classes = this.el.classList; // If the bottom-edge is visible (or about to be), enable height animation if(box.bottom + (offset || 0) < windowEdge) classes.add(edgeClass); // If the bottom-edge isn't visible anyway, disable height animation immediately else if(box.bottom > windowEdge) classes.remove(edgeClass); } } /** * Update the vertical ordinate of each sibling for a particular fold. * * @param {Fold} fold * @param {Number} offset - Pixel distance to adjust by */ updateFold(fold, offset){ let next = fold; let parentFold = this.parentFold; while(next = next.nextFold) next.y += offset; parentFold || this.edgeCheck(offset); fold.height += offset; this.height += offset; parentFold && parentFold.open && this.parent.updateFold(parentFold, offset); } /** * Update the height of each fold to fit its content. */ update(){ let y = 0; let height = 0; for(let i of this.folds){ i.y = y; i.fit(); y += i.height; height += i.height; } let parentFold = this.parentFold; let diff = height - this._height; parentFold ? (parentFold.open && this.parent.updateFold(parentFold, diff)) : this.edgeCheck(diff); this.height = height; } /** * Recalculate the boundaries of an Accordion and its descendants. * * This method should only be called if the width of a container changes, * or a fold's contents have resized unexpectedly (such as when images load). * * @param {Boolean} allowSnap - Snap folds instantly into place without transitioning */ refresh(allowSnap){ let snap = allowSnap ? this.snapClass : false; snap && this.el.classList.add(snap); this.update(); if(this.childAccordions) this.childAccordions.forEach(a => a.parentFold.open ? a.refresh(allowSnap) : (a.parentFold.needsRefresh = true)); snap && setTimeout(() => this.el.classList.remove(snap), 20); } /** * Whether one of the Accordion's folds has been resized incorrectly. * * @type {Boolean} * @readonly * @property */ get wrongSize(){ for(let i of this.folds) if(i.wrongSize) return true; if(this.childAccordions) for(let i of this.childAccordions) if(i.wrongSize) return true; return false; } /** * Return the top-level ancestor this accordion's nested inside. * * @type {Accordion} * @readonly * @property */ get root(){ let result = this; while(result){ if(!result.parent) return result; result = result.parent; } } /** * Alter the rate at which screen-resize events update accordion widths. * * @param {Number} delay - Rate expressed in milliseconds */ static setResizeRate(delay){ let fn = function(){ for(let i of accordions) i.parent || i.disabled || i.refresh(true); }; window.removeEventListener("resize", this.onResize); // Make sure we weren't passed an explicit value of FALSE, or a negative value if(false !== delay && (delay = +delay || 0) >= 0){ this.onResize = delay ? debounce(fn, delay) : fn; window.addEventListener("resize", this.onResize); if(delay) lastResizeRate = delay; } } /** * Return the closest (most deeply-nested) accordion enclosing an element. * * @param {Node} node * @return {Accordion} */ static getAccordion(node){ while(node){ if("accordion" in node) return accordions[node.accordion]; node = node.parentNode; if(!node || node.nodeType !== 1) return null; } } /** * Return the closest (most deeply-nested) fold enclosing an element. * * @param {Node} node * @return {Fold} */ static getFold(node){ while(node){ if("accordionFold" in node) return folds[node.accordionFold]; node = node.parentNode; if(!node || node.nodeType !== 1) return null; } } } Accordion.setResizeRate(25); window.Accordion = Accordion;