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
text/typescript
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;
}
}
}