UNPKG

smdm-slide-menu

Version:

A library agnostic multilevel page menu with a smooth slide effect based on CSS transitions, focused on accessibility.

106 lines (90 loc) 3.48 kB
import { Action } from './SlideMenuOptions.js'; type TriggerEventFn = (action: Action, afterAnimation?: boolean) => void; /** * Manages all CSS-transition animation concerns: * - Moving elements via translateX * - Suppressing transitions for instant DOM updates * - Tracking the in-flight animation state * - Resolving transitionend events to post-animation callbacks */ export class AnimationController { private _isAnimating: boolean = false; private lastAction: Action | null = null; private readonly boundOnTransitionEnd = this.onTransitionEnd.bind(this); constructor( private readonly menuElem: HTMLElement, private readonly sliderElem: HTMLElement, private readonly foldableWrapperElem: HTMLElement, private readonly sliderWrapperElem: HTMLElement, private readonly triggerEvent: TriggerEventFn, ) { this.menuElem.addEventListener('transitionend', this.boundOnTransitionEnd); this.sliderElem.addEventListener('transitionend', this.boundOnTransitionEnd); } get isAnimating(): boolean { return this._isAnimating; } /** * Start the slide animation (the CSS transition) for `elem` by applying a translateX. * Records `action` as the last action so `onTransitionEnd` can fire the `-after` event. * The inner setTimeout flushes current style recalculations before triggering the transition. */ moveElem(elem: HTMLElement, offset: string | number, unit: string = '%', action?: Action): void { if (action !== undefined) { this.lastAction = action; } setTimeout(() => { if (!offset.toString().includes(unit)) { offset += unit; } const newTranslateX = `translateX(${offset})`; if (elem.style.transform !== newTranslateX) { this._isAnimating = true; elem.style.transform = newTranslateX; } }, 0); } /** * Pause CSS transitions, execute `action` synchronously (no animation), then restore. * Forces a reflow between disable and re-enable to flush the CSS changes. */ runWithoutAnimation(action: () => void): void { const transitionElems = [this.menuElem, this.sliderElem]; transitionElems.forEach((elem) => (elem.style.transition = 'none')); action(); // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.menuElem.offsetHeight; // Trigger a reflow, flushing the CSS changes transitionElems.forEach((elem) => elem.style.removeProperty('transition')); this._isAnimating = false; } /** * Record the current action so it can be re-dispatched as an `-after` event * once the CSS transition ends. */ setLastAction(action: Action): void { this.lastAction = action; } /** * Remove the transitionend listeners. Call this from SlideMenu.destroy(). */ destroy(): void { this.menuElem.removeEventListener('transitionend', this.boundOnTransitionEnd); this.sliderElem.removeEventListener('transitionend', this.boundOnTransitionEnd); } private onTransitionEnd(event: Event): void { // Ignore events bubbling up from nested elements that use their own CSS transitions. if ( event.target !== this.menuElem && event.target !== this.sliderElem && event.target !== this.foldableWrapperElem && event.target !== this.sliderWrapperElem ) { return; } this._isAnimating = false; if (this.lastAction) { this.triggerEvent(this.lastAction, true); this.lastAction = null; } } }