UNPKG

metismenujs

Version:

MetisMenu: Collapsible menu plugin with Vanilla-JS

238 lines (193 loc) 6.84 kB
/* eslint-disable max-len */ import { ClassName, Default, MetisMenuEvents } from './constant'; import { IMMOptions } from './interface'; import { EventType, SpecificEventListener, CustomEventListener } from './types'; import { closest } from './util'; class MetisMenu { protected config: IMMOptions; protected element: Element; protected isTransitioning!: boolean; protected disposed: boolean; protected triggerArr: Array<Element>; /** * Creates an instance of MetisMenu. * * @constructor * @param {Element | string} element * @param {IMMOptions} [options] * @memberof MetisMenu */ constructor(element: Element | string, options?: IMMOptions) { this.element = MetisMenu.isElement(element) ? element : document.querySelector<HTMLElement>(element)!; this.config = { ...Default, ...options }; this.disposed = false; this.triggerArr = []; this.init(); } static attach(el: Element, opt?: IMMOptions) { return new MetisMenu(el, opt); } private init(): void { const { METIS, ACTIVE, COLLAPSE } = ClassName; this.element.classList.add(METIS); [].slice.call(this.element.querySelectorAll<HTMLElement>(this.config.subMenu!)).forEach((ul: Element) => { ul.classList.add(COLLAPSE!); const li = closest(ul, this.config.parentTrigger!); if (li?.classList.contains(ACTIVE!)) { this.show(ul as HTMLElement); } else { this.hide(ul); } const a = li?.querySelector<HTMLElement>(this.config.triggerElement!); if (a?.getAttribute('aria-disabled') === 'true') { return; } a?.setAttribute('aria-expanded', 'false'); a?.addEventListener('click', this.clickEvent.bind(this)); this.triggerArr.push(a!); }); } private clickEvent(evt?: Event) { if (!this.disposed) { const target = evt?.currentTarget as Element | null; if (target && target.tagName === 'A') { evt!.preventDefault(); } const li = closest(target!, this.config.parentTrigger!); const ul = li?.querySelector(this.config.subMenu!); this.toggle(ul!); } } update() { this.disposed = false; this.init(); } dispose() { this.triggerArr.forEach((arr) => { arr.removeEventListener('click', this.clickEvent.bind(this)); }); this.disposed = true; } on<K extends EventType>(evtType: K, handler: SpecificEventListener<K>, options?: AddEventListenerOptions | boolean): MetisMenu; on<E extends Event>(evtType: E, handler: CustomEventListener<E>, options?: AddEventListenerOptions | boolean): MetisMenu; on(evtType: MetisMenuEvents, handler: EventListener, options?: AddEventListenerOptions | boolean) { this.element.addEventListener(evtType, handler, options); return this; } off<K extends EventType>(evtType: K, handler: SpecificEventListener<K>, options?: AddEventListenerOptions | boolean): MetisMenu; off<E extends Event>(evtType: E, handler: CustomEventListener<E>, options?: AddEventListenerOptions | boolean): MetisMenu; off(evtType: MetisMenuEvents, handler: EventListener, options?: AddEventListenerOptions | boolean) { this.element.removeEventListener(evtType, handler, options); return this; } emit<T extends object>(evtType: string, evtData: T, shouldBubble = false) { let evt: CustomEvent<T>; if (typeof CustomEvent === 'function') { evt = new CustomEvent<T>(evtType, { bubbles: shouldBubble, detail: evtData, }); } else { evt = document.createEvent('CustomEvent'); evt.initCustomEvent(evtType, shouldBubble, false, evtData); } this.element.dispatchEvent(evt); return this; } toggle(ul: Element) { const li = closest(ul, this.config.parentTrigger!); if (li?.classList.contains(ClassName.ACTIVE)) { this.hide(ul); } else { this.show(ul); } } show(el: Element) { const ul = el as HTMLElement; const { ACTIVE, COLLAPSE, COLLAPSED, COLLAPSING, SHOW, } = ClassName; if (this.isTransitioning || ul.classList.contains(COLLAPSING!)) { return; } const complete = () => { ul.classList.remove(COLLAPSING!); ul.style.height = ''; ul.removeEventListener('transitionend', complete); this.setTransitioning(false); this.emit('shown.metisMenu', { shownElement: ul, }); }; const li = closest(ul, this.config.parentTrigger!); li?.classList.add(ACTIVE!); const a = li?.querySelector<HTMLElement>(this.config.triggerElement!); a?.setAttribute('aria-expanded', 'true'); a?.classList.remove(COLLAPSED!); ul.style.height = '0px'; ul.classList.remove(COLLAPSE!); ul.classList.remove(SHOW!); ul.classList.add(COLLAPSING!); const eleParentSiblins = [].slice .call(li?.parentNode?.children) .filter((c) => c !== li); if (this.config.toggle && eleParentSiblins.length > 0) { eleParentSiblins.forEach((sibli: Element) => { const sibUl = sibli.querySelector<HTMLElement>(this.config.subMenu!); if (sibUl) { this.hide(sibUl); } }); } this.setTransitioning(true); ul.classList.add(COLLAPSE!); ul.classList.add(SHOW!); ul.style.height = `${ul.scrollHeight}px`; this.emit('show.metisMenu', { showElement: ul, }); ul.addEventListener('transitionend', complete); } hide(el: Element) { const { ACTIVE, COLLAPSE, COLLAPSED, COLLAPSING, SHOW, } = ClassName; const ul = el as HTMLElement; if (this.isTransitioning || !ul.classList.contains(SHOW!)) { return; } this.emit('hide.metisMenu', { hideElement: ul, }); const li = closest(ul, this.config.parentTrigger!); li?.classList.remove(ACTIVE!); const complete = () => { ul.classList.remove(COLLAPSING!); ul.classList.add(COLLAPSE!); ul.style.height = ''; ul.removeEventListener('transitionend', complete); this.setTransitioning(false); this.emit('hidden.metisMenu', { hiddenElement: ul, }); }; ul.style.height = `${ul.getBoundingClientRect().height}px`; ul.style.height = `${ul.offsetHeight}px`; ul.classList.add(COLLAPSING!); ul.classList.remove(COLLAPSE!); ul.classList.remove(SHOW!); this.setTransitioning(true); ul.addEventListener('transitionend', complete); ul.style.height = '0px'; const a = li?.querySelector(this.config.triggerElement!); a?.setAttribute('aria-expanded', 'false'); a?.classList.add(COLLAPSED!); } private setTransitioning(isTransitioning: boolean) { this.isTransitioning = isTransitioning; } static isElement(element: unknown): element is Element { return Boolean((element as Element).classList); } } export default MetisMenu;