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/dev/index.tsx b/packages/animation/dev/index.tsx new file mode 100644 index 000000000..1a00725eb --- /dev/null +++ b/packages/animation/dev/index.tsx @@ -0,0 +1,102 @@ +import { type Component, For, Show, createSignal } from "solid-js"; +import { createPresence } from "../src/index.js"; + +const App: Component = () => { + return ( + <> + + + + ); +}; + +const FirstExample = () => { + const [showStuff, setShowStuff] = createSignal(true); + const { isVisible, isMounted } = createPresence(showStuff, { + transitionDuration: 500, + }); + + return ( +
+ + +
+ I am the stuff! +
+
+
+ ); +}; + +const SecondExample = () => { + const items = ["foo", "bar", "baz", "qux"]; + const [item, setItem] = createSignal<(typeof items)[number] | undefined>(items[0]); + const { isMounted, mountedItem, isEntering, isVisible, isExiting } = createPresence(item, { + transitionDuration: 500, + }); + + return ( +
+ + {currItem => ( + + )} + + +
+ {mountedItem()} +
+
+
+ ); +}; + +export default App; diff --git a/packages/animation/package.json b/packages/animation/package.json new file mode 100644 index 000000000..74e12fb53 --- /dev/null +++ b/packages/animation/package.json @@ -0,0 +1,78 @@ +{ + "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" + ], + "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..c0d77fb18 --- /dev/null +++ b/packages/animation/src/presence-animation.ts @@ -0,0 +1,126 @@ +import { createSignal, createEffect, untrack, 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) { + const val = (kf as Record)[key]; + reversed[key] = Array.isArray(val) ? [...val].reverse() : val; + } + return reversed; +} + +/** + * 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); + + // Generation counter: incrementing invalidates any queued enter microtask. + let enterGen = 0; + + const scheduleEnter = () => { + const gen = ++enterGen; + // Defer until after Solid has flushed the re-render and the element + // is in the DOM. Solid flushes synchronously within the current JS task, + // so a microtask always runs after the DOM is up to date. + 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++; // cancel any pending enter microtask + 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 () => { + // show toggled back to true before exit finished — cancel without unmounting + if (!done) anim.cancel(); + }; + } + }, + { defer: true }, + ); + + return { isMounted }; +} 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..05adc2f60 --- /dev/null +++ b/packages/animation/stories/animation.stories.tsx @@ -0,0 +1,1088 @@ +import { createEffect, createSignal, For, onSettled, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { + createAnimate, + makeScrollAnimation, + makeViewAnimation, + makeFlip, + makeStagger, + makeAnimationGroup, + makeAnimate, + makeMotionPath, + makeSequence, + createPresenceAnimation, +} 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} +
+
+ ); + }} +
+
+
+ ); + }, +}); 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':