/** * Declarative enter/leave/move transitions for the view layer. * * This is a thin declarative binding over the existing `motion` primitives — * it does not implement a second animation engine. Enter/leave keyframes run * through the Web Animations API (the same path `motion` uses), FLIP moves * delegate to `motion`'s `flip()` / `capturePosition()`, and every transition * honours the shared reduced-motion preference via `prefersReducedMotion()`. * * Authors attach transitions with plain companion attributes that the * structural directives (`bq-if`, `bq-show`, `bq-for`) read: * * ```html *
*
  • *
  • * ``` * * @module bquery/view * @since 1.15.0 */ import { prefersReducedMotion } from '../../motion/reduced-motion'; /** * Companion attributes consumed by structural directives to drive transitions. * The view pipeline treats these as passive (never warns about them and never * processes them as standalone directives). * * @internal */ export const TRANSITION_ATTRS = [ 'transition', 'transition-duration', 'transition-easing', 'in', 'out', 'animate', ] as const; /** Default enter/leave duration in milliseconds. */ const DEFAULT_DURATION = 200; /** Default easing for enter/leave transitions. */ const DEFAULT_EASING = 'ease'; /** * Built-in transition presets, expressed as enter keyframes (hidden → visible). * The leave keyframes are the reverse (visible → hidden), so a single named * preset describes a symmetric in/out pair. * * @internal */ const PRESETS: Record = { fade: [{ opacity: 0 }, { opacity: 1 }], scale: [ { opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'scale(1)' }, ], 'slide-up': [ { opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'translateY(0)' }, ], 'slide-down': [ { opacity: 0, transform: 'translateY(-10px)' }, { opacity: 1, transform: 'translateY(0)' }, ], 'slide-left': [ { opacity: 0, transform: 'translateX(10px)' }, { opacity: 1, transform: 'translateX(0)' }, ], 'slide-right': [ { opacity: 0, transform: 'translateX(-10px)' }, { opacity: 1, transform: 'translateX(0)' }, ], }; // `slide` is an alias for `slide-up`. PRESETS.slide = PRESETS['slide-up']; /** Resolved transition configuration for an element. */ export type ViewTransitionConfig = { /** Enter keyframes (hidden → visible), or `null` when no enter transition. */ enter: Keyframe[] | null; /** Leave keyframes (visible → hidden), or `null` when no leave transition. */ leave: Keyframe[] | null; /** Duration in milliseconds. */ duration: number; /** CSS easing string. */ easing: string; }; /** * Resolves a preset name to its enter keyframes. An unknown but non-empty name * falls back to `fade` so a typo degrades to a sensible animation rather than * silently doing nothing. An empty name yields `null`. * * @internal */ const resolvePresetKeyframes = (name: string | null): Keyframe[] | null => { if (name == null) return null; const trimmed = name.trim(); if (trimmed === '') return null; return PRESETS[trimmed] ?? PRESETS.fade; }; /** Reverses a keyframe list so an enter preset becomes its leave counterpart. */ const reverseKeyframes = (frames: Keyframe[]): Keyframe[] => [...frames].reverse(); /** * Reads the transition companion attributes from an element and resolves them * into enter/leave keyframes plus timing. Returns `null` when the element has * no transition attributes at all, so callers can take their synchronous path. * * @internal */ export const resolveTransition = ( el: Element, prefix: string ): ViewTransitionConfig | null => { const transitionAttr = el.getAttribute(`${prefix}-transition`); const inAttr = el.getAttribute(`${prefix}-in`); const outAttr = el.getAttribute(`${prefix}-out`); if (transitionAttr == null && inAttr == null && outAttr == null) { return null; } const durationAttr = el.getAttribute(`${prefix}-transition-duration`); const easingAttr = el.getAttribute(`${prefix}-transition-easing`); const parsedDuration = durationAttr != null ? Number(durationAttr) : NaN; const duration = Number.isFinite(parsedDuration) && parsedDuration >= 0 ? parsedDuration : DEFAULT_DURATION; const easing = easingAttr != null && easingAttr.trim() !== '' ? easingAttr.trim() : DEFAULT_EASING; const enter = resolvePresetKeyframes(inAttr ?? transitionAttr); const leaveBase = resolvePresetKeyframes(outAttr ?? transitionAttr); const leave = leaveBase ? reverseKeyframes(leaveBase) : null; return { enter, leave, duration, easing }; }; /** * Cancels any animations currently running on an element. Used to clear a * `fill: 'forwards'` leave animation before re-entering, and to avoid stacking * animations on rapid toggles. No-ops where `Element.getAnimations` is absent * (e.g. some test DOMs). * * @internal */ export const cancelTransitions = (el: Element): void => { const animatable = el as Element & { getAnimations?: () => Animation[] }; if (typeof animatable.getAnimations === 'function') { for (const animation of animatable.getAnimations()) { animation.cancel(); } } }; /** * Runs a keyframe transition on an element and resolves when it finishes. * * Resolves immediately (a no-op) when there are no keyframes, when the user * prefers reduced motion, or when the Web Animations API is unavailable — in * every such case the caller's commit logic still runs, so behaviour is * identical to having no transition. * * @param fill - `'none'` for enter (revert to natural styles afterwards), * `'forwards'` for leave (hold the hidden end-state until the element is * removed, preventing a visible flash). * @internal */ export const runTransition = ( el: Element, keyframes: Keyframe[] | null, config: ViewTransitionConfig, fill: 'none' | 'forwards' ): Promise => { const animatable = el as HTMLElement & { animate?: (frames: Keyframe[], opts: KeyframeAnimationOptions) => Animation; }; if (!keyframes || keyframes.length === 0 || prefersReducedMotion()) { return Promise.resolve(); } if (typeof animatable.animate !== 'function') { return Promise.resolve(); } return new Promise((resolve) => { let settled = false; const finish = (): void => { if (settled) return; settled = true; resolve(); }; const animation = animatable.animate(keyframes, { duration: config.duration, easing: config.easing, fill, }); animation.onfinish = finish; animation.oncancel = finish; // Belt-and-braces for engines that resolve `finished` without firing the // `onfinish`/`oncancel` handlers. if (animation.finished && typeof animation.finished.then === 'function') { animation.finished.then(finish).catch(finish); } }); };