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 defbbd395..c8dcade70 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,13 @@ [![website](https://img.shields.io/badge/website-primitives2.solidjs.community-blue?style=for-the-badge)](https://primitives2.solidjs.community) [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg?style=for-the-badge&logo=pnpm)](https://pnpm.io/) +<<<<<<< HEAD +[![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/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) + +======= [![tested with vitest](https://img.shields.io/badge/tested_with-vitest-6E9F18?style=for-the-badge&logo=vitest)](https://vitest.dev) +>>>>>>> next Solid Primitives is a project dedicated to building high-quality, community-contributed primitives for Solid 2.0. 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. While Solid Primitives isn't officially maintained by the SolidJS Core Team, it's managed by members of the SolidJS core and ecosystem teams. This separation lets the core library iterate independently while keeping Solid Primitives in sync with where Solid is headed. @@ -142,10 +148,18 @@ See the [CHANGELOG](https://github.com/solidjs-community/solid-primitives/tree/n |[sensors](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#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)|[makeAccelerometer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makeaccelerometer)
[createAccelerometer](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createaccelerometer)
[makeGyroscope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makegyroscope)
[createGyroscope](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#creategyroscope)
[makeSensor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makesensor)
[createSensor](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createsensor)
[makeCompass](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makecompass)
[createCompass](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createcompass)
[makeBattery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#makebattery)
[createBattery](https://github.com/solidjs-community/solid-primitives/tree/main/packages/sensors#createbattery)|[![SIZE](https://img.shields.io/badge/size-1.24_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/sensors)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/sensors?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/sensors)|✓| |[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-830_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*

| +<<<<<<< HEAD +|[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)|✓| +|[transition-group](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#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)|[createSwitchTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createswitchtransition)
[createListTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createlisttransition)|[![SIZE](https://img.shields.io/badge/size-852_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/transition-group)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/transition-group?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/transition-group)|✓| +======= |[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-648_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-541_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-974_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)|✓| |[transition-group](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#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)|[createSwitchTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createswitchtransition)
[createListTransition](https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createlisttransition)|[![SIZE](https://img.shields.io/badge/size-1.14_kB-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/transition-group)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/transition-group?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/transition-group)|✓| +>>>>>>> next |[tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween#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)|[createTween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween#createtween)|[![SIZE](https://img.shields.io/badge/size-260_B-blue?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/tween)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/tween?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/tween)|✓| diff --git a/deno.lock b/deno.lock index 36a3baa9d..faa9c528a 100644 --- a/deno.lock +++ b/deno.lock @@ -2118,12 +2118,6 @@ "react" ] }, - "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": { - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dependencies": [ - "@tybys/wasm-util" - ] - }, "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.2_@emnapi+runtime@1.11.2": { "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dependencies": [ @@ -2503,7 +2497,7 @@ "dependencies": [ "@emnapi/core@1.11.1", "@emnapi/runtime@1.11.1", - "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1" + "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2" ], "cpu": ["wasm32"] }, @@ -2789,7 +2783,7 @@ "dependencies": [ "@emnapi/core@1.11.1", "@emnapi/runtime@1.11.1", - "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1" + "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2" ], "cpu": ["wasm32"] }, @@ -8812,6 +8806,13 @@ ] } }, + "packages/animation": { + "packageJson": { + "dependencies": [ + "npm:solid-js@2.0.0-beta.34" + ] + } + }, "packages/async": { "packageJson": { "dependencies": [ 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..0870338d5 --- /dev/null +++ b/packages/animation/README.md @@ -0,0 +1,437 @@ +

+ 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). + +## 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` | +| [`makeMotionPath`](#makemotionpath--createmotionpath) | Animate an element along a CSS Motion Path | +| [`createMotionPath`](#makemotionpath--createmotionpath) | Reactive `makeMotionPath` | +| [`makeSequence`](#makesequence) | Chain animation factories into a sequential playlist | +| [`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 + rangeStart?: string; // default: "entry 0%" — element starts entering the scroll port + rangeEnd?: string; // default: "entry 100%" — element has fully entered the scroll port +}; + +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 +``` + +--- + +## `makeMotionPath` / `createMotionPath` + +Animates an element along a [CSS Motion Path](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_motion_path) using WAAPI — sets `offset-path` and `offset-rotate` on the element and animates `offsetDistance` from `0%` to `100%`. The path/rotation styles are left in place after the animation so `fill: "forwards"` works correctly. + +```ts +// Imperative — path is an SVG path string, passed to path("…") +const anim = makeMotionPath(dotEl, "M0,0 C50,100 150,0 200,100", { + duration: 2000, + fill: "forwards", +}); + +// Any valid offset-path value also works, e.g. a shape function +makeMotionPath(dotEl, "circle(50%)", { duration: 1500, iterations: Infinity }); + +// Reactive — re-runs whenever target, path, or options change +const anim = createMotionPath( + () => dotRef, + () => currentPath(), + { duration: 2000, rotate: "auto" }, +); +anim()?.pause(); +``` + +```ts +type MotionPathOptions = KeyframeAnimationOptions & { + rotate?: string; // offset-rotate — "auto", "0deg", "reverse", etc. Default: "auto" +}; + +function makeMotionPath( + el: HTMLElement, + path: string, + options?: MotionPathOptions, +): Animation + +function createMotionPath( + target: Accessor, + path: MaybeAccessor, + options?: MaybeAccessor, +): Accessor +``` + +--- + +## `makeSequence` + +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 upfront. A factory returning `null`/`undefined` skips that step without breaking the chain. + +Calling `play()` while a sequence is already running discards the current run and starts fresh from the beginning. + +```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 +``` + +```ts +type AnimationFactory = () => Animation | null | undefined; + +type SequenceControls = { + play: () => void; // starts from the first factory, discarding any in-progress run + cancel: () => void; // stops the sequence; the currently-playing animation is cancelled +}; + +function makeSequence(factories: AnimationFactory[]): SequenceControls +``` + +--- + +## `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/deno.jsonc b/packages/animation/deno.jsonc new file mode 100644 index 000000000..e780b5572 --- /dev/null +++ b/packages/animation/deno.jsonc @@ -0,0 +1,24 @@ +{ + "name": "@solid-primitives/animation", + "version": "1.0.0-next.0", + "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", + "exports": "./src/index.ts", + "publish": { + "include": [ + "README.md", + "LICENSE", + "src/**/*.ts", + "src/**/*.tsx", + "package.json" + ], + "exclude": [ + "dist", + "dev", + "test", + "node_modules", + "vitest.config.ts", + "tsconfig.json" + ] + } +} diff --git a/packages/animation/package.json b/packages/animation/package.json new file mode 100644 index 000000000..db61e43b0 --- /dev/null +++ b/packages/animation/package.json @@ -0,0 +1,78 @@ +{ + "name": "@solid-primitives/animation", + "version": "1.0.0-next.0", + "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": "catalog:peer" + }, + "typesVersions": {}, + "devDependencies": { + "solid-js": "catalog:" + } +} 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..0b4736e44 --- /dev/null +++ b/packages/animation/src/index.ts @@ -0,0 +1,9 @@ +export * from "./animate.ts"; +export * from "./scroll-animation.ts"; +export * from "./view-animation.ts"; +export * from "./flip.ts"; +export * from "./stagger.ts"; +export * from "./animation-group.ts"; +export * from "./motion-path.ts"; +export * from "./sequence.ts"; +export * from "./presence-animation.ts"; 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..c227bfaf1 --- /dev/null +++ b/packages/animation/src/presence-animation.ts @@ -0,0 +1,130 @@ +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 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 }; +} 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..510aa8c94 --- /dev/null +++ b/packages/animation/stories/animation.stories.tsx @@ -0,0 +1,1156 @@ +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 d2011c3c7..4e975d8ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,16 @@ importers: specifier: 'catalog:' version: 2.0.0-rc.0 + packages/animation: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + solid-js: + specifier: 'catalog:' + version: 2.0.0-rc.0 + packages/async: devDependencies: solid-js: