Skip to content

Instantly share code, notes, and snippets.

@panoply
Last active February 5, 2022 01:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save panoply/66fe2ac0aecbdba37c7667b7b5d4d1d1 to your computer and use it in GitHub Desktop.
Save panoply/66fe2ac0aecbdba37c7667b7b5d4d1d1 to your computer and use it in GitHub Desktop.
Accordion Component

Accordion Component

A stripped down and refactured accordion component. The logic is adapted from https://github.com/oncode/handorgel. This is just a more lean and performant variant that employs the a lot of the same approaches but with key differences in the internal logic.

Example

Flems Example

$color_1: inherit;
$background_color_1: #fff;
$accordion-border-color: #eee !default;
$accordion-border-width: 1px !default;
$accordion-header-button-bg: #fff !default;
$accordion-header-button-bg-open: #eee !default;
$accordion-header-button-bg-focus: #dfdfdf !default;
$accordion-content-bg: #fff !default;
$accordion-transition-button: background-color 0.2s ease !default;
$accordion-transition-content-outer: height 0.1s ease 0.1s !default;
$accordion-transition-content-outer-open: height 0.2s ease !default;
$accordion-transition-content-inner: opacity 0.1s ease;
$accordion-transition-content-inner-open: opacity 0.3s ease !default;
.accordion {
display: block;
width: 100%;
&-header {
display: block;
margin: 0;
&.bd-bottom {
border-bottom: 1px solid #eee;
}
.icon-chevron-down {
visibility: hidden;
}
.icon-chevron-right {
visibility: visible;
}
&.open {
&.bd-bottom {
border-bottom: none;
}
.icon-chevron-down {
visibility: visible;
}
.icon-chevron-right {
visibility: hidden;
}
}
&.focus > .accordion-button {
outline: none;
}
}
&-button {
cursor: pointer;
display: block;
width: 100%;
padding: 0;
margin: 0;
border: none;
border-radius: 0;
color: $color_1;
font-size: inherit;
text-align: left;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
&::-moz-focus-inner {
border: 0;
}
}
&-content {
display: none;
overflow: hidden;
height: 0;
border-top: 1px solid #eee;
background-color: $background_color_1;
transition: 0.3s ease-in-out 0.1s;
-webkit-transform: translateY(-100%);
&.open {
display: block;
transition: 0.2s ease-in-out;
-webkit-transform: translateY(0);
}
&.opened {
overflow: visible;
> .accordion-inner {
opacity: 1;
-webkit-transition: opacity 0.245s ease-in-out;
transition: opacity 0.245s ease-in-out;
}
}
}
&-inner {
opacity: 0;
-webkit-transition: opacity 0.1s ease-in-out;
transition: opacity 0.1s ease-in-out;
}
}
/* eslint-disable no-use-before-define */
import EventEmitter from 'ev-emitter';
import { MergeExclusive } from 'type-fest';
import { assign } from 'utils/native';
class Folds {
static instances = {};
private accordion: MergeExclusive<Accordion, Element>;
private opts: Accordion['options'];
public id: string;
public header: MergeExclusive<MergeExclusive<EventEmitter, { fold?: Folds }>, HTMLElement>;
public button: HTMLButtonElement;
public content: MergeExclusive<{ fold?: Folds }, HTMLElement>;
public focused: boolean;
public expanded: boolean;
public disabled: boolean;
public listen: Partial<[
buttonFocus: ['focus', Folds['button'], EventListenerOrEventListenerObject],
buttonBlur: ['blur', Folds['button'], EventListenerOrEventListenerObject],
buttonClick: ['click', Folds['button'], EventListenerOrEventListenerObject],
buttonKeydown: ['keydown', Folds['button'], EventListenerOrEventListenerObject],
contentKeydown: ['keydown', Folds['content'], EventListenerOrEventListenerObject],
contentTransition: ['transitionend', Folds['content'], EventListenerOrEventListenerObject ]
]>;
aria = {
button: {
'aria-controls': () => 'c' + this.id,
'aria-expanded': () => this.expanded ? 'true' : 'false',
'aria-disabled': () => this.disabled ? 'true' : 'false'
},
content: {
role: () => 'region',
'aria-labelledby': () => 'h' + this.id
}
};
constructor (
accordion: Folds['accordion'],
header: Folds['header'],
content: Folds['content']
) {
if (header.fold) return;
this.accordion = accordion;
this.opts = this.accordion.options;
this.header = header;
if (this.opts.button) {
this.button = header.firstElementChild as HTMLButtonElement;
} else {
this.button = this.header.getElementsByTagName('button')[0];
}
this.content = content;
this.header.fold = this;
this.content.fold = this;
if (!Folds.instances?.[this.accordion.id]) Folds.instances[this.accordion.id] = 0;
this.id = `${this.accordion.id}f${++Folds.instances[this.accordion.id]}`;
this.header.setAttribute('id', this.id + 'h');
this.content.setAttribute('id', this.id + 'c');
this.focused = false;
this.expanded = false;
this.disabled = false;
this.listen = [];
this.bind();
this.init();
this.initOpen();
this.initFocus();
}
open (transition = true) {
if (this.expanded) return;
this.accordion.emitEvent('accordion:open', [ this ]);
this.expanded = true;
if (!this.opts.collapsible) this.disable();
this.updateAria('button', 'aria-expanded');
this.header.classList.add('open');
this.content.classList.add('open');
if (!transition) {
this.opened();
} else {
const { offsetHeight } = this.content.firstElementChild as HTMLElement;
this.content.style.height = `${offsetHeight}px`;
}
}
close (transition = true) {
if (!this.expanded) return;
this.accordion.emitEvent('accordion:close', [ this ]);
this.expanded = false;
if (!this.opts.collapsible) this.enable();
this.updateAria('button', 'aria-expanded');
this.header.classList.remove('opened');
this.content.classList.remove('opened');
if (!transition) {
this.closed();
} else {
const { offsetHeight } = this.content.firstElementChild as HTMLElement;
this.content.style.height = `${offsetHeight}px`;
requestAnimationFrame(() => { this.content.style.height = '0px'; });
}
}
disable () {
this.disabled = true;
this.updateAria('button', 'aria-disabled');
this.header.classList.add('disabled');
this.content.classList.add('disabled');
}
enable () {
this.disabled = false;
this.updateAria('button', 'aria-disabled');
this.header.classList.remove('disabled');
this.content.classList.remove('disabled');
}
focus () {
this.button.focus();
}
blur () {
this.button.blur();
}
toggle (transition = true) {
if (this.expanded) {
this.close(transition);
} else {
this.open(transition);
}
}
destroy () {
this.unbind();
this.clean();
this.header.classList.remove('open');
this.header.classList.remove('opened');
this.header.classList.remove('focus');
this.content.classList.remove('open');
this.content.classList.remove('open');
this.content.classList.remove('focus');
this.content.style.height = '0px'; // hide content
this.header.fold = null;
this.content.fold = null;
this.header.removeAttribute('id');
this.content.removeAttribute('id');
this.accordion = null;
}
private opened () {
this.content.style.height = 'auto';
this.header.classList.add('opened');
this.content.classList.add('opened');
this.accordion.emitEvent('accordion:opened', [ this ]);
}
private closed () {
this.header.classList.remove('open');
this.content.classList.remove('open');
this.accordion.emitEvent('accordion:closed', [ this ]);
}
private initOpen () {
if (
this.header.getAttribute(this.opts.initialOpenAttr) !== null ||
this.content.getAttribute(this.opts.initialOpenAttr) !== null
) {
if (this.opts.initialOpen) {
setTimeout(() => { this.open(); }, this.opts.initialOpenDelay);
} else {
this.open(false);
}
}
}
private initFocus () {
if (this.button.getAttribute('autofocus') === null) return;
this.onFocus();
}
private init () {
this.updateAria('button');
this.updateAria('content');
}
private clean () {
this.updateAria('button', null, true);
this.updateAria('content', null, true);
}
private updateAria (element: string, property = null, remove = false) {
if (!this.opts.ariaEnabled) return;
if (property) {
this[element].setAttribute(property, this.aria[element][property]());
} else {
for (const property in this.aria[element]) {
if (!this.aria?.[property]) continue;
if (remove) {
this[element].removeAttribute(property);
} else {
this[element].setAttribute(property, this.aria[element][property]());
}
}
}
}
private transition (e: TransitionEvent) {
if (e.target === e.currentTarget && e.propertyName === 'height') {
if (this.expanded) {
this.opened();
} else {
this.closed();
}
}
}
private onFocus () {
this.focused = true;
this.header.classList.add('focus');
this.content.classList.add('focus');
this.accordion.emitEvent('accordion:focus', [ this ]);
}
private onBlur () {
this.focused = false;
this.header.classList.remove('focus');
this.content.classList.remove('focus');
this.accordion.emitEvent('accordion:blur', [ this ]);
}
private onClick (event: Event) {
// ensure focus is on button (click is not seting focus on firefox mac)
this.focus();
if (this.disabled) return;
this.toggle();
}
private onKeydown (e: KeyboardEvent) {
if (!this.opts.keyboad) return;
let action = null;
switch (e.which) {
case 40: action = 'next'; break; // Arrow Down
case 38: action = 'prev'; break; // Arrow Up
case 36: action = 'first'; break; // Home
case 35: action = 'last'; break; // End
case 34: if (e.ctrlKey) action = 'next'; break; // Page down
case 33: if (e.ctrlKey) action = 'prev'; break; // Page Up
}
if (action) {
e.preventDefault();
this.accordion.focus(action);
}
}
private onContentKey (e: KeyboardEvent) {
if (!this.opts.keyboad || !e.ctrlKey) return;
let action = null;
switch (e.which) {
case 34: action = 'next'; break; // Page down
case 33: action = 'prev'; break; // Page Up
}
if (action) {
e.preventDefault();
this.accordion.focus(action);
}
}
private bind () {
this.listen = [
[ 'focus', this.button, this.onFocus.bind(this) ],
[ 'blur', this.button, this.onBlur.bind(this) ],
[ 'click', this.button, this.onClick.bind(this) ],
[ 'keydown', this.button, this.onKeydown.bind(this) ],
[ 'keydown', this.content, this.onContentKey.bind(this) ],
[ 'transitionend', this.content, this.transition.bind(this) ]
];
for (const [ event, element, callback ] of this.listen) {
element.addEventListener(event, callback);
}
}
private unbind () {
for (const [ event, element, callback ] of this.listen) {
element.removeEventListener(event, callback);
}
}
};
/**
* Accordion
*
* Uses the following id combinator
*
* a = 'accordion'
* f = 'fold number'
* h = 'header'
* c = 'content'
*
* eg: a1f1c is accordion with id 1, fold number 1 content target
*/
export class Accordion extends EventEmitter {
static instances = 0;
options: {
keyboad: boolean,
button: boolean,
multiselect: boolean,
ariaEnabled: boolean,
collapsible: boolean,
carouselFocus: boolean,
initialOpenAttr: string,
initialOpen: boolean,
initialOpenDelay: number,
} = {
keyboad: true,
button: false,
multiselect: true,
ariaEnabled: true,
collapsible: true,
carouselFocus: true,
initialOpen: true,
initialOpenDelay: 200,
initialOpenAttr: 'data-open'
};
id: string;
folds: Folds[];
element: MergeExclusive<{ fold?: Folds; accordion?: Accordion; }, HTMLElement>;
active: Function;
constructor (element: Element, options: Partial<Accordion['options']> = {}) {
super();
this.element = element as Accordion['element'];
this.element.accordion = this;
this.id = `a${++Accordion.instances}`;
this.element.setAttribute('id', this.id);
this.options = assign(this.options, options);
this.folds = [];
this.bind();
this.init();
this.update();
}
update () {
this.folds = [];
const children = this.element.children;
const length = children.length;
for (let i = 0; i < length; i = i + 2) {
const header = children[i] as Folds['header'];
const content = children[i + 1] as Folds['content'];
// get fold instance if there is already one
let fold: Folds = header.fold;
// create new one when header and content exist
if (!fold && header && content) fold = new Folds(this, header, content);
if (fold) this.folds.push(fold);
}
}
focus (target: string) {
let focused = null;
const folds = this.folds.length;
for (let i = 0; i < folds && focused === null; i++) {
if (this.folds[i].focused) focused = i;
}
if ((target === 'prev' || target === 'next') && focused === null) {
target = target === 'prev' ? 'last' : 'first';
}
if (target === 'prev' && focused === 0) {
if (!this.options.carouselFocus) return;
target = 'last';
}
if (target === 'next' && focused === folds - 1) {
if (!this.options.carouselFocus) return;
target = 'first';
}
switch (target) {
case 'prev': this.folds[--focused].focus(); break;
case 'next': this.folds[++focused].focus(); break;
case 'last': this.folds[folds - 1].focus(); break;
case 'first': default: this.folds[0].focus();
}
}
destroy () {
this.emitEvent('destroy');
this.element.removeAttribute('id');
for (const fold of this.folds) fold.destroy();
this.unbind();
this.clean();
this.element.accordion = null;
this.emitEvent('destroyed');
}
private handleFoldOpen (openFold: Folds) {
if (this.options.multiselect) return;
for (const fold of this.folds) if (openFold !== fold) fold.close();
}
private init () {
if (!this.options.ariaEnabled) return;
if (this.options.multiselect) {
this.element.setAttribute('aria-multiselectable', 'true');
}
}
private clean () {
this.element.removeAttribute('aria-multiselectable');
}
private bind () {
this.active = this.handleFoldOpen.bind(this);
this.on('accordion:open', this.active);
}
private unbind () {
this.off('accordion:open', this.active);
}
}
export function accordion (element: Element, options: Partial<Accordion['options']> = {}) {
return new Accordion(element, options);
}
// Minified component
var f=typeof globalThis!="undefined"?globalThis:typeof window!="undefined"?window:typeof global!="undefined"?global:typeof self!="undefined"?self:{},r={exports:{}};(function(t){(function(e,s){t.exports?t.exports=s():e.EvEmitter=s()})(typeof window!="undefined"?window:f,function(){function e(){}let s=e.prototype;return s.on=function(i,n){if(!i||!n)return this;let o=this._events=this._events||{},h=o[i]=o[i]||[];return h.includes(n)||h.push(n),this},s.once=function(i,n){if(!i||!n)return this;this.on(i,n);let o=this._onceEvents=this._onceEvents||{},h=o[i]=o[i]||{};return h[n]=!0,this},s.off=function(i,n){let o=this._events&&this._events[i];if(!o||!o.length)return this;let h=o.indexOf(n);return h!=-1&&o.splice(h,1),this},s.emitEvent=function(i,n){let o=this._events&&this._events[i];if(!o||!o.length)return this;o=o.slice(0),n=n||[];let h=this._onceEvents&&this._onceEvents[i];for(let l of o)h&&h[l]&&(this.off(i,l),delete h[l]),l.apply(this,n);return this},s.allOff=function(){return delete this._events,delete this._onceEvents,this},e})})(r);var p=r.exports;const{create:y,assign:b,is:E,defineProperty:x,values:A}=Object,a=class{constructor(t,e,s){this.aria={button:{"aria-controls":()=>"c"+this.id,"aria-expanded":()=>this.expanded?"true":"false","aria-disabled":()=>this.disabled?"true":"false"},content:{role:()=>"region","aria-labelledby":()=>"h"+this.id}};var i;e.fold||(this.accordion=t,this.opts=this.accordion.options,this.header=e,this.opts.button?this.button=e.firstElementChild:this.button=this.header.getElementsByTagName("button")[0],this.content=s,this.header.fold=this,this.content.fold=this,((i=a.instances)==null?void 0:i[this.accordion.id])||(a.instances[this.accordion.id]=0),this.id=`${this.accordion.id}f${++a.instances[this.accordion.id]}`,this.header.setAttribute("id",this.id+"h"),this.content.setAttribute("id",this.id+"c"),this.focused=!1,this.expanded=!1,this.disabled=!1,this.listen=[],this.bind(),this.init(),this.initOpen(),this.initFocus())}open(t=!0){if(!this.expanded)if(this.accordion.emitEvent("accordion:open",[this]),this.expanded=!0,this.opts.collapsible||this.disable(),this.updateAria("button","aria-expanded"),this.header.classList.add("open"),this.content.classList.add("open"),!t)this.opened();else{const{offsetHeight:e}=this.content.firstElementChild;this.content.style.height=`${e}px`}}close(t=!0){if(!!this.expanded)if(this.accordion.emitEvent("accordion:close",[this]),this.expanded=!1,this.opts.collapsible||this.enable(),this.updateAria("button","aria-expanded"),this.header.classList.remove("opened"),this.content.classList.remove("opened"),!t)this.closed();else{const{offsetHeight:e}=this.content.firstElementChild;this.content.style.height=`${e}px`,requestAnimationFrame(()=>{this.content.style.height="0px"})}}disable(){this.disabled=!0,this.updateAria("button","aria-disabled"),this.header.classList.add("disabled"),this.content.classList.add("disabled")}enable(){this.disabled=!1,this.updateAria("button","aria-disabled"),this.header.classList.remove("disabled"),this.content.classList.remove("disabled")}focus(){this.button.focus()}blur(){this.button.blur()}toggle(t=!0){this.expanded?this.close(t):this.open(t)}destroy(){this.unbind(),this.clean(),this.header.classList.remove("open"),this.header.classList.remove("opened"),this.header.classList.remove("focus"),this.content.classList.remove("open"),this.content.classList.remove("open"),this.content.classList.remove("focus"),this.content.style.height="0px",this.header.fold=null,this.content.fold=null,this.header.removeAttribute("id"),this.content.removeAttribute("id"),this.accordion=null}opened(){this.content.style.height="auto",this.header.classList.add("opened"),this.content.classList.add("opened"),this.accordion.emitEvent("accordion:opened",[this])}closed(){this.header.classList.remove("open"),this.content.classList.remove("open"),this.accordion.emitEvent("accordion:closed",[this])}initOpen(){(this.header.getAttribute(this.opts.initialOpenAttr)!==null||this.content.getAttribute(this.opts.initialOpenAttr)!==null)&&(this.opts.initialOpen?setTimeout(()=>{this.open()},this.opts.initialOpenDelay):this.open(!1))}initFocus(){this.button.getAttribute("autofocus")!==null&&this.onFocus()}init(){this.updateAria("button"),this.updateAria("content")}clean(){this.updateAria("button",null,!0),this.updateAria("content",null,!0)}updateAria(t,e=null,s=!1){var i;if(!!this.opts.ariaEnabled)if(e)this[t].setAttribute(e,this.aria[t][e]());else for(const n in this.aria[t])!((i=this.aria)==null?void 0:i[n])||(s?this[t].removeAttribute(n):this[t].setAttribute(n,this.aria[t][n]()))}transition(t){t.target===t.currentTarget&&t.propertyName==="height"&&(this.expanded?this.opened():this.closed())}onFocus(){this.focused=!0,this.header.classList.add("focus"),this.content.classList.add("focus"),this.accordion.emitEvent("accordion:focus",[this])}onBlur(){this.focused=!1,this.header.classList.remove("focus"),this.content.classList.remove("focus"),this.accordion.emitEvent("accordion:blur",[this])}onClick(t){this.focus(),!this.disabled&&this.toggle()}onKeydown(t){if(!this.opts.keyboad)return;let e=null;switch(t.which){case 40:e="next";break;case 38:e="prev";break;case 36:e="first";break;case 35:e="last";break;case 34:t.ctrlKey&&(e="next");break;case 33:t.ctrlKey&&(e="prev");break}e&&(t.preventDefault(),this.accordion.focus(e))}onContentKey(t){if(!this.opts.keyboad||!t.ctrlKey)return;let e=null;switch(t.which){case 34:e="next";break;case 33:e="prev";break}e&&(t.preventDefault(),this.accordion.focus(e))}bind(){this.listen=[["focus",this.button,this.onFocus.bind(this)],["blur",this.button,this.onBlur.bind(this)],["click",this.button,this.onClick.bind(this)],["keydown",this.button,this.onKeydown.bind(this)],["keydown",this.content,this.onContentKey.bind(this)],["transitionend",this.content,this.transition.bind(this)]];for(const[t,e,s]of this.listen)e.addEventListener(t,s)}unbind(){for(const[t,e,s]of this.listen)e.removeEventListener(t,s)}};let d=a;d.instances={};const u=class extends p{constructor(t,e={}){super();this.options={keyboad:!0,button:!1,multiselect:!0,ariaEnabled:!0,collapsible:!0,carouselFocus:!0,initialOpen:!0,initialOpenDelay:200,initialOpenAttr:"data-open"},this.element=t,this.element.accordion=this,this.id=`a${++u.instances}`,this.element.setAttribute("id",this.id),this.options=b(this.options,e),this.folds=[],this.bind(),this.init(),this.update()}update(){this.folds=[];const t=this.element.children,e=t.length;for(let s=0;s<e;s=s+2){const i=t[s],n=t[s+1];let o=i.fold;!o&&i&&n&&(o=new d(this,i,n)),o&&this.folds.push(o)}}focus(t){let e=null;const s=this.folds.length;for(let i=0;i<s&&e===null;i++)this.folds[i].focused&&(e=i);if((t==="prev"||t==="next")&&e===null&&(t=t==="prev"?"last":"first"),t==="prev"&&e===0){if(!this.options.carouselFocus)return;t="last"}if(t==="next"&&e===s-1){if(!this.options.carouselFocus)return;t="first"}switch(t){case"prev":this.folds[--e].focus();break;case"next":this.folds[++e].focus();break;case"last":this.folds[s-1].focus();break;case"first":default:this.folds[0].focus()}}destroy(){this.emitEvent("destroy"),this.element.removeAttribute("id");for(const t of this.folds)t.destroy();this.unbind(),this.clean(),this.element.accordion=null,this.emitEvent("destroyed")}handleFoldOpen(t){if(!this.options.multiselect)for(const e of this.folds)t!==e&&e.close()}init(){!this.options.ariaEnabled||this.options.multiselect&&this.element.setAttribute("aria-multiselectable","true")}clean(){this.element.removeAttribute("aria-multiselectable")}bind(){this.active=this.handleFoldOpen.bind(this),this.on("accordion:open",this.active)}unbind(){this.off("accordion:open",this.active)}};let c=u;c.instances=0;function v(t,e={}){return new c(t,e)}export{c as Accordion,v as accordion};
import { accordion as Accordion } from 'modules/accordion';
const accordion = Accordion(this.element, {
multiselect: true,
button: true,
collapsible: true,
keyboad: true,
initialOpen: true,
initialOpenDelay: 0
});
// Destory
accordion.destroy();
// Opens the 2nd box
accordion.folds[1].open();
// Closes the 2nd box
accordion.folds[1].close();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment