@financial-times/o-expander
Version:
Content-aware helper for expanding and collapsing content or lists.
386 lines (351 loc) • 14.9 kB
JavaScript
import viewport from '@financial-times/o-viewport';
// Used to create a unique o-expander id.
let count = 0;
class ExpanderUtility {
/**
* Class constructor.
*
* @param {HTMLElement} oExpanderElement - The component element in the DOM
* @param {object} opts - An options object for configuring the component.
* @param {string | number} opts.shrinkTo ['height'] - The expander collapse method, "height", "hidden", or a number of items.
* @param {string | number} opts.toggleState ['all'] - How to update the expander toggles: "all" to update text and aria-expanded attributes, "aria" to update only aria-expanded attributes, "none" to avoid updating toggles on click.
* @param {string} opts.expandedToggleText ['fewer'] - Toggle text when the expander is collapsed. Defaults to "fewer", or "less" when `shrinkTo` is "height", or "hidden" when `shrinkTo` is "hidden".
* @param {string} opts.collapsedToggleText ['more'] - Toggle text when the expander is collapsed. Defaults to "more" or "show" when `shrinkTo` is "hidden".
* @param {object} opts.selectors - The selectors for expander elements.
* @param {string} opts.selectors.toggle - A selector for the expanders toggles e.g. `.my-expander__toggle`.
* @param {string} opts.selectors.content - A selector for the expanders content, which will collapse or expand e.g. `.my-expander__content`.
* @param {string} opts.selectors.item - A selector for the items within the expander content e.g. `li` (required only when `shrinkTo` is set to a number).
* @param {object} opts.classnames - The classnames to apply to the expander for different states.
* @param {string} opts.classnames.initialized - The class to apply to the top level of the expander when initialised by JS e.g. `.my-expander--initialized`.
* @param {string} opts.classnames.inactive - The class to apply to the top level of the expander when it can not expand or collapse e.g. `.my-expander--inactive`.
* @param {string} opts.classnames.expanded - The class to apply to the expander content when it is expanded e.g. `.my-expander--expanded`.
* @param {string} opts.classnames.collapsed - The class to apply to the expander content when it is collapsed JS e.g. `.my-expander--collapsed`.
* @param {string} opts.classnames.collapsibleItem - The class to apply to any item (see the `selectors.item` option) which will be hidden when collapsed e.g. `.my-expander__collapsible-item` (required only when `shrinkTo` is set to a number).
*/
constructor(oExpanderElement, opts) {
// Error if the expander element is not an element.
if(!(oExpanderElement instanceof Element)) {
throw new Error('Expected an expander Element.');
}
//Do not initialised if it was already initiated
if(oExpanderElement?.oExpander?.initialized){
return;
}
// Error if no options are given.
if (typeof opts !== 'object') {
throw new Error(`Expected an \`opts\` object, found type of "${typeof opts}".`);
}
// Set expander state.
// 'expanded', 'collapsed', or 'null';
this._currentState = null;
// Get configurable options.
this.options = Object.assign({}, {
shrinkTo: 'height',
toggleState: 'all',
}, opts);
// If `shrinkTo` is a number, cast to an actual number using the
// unary operator `+`. I.e so `typeof` returns `number`.
if (!isNaN(this.options.shrinkTo)) {
this.options.shrinkTo = Number(this.options.shrinkTo);
}
// Validate the required selectors are configured.
// The `item` selector is only required if this expander is a
// "number" expander, i.e. based on the number of visible content items.
const requiredSelectors = ['toggle', 'content'];
if (typeof this.options.shrinkTo === 'number') {
requiredSelectors.push(`item`);
}
const actualSelectors = Object.keys(opts.selectors);
const missingSelectors = requiredSelectors.filter(s => actualSelectors.indexOf(s) === -1);
if (typeof opts.selectors !== 'object' || missingSelectors.length) {
throw new Error(`Expected the following "selectors" to be specified within the options object "${requiredSelectors}", missing "${missingSelectors}".`);
}
// Validate the required classnames are configured.
// The `collapsibleItem` class is only required if this expander is a
// "number" expander, i.e. based on the number of visible content items.
const requiredClassnames = [
'initialized',
'inactive',
'expanded',
'collapsed'
];
if (typeof this.options.shrinkTo === 'number') {
requiredClassnames.push(`collapsibleItem`);
}
const actualClassnames = Object.keys(opts.classnames);
const missingClassnames = requiredClassnames.filter(s => actualClassnames.indexOf(s) === -1);
if (typeof opts.selectors !== 'object' || missingClassnames.length) {
throw new Error(`Expected the following "classnames" to be specified within the options object "${requiredClassnames}", missing "${missingClassnames}".`);
}
// If the user has not configured toggle text for the expanded state,
// set it based on the "shrinkTo" option: "hide" when hiding collapsed
// items; "less" when obscuring by reducing the container height by a
// given value; "fewer" otherwise.
if (!this.options.expandedToggleText) {
switch (this.options.shrinkTo) {
case 'hidden':
this.options.expandedToggleText = 'hide';
break;
case 'height':
this.options.expandedToggleText = 'less';
break;
default:
this.options.expandedToggleText = 'fewer';
break;
}
}
// If the user has not configured toggle text for the collapsed state,
// set it based on the "shrinkTo" option: "show" hiding collapsed items;
// or "more" when collapsing to a height.
if (!this.options.collapsedToggleText) {
this.options.collapsedToggleText = this.options.shrinkTo === 'hidden' ? 'show' : 'more';
this.options.collapsedToggleText += ' <span class="o-expander__visually-hidden">(content will be shown above button)</span>';
}
// Elements.
this.oExpanderElement = oExpanderElement;
this.contentElement = this.oExpanderElement.querySelector(this.options.selectors.content);
//Add data-o-component=o-expander in case it doesn't have it
this.oExpanderElement.setAttribute('data-o-component','o-expander')
this.toggles = [].slice.apply(this.oExpanderElement.querySelectorAll(this.options.selectors.toggle)).filter((item) =>
//Do not get nested elements
item.closest('[data-o-component="o-expander"]') === this.oExpanderElement
);
if (!this.toggles.length) {
throw new Error(
'o-expander needs a toggle link or button. ' +
`None were found for toggle selector "${this.options.selectors.toggle}".`
);
}
// Set `aria-controls` on each toggle using expander ids.
this.id = this.contentElement.id;
if (!this.id) {
while (document.querySelector('#o-expander__toggle--' + count)) {
count++;
}
this.id = this.contentElement.id = 'o-expander__toggle--' + count;
}
this.toggles.forEach(toggle => toggle.setAttribute('aria-controls', this.id));
// Add a click event to each toggle.
this.toggles.forEach(toggle => {
toggle.addEventListener('click', () => this.toggle());
});
// If shrinking based on a height set in css, reapply the expander on
// orientation and resize events.
if (this.options.shrinkTo === 'height') {
viewport.listenTo('resize');
viewport.listenTo('orientation');
document.body.addEventListener('oViewport.orientation', () => this.apply());
document.body.addEventListener('oViewport.resize', () => this.apply());
}
// Add a class to indicate the expander is initialised, which
// may be styled against for progressive enhancement (we shouldn't hide
// content when the expander fails to load).
this.oExpanderElement.classList.add(this.options.classnames.initialized);
this.initialized = true;
//Add property oExpander to the main element referencing this object
oExpanderElement.oExpander = this;
// Apply the configured expander.
this.apply(true);
// Setup. Fire the `oExpander.init` event.
this._dispatchEvent('init');
}
/**
* Recalculate and apply the styles to expand or collapse the expander
* according to its current state.
*
* @param {boolean} isSilent [false] Set to true to avoid firing the `oExpander.expand` or `oExpander.collapse` events.
* @returns {void}
*/
apply(isSilent) {
if (!this._isActive()) {
this.oExpanderElement.classList.add(this.options.classnames.inactive);
} else {
//Remove the inactive class, this expander may be toggled.
this.oExpanderElement.classList.remove(this.options.classnames.inactive);
// Mark collapsible items with the `o-expander__collapsible-item` classnames.
if (typeof this.options.shrinkTo === 'number') {
const collapsibleCountElements = this._getCollapseableItems();
collapsibleCountElements.forEach(el => el.classList.add(this.options.classnames.collapsibleItem));
}
// Collapse or expand.
if (this.isCollapsed()) {
this.collapse(isSilent);
} else {
this.expand(isSilent);
}
}
}
/**
* Toggle the expander so expands or, if it's already expanded, collapses.
*
* @returns {void}
*/
toggle() {
if (this.isCollapsed()) {
this.expand();
} else {
this.collapse();
}
}
/**
* Expand the expander.
*
* @param {boolean} isSilent [false] Set to true to avoid firing the `oExpander.expand` event.
* @returns {void}
*/
expand(isSilent) {
this._setExpandedState('expand', isSilent);
}
/**
* Collapse the expander.
*
* @param {boolean} isSilent [false] Set to true to avoid firing the `oExpander.collapse` event.
* @returns {void}
*/
collapse(isSilent) {
this._setExpandedState('collapse', isSilent);
}
/**
* Return true if the expander is currently collapsed.
*
* @returns {boolean} - is the expander collapsed
*/
isCollapsed() {
// If the expander has been run we store the current state.
if (this._currentState) {
return this._currentState === 'collapse';
}
// If not check for dom attributes to decide if the user intends
// the expander to be expanded or collapsed by default.
if (this.options.shrinkTo === 'hidden') {
// Check is not false so hidden expanders collapse by default.
return this.contentElement.getAttribute('aria-hidden') !== 'false';
}
return !this.contentElement.classList.contains(this.options.classnames.expanded);
}
/**
* Remove the expander from the page.
*
* @returns {void}
*/
destroy() {
if (this.options.shrinkTo === 'height') {
document.body.removeEventListener('oViewport.orientation', () => this.apply());
document.body.removeEventListener('oViewport.resize', () => this.apply());
}
this.toggles.forEach(toggle => {
toggle.removeEventListener('click', this.toggle);
toggle.removeAttribute('aria-controls');
toggle.removeAttribute('aria-expanded');
});
this.contentElement.removeAttribute('aria-hidden');
this.contentElement.classList.remove(this.options.classnames.expanded);
this.contentElement.classList.remove(this.options.classnames.collapsed);
this.oExpanderElement.classList.remove(this.options.classnames.initialized);
}
/**
* @returns {Array<Element>} - All collapseable content items.
*/
_getCollapseableItems() {
const allCountElements = this._getItems();
return Array.from(allCountElements).splice(this.options.shrinkTo);
}
/**
* @returns {Array<Element>} - All content items.
*/
_getItems() {
if (typeof this.options.shrinkTo !== 'number') {
throw new Error(
'Can not get items for an expander which is not based on a ' +
'number of items.'
);
}
return this.contentElement.querySelectorAll(this.options.selectors.item);
}
/**
* Return whether the expander has something to hide / show.
* i.e. if expanding/collapsing would do anything.
*
* @returns {boolean} - does the expander have something to hide?
* @access private
*/
_isActive() {
// An expander may always toggle an expander which hides items.
if (this.options.shrinkTo === 'hidden') {
return true;
}
// An expander based on the number of items in a container may only
// collapse if the items length exceeds the number to shrink to. I.e.
// a list of 2 can't collapse to 5.
if (typeof this.options.shrinkTo === 'number') {
const items = this._getItems();
return items.length > this.options.shrinkTo;
}
// If the expander is based on a height then check the content overflows
// the content container.
let overflows = false;
if (this.isCollapsed()) {
overflows = this.contentElement.clientHeight < this.contentElement.scrollHeight;
} else {
this.collapse();
overflows = this.contentElement.clientHeight < this.contentElement.scrollHeight;
this.expand();
}
return overflows;
}
/**
* Expand or collapse the expander.
*
* @param {boolean} state "expand" or "collapse".
* @param {boolean} isSilent [false] Set to true to avoid firing the `oExpander.collapse` or `oExpander.expand` events.
* @returns {void}
* @access private
*/
_setExpandedState(state, isSilent) {
// Record the current state of the expander.
this._currentState = state;
// Toggle expanded and collapsed classes.
this.contentElement.classList.toggle(this.options.classnames.expanded, state === 'expand');
this.contentElement.classList.toggle(this.options.classnames.collapsed, state !== 'expand');
// Set `aria-hidden`.
const ariaHidden = state === 'expand' ? 'false' : 'true';
// If toggling all content set `aria-hidden` on the content element.
if (this.options.shrinkTo === 'hidden') {
this.contentElement.setAttribute('aria-hidden', ariaHidden);
}
// If toggling elements based on the number of items, set `aria-hidden`
// on collapseable items.
if (typeof this.options.shrinkTo === 'number') {
const collapsibleCountElements = this._getCollapseableItems();
collapsibleCountElements.forEach(el =>
el.setAttribute('aria-hidden', ariaHidden)
);
}
// Set the toggle text and `aria-expanded` attribute.
if (this.options.toggleState !== 'none') {
this.toggles.forEach(toggle => {
if (this.options.toggleState !== 'aria') {
toggle.innerHTML = state === 'expand' ?
this.options.expandedToggleText :
this.options.collapsedToggleText;
}
toggle.setAttribute('aria-expanded', state === 'expand' ? 'true' : 'false');
});
}
// Dispatch `oExpander.collapse` or `oExpander.expand` event.
if (!isSilent) {
this._dispatchEvent(state);
}
}
/**
* Fire a bubbling o-expander event with the correct namespace.
*
* @param {string} name The event name. E.g. "example" will fire an "oExpander.example" event.
* @returns {void}
* @access private
*/
_dispatchEvent(name) {
this.oExpanderElement.dispatchEvent(new CustomEvent('oExpander.' + name, { bubbles: true }));
}
}
export default ExpanderUtility;