@js-components/accordion
Version:
Lightweight and accessible Accordion
292 lines (236 loc) • 11.6 kB
text/typescript
import { assignNewUniqueIdToElement, isHTMLElement } from "./utilities";
import { findAccordionTriggers, initTrigger } from "./trigger";
///// CORE /////
export const PREFIX: string = "jsc";
export const EXTRA_TIME_FOR_TRANSITION = 60; //ms
export const TRANSITION_TIME = 300; //ms
export const TRANSITION_STATE_CLASSNAME = 'colexping';
export const INIT_CLASSNAME = `${PREFIX}-initialized`;
//// ATTRIBUTE AND SELECTOR /////
export const CONTAINER_ATTR: string = `data-${PREFIX}-accordion-container`;
export const CONTAINER_SELECTOR: string = `[${CONTAINER_ATTR}]`;
export const ACCORDION_ITEM_WRAPPER_ATTR: string = `data-${PREFIX}-accordion-item`;
export const ACCORDION_ITEM_WRAPPER_SELECTOR: string = `[${ACCORDION_ITEM_WRAPPER_ATTR}]`;
export const ACCORDION_ATTR: string = `data-${PREFIX}-accordion`;
export const ACCORDION_SELECTOR: string = `[${ACCORDION_ATTR}]`;
export const TRIGGER_ATTR: string = `data-${PREFIX}-target`;
export const TRIGGER_SELECTOR: string = `[${TRIGGER_ATTR}]`;
export const COLLAPSE_ATTR: string = "data-collapse";
export const TOGGLE_TYPE_ATTR = `data-accordion-${PREFIX}-type`;
export const DURATION_ATTR = `data-${PREFIX}-duration`;
export const DURATION_CSS_VAR = `--${PREFIX}-ac-duration`;
export const EXPANDED_CSS_CLASS = 'expanded';
export const COLLAPSED_CSS_CLASS = 'collapsed';
export const DATA_WRAPPER_SELECTOR_ATTR = `data-${PREFIX}-wrapper-selector`;
export const DATA_ACCORDION_SELECTOR_ATTR = `data-${PREFIX}-accordion-selector`;
export const DATA_TRIGGER_SELECTOR_ATTR = `data-${PREFIX}-trigger-selector`;
export function isContainer( element: Element ) {
return element.getAttribute( CONTAINER_ATTR ) !== null
}
export function SELECT_TRIGGER_ACCORDION( selector: string ): string {
return `[${TRIGGER_ATTR}="${selector}"]`;
}
/**
* @param wrapperEl - accordion wrapper element
* @param wrapperSelector - wrapper css selector
* @param accordionSelector - accordion css selector
* @param triggerSelector - trigger css selector
* @param collapsed - will accordion be collapsed or not (default: true)
* @returns true if everything is good or false unless otherwise
* @description it will init every accordions and triggers inside wrapper element
*/
export function initWrapper( wrapperEl: unknown, wrapperSelector: string, accordionSelector: string, triggerSelector: undefined | null | string = undefined, collapsed: boolean = true ): boolean {
if( !isHTMLElement( wrapperEl ) ) return false
const accordion = findAccordionInsideWrapper( wrapperEl, wrapperSelector, accordionSelector );
if( !accordion ) return false
///accordion found then this element is an accordion wrapper
wrapperEl.setAttribute( ACCORDION_ITEM_WRAPPER_ATTR, "true" );
const triggers = findAccordionTriggers( triggerSelector, wrapperEl, wrapperSelector, accordion );
///it needs to done after selecting triggers, because below function
// will set accordion ID if it doesn't have, it will effect selecting
// proper triggers
// TODO: test
initAccordion( accordion, collapsed );
triggers.forEach( trigger => {
// checking accordion is truthy just because TS giving me some error
if( accordion && accordion.id ) initTrigger( trigger, accordion.id, collapsed );
});
toggleActiveCSSClass( wrapperEl, collapsed );
return true
}
export function initAccordion( accordion: HTMLElement, initCollapse: boolean = true ) {
///set new id if the container don't have one
///for assigning this id to trigger target
if( accordion.id === '' ) {
assignNewUniqueIdToElement( accordion );
}
///if accordion has this attribute and it's set to false then don't need to do anything
///accordion still be initilized but it will not work, user will have to use
///method "enable" or setting it's attribute value to anything other than 'false'.
if( accordion.getAttribute( ACCORDION_ATTR ) !== "false" ) {
accordion.setAttribute( ACCORDION_ATTR, "true" );
}
accordion.setAttribute( "data-collapse", initCollapse + "" );
///hide the element if initial collapse is true
if( initCollapse ) accordion.style.display = "none";
}
export function getContainer( accordion: HTMLElement ): HTMLElement | undefined {
const container = accordion.closest( CONTAINER_SELECTOR );
if( container instanceof HTMLElement ) return container
return undefined
}
/**
* @param wrapperEl - accordion wrapper element
* @param wrapperSelector - wrapper css selector
* @param accordionSelector - accordion css selector
* @returns Accordion Element if found one
* @description find accordion inside wrapper (not nested wrapper)
*/
export function findAccordionInsideWrapper( wrapperEl: HTMLElement, wrapperSelector: string, accordionSelector: string = ACCORDION_SELECTOR ): null | HTMLElement {
const accordion = wrapperEl.querySelector( accordionSelector );
if( isHTMLElement( accordion ) ) {
const closestWrapper = accordion.closest( wrapperSelector );
if( closestWrapper === wrapperEl ) return accordion
}
return null
}
/**
* @param container - accordions container
* @returns wrapper css selector
* @description it will get wrapper selector string from container data attribute,
* the attribute has set when new instance of accordion was created
*/
export function getWrapperSelector( container: HTMLElement ): string {
const selector = container.getAttribute( DATA_WRAPPER_SELECTOR_ATTR );
return selector ? selector : ACCORDION_ITEM_WRAPPER_SELECTOR
}
/**
* @param container - accordions container
* @returns accordon css selector
* @description it will get accordion selector string from container data attribute,
* the attribute has set when new instance of accordion was created
*/
export function getAccordionSelector( container: HTMLElement ): string {
const selector = container.getAttribute( DATA_ACCORDION_SELECTOR_ATTR );
return selector ? selector : ACCORDION_ATTR
}
/**
* @param container - accordions container
* @returns trigger css selector
* @description it will get trigger selector string from container data attribute,
* the attribute has set when new instance of accordion was created
*/
export function getTriggerSelector( container: HTMLElement ): string {
const selector = container.getAttribute( DATA_TRIGGER_SELECTOR_ATTR );
return selector ? selector : TRIGGER_SELECTOR
}
export function getTransitionDuration( container: HTMLElement ): number | undefined {
let duration;
if( container ) {
const containerDuration = container.getAttribute( DURATION_ATTR );
if( containerDuration !== null && typeof +containerDuration === "number" && +containerDuration > 0 ) {
duration = +containerDuration;
}
}
return duration;
}
export function getRelativeAccordions( accordion: HTMLElement ): HTMLElement[] | null {
const closestContainer = accordion.closest( CONTAINER_SELECTOR ) as HTMLElement | null;
if( closestContainer ) {
let parent = accordion.parentElement;
// try to find closest wrapper
while( parent?.getAttribute( ACCORDION_ITEM_WRAPPER_ATTR ) === null && parent !== closestContainer ) {
parent = parent.parentElement;
}
if( parent && parent.parentElement ) {
const relativeAccordions: HTMLElement[] = [];
const wrappers = parent.parentElement.querySelectorAll( `:scope > ${ACCORDION_ITEM_WRAPPER_SELECTOR}` );
wrappers.forEach( wrapper => {
const accordion = wrapper.querySelector( ACCORDION_SELECTOR );
if( accordion && accordion instanceof HTMLElement && accordion.closest( ACCORDION_ITEM_WRAPPER_SELECTOR ) === wrapper ) {
relativeAccordions.push( accordion );
}
});
return relativeAccordions
}
}
return null
}
/**
* returns true or false whether accordion is collapsed or not
*/
export function isAccordionCollapsed( accordion: HTMLElement ): boolean {
return accordion.dataset.collapse === 'true' ? true : false;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function beforeAccordionTransition( accordion: HTMLElement, callBackFunc?: Function ) {
if( typeof callBackFunc === 'function' ) callBackFunc();
///add a class to accordion to let the "state" know that it's transitioning
accordion.classList.add( TRANSITION_STATE_CLASSNAME );
let duration: number = TRANSITION_TIME;
const container = getContainer( accordion );
if( container ) {
const containerDuration = getTransitionDuration( container );
if( containerDuration ) duration = containerDuration;
}
accordion.style.transition = `height ${duration}ms ease-in-out`;
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function afterAccordionTransitionFinish( accordion: HTMLElement, callBackFunc?: Function ) {
let duration: number = TRANSITION_TIME;
const container = getContainer( accordion );
if( container ) {
const containerDuration = getTransitionDuration( container );
if( containerDuration ) duration = containerDuration;
}
// adding extra time because sometimes accordion will not transition properly
duration += EXTRA_TIME_FOR_TRANSITION;
setTimeout( () => {
if( typeof callBackFunc === 'function' ) callBackFunc();
accordion.style.transition = '';
accordion.style.height = '';
accordion.classList.remove( TRANSITION_STATE_CLASSNAME );
}, duration );
}
export function isAccordionsTransitioning( accordion: HTMLElement ): boolean {
const relativeAccordions = getRelativeAccordions( accordion );
if( relativeAccordions ) {
for( let i = 0; i < relativeAccordions.length; i++ ) {
if( relativeAccordions[i].classList.contains( TRANSITION_STATE_CLASSNAME ) ) return true
}
}
return false
}
export function getAccordionType( element: HTMLElement ) {
return element.closest( CONTAINER_SELECTOR )?.getAttribute( TOGGLE_TYPE_ATTR );
}
/**
* @param accordionContainer - container element of accordion
* @param position - number of the accordion from top
* @returns HTMLElement | null whether if succeed or not
* @description find accordion in the container from top position
*/
export function findAccordionWithPosition( accordionContainer: HTMLElement, position: number ) {
if( Number.isInteger( position ) && position > 0 ) {
const selectedAccordion = accordionContainer.querySelector( `${ACCORDION_ITEM_WRAPPER_SELECTOR}:nth-child(${position}) ${ACCORDION_SELECTOR}`);
if( selectedAccordion && selectedAccordion instanceof HTMLElement ) {
return selectedAccordion
}
}
return null
}
/**
* @param element - which element to toggle classes
* @param isCollapsed - whether accordion is collapsed or not. (default true)
* @description add collapse CSS class and remove expand CSS class and
* vice-versa if `isCollapsed` is false
*/
export function toggleActiveCSSClass( element: Element, isCollapsed: boolean = true ) {
if( isCollapsed ) {
element.classList.add( COLLAPSED_CSS_CLASS );
element.classList.remove( EXPANDED_CSS_CLASS );
} else {
element.classList.add( EXPANDED_CSS_CLASS );
element.classList.remove( COLLAPSED_CSS_CLASS );
}
}