diff --git a/.changeset/animation-initial.md b/.changeset/animation-initial.md new file mode 100644 index 000000000..27e38e3ad --- /dev/null +++ b/.changeset/animation-initial.md @@ -0,0 +1,21 @@ +--- +"@solid-primitives/animation": minor +--- + +New package. Provides reactive and imperative wrappers for the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI). All primitives follow the `make*` / `create*` convention: `make*` is imperative and returns immediately, `create*` is a reactive wrapper that re-runs on dependency change and cancels on owner disposal. + +- `makeAnimate(el, keyframes, options?)` — thin wrapper around `element.animate()` +- `createAnimate(target, keyframes, options?)` — reactive `makeAnimate`; re-runs whenever target, keyframes, or options change +- `makeScrollAnimation(el, keyframes, options?)` — scroll-driven animation via `ScrollTimeline` +- `createScrollAnimation(target, keyframes, options?)` — reactive `makeScrollAnimation` +- `makeViewAnimation(el, keyframes, options?)` — viewport-driven animation via `ViewTimeline`; defaults `rangeStart`/`rangeEnd` to the entry phase so initially-visible elements animate correctly +- `createViewAnimation(target, keyframes, options?)` — reactive `makeViewAnimation` +- `makeFlip(el, options?)` — FLIP layout animation; `snapshot()` before DOM change, `flip()` after +- `makeStagger(els, keyframes, options?)` — staggered WAAPI animation across a list of elements with per-element delay offset +- `createStagger(targets, keyframes, options?)` — reactive `makeStagger` +- `makeAnimationGroup(animations)` — coordinates a static list of `Animation` objects as a unit; forwards `play`, `pause`, `cancel`, `reverse`, and `finish` to all simultaneously +- `createAnimationGroup(animations)` — reactive `makeAnimationGroup`; re-derives the group whenever the accessor returns a new list +- `makeMotionPath(el, path, options?)` — animates an element along a CSS `offset-path` using WAAPI +- `createMotionPath(target, path, options?)` — reactive `makeMotionPath` +- `makeSequence(factories)` — chains animation factories into a sequential playlist; each factory is called lazily when its predecessor finishes +- `createPresenceAnimation(target, show, options)` — manages mount/unmount lifecycle with WAAPI enter/exit animations; element stays mounted until its exit animation completes diff --git a/README.md b/README.md index bf1aad04a..28c593f21 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg?style=for-the-badge&logo=pnpm)](https://pnpm.io/) [![tested with vitest](https://img.shields.io/badge/tested_with-vitest-6E9F18?style=for-the-badge&logo=vitest)](https://vitest.dev) -[![combined-downloads](https://img.shields.io/endpoint?style=for-the-badge&url=https://combined-npm-downloads.deno.dev/@solid-primitives/a11y,@solid-primitives/active-element,@solid-primitives/analytics,@solid-primitives/audio,@solid-primitives/bounds,@solid-primitives/broadcast-channel,@solid-primitives/clipboard,@solid-primitives/connectivity,@solid-primitives/context,@solid-primitives/controlled-props,@solid-primitives/controlled-signal,@solid-primitives/cookies,@solid-primitives/cursor,@solid-primitives/date,@solid-primitives/db-store,@solid-primitives/deep,@solid-primitives/destructure,@solid-primitives/devices,@solid-primitives/event-bus,@solid-primitives/event-dispatcher,@solid-primitives/event-listener,@solid-primitives/event-props,@solid-primitives/fetch,@solid-primitives/filesystem,@solid-primitives/flux-store,@solid-primitives/focus,@solid-primitives/form,@solid-primitives/fullscreen,@solid-primitives/geolocation,@solid-primitives/graphql,@solid-primitives/history,@solid-primitives/i18n,@solid-primitives/idle,@solid-primitives/immutable,@solid-primitives/input-mask,@solid-primitives/interaction,@solid-primitives/intersection-observer,@solid-primitives/jsx-tokenizer,@solid-primitives/keyboard,@solid-primitives/keyed,@solid-primitives/lifecycle,@solid-primitives/list,@solid-primitives/list-state,@solid-primitives/map,@solid-primitives/marker,@solid-primitives/masonry,@solid-primitives/match,@solid-primitives/media,@solid-primitives/mediastream,@solid-primitives/memo,@solid-primitives/mouse,@solid-primitives/mutable,@solid-primitives/mutation-observer,@solid-primitives/notification,@solid-primitives/orientation,@solid-primitives/page-utilities,@solid-primitives/pagination,@solid-primitives/permission,@solid-primitives/platform,@solid-primitives/pointer,@solid-primitives/presence,@solid-primitives/promise,@solid-primitives/props,@solid-primitives/queue,@solid-primitives/raf,@solid-primitives/range,@solid-primitives/refs,@solid-primitives/resize-observer,@solid-primitives/resource,@solid-primitives/rootless,@solid-primitives/scheduled,@solid-primitives/script-loader,@solid-primitives/scroll,@solid-primitives/selection,@solid-primitives/sensors,@solid-primitives/set,@solid-primitives/share,@solid-primitives/signal-builders,@solid-primitives/spring,@solid-primitives/sse,@solid-primitives/state-machine,@solid-primitives/static-store,@solid-primitives/storage,@solid-primitives/styles,@solid-primitives/timer,@solid-primitives/transition-group,@solid-primitives/trigger,@solid-primitives/tween,@solid-primitives/upload,@solid-primitives/vibrate,@solid-primitives/video,@solid-primitives/virtual,@solid-primitives/websocket,@solid-primitives/workers)](https://dash.deno.com/playground/combined-npm-downloads) +[![combined-downloads](https://img.shields.io/endpoint?style=for-the-badge&url=https://combined-npm-downloads.deno.dev/@solid-primitives/a11y,@solid-primitives/active-element,@solid-primitives/analytics,@solid-primitives/audio,@solid-primitives/bounds,@solid-primitives/broadcast-channel,@solid-primitives/clipboard,@solid-primitives/connectivity,@solid-primitives/context,@solid-primitives/controlled-props,@solid-primitives/controlled-signal,@solid-primitives/cookies,@solid-primitives/cursor,@solid-primitives/date,@solid-primitives/db-store,@solid-primitives/deep,@solid-primitives/destructure,@solid-primitives/devices,@solid-primitives/event-bus,@solid-primitives/event-dispatcher,@solid-primitives/event-listener,@solid-primitives/event-props,@solid-primitives/fetch,@solid-primitives/filesystem,@solid-primitives/flux-store,@solid-primitives/focus,@solid-primitives/form,@solid-primitives/fullscreen,@solid-primitives/geolocation,@solid-primitives/graphql,@solid-primitives/history,@solid-primitives/i18n,@solid-primitives/idle,@solid-primitives/immutable,@solid-primitives/input-mask,@solid-primitives/interaction,@solid-primitives/intersection-observer,@solid-primitives/jsx-tokenizer,@solid-primitives/keyboard,@solid-primitives/keyed,@solid-primitives/lifecycle,@solid-primitives/list,@solid-primitives/list-state,@solid-primitives/map,@solid-primitives/marker,@solid-primitives/masonry,@solid-primitives/match,@solid-primitives/media,@solid-primitives/mediastream,@solid-primitives/memo,@solid-primitives/mouse,@solid-primitives/mutable,@solid-primitives/mutation-observer,@solid-primitives/notification,@solid-primitives/orientation,@solid-primitives/page-utilities,@solid-primitives/pagination,@solid-primitives/permission,@solid-primitives/platform,@solid-primitives/pointer,@solid-primitives/animation,@solid-primitives/promise,@solid-primitives/props,@solid-primitives/queue,@solid-primitives/raf,@solid-primitives/range,@solid-primitives/refs,@solid-primitives/resize-observer,@solid-primitives/resource,@solid-primitives/rootless,@solid-primitives/scheduled,@solid-primitives/script-loader,@solid-primitives/scroll,@solid-primitives/selection,@solid-primitives/sensors,@solid-primitives/set,@solid-primitives/share,@solid-primitives/signal-builders,@solid-primitives/spring,@solid-primitives/sse,@solid-primitives/state-machine,@solid-primitives/static-store,@solid-primitives/storage,@solid-primitives/styles,@solid-primitives/timer,@solid-primitives/transition-group,@solid-primitives/trigger,@solid-primitives/tween,@solid-primitives/upload,@solid-primitives/vibrate,@solid-primitives/video,@solid-primitives/virtual,@solid-primitives/websocket,@solid-primitives/workers)](https://dash.deno.com/playground/combined-npm-downloads) Solid Primitives is a project dedicated to building high-quality, community-contributed primitives for SolidJS. Every utility is thoroughly tested, continuously maintained, and reviewed against a consistent quality bar before it lands in the repository. Our aim is to extend Solid's primary and secondary primitives with a well-rounded set of tertiary primitives. @@ -139,6 +139,7 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n |[orientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[makeOrientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#makeorientation)
[createOrientation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/orientation#createorientation)|[![SIZE](https://img.shields.io/badge/size-489_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/orientation)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/orientation?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/orientation)|✓| |[vibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[isVibrationSupported](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#isvibrationsupported)
[makeVibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#makevibrate)
[createVibrate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#createvibrate)
[frequencyToPattern](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#frequencytopattern)
[makePulse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#makepulse)
[createPulse](https://github.com/solidjs-community/solid-primitives/tree/main/packages/vibrate#createpulse)|[![SIZE](https://img.shields.io/badge/size-829_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/vibrate)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/vibrate?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/vibrate)|✓| |

*Animation*

| +|[animation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createAnimate](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createanimate)
[createScrollAnimation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createscrollanimation)
[createViewAnimation](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createviewanimation)
[makeFlip](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#makeflip)
[createStagger](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createstagger)
[createAnimationGroup](https://github.com/solidjs-community/solid-primitives/tree/main/packages/animation#createanimationgroup)|[![SIZE](https://img.shields.io/badge/size-TBD-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/animation)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/animation?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/animation)|| |[presence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPresence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#createpresence)|[![SIZE](https://img.shields.io/badge/size-649_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/presence)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/presence?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/presence)|✓| |[raf](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createRAF](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createraf)
[createMs](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createms)
[targetFPS](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#targetfps)|[![SIZE](https://img.shields.io/badge/size-539_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/raf)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/raf?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/raf)|✓| |[spring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createspring)
[createDerivedSpring](https://github.com/solidjs-community/solid-primitives/tree/main/packages/spring#createderivedspring)|[![SIZE](https://img.shields.io/badge/size-753_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/spring)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/spring?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/spring)|✓| diff --git a/packages/animation/CHANGELOG.md b/packages/animation/CHANGELOG.md new file mode 100644 index 000000000..02a02c3fd --- /dev/null +++ b/packages/animation/CHANGELOG.md @@ -0,0 +1,10 @@ +# @solid-primitives/animation + +## 0.0.1 + +### Minor Changes + +- Initial release. WAAPI-based animation primitives for SolidJS: `makeAnimate`, `createAnimate`, + `makeScrollAnimation`, `createScrollAnimation`, `makeViewAnimation`, `createViewAnimation`, + `makeFlip`, `makeStagger`, `createStagger`, `makeAnimationGroup`, `createAnimationGroup`, + `makeMotionPath`, `createMotionPath`, `makeSequence`, `createPresenceAnimation`. diff --git a/packages/animation/LICENSE b/packages/animation/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/animation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/animation/README.md b/packages/animation/README.md new file mode 100644 index 000000000..742cc2784 --- /dev/null +++ b/packages/animation/README.md @@ -0,0 +1,357 @@ +

+ Solid Primitives animation +

+ +# @solid-primitives/animation + +[![version](https://img.shields.io/npm/v/@solid-primitives/animation?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/animation) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) +[![tested with vitest](https://img.shields.io/badge/tested_with-vitest-6E9F18?style=for-the-badge&logo=vitest)](https://vitest.dev) + +Solid primitives for the [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) (WAAPI). Each primitive follows the `make` / `create`. +## Installation + +```bash +npm install @solid-primitives/animation +# or +pnpm add @solid-primitives/animation +``` + +## Primitives + +| Primitive | Description | +|---|---| +| [`makeAnimate`](#makeanimate--createanimate) | Imperative `element.animate()` wrapper | +| [`createAnimate`](#makeanimate--createanimate) | Reactive `makeAnimate` | +| [`makeScrollAnimation`](#makescrollanimation--createscrollanimation) | Scroll-driven animation via `ScrollTimeline` | +| [`createScrollAnimation`](#makescrollanimation--createscrollanimation) | Reactive `makeScrollAnimation` | +| [`makeViewAnimation`](#makeviewanimation--createviewanimation) | Viewport-driven animation via `ViewTimeline` | +| [`createViewAnimation`](#makeviewanimation--createviewanimation) | Reactive `makeViewAnimation` | +| [`makeFlip`](#makeflip) | FLIP layout animation | +| [`makeStagger`](#makestagger--createstagger) | Staggered animations across a list of elements | +| [`createStagger`](#makestagger--createstagger) | Reactive `makeStagger` | +| [`makeAnimationGroup`](#makeanimationgroup--createanimationgroup) | Coordinate multiple animations as a unit | +| [`createAnimationGroup`](#makeanimationgroup--createanimationgroup) | Reactive `makeAnimationGroup` | +| [`createPresenceAnimation`](#createpresenceanimation) | Mount/unmount lifecycle with WAAPI enter/exit animations | + +--- + +## `makeAnimate` / `createAnimate` + +`makeAnimate` is a thin wrapper around `element.animate()` with TypeScript types. `createAnimate` replays the animation whenever `target`, `keyframes`, or `options` change reactively, and cancels it when the owner disposes. + +```ts +// Imperative +const anim = makeAnimate(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 300 }); +anim.pause(); + +// Reactive +const anim = createAnimate( + () => ref, + [{ opacity: 0 }, { opacity: 1 }], + { duration: 300, fill: "forwards" }, +); +// anim() is the current Animation instance, or undefined while ref is unset +anim()?.pause(); +``` + +```ts +function makeAnimate( + el: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: KeyframeAnimationOptions, +): Animation + +function createAnimate( + target: Accessor, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor +``` + +--- + +## `makeScrollAnimation` / `createScrollAnimation` + +Plays a WAAPI animation whose progress is driven by scroll position via [`ScrollTimeline`](https://developer.mozilla.org/en-US/docs/Web/API/ScrollTimeline). No scroll listeners or RAF loops needed. + +```ts +// Fade + rise as the user scrolls down the page +const anim = createScrollAnimation( + () => ref, + [{ opacity: 0, transform: "translateY(20px)" }, { opacity: 1, transform: "none" }], + { fill: "both" }, +); + +// Tie progress to a specific scroll container +const anim = createScrollAnimation(() => ref, keyframes, { + fill: "both", + source: scrollContainerEl, + axis: "block", +}); +``` + +```ts +type ScrollAnimationOptions = Omit & { + source?: Element; // scroll container — defaults to document root scroller + axis?: "block" | "inline" | "x" | "y"; +}; + +function makeScrollAnimation( + el: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: ScrollAnimationOptions, +): Animation + +function createScrollAnimation( + target: Accessor, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor +``` + +--- + +## `makeViewAnimation` / `createViewAnimation` + +Plays a WAAPI animation whose progress is driven by an element's intersection with the scroll port via [`ViewTimeline`](https://developer.mozilla.org/en-US/docs/Web/API/ViewTimeline). Replaces the IntersectionObserver + class-toggle pattern. + +```ts +// Animate the element itself as it enters the viewport +const anim = createViewAnimation( + () => ref, + [{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "none" }], + { fill: "both" }, +); + +// Observe a different element than the one being animated +const anim = createViewAnimation(() => animatedEl, keyframes, { + fill: "both", + subject: triggerEl, + inset: "0px 0px -100px 0px", +}); +``` + +```ts +type ViewAnimationOptions = Omit & { + subject?: Element; // element to observe — defaults to target + axis?: "block" | "inline" | "x" | "y"; + inset?: string | string[]; // shrinks/expands the intersection root +}; + +function makeViewAnimation( + el: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: ViewAnimationOptions, +): Animation + +function createViewAnimation( + target: Accessor, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor +``` + +--- + +## `makeFlip` + +FLIP (First–Last–Invert–Play) layout animation. Call `snapshot()` before the DOM change to record the element's current geometry, then call `flip()` after to animate from the old position/size to the new one. + +```tsx +let el!: HTMLUListElement; +const { snapshot, flip } = makeFlip(el, { duration: 300, easing: "ease" }); + +const handleReorder = () => { + snapshot(); + setItems(prev => [...prev].reverse()); // DOM updates synchronously + flip(); +}; + +return
    ...
; +``` + +`flip()` is a no-op if `snapshot()` was never called or if the geometry didn't change. It resets the captured rect after each call, so a second `flip()` without a new `snapshot()` is always a no-op. + +> **Note:** geometry is measured via `getBoundingClientRect` (viewport coordinates). Elements inside `position: fixed` or `position: absolute` ancestors may need coordinate adjustment. + +```ts +function makeFlip( + el: Element, + options?: KeyframeAnimationOptions, +): { snapshot: () => void; flip: () => Animation | undefined } +``` + +--- + +## `makeStagger` / `createStagger` + +Applies a WAAPI animation to a list of elements with a per-element delay offset. The `stagger` option is added on top of the base `delay`. + +```ts +// Imperative — animate a static list of elements +makeStagger(listItems, [{ opacity: 0 }, { opacity: 1 }], { + duration: 400, + stagger: 60, +}); + +// Reactive — re-runs (cancelling previous animations) when the target list changes +const itemRefs: HTMLLIElement[] = []; + +const anims = createStagger( + () => itemRefs, + [{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], + { duration: 400, stagger: 60, easing: "ease-out" }, +); +``` + +```ts +type StaggerOptions = KeyframeAnimationOptions & { + stagger?: number; // ms added per element on top of `delay` +}; + +function makeStagger( + els: Element[], + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: StaggerOptions, +): Animation[] + +function createStagger( + targets: Accessor<(Element | null | undefined)[]>, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor +``` + +--- + +## `makeAnimationGroup` / `createAnimationGroup` + +Coordinates a list of `Animation` objects as a single unit. All five control methods are forwarded to every non-null animation simultaneously. Pairs naturally with `makeAnimate` and `makeStagger`. + +`makeAnimationGroup` takes a static array. `createAnimationGroup` takes an accessor and re-derives the group whenever the list changes — each control method always operates on the most recent set of animations. + +```ts +// Imperative — static list +const header = makeAnimate(headerEl, fadeIn, { duration: 300 }); +const body = makeAnimate(bodyEl, fadeIn, { duration: 300, delay: 100 }); +const footer = makeAnimate(footerEl, fadeIn, { duration: 300, delay: 200 }); + +const group = makeAnimationGroup([header, body, footer]); + +group.pause(); +group.play(); +group.cancel(); +``` + +```tsx +// Reactive — list changes when items() changes +const itemRefs: HTMLLIElement[] = []; +const [items, setItems] = createSignal(data); + +const anims = createStagger( + () => itemRefs, + [{ opacity: 0 }, { opacity: 1 }], + { duration: 300, stagger: 40 }, +); + +// group.play() / pause() always targets the animations from the latest render +const group = createAnimationGroup(anims); + +return ( + +
    + + {(item, i) =>
  • {item.name}
  • } +
    +
+); +``` + +```ts +type AnimationGroupControls = { + play: () => void; + pause: () => void; + cancel: () => void; + reverse: () => void; + finish: () => void; +}; + +function makeAnimationGroup( + animations: (Animation | null | undefined)[], +): AnimationGroupControls + +function createAnimationGroup( + animations: Accessor<(Animation | null | undefined)[]>, +): AnimationGroupControls +``` + +--- + +## `createPresenceAnimation` + +Manages mount/unmount lifecycle with WAAPI enter and exit animations. Pass a `target` ref accessor, a `show` signal, and enter/exit keyframes. The returned `isMounted` accessor should gate the element's presence in the DOM — the element stays mounted until its exit animation finishes. + +Exit keyframes default to the enter keyframes reversed. If `show` toggles back to `true` while an exit is in progress, the exit is cancelled and the enter restarts. + +```tsx +const [show, setShow] = createSignal(false); +let el!: HTMLDivElement; + +const { isMounted } = createPresenceAnimation(() => el, show, { + enter: [ + { opacity: 0, transform: "translateY(8px)" }, + { opacity: 1, transform: "none" }, + ], + enterOptions: { duration: 250, easing: "ease-out" }, + // exit defaults to reversed enter — fade out and slide down +}); + +return ( + <> + + +
Hello
+
+ +); +``` + +```tsx +// Separate enter and exit keyframes + options +const { isMounted } = createPresenceAnimation(() => el, show, { + enter: [{ opacity: 0, transform: "scale(0.95)" }, { opacity: 1, transform: "none" }], + exit: [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0.95)" }], + enterOptions: { duration: 200, easing: "ease-out" }, + exitOptions: { duration: 150, easing: "ease-in" }, +}); +``` + +```ts +type PresenceAnimationOptions = { + enter: Keyframe[] | PropertyIndexedKeyframes | null; + exit?: Keyframe[] | PropertyIndexedKeyframes | null; // defaults to reversed enter + enterOptions?: KeyframeAnimationOptions; + exitOptions?: KeyframeAnimationOptions; // defaults to enterOptions + initialEnter?: boolean; // animate on first mount (default: false) +}; + +function createPresenceAnimation( + target: Accessor, + show: MaybeAccessor, + options: PresenceAnimationOptions, +): { isMounted: Accessor } +``` + +--- + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) + +## Related + +- [`@solid-primitives/presence`](https://www.npmjs.com/package/@solid-primitives/presence) — mount/unmount lifecycle coordination for CSS transitions +- [`@solid-primitives/transition-group`](https://www.npmjs.com/package/@solid-primitives/transition-group) — `` for lists +- [`@solid-primitives/spring`](https://www.npmjs.com/package/@solid-primitives/spring) — spring-physics value interpolation +- [`@solid-primitives/tween`](https://www.npmjs.com/package/@solid-primitives/tween) — tween value interpolation diff --git a/packages/animation/package.json b/packages/animation/package.json new file mode 100644 index 000000000..042f3038d --- /dev/null +++ b/packages/animation/package.json @@ -0,0 +1,80 @@ +{ + "name": "@solid-primitives/animation", + "version": "0.0.1", + "description": "SolidJS primitives for the Web Animations API (WAAPI) — reactive wrappers for element.animate, scroll timelines, view timelines, FLIP, stagger, and animation groups.", + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/animation", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "primitive": { + "name": "animation", + "stage": 0, + "list": [ + "makeAnimate", + "createAnimate", + "makeScrollAnimation", + "createScrollAnimation", + "makeViewAnimation", + "createViewAnimation", + "makeFlip", + "makeStagger", + "createStagger", + "makeAnimationGroup", + "createAnimationGroup", + "makeMotionPath", + "createMotionPath", + "makeSequence", + "createPresenceAnimation", + "createPresenceA", + "createPresenceB" + ], + "category": "Animation" + }, + "files": [ + "dist" + ], + "private": false, + "sideEffects": false, + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "keywords": [ + "animation", + "animate", + "waapi", + "web-animations", + "scroll-timeline", + "view-timeline", + "flip", + "stagger", + "solid", + "solidjs" + ], + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, + "peerDependencies": { + "solid-js": "^2.0.0-beta.15" + }, + "typesVersions": {}, + "devDependencies": { + "solid-js": "2.0.0-beta.15" + } +} diff --git a/packages/animation/src/animate.ts b/packages/animation/src/animate.ts new file mode 100644 index 000000000..339c3cb11 --- /dev/null +++ b/packages/animation/src/animate.ts @@ -0,0 +1,41 @@ +import { createSignal, createEffect, type Accessor } from "solid-js"; +import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils"; + +/** Plays a WAAPI animation on `el` and returns the `Animation` instance. */ +export function makeAnimate( + el: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: KeyframeAnimationOptions, +): Animation { + return el.animate(keyframes, options); +} + +/** + * Reactive wrapper around {@link makeAnimate}. Re-runs (cancelling the prior + * animation) whenever `target`, `keyframes`, or `options` change. Cancels + * automatically when the owner scope is disposed. + */ +export function createAnimate( + target: Accessor, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor { + const getKf = asAccessor(keyframes); + const getOpts = typeof options === "function" ? options : () => options; + const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS); + + createEffect( + () => ({ el: target(), kf: getKf(), opts: getOpts() }), + ({ el, kf, opts }) => { + if (!el) { + setAnimation(undefined); + return; + } + const anim = makeAnimate(el, kf, opts); + setAnimation(anim); + return () => anim.cancel(); + }, + ); + + return animation; +} diff --git a/packages/animation/src/animation-group.ts b/packages/animation/src/animation-group.ts new file mode 100644 index 000000000..3b6e85c51 --- /dev/null +++ b/packages/animation/src/animation-group.ts @@ -0,0 +1,49 @@ +import { createMemo, type Accessor } from "solid-js"; + +export type AnimationGroupControls = { + play: () => void; + pause: () => void; + cancel: () => void; + reverse: () => void; + finish: () => void; +}; + +/** + * Coordinates a static list of `Animation` objects as a single unit. + * Each control method is forwarded to all non-null animations simultaneously. + * + * Pairs naturally with {@link makeAnimate} and {@link makeStagger}. + */ +export function makeAnimationGroup( + animations: (Animation | null | undefined)[], +): AnimationGroupControls { + const all = animations.filter((a): a is Animation => a != null); + return { + play: () => all.forEach(a => a.play()), + pause: () => all.forEach(a => a.pause()), + cancel: () => all.forEach(a => a.cancel()), + reverse: () => all.forEach(a => a.reverse()), + finish: () => all.forEach(a => a.finish()), + }; +} + +/** + * Reactive wrapper around {@link makeAnimationGroup}. Re-derives the group + * controls whenever the `animations` accessor returns a new list. + * + * Each method on the returned object always operates on the most recent set of + * animations — calling `play()` after the list changes will target the new + * animations, not the old ones. + */ +export function createAnimationGroup( + animations: Accessor<(Animation | null | undefined)[]>, +): AnimationGroupControls { + const group = createMemo(() => makeAnimationGroup(animations())); + return { + play: () => group().play(), + pause: () => group().pause(), + cancel: () => group().cancel(), + reverse: () => group().reverse(), + finish: () => group().finish(), + }; +} diff --git a/packages/animation/src/flip.ts b/packages/animation/src/flip.ts new file mode 100644 index 000000000..e064bd792 --- /dev/null +++ b/packages/animation/src/flip.ts @@ -0,0 +1,42 @@ +/** + * FLIP (First–Last–Invert–Play) layout animation using WAAPI. + * + * Call `snapshot()` immediately before the DOM change, then `flip()` immediately + * after. `flip()` reads the new geometry, inverts the delta, and plays the + * animation from the old position/size to the new one. + * + * Note: geometry is measured in viewport coordinates via `getBoundingClientRect`. + * Elements inside `position: fixed/absolute` ancestors will need their own + * coordinate adjustment. + */ +export function makeFlip( + el: Element, + options?: KeyframeAnimationOptions, +): { snapshot: () => void; flip: () => Animation | undefined } { + let rect: DOMRect | undefined; + + return { + snapshot() { + rect = el.getBoundingClientRect(); + }, + flip() { + if (!rect) return; + const next = el.getBoundingClientRect(); + const prev = rect; + rect = undefined; + if (next.width === 0 || next.height === 0) return; + const dx = prev.left - next.left; + const dy = prev.top - next.top; + const sx = prev.width / next.width; + const sy = prev.height / next.height; + if (dx === 0 && dy === 0 && sx === 1 && sy === 1) return; + return el.animate( + [ + { transformOrigin: "top left", transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` }, + { transformOrigin: "top left", transform: "none" }, + ], + options, + ); + }, + }; +} diff --git a/packages/animation/src/index.ts b/packages/animation/src/index.ts new file mode 100644 index 000000000..c283b01b0 --- /dev/null +++ b/packages/animation/src/index.ts @@ -0,0 +1,9 @@ +export * from "./animate.js"; +export * from "./scroll-animation.js"; +export * from "./view-animation.js"; +export * from "./flip.js"; +export * from "./stagger.js"; +export * from "./animation-group.js"; +export * from "./motion-path.js"; +export * from "./sequence.js"; +export * from "./presence-animation.js"; diff --git a/packages/animation/src/motion-path.ts b/packages/animation/src/motion-path.ts new file mode 100644 index 000000000..e483ea22e --- /dev/null +++ b/packages/animation/src/motion-path.ts @@ -0,0 +1,61 @@ +import { createSignal, createEffect, type Accessor } from "solid-js"; +import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils"; + +export type MotionPathOptions = KeyframeAnimationOptions & { + /** + * CSS `offset-rotate` value controlling element orientation along the path. + * Pass `"auto"` to rotate with the path tangent, `"0deg"` to keep the + * element's original orientation, or any CSS angle / `"reverse"`. + * Default: `"auto"`. + */ + rotate?: string; +}; + +/** + * Animates `el` along a CSS Motion Path using WAAPI. Sets `offset-path` and + * `offset-rotate` on the element as a side effect; these are left in place + * after the animation so `fill: "forwards"` works correctly. + * + * @param path SVG path string passed to `path("…")`, or any valid + * `offset-path` value (e.g. `"circle(50%)"`, `"ray(45deg)"`) + */ +export function makeMotionPath( + el: HTMLElement, + path: string, + options?: MotionPathOptions, +): Animation { + const { rotate = "auto", ...animOptions } = options ?? {}; + el.style.offsetPath = path.includes("(") ? path : `path("${path}")`; + el.style.offsetRotate = rotate; + el.style.offsetAnchor = "center"; + return el.animate( + [{ offsetDistance: "0%" }, { offsetDistance: "100%" }], + animOptions, + ); +} + +/** + * Reactive wrapper around {@link makeMotionPath}. Re-runs whenever + * `target`, `path`, or `options` change. + */ +export function createMotionPath( + target: Accessor, + path: MaybeAccessor, + options?: MaybeAccessor, +): Accessor { + const getPath = asAccessor(path); + const getOpts = typeof options === "function" ? options : () => options; + const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS); + + createEffect( + () => ({ el: target(), path: getPath(), opts: getOpts() }), + ({ el, path, opts }) => { + if (!el) { setAnimation(undefined); return; } + const anim = makeMotionPath(el, path, opts); + setAnimation(anim); + return () => anim.cancel(); + }, + ); + + return animation; +} diff --git a/packages/animation/src/presence-animation.ts b/packages/animation/src/presence-animation.ts new file mode 100644 index 000000000..c7a8fb614 --- /dev/null +++ b/packages/animation/src/presence-animation.ts @@ -0,0 +1,505 @@ +import { + createSignal, + createEffect, + createRenderEffect, + createMemo, + untrack, + isPending, + type Accessor, +} from "solid-js"; +import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils"; + +export type PresenceAnimationOptions = { + /** Keyframes played when the element enters. */ + enter: Keyframe[] | PropertyIndexedKeyframes | null; + /** + * Keyframes played when the element exits. + * Defaults to the enter keyframes in reverse. + */ + exit?: Keyframe[] | PropertyIndexedKeyframes | null; + /** WAAPI options for the enter animation. */ + enterOptions?: KeyframeAnimationOptions; + /** + * WAAPI options for the exit animation. + * Defaults to `enterOptions`. + */ + exitOptions?: KeyframeAnimationOptions; + /** + * Play the enter animation on the initial mount when `show` is already + * `true`. Defaults to `false`. + */ + initialEnter?: boolean; +}; + +function reverseKeyframes( + kf: Keyframe[] | PropertyIndexedKeyframes | null, +): Keyframe[] | PropertyIndexedKeyframes | null { + if (!kf) return kf; + if (Array.isArray(kf)) return [...kf].reverse(); + const reversed: PropertyIndexedKeyframes = {}; + for (const key in kf as PropertyIndexedKeyframes) { + const val = (kf as PropertyIndexedKeyframes)[key]; + (reversed as Record)[key] = Array.isArray(val) ? [...val].reverse() : val; + } + return reversed; +} + +// ─── createPresenceAnimation ───────────────────────────────────────────────── + +/** + * Manages mount/unmount lifecycle with WAAPI enter and exit animations. + * + * `isMounted` should gate the element's presence in the DOM (e.g. as the + * `when` prop of ``). The enter animation plays after the element + * mounts; the exit animation plays on the element before it is removed, and + * the element stays in the DOM until that animation completes. + * + * If `show` toggles back to `true` while an exit animation is in progress, + * the exit is cancelled and the enter animation restarts. + * + * @example + * ```tsx + * const [show, setShow] = createSignal(false); + * let el!: HTMLDivElement; + * + * const { isMounted } = createPresenceAnimation(() => el, show, { + * enter: [{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], + * enterOptions: { duration: 250, easing: "ease-out", fill: "both" }, + * exitOptions: { duration: 180, easing: "ease-in", fill: "forwards" }, + * }); + * + * return ( + * <> + * + * + *
Hello
+ *
+ * + * ); + * ``` + */ +export function createPresenceAnimation( + target: Accessor, + show: MaybeAccessor, + options: PresenceAnimationOptions, +): { isMounted: Accessor } { + const getShow = asAccessor(show); + const [isMounted, setIsMounted] = createSignal(untrack(getShow), INTERNAL_OPTIONS); + + let enterGen = 0; + + const scheduleEnter = () => { + const gen = ++enterGen; + queueMicrotask(() => { + if (gen !== enterGen) return; + const el = untrack(target); + if (!el) return; + el.animate(options.enter, options.enterOptions); + }); + }; + + if (options.initialEnter && untrack(getShow)) { + scheduleEnter(); + } + + createEffect( + () => getShow(), + shouldShow => { + if (shouldShow) { + setIsMounted(true); + scheduleEnter(); + } else { + enterGen++; + const el = untrack(target); + if (!el) { + setIsMounted(false); + return; + } + const exitKf = options.exit ?? reverseKeyframes(options.enter); + const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions); + let done = false; + anim.addEventListener( + "finish", + () => { + done = true; + setIsMounted(false); + }, + { once: true }, + ); + return () => { + if (!done) anim.cancel(); + }; + } + }, + { defer: true }, + ); + + return { isMounted }; +} + +// ─── createPresenceA ───────────────────────────────────────────────────────── + +/** + * `createPresenceA` — deferred-`isMounted` approach. + * + * The component's reactive scope stays alive during the exit animation because + * `isMounted` only goes `false` after the WAAPI `.finish` event fires. Gate + * `` on `isMounted()`, not on the raw `show` value. + * + * Uses `createRenderEffect` so animation setup runs in the render phase — + * before user effects in the same flush — keeping it tight with DOM updates. + * + * @example + * ```tsx + * const { isMounted, isExiting } = createPresenceA(() => el, show, { + * enter: [{ opacity: 0 }, { opacity: 1 }], + * enterOptions: { duration: 250 }, + * exitOptions: { duration: 180 }, + * }); + * + * return ( + * + *
+ * // signals stay alive during exit + *
+ *
+ * ); + * ``` + */ +export function createPresenceA( + target: Accessor, + show: MaybeAccessor, + options: PresenceAnimationOptions, +): { isMounted: Accessor; isExiting: Accessor } { + const getShow = asAccessor(show); + const [isMounted, setIsMounted] = createSignal(untrack(getShow), INTERNAL_OPTIONS); + const [isExiting, setIsExiting] = createSignal(false, INTERNAL_OPTIONS); + + let enterGen = 0; + + if (options.initialEnter && untrack(getShow)) { + const gen = ++enterGen; + queueMicrotask(() => { + if (gen !== enterGen) return; + const el = untrack(target); + if (!el) return; + el.animate(options.enter, options.enterOptions); + }); + } + + // createRenderEffect runs its apply in the render phase, before user effects, + // so animation setup is tightly coupled to the DOM-update cycle. + // The `prev === undefined` guard skips the initial synchronous apply. + createRenderEffect( + () => getShow(), + (shouldShow, prev) => { + if (prev === undefined) return; // skip initial mount + if (shouldShow) { + setIsMounted(true); + setIsExiting(false); + const gen = ++enterGen; + queueMicrotask(() => { + if (gen !== enterGen) return; + const el = untrack(target); + if (!el) return; + el.animate(options.enter, options.enterOptions); + }); + } else { + enterGen++; + setIsExiting(true); + const el = untrack(target); + if (!el) { + setIsExiting(false); + setIsMounted(false); + return; + } + const exitKf = options.exit ?? reverseKeyframes(options.enter); + const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions); + let done = false; + anim.addEventListener( + "finish", + () => { + done = true; + setIsExiting(false); + setIsMounted(false); + }, + { once: true }, + ); + return () => { + if (!done) { + anim.cancel(); + setIsExiting(false); + } + }; + } + }, + ); + + return { isMounted, isExiting }; +} + +// ─── createPresenceB ───────────────────────────────────────────────────────── + +/** + * `createPresenceB` — Solid 2.0 deferred-disposal approach via async signal. + * + * The component's reactive scope stays alive during the exit animation through + * Solid 2.0's built-in pending-signal mechanism: + * + * 1. `gate` is a **non-sync `createMemo`** (no `sync: true`). + * When exiting, it returns a `Promise` instead of `false`. + * 2. Returning a Promise causes `handleAsync` to throw `NotReadyError`, + * which puts `gate` into `STATUS_PENDING`. + * 3. `notifyStatus(STATUS_PENDING)` propagates to `gate`'s subscribers via + * `queuePendingNode` — **not** `insertSubs` / dirty-heap marking. + * 4. Show's internal `createRenderEffect` ends up in `_pendingNodes` only. + * It is never recomputed, so `_pendingFirstChild` is never set. + * 5. `commitPendingNode` finds no zombie children → the component owner is + * **not disposed** — all signals and event handlers stay alive. ✓ + * 6. When the WAAPI animation finishes, the Promise resolves. `asyncWrite` + * sets `gate._value = undefined` and calls `insertSubs`, which makes Show + * recompute and finally remove the component. + * + * Use `gate()` (not the raw `show` value) as the `` gate. + * `createRenderEffect` is used for the enter animation so it runs in the + * render phase — the same phase Show uses — before any user effects. + * + * @example + * ```tsx + * const { gate, isExiting } = createPresenceB(() => el, show, { + * enter: [{ opacity: 0 }, { opacity: 1 }], + * exitOptions: { duration: 1500 }, + * }); + * + * return ( + * + *
+ * // signals alive — disposed only after animation resolves + *
+ *
+ * ); + * ``` + */ +export function createPresenceB( + target: Accessor, + show: MaybeAccessor, + options: PresenceAnimationOptions, +): { gate: Accessor; isExiting: Accessor } { + const getShow = asAccessor(show); + // exitAnimRunning tracks whether the WAAPI exit animation is physically playing. + // isExiting is derived: it's automatically false whenever getShow() is true, + // so no effect is needed to reset it when show flips back — the memo re-derives + // the correct value synchronously in the same flush as the signal write. + const [exitAnimRunning, setExitAnimRunning] = createSignal(false, INTERNAL_OPTIONS); + const isExiting = createMemo(() => !getShow() && exitAnimRunning()); + + let exitAnim: Animation | undefined; + let enterAnim: Animation | undefined; + let animPromise: Promise | undefined; + let enterGen = 0; + let exitGen = 0; + // exitCompleted prevents the gate from re-creating the exit Promise after the + // animation finishes. Without it, asyncWrite triggers a gate recompute where + // animPromise is already undefined → new Promise created → infinite exit loop. + let exitCompleted = false; + // Track whether this instance has ever been shown. Before the first mount, + // the gate should return plain `false` rather than a Promise — returning a + // Promise on a never-mounted slide would trigger handleAsync/initTransition + // during the initial render and can prevent other slides from appearing. + let hasBeenShown = untrack(getShow); + + // Non-sync memo (no `sync: true`) — can return a Promise, making it + // STATUS_PENDING via handleAsync. When pending, notifyStatus propagates to + // Show's internal createRenderEffect via queuePendingNode (not insertSubs), + // so that render effect is never recomputed and its component owner is + // never touched. See JSDoc above for the full step-by-step. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gate = createMemo((): any => { + const shouldShow = !!getShow(); + + if (shouldShow) { + hasBeenShown = true; + exitCompleted = false; + // Increment exitGen to cancel any pending exit microtask that hasn't + // fired yet. Without this, the microtask starts the exit animation even + // though show is already true again — causing both exit and enter + // animations to compete on the same element. + exitGen++; + if (exitAnim) { + exitAnim.cancel(); + exitAnim = undefined; + } + // Always clear animPromise, not just when exitAnim was set. If the exit + // microtask hasn't run yet exitAnim is undefined, but animPromise holds + // the stale Promise. Clearing it here prevents asyncWrite from running + // after the stale microtask calls resolve(). + animPromise = undefined; + return true; + } + + // If this instance was never mounted, just return false — no exit + // animation is needed and no Promise should be created. + if (!hasBeenShown) return false; + + // After a completed exit, return false cleanly so Show can dispose the + // component. Without this guard the gate would create a new Promise every + // time asyncWrite re-triggers it, looping forever. + if (exitCompleted) return false; + + // Create the exit Promise once. Returning the same Promise object on + // subsequent recomputes is safe: handleAsync guards stale asyncWrite + // callbacks via `el._inFlight !== result`. + if (!animPromise) { + // Cancel any pending enter microtask (gen check) AND any enter animation + // that already started. Without the latter, a running enter animation + // competes with the new exit animation on the same element. + enterGen++; + if (enterAnim) { + enterAnim.cancel(); + enterAnim = undefined; + } + const gen = ++exitGen; + animPromise = new Promise(resolve => { + queueMicrotask(() => { + // If show flipped back to true before this microtask ran, exitGen + // was already incremented in the shouldShow=true branch above. + if (gen !== exitGen) { + resolve(); // stale — asyncWrite is a no-op (guarded by _inFlight) + return; + } + const el = untrack(target); + if (!el) { + exitCompleted = true; + animPromise = undefined; + setExitAnimRunning(false); + resolve(); + return; + } + setExitAnimRunning(true); + const exitKf = options.exit ?? reverseKeyframes(options.enter); + const anim = el.animate(exitKf, options.exitOptions ?? options.enterOptions); + exitAnim = anim; + anim.addEventListener( + "finish", + () => { + if (exitAnim === anim) exitAnim = undefined; + animPromise = undefined; + exitCompleted = true; + setExitAnimRunning(false); + resolve(); // → asyncWrite → gate._value = undefined → Show removes component + }, + { once: true }, + ); + // When the exit animation is cancelled (show flipped true mid-exit), the + // finish event never fires. Listen to cancel to reset exitAnimRunning so + // the signal matches reality. Guard against the case where a newer exit + // animation is already running when this event fires. + anim.addEventListener( + "cancel", + () => { + if (exitAnim === anim) exitAnim = undefined; + setExitAnimRunning(false); + }, + { once: true }, + ); + }); + }); + } + + return animPromise; + }) as unknown as Accessor; + + // isExiting is derived (memo), so it resets automatically when getShow() goes + // true — no manual reset needed. This effect only handles the enter animation. + createEffect( + () => getShow(), + shouldShow => { + if (!shouldShow) return; + const gen = ++enterGen; + queueMicrotask(() => { + if (gen !== enterGen) return; + const el = untrack(target); + if (!el) return; + enterAnim = el.animate(options.enter, options.enterOptions); + enterAnim.addEventListener( + "finish", + () => { + enterAnim = undefined; + }, + { once: true }, + ); + }); + }, + { defer: true }, + ); + + if (options.initialEnter && untrack(getShow)) { + const gen = ++enterGen; + queueMicrotask(() => { + if (gen !== enterGen) return; + const el = untrack(target); + if (!el) return; + enterAnim = el.animate(options.enter, options.enterOptions); + enterAnim.addEventListener( + "finish", + () => { + enterAnim = undefined; + }, + { once: true }, + ); + }); + } + + return { gate, isExiting }; +} + +export function createPresenceC( + target: Accessor, + show: Accessor, + options: any, +) { + let animationP: Promise | undefined = undefined; + + const isMounted = createMemo(async () => { + if (show()) { + return true; + } else { + if (!animationP) return false; + await animationP; + return false; + } + }); + + const isEntered = createMemo(async () => { + if (show()) { + if (!animationP) return true; + await animationP; + return true; + } else { + return false; + } + }); + + const isEntering = () => isPending(isEntered); + const isExiting = () => isPending(isMounted); + + createEffect(show, show => { + const anim = show + ? target().animate(options.enter, options.enterOptions) + : target().animate(options.exit, options.exitOptions ?? options.enterOptions); + + animationP = new Promise(resolve => { + anim.addEventListener( + "finish", + () => { + animationP = undefined; + resolve(); + }, + { once: true }, + ); + }); + return () => animationP && anim.cancel(); + }); + + return { isMounted, isEntered, isEntering, isExiting }; +} diff --git a/packages/animation/src/scroll-animation.ts b/packages/animation/src/scroll-animation.ts new file mode 100644 index 000000000..b45625ded --- /dev/null +++ b/packages/animation/src/scroll-animation.ts @@ -0,0 +1,56 @@ +import { createSignal, createEffect, type Accessor } from "solid-js"; +import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils"; + +type ScrollAxis = "block" | "inline" | "x" | "y"; +declare const ScrollTimeline: new (options?: { source?: Element; axis?: ScrollAxis }) => AnimationTimeline; + +export type ScrollAnimationOptions = Omit & { + /** Scrolling container. Defaults to the document root scroller. */ + source?: Element; + axis?: ScrollAxis; +}; + +/** Plays a scroll-driven WAAPI animation on `el` via `ScrollTimeline`. */ +export function makeScrollAnimation( + el: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: ScrollAnimationOptions, +): Animation { + const { source, axis, ...animOptions } = options ?? {}; + return el.animate(keyframes, { + ...animOptions, + timeline: new ScrollTimeline({ + ...(source !== undefined && { source }), + ...(axis !== undefined && { axis }), + }), + }); +} + +/** + * Reactive wrapper around {@link makeScrollAnimation}. Re-runs whenever + * `target`, `keyframes`, or `options` change. + */ +export function createScrollAnimation( + target: Accessor, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor { + const getKf = asAccessor(keyframes); + const getOpts = typeof options === "function" ? options : () => options; + const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS); + + createEffect( + () => ({ el: target(), kf: getKf(), opts: getOpts() }), + ({ el, kf, opts }) => { + if (!el) { + setAnimation(undefined); + return; + } + const anim = makeScrollAnimation(el, kf, opts); + setAnimation(anim); + return () => anim.cancel(); + }, + ); + + return animation; +} diff --git a/packages/animation/src/sequence.ts b/packages/animation/src/sequence.ts new file mode 100644 index 000000000..63544aa8b --- /dev/null +++ b/packages/animation/src/sequence.ts @@ -0,0 +1,62 @@ +/** A zero-argument factory that creates an `Animation` when called. */ +export type AnimationFactory = () => Animation | null | undefined; + +export type SequenceControls = { + /** Starts the sequence from the first factory. Discards any in-progress run. */ + play: () => void; + /** Stops the sequence. The currently-playing animation is cancelled. */ + cancel: () => void; +}; + +/** + * Chains animation factories into a sequential playlist: each factory is + * called and its animation allowed to finish before the next factory runs. + * + * Factories are invoked **lazily** — each is called only when its turn + * arrives, so animations are created and started just in time rather than + * all at once upfront. Passing `null`/`undefined` from a factory skips that + * step without breaking the chain. + * + * Calling `play()` while a sequence is running discards the current run and + * starts fresh from the beginning. + * + * @example + * ```ts + * const seq = makeSequence([ + * () => makeAnimate(headerEl, fadeIn, { duration: 300 }), + * () => makeAnimate(bodyEl, slideIn, { duration: 400 }), + * () => makeAnimate(footerEl, fadeIn, { duration: 300 }), + * ]); + * + * seq.play(); // header → body → footer, each starts after the last finishes + * seq.cancel(); // stops immediately + * seq.play(); // restart from the beginning + * ``` + */ +export function makeSequence(factories: AnimationFactory[]): SequenceControls { + let generation = 0; + let current: Animation | null = null; + + return { + play() { + current?.cancel(); + const gen = ++generation; + let i = 0; + + const next = () => { + if (gen !== generation || i >= factories.length) return; + const anim = factories[i++]!(); + if (!anim) { next(); return; } + current = anim; + anim.addEventListener("finish", next, { once: true }); + }; + + next(); + }, + cancel() { + generation++; + current?.cancel(); + current = null; + }, + }; +} diff --git a/packages/animation/src/stagger.ts b/packages/animation/src/stagger.ts new file mode 100644 index 000000000..6265f0853 --- /dev/null +++ b/packages/animation/src/stagger.ts @@ -0,0 +1,43 @@ +import { createSignal, createEffect, type Accessor } from "solid-js"; +import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils"; + +export type StaggerOptions = KeyframeAnimationOptions & { + /** Additional delay in milliseconds between each element, stacked on top of `delay`. */ + stagger?: number; +}; + +/** Plays a staggered WAAPI animation across `els`, returning all `Animation` instances. */ +export function makeStagger( + els: Element[], + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: StaggerOptions, +): Animation[] { + const { stagger = 0, ...animOptions } = options ?? {}; + const baseDelay = typeof animOptions.delay === "number" ? animOptions.delay : 0; + return els.map((el, i) => el.animate(keyframes, { ...animOptions, delay: baseDelay + i * stagger })); +} + +/** + * Reactive wrapper around {@link makeStagger}. Re-runs (cancelling previous + * animations) whenever `targets`, `keyframes`, or `options` change reactively. + */ +export function createStagger( + targets: Accessor<(Element | null | undefined)[]>, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor { + const getKf = asAccessor(keyframes); + const getOpts = typeof options === "function" ? options : () => options; + const [animations, setAnimations] = createSignal([], INTERNAL_OPTIONS); + + createEffect( + () => ({ els: targets(), kf: getKf(), opts: getOpts() }), + ({ els, kf, opts }) => { + const anims = makeStagger(els.filter((el): el is Element => el != null), kf, opts); + setAnimations(anims); + return () => anims.forEach(a => a.cancel()); + }, + ); + + return animations; +} diff --git a/packages/animation/src/view-animation.ts b/packages/animation/src/view-animation.ts new file mode 100644 index 000000000..aa157be1c --- /dev/null +++ b/packages/animation/src/view-animation.ts @@ -0,0 +1,79 @@ +import { createSignal, createEffect, type Accessor } from "solid-js"; +import { type MaybeAccessor, asAccessor, INTERNAL_OPTIONS } from "@solid-primitives/utils"; + +type ScrollAxis = "block" | "inline" | "x" | "y"; +declare const ViewTimeline: new (options: { + subject: Element; + axis?: ScrollAxis; + inset?: string | string[]; +}) => AnimationTimeline; + +export type ViewAnimationOptions = Omit & { + /** + * The element whose intersection with the scroll port drives the timeline. + * Defaults to `target` itself. + */ + subject?: Element; + axis?: ScrollAxis; + inset?: string | string[]; + /** + * Start of the animation range within the ViewTimeline. + * Defaults to `"entry 0%"` (element starts entering the scroll port). + * Accepts any CSS `` string, e.g. `"cover 0%"`. + */ + rangeStart?: string; + /** + * End of the animation range within the ViewTimeline. + * Defaults to `"entry 100%"` (element has fully entered the scroll port). + * Accepts any CSS `` string, e.g. `"cover 100%"`. + */ + rangeEnd?: string; +}; + +/** Plays a viewport-driven WAAPI animation on `el` via `ViewTimeline`. */ +export function makeViewAnimation( + el: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: ViewAnimationOptions, +): Animation { + const { subject, axis, inset, rangeStart = "entry 0%", rangeEnd = "entry 100%", ...animOptions } = options ?? {}; + return el.animate(keyframes, { + rangeStart, + rangeEnd, + ...animOptions, + timeline: new ViewTimeline({ + subject: subject ?? el, + ...(axis !== undefined && { axis }), + ...(inset !== undefined && { inset }), + }), + } as KeyframeAnimationOptions); +} + +/** + * Reactive wrapper around {@link makeViewAnimation}. Re-runs whenever + * `target`, `keyframes`, or `options` change. + */ +export function createViewAnimation( + target: Accessor, + keyframes: MaybeAccessor, + options?: MaybeAccessor, +): Accessor { + const getKf = asAccessor(keyframes); + const getOpts = typeof options === "function" ? options : () => options; + const [animation, setAnimation] = createSignal(undefined, INTERNAL_OPTIONS); + + createEffect( + () => ({ el: target(), kf: getKf(), opts: getOpts() }), + ({ el, kf, opts }) => { + if (!el) { + setAnimation(undefined); + return; + } + const anim = makeViewAnimation(el, kf, opts); + setAnimation(anim); + return () => anim.cancel(); + }, + ); + + return animation; +} diff --git a/packages/animation/stories/animation.stories.tsx b/packages/animation/stories/animation.stories.tsx new file mode 100644 index 000000000..71e6faafa --- /dev/null +++ b/packages/animation/stories/animation.stories.tsx @@ -0,0 +1,2171 @@ +import { createEffect, createSignal, For, onCleanup, onSettled, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { + createAnimate, + makeScrollAnimation, + makeViewAnimation, + makeFlip, + makeStagger, + makeAnimationGroup, + makeAnimate, + makeMotionPath, + makeSequence, + createPresenceAnimation, + createPresenceA, + createPresenceB, + createPresenceC, +} from "@solid-primitives/animation"; +import readme from "../README.md?raw"; +import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js"; + +const meta = preview.meta({ + title: "Animation/Animation", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { component: readme }, + }, + }, +}); + +export default meta; + +const ACCENT_COLORS = ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6"] as const; + +export const Animate = meta.story({ + name: "Animate an element", + parameters: { + docs: { + description: { + story: + "`createAnimate` re-runs the animation whenever `target`, `keyframes`, or `options` change. " + + "Tap a swatch to change the hue mid-animation — the keyframes accessor re-evaluates and the " + + "animation restarts. The returned `Animation` accessor gives direct playback control.", + }, + }, + }, + render: () => { + let boxRef!: HTMLDivElement; + const [target, setTarget] = createSignal(null); + const [color, setColor] = createSignal(ACCENT_COLORS[0]); + const [playState, setPlayState] = createSignal("idle"); + + const anim = createAnimate( + target, + () => [ + { transform: "scale(1)", background: color() }, + { transform: "scale(1.3)", background: color(), filter: "brightness(1.3)" }, + { transform: "scale(1)", background: color() }, + ], + { duration: 1200, iterations: Infinity, easing: "ease-in-out" }, + ); + + createEffect( + () => anim(), + a => { + if (!a) { + setPlayState("idle"); + return; + } + setPlayState(a.playState); + const update = () => setPlayState(a.playState); + a.addEventListener("play", update); + a.addEventListener("pause", update); + a.addEventListener("cancel", update); + a.addEventListener("finish", update); + return () => { + a.removeEventListener("play", update); + a.removeEventListener("pause", update); + a.removeEventListener("cancel", update); + a.removeEventListener("finish", update); + }; + }, + ); + + onSettled(() => { + setTarget(boxRef); + }); + + return ( + +

createAnimate

+ +
+
+
+ +
+
+ + {c => ( +
+
+ +
+ + + + + + +
+ +
+ +
+ + ); + }, +}); + +export const ScrollAnim = meta.story({ + name: "Scroll-driven progress bar", + parameters: { + docs: { + description: { + story: + "`makeScrollAnimation` ties animation progress to scroll position via `ScrollTimeline` — " + + "no scroll listeners or RAF loops needed. Scroll the list to see the progress bar fill. " + + "Requires Chrome 115+.", + }, + }, + }, + render: () => { + let containerRef!: HTMLDivElement; + let barRef!: HTMLDivElement; + + onSettled(() => { + makeScrollAnimation( + barRef, + [ + { opacity: 0.2, transform: "scaleX(0)" }, + { opacity: 1, transform: "scaleX(1)" }, + ], + { fill: "both", source: containerRef, axis: "block" }, + ); + }); + + return ( + +

makeScrollAnimation

+

+ Scroll the list — the bar fills as you go. +

+ +
+
+
+ +
+ i + 1)}> + {n => ( +
+ Item {n} +
+ )} +
+
+ + ); + }, +}); + +const VIEW_CARDS = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta", "Theta"]; +const VIEW_COLORS = [ + "#6366f1", + "#8b5cf6", + "#a855f7", + "#ec4899", + "#f43f5e", + "#f97316", + "#10b981", + "#3b82f6", +]; + +export const ViewAnim = meta.story({ + name: "Animate cards into view", + parameters: { + docs: { + description: { + story: + "`makeViewAnimation` drives an animation with `ViewTimeline`, animating each element as it " + + "enters the scroll port. Scroll the list to see cards fade and rise in. Requires Chrome 115+.", + }, + }, + }, + render: () => { + const itemRefs: HTMLDivElement[] = []; + + onSettled(() => { + for (const el of itemRefs) { + makeViewAnimation( + el, + [ + { opacity: 0, transform: "translateY(48px) scale(0.88)" }, + { opacity: 1, transform: "translateY(0) scale(1)" }, + ], + { fill: "both", rangeStart: "entry 30%" }, + ); + } + }); + + return ( + +

makeViewAnimation

+ +
+ + {(label, i) => ( +
(itemRefs[i()] = el)} + style={{ + padding: "1rem", + background: VIEW_COLORS[i()], + "border-radius": "8px", + color: "white", + "font-weight": "600", + "font-size": "0.9rem", + "flex-shrink": "0", + }} + > + {label} +
+ )} +
+
+ +

+ Scroll to see each card animate in via ViewTimeline. +

+
+ ); + }, +}); + +export const Flip = meta.story({ + name: "FLIP a box between sides", + parameters: { + docs: { + description: { + story: + "`makeFlip` records an element's position with `snapshot()`, then smoothly transitions from " + + "the old layout geometry to the new one with `flip()`. Call `snapshot()` immediately before " + + "the DOM change and `flip()` immediately after.", + }, + }, + }, + render: () => { + let boxRef!: HTMLDivElement; + const [right, setRight] = createSignal(false); + let flipController = { snapshot: () => {}, flip: () => undefined as Animation | undefined }; + + onSettled(() => { + flipController = makeFlip(boxRef, { + duration: 480, + easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", + }); + }); + + const toggle = () => { + flipController.snapshot(); + setRight(v => !v); + flipController.flip(); + }; + + return ( + +

makeFlip

+ +
+
+
+ + + +

+ The box jumps instantly in the DOM — FLIP animates back from the old position. +

+ + ); + }, +}); + +const STAGGER_COLORS = ["#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f97316", "#10b981"]; + +export const Stagger = meta.story({ + name: "Stagger a grid of items", + parameters: { + docs: { + description: { + story: + "`makeStagger` runs a WAAPI animation across a list of elements with an incremental per-element " + + "delay. `stagger` adds offset on top of the base `delay`. Use `createStagger` for a reactive " + + "wrapper that re-runs when the target list or keyframes change.", + }, + }, + }, + render: () => { + const itemRefs: HTMLDivElement[] = []; + + const animate = () => + makeStagger( + itemRefs, + [ + { opacity: 0, transform: "translateY(20px) scale(0.8)" }, + { opacity: 1, transform: "none" }, + ], + { duration: 500, stagger: 80, fill: "both", easing: "ease-out" }, + ); + + onSettled(() => { + animate(); + }); + + return ( + +

makeStagger

+ +
+ + {(c, i) => ( +
(itemRefs[i()] = el)} + style={{ + height: "64px", + background: c, + "border-radius": "10px", + opacity: "0", + }} + /> + )} + +
+ + + + ); + }, +}); + +const PANEL_LABELS = ["Header", "Body", "Footer"] as const; +const PANEL_COLORS = ["#6366f1", "#ec4899", "#10b981"] as const; + +const ENTER_KEYFRAMES: Keyframe[] = [ + { opacity: 0, transform: "translateY(14px)" }, + { opacity: 1, transform: "none" }, +]; + +export const AnimGroup = meta.story({ + name: "Control multiple animations as a group", + parameters: { + docs: { + description: { + story: + "`makeAnimationGroup` forwards play / pause / cancel / reverse / finish to every animation in " + + "a set simultaneously. Each panel has its own staggered entrance animation; the group buttons " + + "control all three at once.", + }, + }, + }, + render: () => { + const panelRefs: HTMLDivElement[] = []; + + let group = makeAnimationGroup([]); + + const buildGroup = () => { + group = makeAnimationGroup( + panelRefs.map((el, i) => + makeAnimate(el, ENTER_KEYFRAMES, { duration: 600, delay: i * 140, fill: "both" }), + ), + ); + }; + + onSettled(() => buildGroup()); + + const replay = () => buildGroup(); + + return ( + +

makeAnimationGroup

+ +
+ + {(label, i) => ( +
(panelRefs[i()] = el)} + style={{ + padding: "0.75rem 1rem", + background: PANEL_COLORS[i()], + color: "white", + "border-radius": "8px", + "font-weight": "600", + opacity: "0", + }} + > + {label} +
+ )} +
+
+ +
+ + + + + + + +
+
+ ); + }, +}); + +const PATHS = { + Wave: "M 20,80 C 80,20 140,140 200,80 S 320,20 380,80", + Loop: "M 200,140 C 280,140 340,80 340,40 S 280,-60 200,-60 S 60,0 60,40 S 120,140 200,140", + Corner: "M 20,20 L 360,20 L 360,160", +} as const; + +export const MotionPath = meta.story({ + name: "Animate along a motion path", + parameters: { + docs: { + description: { + story: + "`makeMotionPath` sets `offset-path` on the element and animates `offsetDistance` from 0% to " + + "100% via WAAPI. The element follows the path and optionally rotates to match the tangent. " + + "Requires Chrome 79+ / Firefox 72+ / Safari 15.4+.", + }, + }, + }, + render: () => { + let dotRef!: HTMLDivElement; + const [pathKey, setPathKey] = createSignal("Wave"); + let currentAnim: Animation | undefined; + + const play = (key: keyof typeof PATHS) => { + currentAnim?.cancel(); + currentAnim = makeMotionPath(dotRef, PATHS[key], { + duration: 1800, + easing: "ease-in-out", + iterations: Infinity, + rotate: "auto", + }); + }; + + onSettled(() => { + play("Wave"); + }); + + return ( + +

makeMotionPath

+ +
+ + + +
+
+ +
+ + + {key => ( + + )} + + +
+ + ); + }, +}); + +const SEQ_STEPS = [ + { label: "Step 1 — fade in header", color: "#6366f1" }, + { label: "Step 2 — slide in body", color: "#ec4899" }, + { label: "Step 3 — pop in footer", color: "#10b981" }, +] as const; + +export const Sequence = meta.story({ + name: "Chain animations in sequence", + parameters: { + docs: { + description: { + story: + "`makeSequence` chains animation factories so each runs only after the previous finishes. " + + "Factories are called lazily — each animation is created just before it plays. " + + "Calling `play()` again restarts from the beginning.", + }, + }, + }, + render: () => { + const stepRefs: HTMLDivElement[] = []; + let seq: ReturnType; + + onSettled(() => { + seq = makeSequence( + SEQ_STEPS.map( + (_, i) => () => + makeAnimate( + stepRefs[i]!, + [ + { + opacity: 0, + transform: i === 2 ? "scale(0.7)" : `translateX(${i % 2 === 0 ? "-" : ""}24px)`, + }, + { opacity: 1, transform: i === 2 ? "scale(1)" : "none" }, + ], + { duration: 500, easing: "ease-out", fill: "both" }, + ), + ), + ); + seq.play(); + }); + + return ( + +

makeSequence

+ +
+ + {(step, i) => ( +
(stepRefs[i()] = el)} + style={{ + padding: "0.85rem 1rem", + background: step.color, + color: "white", + "border-radius": "8px", + "font-size": "0.9rem", + "font-weight": "500", + opacity: "0", + }} + > + {step.label} +
+ )} +
+
+ + + + + +
+ ); + }, +}); + +const TOAST_PRESETS = [ + { + title: "Changes saved", + body: "Your edits have been saved successfully.", + color: "#10b981", + icon: "✓", + }, + { + title: "Upload failed", + body: "The file could not be uploaded. Please try again.", + color: "#f43f5e", + icon: "✕", + }, + { + title: "Invite sent", + body: "An invitation was sent to the address on file.", + color: "#6366f1", + icon: "→", + }, + { + title: "Low storage", + body: "You are approaching your storage limit.", + color: "#f59e0b", + icon: "!", + }, +] as const; + +export const PresenceToasts = meta.story({ + name: "Dismissible toast notifications", + parameters: { + docs: { + description: { + story: + "Each toast is independently controlled by a `createPresenceAnimation` instance. " + + "Clicking × sets that toast's `show` signal to false — the exit animation plays and the DOM " + + "node is removed only after it completes. All toasts can exit simultaneously.", + }, + }, + }, + render: () => { + type ToastEntry = { + id: number; + title: string; + body: string; + color: string; + icon: string; + show: () => boolean; + dismiss: () => void; + }; + + const [toasts, setToasts] = createSignal([]); + let nextId = 0; + let presetCursor = 0; + + const removeToast = (id: number) => setToasts(prev => prev.filter(t => t.id !== id)); + + const addToast = () => { + const preset = TOAST_PRESETS[presetCursor++ % TOAST_PRESETS.length]!; + const id = nextId++; + const [show, setShow] = createSignal(true); + setToasts(prev => [...prev, { id, ...preset, show, dismiss: () => setShow(false) }]); + }; + + return ( + +

createPresenceAnimation

+ + + + + + +
+
+ + {toast => { + let el!: HTMLDivElement; + + const { isMounted } = createPresenceAnimation(() => el, toast.show, { + enter: [ + { opacity: 0, transform: "translateX(110%) scale(0.92)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "translateX(110%) scale(0.95)" }, + ], + enterOptions: { + duration: 400, + easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", + fill: "both", + }, + exitOptions: { duration: 240, easing: "ease-in", fill: "forwards" }, + initialEnter: true, + }); + + createEffect( + () => isMounted(), + mounted => { + if (!mounted) removeToast(toast.id); + }, + { defer: true }, + ); + + return ( + +
+
+ {toast.icon} +
+
+
+ {toast.title} +
+
+ {toast.body} +
+
+ +
+
+ ); + }} +
+
+
+
+ ); + }, +}); + +const MODAL_CARDS = [ + { + title: "Save changes?", + body: "Your unsaved edits will be lost if you close without saving.", + confirm: "Save", + color: "#6366f1", + }, + { + title: "Delete item?", + body: "This action cannot be undone. The item will be permanently removed.", + confirm: "Delete", + color: "#f43f5e", + }, + { + title: "Send invite?", + body: "An email invitation will be sent to the address you entered.", + confirm: "Send", + color: "#10b981", + }, +] as const; + +export const PresenceModal = meta.story({ + name: "Animate a modal dialog", + parameters: { + docs: { + description: { + story: + "A backdrop + modal pair, each with independent `createPresenceAnimation` instances. " + + "The modal scales and fades in while the backdrop fades — both exit fully before the DOM is cleaned up.", + }, + }, + }, + render: () => { + const [cardIndex, setCardIndex] = createSignal(0); + const [show, setShow] = createSignal(false); + let backdropEl!: HTMLDivElement; + let modalEl!: HTMLDivElement; + + const { isMounted: backdropMounted } = createPresenceAnimation(() => backdropEl, show, { + enter: [{ opacity: 0 }, { opacity: 1 }], + enterOptions: { duration: 200, easing: "ease-out", fill: "both" }, + exitOptions: { duration: 180, easing: "ease-in", fill: "forwards" }, + }); + + const { isMounted: modalMounted } = createPresenceAnimation(() => modalEl, show, { + enter: [ + { opacity: 0, transform: "scale(0.88) translateY(16px)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "scale(0.92) translateY(8px)" }, + ], + enterOptions: { duration: 320, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", fill: "both" }, + exitOptions: { duration: 180, easing: "ease-in", fill: "forwards" }, + }); + + const card = () => MODAL_CARDS[cardIndex()]; + + return ( + +

createPresenceAnimation

+ +
+ + + {(c, i) => ( + + )} + + +
+ + {/* Backdrop + modal share one Show so the modal never needs transform-based centering */} + +
setShow(false)} + style={{ + position: "fixed", + inset: "0", + background: "rgba(0,0,0,0.4)", + display: "flex", + "align-items": "center", + "justify-content": "center", + "z-index": "50", + }} + > + +
e.stopPropagation()} + style={{ + width: "300px", + background: "white", + "border-radius": "14px", + padding: "1.5rem", + "box-shadow": "0 20px 60px rgba(0,0,0,0.25)", + "z-index": "51", + display: "flex", + "flex-direction": "column", + gap: "0.75rem", + }} + > +

{card().title}

+

+ {card().body} +

+ + + + +
+
+
+
+
+ ); + }, +}); + +const TABS = [ + { label: "Profile", color: "#6366f1", body: "Manage your name, avatar, and contact details." }, + { + label: "Security", + color: "#f43f5e", + body: "Update your password and two-factor authentication settings.", + }, + { + label: "Billing", + color: "#10b981", + body: "View invoices, update your payment method, or cancel your plan.", + }, +] as const; + +export const PresenceTabs = meta.story({ + name: "Crossfade between tab panels", + parameters: { + docs: { + description: { + story: + "Each tab panel is a separate `createPresenceAnimation` instance keyed to whether it is active. " + + "Switching tabs plays the outgoing panel's exit animation before removing it, then plays the " + + "incoming panel's enter animation.", + }, + }, + }, + render: () => { + const [active, setActive] = createSignal(0); + + return ( + +

createPresenceAnimation — tabs

+ +
+ + {(tab, i) => ( + + )} + +
+ +
+ + {(tab, i) => { + let el!: HTMLDivElement; + const { isMounted } = createPresenceAnimation( + () => el, + () => active() === i(), + { + enter: [ + { opacity: 0, transform: "translateX(10px)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "translateX(-10px)" }, + ], + enterOptions: { duration: 200, easing: "ease-out" }, + exitOptions: { duration: 150, easing: "ease-in" }, + }, + ); + + return ( + +
+ {tab.label} — {tab.body} +
+
+ ); + }} +
+
+
+ ); + }, +}); + +// ─── createPresenceA ────────────────────────────────────────────────────────── + +// A self-contained interactive widget defined outside any reactive scope so +// its identity is stable across renders. +const InteractiveCard = (props: { + ref: (el: HTMLDivElement) => void; + isExiting: () => boolean; +}) => { + const [count, setCount] = createSignal(0); + const [note, setNote] = createSignal(""); + + return ( +
+
+ + {props.isExiting() ? "Exiting — signals still live" : "Mounted"} + +
+ + {/* Counter — signal lives inside this component */} +
+ + + {count()} + + + counter signal +
+ + {/* Text input — local state lives inside this component */} + setNote(e.currentTarget.value)} + placeholder="Type something…" + style={{ + padding: "0.45rem 0.75rem", + border: "1px solid #bae6fd", + "border-radius": "6px", + "font-size": "0.875rem", + background: "white", + outline: "none", + }} + /> + + input state: "{note()}" + +
+ ); +}; + +export const PresenceAStory = meta.story({ + name: "createPresenceA — signals alive during exit", + parameters: { + docs: { + description: { + story: + "`createPresenceA` extends `createPresenceAnimation` with an `isExiting` accessor and makes the " + + "signal-survival guarantee explicit. The exit animation runs for **2 seconds** so you can " + + "interact with the counter and input while the card is animating out — both keep working " + + "because the component owner is only disposed after `isMounted` goes `false`, which happens " + + "only after the WAAPI `.finish` event fires.", + }, + }, + }, + render: () => { + const [show, setShow] = createSignal(true); + let el!: HTMLDivElement; + + const { isMounted, isExiting } = createPresenceA(() => el, show, { + enter: [ + { opacity: 0, transform: "translateY(12px) scale(0.94)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "translateY(12px) scale(0.94)" }, + ], + enterOptions: { duration: 350, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", fill: "both" }, + // Slow exit so you can observe interactivity during animation + exitOptions: { duration: 1250, easing: "ease-in", fill: "forwards" }, + initialEnter: true, + }); + + return ( + +

createPresenceA

+

+ Click +/− or type while the card is exiting (2 s animation) — signals keep working. +

+ + + + + +
+ + isMounted:{" "} + + {String(isMounted())} + + + + isExiting:{" "} + + {String(isExiting())} + + +
+ + + + +
+ ); + }, +}); + +// ─── createPresenceB ────────────────────────────────────────────────────────── + +const SLIDES = [ + { id: "alpha", label: "Alpha", color: "#6366f1", emoji: "🔮" }, + { id: "beta", label: "Beta", color: "#ec4899", emoji: "🌸" }, + { id: "gamma", label: "Gamma", color: "#10b981", emoji: "🌿" }, +] as const; + +type Slide = (typeof SLIDES)[number]; + +const SLIDE_ANIM_OPTIONS = { + enter: [ + { opacity: 0, transform: "translateX(24px) scale(0.95)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "translateX(-24px) scale(0.95)" }, + ], + enterOptions: { + duration: 280, + easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", + fill: "both" as FillMode, + }, + // Slow exit so you can observe the counter value persisting while animating out + exitOptions: { duration: 400, easing: "ease-in", fill: "forwards" as FillMode }, + initialEnter: true, +}; + +// Defined at module level so each instance has a stable, independent reactive scope. +const SlideCardB = (props: { slide: Slide; active: () => Slide }) => { + let el!: HTMLDivElement; + const [count, setCount] = createSignal(0); + + const { gate, isExiting } = createPresenceB( + () => el, + () => props.active() === props.slide, + SLIDE_ANIM_OPTIONS, + ); + + const s = props.slide; + + return ( + +
+
+ {s.emoji} + {s.label} + + + exiting… + + +
+ +
+ + + {count()} + + + {s.label}'s counter +
+
+
+ ); +}; + +export const PresenceBStory = meta.story({ + name: "createPresenceB — deferred disposal via async gate", + parameters: { + docs: { + description: { + story: + "`createPresenceB` uses Solid 2.0's built-in deferred-disposal mechanism. When `show` goes " + + "false, the `gate` memo returns a `Promise` instead of `false`. Returning a Promise causes " + + "`handleAsync` to throw `NotReadyError`, putting `gate` into `STATUS_PENDING`. " + + "`notifyStatus` propagates to Show's internal `createRenderEffect` via `queuePendingNode` " + + "(not the dirty heap), so Show never recomputes and the component owner is **not disposed**. " + + "When the WAAPI animation finishes the Promise resolves, `asyncWrite` makes Show recompute, " + + "and the component is finally removed. Each slide below has its own `count` signal — " + + "increment it, switch slides, and observe the old value during the 1.4 s exit.", + }, + }, + }, + render: () => { + const [active, setActive] = createSignal(SLIDES[0]); + + return ( + +

createPresenceB

+

+ Increment a counter, then switch slides. The old slide's count persists during its 1.4 s + exit — the component owner is deferred via an async gate memo. +

+ +
+ + {slide => ( + + )} + +
+ + {/* Stacked so entering and exiting slides overlap during crossfade */} +
+ {slide => } +
+ +
+ active: {active().label} +
+
+ ); + }, +}); + +// ─── createPresenceB diagnostic / stress-test ──────────────────────────────── + +// Card defined at module level for a stable reactive scope (same pattern as SlideCardB). +// Props receive gate/isExiting from the parent so the parent can read them for diagnostics. +const DiagCard = (props: { + ref: (el: HTMLDivElement) => void; + label: string; + color: string; + isExiting: () => boolean; +}) => { + const [count, setCount] = createSignal(0); + const c = props.color; + + return ( +
+ + {props.label} + + +
+ + + {count()} + + +
+ + + + EXITING + + +
+ ); +}; + +export const PresenceBDiagnostic = meta.story({ + name: "createPresenceB — rapid-toggle diagnostic", + parameters: { + docs: { + description: { + story: + "Stress-test harness for `createPresenceB`. Click tabs rapidly or hit **Stress test** " + + "to fire 20 toggles at 80 ms intervals — far faster than the 1.2 s exit animation. " + + "The diagnostic row shows each slide's live reactive state derived from `isExiting()` " + + "and the `active` signal. A healthy run always has exactly one slide **active** or " + + "**exiting**. If both go **–** simultaneously, or a slide stays stuck as **exiting** " + + "while it should be **active**, the primitive has glitched.", + }, + }, + }, + render: () => { + const [active, setActive] = createSignal(0); + const [stressRunning, setStressRunning] = createSignal(false); + + // Refs live in parent scope so createPresenceB can capture them while also + // allowing isExiting/gate to be read at the parent level for the diagnostic row. + let alphaEl: HTMLDivElement | undefined; + let betaEl: HTMLDivElement | undefined; + + const DIAG_OPTS = { + enter: [ + { opacity: 0, transform: "translateX(18px)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "translateX(-18px)" }, + ], + enterOptions: { duration: 350, easing: "ease-out", fill: "both" as FillMode }, + // Long exit so rapid clicks reliably interrupt the animation mid-flight. + exitOptions: { duration: 1200, easing: "ease-in", fill: "forwards" as FillMode }, + initialEnter: true, + }; + + const { gate: alphaGate, isExiting: alphaExiting } = createPresenceB( + () => alphaEl, + () => active() === 0, + DIAG_OPTS, + ); + + const { gate: betaGate, isExiting: betaExiting } = createPresenceB( + () => betaEl, + () => active() === 1, + DIAG_OPTS, + ); + + let stressTimer: ReturnType | undefined; + + const runStress = () => { + clearInterval(stressTimer); + setStressRunning(true); + let ticks = 0; + stressTimer = setInterval(() => { + setActive(v => 1 - v); + ticks++; + if (ticks >= 20) { + clearInterval(stressTimer); + stressTimer = undefined; + setStressRunning(false); + } + }, 80); + }; + + onCleanup(() => clearInterval(stressTimer)); + + const PAIR = [ + { label: "Alpha", color: "#6366f1" }, + { label: "Beta", color: "#ec4899" }, + ] as const; + + const slideState = (i: number, isExiting: () => boolean): string => { + if (isExiting()) return "exiting"; + if (active() === i) return "active"; + return "–"; + }; + + const stateColor = (s: string): string => + s === "active" ? "#16a34a" : s === "exiting" ? "#d97706" : "#94a3b8"; + + return ( + +

createPresenceB — stress test

+ +
+ + {(p, i) => ( + + )} + +
+ + {/* Stacked panels — entering and exiting slides overlap during crossfade */} +
+ + (alphaEl = el)} + label="Alpha" + color="#6366f1" + isExiting={alphaExiting} + /> + + + (betaEl = el)} + label="Beta" + color="#ec4899" + isExiting={betaExiting} + /> + +
+ + {/* Diagnostic row — reads only sync signals, no async gate interaction */} +
+ + Alpha + Beta + state + + {slideState(0, alphaExiting)} + + + {slideState(1, betaExiting)} + +
+ + + + + +

+ Expected: at any moment exactly one slide shows{" "} + active or{" "} + exiting. If both become{" "} + simultaneously, or a slide stays{" "} + exiting after it should be active again, + the animation has glitched. +

+
+ ); + }, +}); + +// ─── createPresenceC diagnostic ────────────────────────────────────────────── + +export const PresenceCDiagnostic = meta.story({ + name: "createPresenceC — async-memo diagnostic", + parameters: { + docs: { + description: { + story: + "Diagnostic harness for `createPresenceC`. Shows all four returned values " + + "(`isMounted`, `isEntered`, `isEntering`, `isExiting`) live as you toggle or stress-test. " + + "**Note**: `target()` is called unconditionally in the effect so `show` must start `true` " + + "or the initial effect apply throws on a null element — this story starts visible. " + + "The key question: does `isMounted` stay `true` for the full 1 s exit animation, " + + "and do `isExiting`/`isEntering` correctly reflect animation state?", + }, + }, + }, + render: () => { + const [show, setShow] = createSignal(true); + const [stressRunning, setStressRunning] = createSignal(false); + + let el: HTMLDivElement | undefined; + + const C_OPTS = { + enter: [ + { opacity: 0, transform: "translateX(20px)" }, + { opacity: 1, transform: "none" }, + ], + exit: [ + { opacity: 1, transform: "none" }, + { opacity: 0, transform: "translateX(-20px)" }, + ], + enterOptions: { duration: 400, easing: "ease-out", fill: "both" as FillMode }, + // Long exit so timing issues are clearly visible. + exitOptions: { duration: 1000, easing: "ease-in", fill: "forwards" as FillMode }, + }; + + // createPresenceC: show must start true so target() is non-null when the + // non-deferred effect apply fires on initial mount. + const { isMounted, isEntered, isEntering, isExiting } = createPresenceC( + () => el!, + show, + C_OPTS, + ); + + let stressTimer: ReturnType | undefined; + + const runStress = () => { + clearInterval(stressTimer); + setStressRunning(true); + let ticks = 0; + stressTimer = setInterval(() => { + setShow(v => !v); + ticks++; + if (ticks >= 20) { + clearInterval(stressTimer); + stressTimer = undefined; + setShow(true); + setStressRunning(false); + } + }, 80); + }; + + onCleanup(() => clearInterval(stressTimer)); + + const Flag = (props: { label: string; value: () => boolean }) => ( +
+ {props.label} + + {String(props.value())} + +
+
+ ); + + return ( + +

createPresenceC — diagnostic

+ + + + {/* Panel — gated on isMounted to stay alive during exit animation */} +
+ boolean)()}> +
(el = r)} + style={{ + position: "absolute", + inset: "0", + background: "#eef2ff", + border: "2px solid #6366f1", + "border-radius": "10px", + display: "flex", + "align-items": "center", + "justify-content": "center", + "font-weight": "700", + color: "#4f46e5", + "font-size": "0.9rem", + }} + > + {isExiting() ? "Exiting…" : isEntering() ? "Entering…" : "Mounted"} +
+
+
+ + {/* Live state table */} +
+ boolean} /> + boolean} /> + + +
+ + + + + +

+ Expected: isMounted stays true{" "} + for the full 1 s exit. isExiting ={" "} + true during exit,{" "} + isEntering = true{" "} + during enter. If isMounted flips to{" "} + false instantly on hide, the async-memo + timing is off — animationP isn't reactive so the memo reads it before the + effect sets it. +

+
+ ); + }, +}); + +// ─── createPresenceC gate test ──────────────────────────────────────────────── + +export const PresenceCGateTest = meta.story({ + name: "createPresenceC — deferred disposal gate test", + parameters: { + docs: { + description: { + story: + "Tests whether `isMounted` acts as a true deferred-disposal gate. " + + "The inner component holds a counter (reactive signal) and a live tick timer. " + + "Gate `` on `isMounted()` — if deferred disposal works, both the counter " + + "and the tick keep running during the 1.2 s exit animation. " + + "If the component is disposed immediately when `show` flips to `false`, " + + "the counter and tick disappear instantly — the gate mechanism is broken.", + }, + }, + }, + render: () => { + const [show, setShow] = createSignal(true); + + let el: HTMLDivElement | undefined; + + const GATE_OPTS = { + enter: [{ opacity: 0, transform: "scale(0.92)" }, { opacity: 1, transform: "none" }], + exit: [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0.92)" }], + enterOptions: { duration: 350, easing: "ease-out", fill: "both" as FillMode }, + exitOptions: { duration: 1200, easing: "ease-in", fill: "forwards" as FillMode }, + }; + + const { isMounted, isEntering, isExiting } = createPresenceC( + () => el!, + show, + GATE_OPTS, + ); + + // Inner component: has its own reactive state to prove it survives during exit. + const Inner = () => { + const [count, setCount] = createSignal(0); + const [ticks, setTicks] = createSignal(0); + + // Tick every 200 ms — proves the reactive scope is still alive. + const timer = setInterval(() => setTicks(t => t + 1), 200); + onCleanup(() => clearInterval(timer)); + + const c = "#6366f1"; + return ( +
+
+ Counter + + + {count()} + + +
+
+ ticks: {ticks()}  · {isExiting() ? "⟵ exiting" : isEntering() ? "⟶ entering" : "mounted"} +
+
+ ); + }; + + return ( + +

createPresenceC — gate test

+ + + + {/* Gate on isMounted — component should stay alive during exit */} +
+ boolean)()}> +
(el = r)} + style={{ + background: "#f5f3ff", + border: `2px solid ${isExiting() ? "#a78bfa" : "#6366f1"}`, + "border-radius": "12px", + transition: "border-color 0.2s", + }} + > + +
+
+
+ + {/* Gate state */} +
+ isMounted + boolean)() ? "#16a34a" : "#ef4444" }}> + {String((isMounted as unknown as () => boolean)())} + + isExiting + + {String(isExiting())} + + isEntering + + {String(isEntering())} + +
+ +

+ Click Hide then immediately click the counter buttons. If the counter + responds for ~1.2 s before disappearing, deferred disposal works. If it vanishes + instantly, isMounted returned false synchronously instead of + staying pending. +

+
+ ); + }, +}); diff --git a/packages/animation/test/index.test.ts b/packages/animation/test/index.test.ts new file mode 100644 index 000000000..439508531 --- /dev/null +++ b/packages/animation/test/index.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createRoot, createSignal, flush } from "solid-js"; +import { + makeAnimate, + createAnimate, + makeScrollAnimation, + createScrollAnimation, + makeViewAnimation, + createViewAnimation, + makeFlip, + makeStagger, + createStagger, + makeAnimationGroup, +} from "../src/index.js"; + +// Helpers + +function mockAnim() { + return { cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), reverse: vi.fn() } as unknown as Animation; +} + +function makeEl() { + const el = document.createElement("div"); + const anim = mockAnim(); + el.animate = vi.fn(() => anim) as unknown as typeof el.animate; + return { el, anim }; +} + +const rect = (x: number, y: number, w: number, h: number): DOMRect => + ({ left: x, top: y, width: w, height: h, right: x + w, bottom: y + h, x, y, toJSON: () => {} }) as DOMRect; + +declare global { + var ScrollTimeline: new (...args: unknown[]) => AnimationTimeline; + var ViewTimeline: new (...args: unknown[]) => AnimationTimeline; +} + +const KF: Keyframe[] = [{ opacity: "0" }, { opacity: "1" }]; +const OPTS: KeyframeAnimationOptions = { duration: 300 }; + +beforeEach(() => { + vi.stubGlobal("ScrollTimeline", vi.fn(() => ({}))); + vi.stubGlobal("ViewTimeline", vi.fn(() => ({}))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +// makeAnimate + +describe("makeAnimate", () => { + it("calls el.animate with the provided keyframes and options", () => { + const { el, anim } = makeEl(); + const result = makeAnimate(el, KF, OPTS); + expect(el.animate).toHaveBeenCalledWith(KF, OPTS); + expect(result).toBe(anim); + }); + + it("accepts null keyframes", () => { + const { el } = makeEl(); + makeAnimate(el, null); + expect(el.animate).toHaveBeenCalledWith(null, undefined); + }); +}); + +// createAnimate + +describe("createAnimate", () => { + it("returns undefined when target is null", () => { + createRoot(dispose => { + const anim = createAnimate(() => null, KF, OPTS); + expect(anim()).toBeUndefined(); + dispose(); + }); + }); + + it("creates an animation when target becomes available", () => { + const { el, anim } = makeEl(); + const [target, setTarget] = createSignal(null); + let result!: ReturnType; + const dispose = createRoot(d => { + result = createAnimate(target, KF, OPTS); + return d; + }); + + expect(result()).toBeUndefined(); + setTarget(el); + flush(); + expect(el.animate).toHaveBeenCalledWith(KF, OPTS); + expect(result()).toBe(anim); + dispose(); + }); + + it("cancels the old animation and creates a new one when keyframes change", () => { + const { el } = makeEl(); + const anim1 = mockAnim(); + const anim2 = mockAnim(); + (el.animate as ReturnType) + .mockReturnValueOnce(anim1) + .mockReturnValueOnce(anim2); + + const [kf, setKf] = createSignal(KF); + const dispose = createRoot(d => { + createAnimate(() => el, kf, OPTS); + flush(); + return d; + }); + + expect(el.animate).toHaveBeenCalledTimes(1); + setKf([{ transform: "scale(0)" }, { transform: "none" }]); + flush(); + expect(anim1.cancel).toHaveBeenCalledOnce(); + expect(el.animate).toHaveBeenCalledTimes(2); + dispose(); + }); + + it("cancels the animation when the owner is disposed", () => { + const { el, anim } = makeEl(); + const dispose = createRoot(d => { + createAnimate(() => el, KF, OPTS); + flush(); + return d; + }); + + expect(anim.cancel).not.toHaveBeenCalled(); + dispose(); + expect(anim.cancel).toHaveBeenCalledOnce(); + }); + + it("resets to undefined when target becomes null after being set", () => { + const { el } = makeEl(); + const [target, setTarget] = createSignal(el); + let result!: ReturnType; + const dispose = createRoot(d => { + result = createAnimate(target, KF, OPTS); + flush(); + return d; + }); + + expect(result()).toBeDefined(); + setTarget(null); + flush(); + expect(result()).toBeUndefined(); + dispose(); + }); +}); + +// makeScrollAnimation + +describe("makeScrollAnimation", () => { + it("constructs a ScrollTimeline and passes it to el.animate", () => { + const { el } = makeEl(); + makeScrollAnimation(el, KF, OPTS); + + expect(globalThis.ScrollTimeline).toHaveBeenCalledWith({ source: undefined, axis: undefined }); + expect(el.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ timeline: {} })); + }); + + it("forwards source and axis to ScrollTimeline", () => { + const { el } = makeEl(); + const source = document.createElement("div"); + makeScrollAnimation(el, KF, { duration: 300, source, axis: "block" }); + + expect(globalThis.ScrollTimeline).toHaveBeenCalledWith({ source, axis: "block" }); + expect(el.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ duration: 300 })); + // source and axis must not bleed into animOptions + expect(el.animate).toHaveBeenCalledWith( + KF, + expect.not.objectContaining({ source, axis: "block" }), + ); + }); +}); + +// createScrollAnimation + +describe("createScrollAnimation", () => { + it("returns undefined when target is null", () => { + createRoot(dispose => { + expect(createScrollAnimation(() => null, KF)()).toBeUndefined(); + dispose(); + }); + }); + + it("creates a scroll animation when target is set", () => { + const { el } = makeEl(); + const [target, setTarget] = createSignal(null); + let result!: ReturnType; + const dispose = createRoot(d => { + result = createScrollAnimation(target, KF, OPTS); + return d; + }); + + setTarget(el); + flush(); + expect(globalThis.ScrollTimeline).toHaveBeenCalled(); + expect(result()).toBeDefined(); + dispose(); + }); + + it("cancels on dispose", () => { + const { el, anim } = makeEl(); + const dispose = createRoot(d => { + createScrollAnimation(() => el, KF, OPTS); + flush(); + return d; + }); + dispose(); + expect(anim.cancel).toHaveBeenCalledOnce(); + }); +}); + +// makeViewAnimation + +describe("makeViewAnimation", () => { + it("defaults subject to the target element", () => { + const { el } = makeEl(); + makeViewAnimation(el, KF, OPTS); + + expect(globalThis.ViewTimeline).toHaveBeenCalledWith( + expect.objectContaining({ subject: el }), + ); + }); + + it("uses an explicit subject when provided", () => { + const { el } = makeEl(); + const subject = document.createElement("section"); + makeViewAnimation(el, KF, { ...OPTS, subject }); + + expect(globalThis.ViewTimeline).toHaveBeenCalledWith( + expect.objectContaining({ subject }), + ); + }); + + it("forwards axis and inset to ViewTimeline and strips them from animOptions", () => { + const { el } = makeEl(); + makeViewAnimation(el, KF, { duration: 400, axis: "inline", inset: "auto 10%" }); + + expect(globalThis.ViewTimeline).toHaveBeenCalledWith( + expect.objectContaining({ axis: "inline", inset: "auto 10%" }), + ); + expect(el.animate).toHaveBeenCalledWith( + KF, + expect.not.objectContaining({ axis: "inline", inset: "auto 10%" }), + ); + }); +}); + +// createViewAnimation + +describe("createViewAnimation", () => { + it("returns undefined when target is null", () => { + createRoot(dispose => { + expect(createViewAnimation(() => null, KF)()).toBeUndefined(); + dispose(); + }); + }); + + it("creates a view animation when target is set", () => { + const { el } = makeEl(); + const [target, setTarget] = createSignal(null); + let result!: ReturnType; + const dispose = createRoot(d => { + result = createViewAnimation(target, KF); + return d; + }); + + setTarget(el); + flush(); + expect(globalThis.ViewTimeline).toHaveBeenCalled(); + expect(result()).toBeDefined(); + dispose(); + }); + + it("cancels on dispose", () => { + const { el, anim } = makeEl(); + const dispose = createRoot(d => { + createViewAnimation(() => el, KF, OPTS); + flush(); + return d; + }); + dispose(); + expect(anim.cancel).toHaveBeenCalledOnce(); + }); +}); + +// makeFlip + +describe("makeFlip", () => { + it("flip returns undefined if snapshot was never called", () => { + const { el } = makeEl(); + const { flip } = makeFlip(el, OPTS); + expect(flip()).toBeUndefined(); + expect(el.animate).not.toHaveBeenCalled(); + }); + + it("animates from the snapshotted geometry to the new geometry", () => { + const { el } = makeEl(); + vi.spyOn(el, "getBoundingClientRect") + .mockReturnValueOnce(rect(0, 0, 100, 50)) // snapshot + .mockReturnValueOnce(rect(20, 10, 100, 50)); // flip + + const { snapshot, flip } = makeFlip(el, OPTS); + snapshot(); + flip(); + + expect(el.animate).toHaveBeenCalledWith( + [ + { transformOrigin: "top left", transform: "translate(-20px, -10px) scale(1, 1)" }, + { transformOrigin: "top left", transform: "none" }, + ], + OPTS, + ); + }); + + it("returns undefined and skips animate when geometry is unchanged", () => { + const { el } = makeEl(); + vi.spyOn(el, "getBoundingClientRect").mockReturnValue(rect(0, 0, 100, 50)); + + const { snapshot, flip } = makeFlip(el, OPTS); + snapshot(); + expect(flip()).toBeUndefined(); + expect(el.animate).not.toHaveBeenCalled(); + }); + + it("resets after flip so a second flip without a new snapshot is a no-op", () => { + const { el } = makeEl(); + vi.spyOn(el, "getBoundingClientRect") + .mockReturnValueOnce(rect(0, 0, 100, 50)) + .mockReturnValueOnce(rect(20, 10, 100, 50)); + + const { snapshot, flip } = makeFlip(el, OPTS); + snapshot(); + flip(); + expect(flip()).toBeUndefined(); // no snapshot → no-op + expect(el.animate).toHaveBeenCalledTimes(1); + }); + + it("accounts for size changes via scale", () => { + const { el } = makeEl(); + vi.spyOn(el, "getBoundingClientRect") + .mockReturnValueOnce(rect(0, 0, 200, 100)) // snapshot: larger + .mockReturnValueOnce(rect(0, 0, 100, 50)); // flip: smaller + + const { snapshot, flip } = makeFlip(el); + snapshot(); + flip(); + + expect(el.animate).toHaveBeenCalledWith( + [ + { transformOrigin: "top left", transform: "translate(0px, 0px) scale(2, 2)" }, + { transformOrigin: "top left", transform: "none" }, + ], + undefined, + ); + }); +}); + +// makeStagger + +describe("makeStagger", () => { + it("animates each element with staggered delays", () => { + const els = [document.createElement("div"), document.createElement("div"), document.createElement("div")]; + els.forEach(el => { el.animate = vi.fn(() => mockAnim()) as unknown as typeof el.animate; }); + + makeStagger(els, KF, { duration: 300, stagger: 50 }); + + expect(els[0]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 0 })); + expect(els[1]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 50 })); + expect(els[2]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 100 })); + }); + + it("stacks stagger on top of the base delay", () => { + const els = [document.createElement("div"), document.createElement("div")]; + els.forEach(el => { el.animate = vi.fn(() => mockAnim()) as unknown as typeof el.animate; }); + + makeStagger(els, KF, { delay: 100, stagger: 50 }); + + expect(els[0]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 100 })); + expect(els[1]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 150 })); + }); + + it("defaults stagger to 0 when not provided", () => { + const els = [document.createElement("div"), document.createElement("div")]; + els.forEach(el => { el.animate = vi.fn(() => mockAnim()) as unknown as typeof el.animate; }); + + makeStagger(els, KF, { delay: 0 }); + + expect(els[0]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 0 })); + expect(els[1]!.animate).toHaveBeenCalledWith(KF, expect.objectContaining({ delay: 0 })); + }); + + it("returns one Animation per element", () => { + const els = [document.createElement("div"), document.createElement("div")]; + const anims = els.map(() => mockAnim()); + els.forEach((el, i) => { el.animate = vi.fn(() => anims[i]) as unknown as typeof el.animate; }); + + const result = makeStagger(els, KF, { stagger: 100 }); + expect(result).toHaveLength(2); + expect(result[0]).toBe(anims[0]); + expect(result[1]).toBe(anims[1]); + }); +}); + +// createStagger + +describe("createStagger", () => { + it("creates animations for each non-null target", () => { + const els = [document.createElement("div"), document.createElement("div")]; + els.forEach(el => { el.animate = vi.fn(() => mockAnim()) as unknown as typeof el.animate; }); + + createRoot(dispose => { + const anims = createStagger(() => els, KF, { stagger: 50 }); + flush(); + expect(anims()).toHaveLength(2); + expect(els[0]!.animate).toHaveBeenCalled(); + expect(els[1]!.animate).toHaveBeenCalled(); + dispose(); + }); + }); + + it("filters out null and undefined targets", () => { + const el = document.createElement("div"); + el.animate = vi.fn(() => mockAnim()) as unknown as typeof el.animate; + + createRoot(dispose => { + const anims = createStagger(() => [el, null, undefined], KF); + flush(); + expect(anims()).toHaveLength(1); + dispose(); + }); + }); + + it("cancels all animations on dispose", () => { + const els = [document.createElement("div"), document.createElement("div")]; + const anims = els.map(() => mockAnim()); + els.forEach((el, i) => { el.animate = vi.fn(() => anims[i]) as unknown as typeof el.animate; }); + + const dispose = createRoot(d => { + createStagger(() => els, KF, { stagger: 50 }); + flush(); + return d; + }); + + dispose(); + expect(anims[0]!.cancel).toHaveBeenCalledOnce(); + expect(anims[1]!.cancel).toHaveBeenCalledOnce(); + }); + + it("cancels old animations and restarts when targets change", () => { + const el1 = document.createElement("div"); + const el2 = document.createElement("div"); + const anim1 = mockAnim(); + const anim2 = mockAnim(); + el1.animate = vi.fn(() => anim1) as unknown as typeof el1.animate; + el2.animate = vi.fn(() => anim2) as unknown as typeof el2.animate; + + const [targets, setTargets] = createSignal([el1]); + const dispose = createRoot(d => { + createStagger(targets, KF); + flush(); + return d; + }); + + expect(el1.animate).toHaveBeenCalledTimes(1); + setTargets([el1, el2]); + flush(); + expect(anim1.cancel).toHaveBeenCalledOnce(); + expect(el1.animate).toHaveBeenCalledTimes(2); + expect(el2.animate).toHaveBeenCalledTimes(1); + dispose(); + }); +}); + +// makeAnimationGroup + +describe("makeAnimationGroup", () => { + it("forwards play, pause, cancel, reverse, and finish to all animations", () => { + const a = mockAnim(); + const b = mockAnim(); + const group = makeAnimationGroup([a, b]); + + group.play(); + expect(a.play).toHaveBeenCalledOnce(); + expect(b.play).toHaveBeenCalledOnce(); + + group.pause(); + expect(a.pause).toHaveBeenCalledOnce(); + expect(b.pause).toHaveBeenCalledOnce(); + + group.cancel(); + expect(a.cancel).toHaveBeenCalledOnce(); + expect(b.cancel).toHaveBeenCalledOnce(); + + group.reverse(); + expect(a.reverse).toHaveBeenCalledOnce(); + expect(b.reverse).toHaveBeenCalledOnce(); + + group.finish(); + expect(a.finish).toHaveBeenCalledOnce(); + expect(b.finish).toHaveBeenCalledOnce(); + }); + + it("skips null and undefined entries", () => { + const a = mockAnim(); + const group = makeAnimationGroup([a, null, undefined]); + + group.play(); + expect(a.play).toHaveBeenCalledOnce(); + }); + + it("does nothing when the list is empty", () => { + expect(() => makeAnimationGroup([]).play()).not.toThrow(); + }); +}); diff --git a/packages/animation/tsconfig.json b/packages/animation/tsconfig.json new file mode 100644 index 000000000..dc1970e16 --- /dev/null +++ b/packages/animation/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { + "path": "../utils" + } + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce61f851d..d1dd3bf6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,16 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/animation: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + solid-js: + specifier: 2.0.0-beta.15 + version: 2.0.0-beta.15 + packages/audio: dependencies: '@solid-primitives/static-store':