diff --git a/packages/spring/LICENSE b/packages/spring/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/spring/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/spring/README.md b/packages/spring/README.md new file mode 100644 index 000000000..065d2a428 --- /dev/null +++ b/packages/spring/README.md @@ -0,0 +1,63 @@ +

+ Solid Primitives spring +

+ +# @solid-primitives/spring + +[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/spring?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/spring) +[![version](https://img.shields.io/npm/v/@solid-primitives/spring?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/spring) +[![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) + +A small SolidJS hook to interpolate signal changes with spring physics. Inspired by & directly forked from [`svelte-motion/spring`](https://svelte.dev/docs/svelte-motion#spring) as such, has a very familiar API design. + +With this primitive, you can easily animate values that can be interpolated like `number`, `date`, and collections (arrays or nested objects) of those datatypes. + +- `createSpring` - Provides a getter and setter for the spring primitive. +- `createDerivedSpring` - Provides only a getter for the spring primitive deriving from an accessor parameter. Similar to the [@solid-primitives/tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween) API. + +The following physics options are available: + +- `stiffness` (number, default `0.15`) — a value between 0 and 1 where higher means a 'tighter' spring +- `damping` (number, default `0.8`) — a value between 0 and 1 where lower means a 'springier' spring +- `precision` (number, default `0.01`) — determines the threshold at which the spring is considered to have 'settled', where lower means more precise + +## Installation + +```bash +npm install @solid-primitives/spring +# or +yarn add @solid-primitives/spring +# or +pnpm add @solid-primitives/spring +``` + +## How to use it + +```ts +// Basic Example +const [progress, setProgress] = createSpring(0); + +// Example with options (less sudden movement) +const [radialProgress, setRadialProgress] = createSpring(0, { stiffness: 0.05 }); + +// Example with collections (e.g. Object or Array). +const [xy, setXY] = createSpring( + { x: 50, y: 50 }, + { stiffness: 0.08, damping: 0.2, precision: 0.01 }, +); + +// Example deriving from an existing signal. +const [myNumber, myNumber] = createSignal(20); +const springedValue = createDerivedSpring(myNumber, { stiffness: 0.03 }); +``` + +## Demo + +- **[Playground](https://primitives.solidjs.community/playground/spring)** - [source code](https://github.com/solidjs-community/solid-primitives/blob/main/packages/spring/dev/index.tsx) + +- **[CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr)** + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/spring/dev/index.tsx b/packages/spring/dev/index.tsx new file mode 100644 index 000000000..d59259571 --- /dev/null +++ b/packages/spring/dev/index.tsx @@ -0,0 +1,121 @@ +import { createSpring } from "../src/index.js" + +export default function App() { + const [progress, setProgress] = createSpring(0); + const [radialProgress, setRadialProgress] = createSpring(0, { + stiffness: 0.1, damping: 0.3 + }); + const [xy, setXY] = createSpring({ x: 50, y: 50 }, { stiffness: 0.1, damping: 0.3 }); + const [date, setDate] = createSpring(new Date()); + + function toggleProgress() { + if (progress() === 0) setProgress(1); + else setProgress(0); + } + function toggleRadialProgress() { + if (radialProgress() === 0) setRadialProgress(1); + else setRadialProgress(0); + } + let d = false + function toggleXY() { + if (d = !d) setXY({ x: 200, y: 200 }); + else setXY({ x: 50, y: 50 }); + } + function toggleDate() { + if (date().getDate() === new Date("2024-12-01").getDate()) setDate(new Date("2024-04-14")); + else setDate(new Date("2024-12-01")); + } + + return <> + +
+
+ + + + +
+ + {/* Progress */} +
+ + +

{(progress() * 100).toFixed(0)}%

+
+ + {/* Radial progress */} +
+ + + + + {(radialProgress() * 100).toFixed(0)}% +
+ + {/* XY */} +
+ {xy().x.toFixed(0)}x{xy().y.toFixed(0)} +
+ + {/* Date */} +
{date()+""}
+
+ +}; diff --git a/packages/spring/package.json b/packages/spring/package.json new file mode 100644 index 000000000..cfb0e5ff5 --- /dev/null +++ b/packages/spring/package.json @@ -0,0 +1,63 @@ +{ + "name": "@solid-primitives/spring", + "version": "0.0.1", + "description": "Primitive that creates spring physics functions.", + "author": "Carlo Taleon ", + "contributors": ["Damian Tarnawski "], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/spring", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "spring", + "stage": 0, + "list": [ + "createSpring", + "createDerivedSpring" + ], + "category": "Animation" + }, + "keywords": [ + "animate", + "tween", + "spring", + "solid", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "tsx ../../scripts/dev.ts", + "build": "tsx ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } +} diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts new file mode 100644 index 000000000..36a53fd53 --- /dev/null +++ b/packages/spring/src/index.ts @@ -0,0 +1,215 @@ +import { Accessor, createEffect, createSignal, onCleanup } from "solid-js"; +import { isServer } from "solid-js/web"; + +// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/motion/utils.js + +function is_date(obj: any): obj is Date { + return Object.prototype.toString.call(obj) === "[object Date]"; +} + +// =========================================================================== +// createSpring hook +// =========================================================================== + +export type SpringOptions = { + /** + * Stiffness of the spring. Higher values will create more sudden movement. + * @default 0.15 + */ + stiffness?: number; + /** + * Strength of opposing force. If set to 0, spring will oscillate indefinitely. + * @default 0.8 + */ + damping?: number; + /** + * Precision is the threshold relative to the target value at which the + * animation will stop based on the current value. + * + * From 0, if the target value is 500, and the precision is 500, it will stop + * the animation instantly (no animation, similar to `hard: true`). + * + * From 0, if the target value is 500, and the precision is 0.01, it will stop the + * animation when the current value reaches 499.99 or 500.01 (longer animation). + * + * @default 0.01 + */ + precision?: number; +}; + +export type SpringTarget = + | number + | Date + | { [key: string]: number | Date | SpringTarget } + | readonly (number | Date)[] + | readonly SpringTarget[]; + +/** + * "Widen" Utility Type so that number types are not converted to + * literal types when passed to `createSpring`. + * + * e.g. createSpring(0) returns `0`, not `number`. + */ +export type WidenSpringTarget = T extends number ? number : T; + +export type SpringSetterOptions = { hard?: boolean; soft?: boolean | number } +export type SpringSetter = ( + newValue: T | ((prev: T) => T), + opts?: SpringSetterOptions, +) => Promise; + +/** + * Creates a signal and a setter that uses spring physics when interpolating from + * one value to another. This means when the value changes, instead of + * transitioning at a steady rate, it "bounces" like a spring would, + * depending on the physics paramters provided. This adds a level of realism to + * the transitions and can enhance the user experience. + * + * `T` - The type of the signal. It works for the basic data types that can be + * interpolated: `number`, a `Date`, `Array` or a nested object of T. + * + * @param initialValue The initial value of the signal. + * @param options Options to configure the physics of the spring. + * @returns Returns the spring value and a setter. + * + * @example + * const [progress, setProgress] = createSpring(0, { stiffness: 0.15, damping: 0.8 }); + */ +export function createSpring( + initialValue: T, + options: SpringOptions = {}, +): [Accessor>, SpringSetter>] { + const [signal, setSignal] = createSignal(initialValue); + const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; + + if (isServer) { + return [signal as any, ((param: any, opts: SpringSetterOptions = {}) => { + if (opts.hard || signal() == null || (stiffness >= 1 && damping >= 1)) { + setSignal(param); + return Promise.resolve(); + } + return new Promise(() => {}); + }) as any] + } + + let value_current = initialValue; + let value_last = initialValue; + let value_target = initialValue; + let inv_mass = 1; + let inv_mass_recovery_rate = 0; + let raf_id = 0 + let settled = true + let time_last = 0; + let time_delta = 0 + let resolve = () => {} + + const cleanup = onCleanup(() => { + cancelAnimationFrame(raf_id) + raf_id = 0 + resolve() + }) + + const frame: FrameRequestCallback = time => { + time_delta = Math.max(1 / 60, (time - time_last) * 60 / 1000) // guard against d<=0 + time_last = time + + inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1) + settled = true + + let new_value = tick(value_last, value_current, value_target) + value_last = value_current + setSignal(value_current = new_value) + + if (settled) { + cleanup() + } else { + raf_id = requestAnimationFrame(frame) + } + } + + const set: SpringSetter = (param, opts = {}) => { + value_target = typeof param === "function" ? param(value_current) : param; + + if (opts.hard || (stiffness >= 1 && damping >= 1)) { + cleanup() + setSignal(_ => value_current = value_last = value_target); + return Promise.resolve(); + } + + if (opts.soft) { + inv_mass_recovery_rate = 1 / (typeof opts.soft === "number" ? opts.soft * 60 : 30); + inv_mass = 0; // Infinite mass, unaffected by spring forces. + } + + if (raf_id === 0) { + time_last = performance.now() + raf_id = requestAnimationFrame(frame) + } + + return new Promise(r => resolve = r); + }; + + const tick = (last: T, current: T, target: T): any => { + if (typeof current === "number" || is_date(current)) { + const delta = +target - +current; + const velocity = (+current - +last) / time_delta; + const spring = stiffness * delta; + const damper = damping * velocity; + const acceleration = (spring - damper) * inv_mass; + const d = (velocity + acceleration) * time_delta; + + if (Math.abs(d) < precision && Math.abs(delta) < precision) { + return target; // settled + } + + settled = false; // signal loop to keep ticking + return typeof current === "number" ? current + d : new Date(+current + d); + } + + if (Array.isArray(current)) { + // @ts-expect-error + return current.map((_, i) => tick(last[i], current[i], target[i])); + } + + if (typeof current === "object") { + const next = {...current}; + for (const k in current) { + // @ts-expect-error + next[k] = tick(last[k], current[k], target[k]); + } + return next; + } + + throw new Error(`Cannot spring ${typeof current} values`); + }; + + return [signal as any, set as any]; +} + +// =========================================================================== +// createDerivedSpring hook +// =========================================================================== + +/** + * Creates a spring value that interpolates based on changes on a passed signal. + * Works similar to the `@solid-primitives/tween` + * + * @param target Target to be modified. + * @param options Options to configure the physics of the spring. + * @returns Returns the spring value only. + * + * @example + * const percent = createMemo(() => current() / total() * 100); + * + * const springedPercent = createDerivedSignal(percent, { stiffness: 0.15, damping: 0.8 }); + */ +export function createDerivedSpring( + target: Accessor, + options?: SpringOptions, +) { + const [springValue, setSpringValue] = createSpring(target(), options); + + createEffect(() => setSpringValue(target() as WidenSpringTarget)); + + return springValue; +} diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts new file mode 100644 index 000000000..291e4c396 --- /dev/null +++ b/packages/spring/test/index.test.ts @@ -0,0 +1,202 @@ +import { createEffect, createRoot, createSignal } from "solid-js"; +import { describe, expect, it, vi, afterAll, } from "vitest"; +import { createDerivedSpring, createSpring } from "../src/index.js"; + +let _time = 0 +let _raf_last_id = 0; +let _raf_callbacks_old = new Map(); +let _raf_callbacks_new = new Map(); + +function _progress_time(by: number) { + _time += by + _raf_callbacks_old = _raf_callbacks_new; + _raf_callbacks_new = new Map(); + _raf_callbacks_old.forEach(c => c(_time)); + _raf_callbacks_old.clear(); +} + +let _now = performance.now +performance.now = () => _time +afterAll(() => { + performance.now = _now +}) + +vi.stubGlobal("requestAnimationFrame", function (callback: FrameRequestCallback): number { + const id = _raf_last_id++; + _raf_callbacks_new.set(id, callback); + return id; +}); +vi.stubGlobal("cancelAnimationFrame", function (id: number): void { + _raf_callbacks_new.delete(id); +}); + +describe("createSpring", () => { + + it("returns values", () => { + const [[spring, setSpring], dispose] = createRoot(d => [createSpring({ progress: 0 }), d]); + expect(spring().progress).toBe(0); + dispose(); + }); + + it("Setter does not subscribe to self", () => { + let runs = 0 + const [signal, setSignal] = createSignal(0) + + const [setSpring, dispose] = createRoot(dispose => { + const [, setSpring] = createSpring(0) + + createEffect(() => { + runs++ + setSpring(p => { + signal() // this one should be tracked + return p+1 + }, { hard: true }) + }) + + return [setSpring, dispose] + }); + expect(runs).toBe(1) + + setSpring(p => p+1, { hard: true }) + expect(runs).toBe(1) + + setSignal(1) + expect(runs).toBe(2) + + dispose(); + }); + + it("instantly updates `number` when set with hard.", () => { + const start = 0; + const end = 50; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toBe(start); + setSpring(end, { hard: true }); + + expect(spring()).toBe(end); + + dispose(); + }); + + it("instantly updates `Date` when set with hard.", () => { + const start = new Date("2024-04-14T00:00:00.000Z"); + const end = new Date("2024-04-14T00:00:00.000Z"); + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring().getDate()).toBe(start.getDate()); + setSpring(end, { hard: true }); // Set to 100 here. + + expect(spring().getDate()).toBe(end.getDate()); + + dispose(); + }); + + it("instantly updates `{ progress: 1 }` when set with hard.", () => { + const start = { progress: 1 }; + const end = { progress: 100 }; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toMatchObject(start); + setSpring(end, { hard: true }); // Set to 100 here. + + expect(spring()).toMatchObject(end); + + dispose(); + }); + + it("instantly updates `Array` when set with hard.", () => { + const start = [1, 2, 3]; + const end = [20, 15, 20]; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toMatchObject(start); + setSpring(end, { hard: true }); // Set to 100 here. + + expect(spring()).toMatchObject(end); + + dispose(); + }); + + it("instantly updates `number` when set with hard using a function as an argument.", () => { + const start = 0; + const end = 50; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toBe(start); + setSpring(_ => end, { hard: true }); // Using a function as an argument. + + expect(spring()).toBe(end); + + dispose(); + }); + + it("instantly updates `{ progress: 1 }` when set with hard using a function as an argument.", () => { + const start = { progress: 1 }; + const end = { progress: 100 }; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toMatchObject(start); + setSpring(_ => ({ progress: 100 }), { hard: true }); // Using a function as an argument. + + expect(spring()).toMatchObject(end); + + dispose(); + }); + + it("updates toward target", () => { + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(0), d]); + + expect(spring()).toBe(0); + setSpring(50); + expect(spring()).toBe(0); + + _progress_time(300) + + // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) + expect(spring()).not.toBe(50); + expect(spring()).toBeGreaterThan(50 / 2); + dispose(); + }); + + it("updates array of objects toward target", () => { + const start = [{foo: 1}, {foo: 2}, {foo: 3}]; + const end = [{foo: 20}, {foo: 15}, {foo: 20}]; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toMatchObject(start); + setSpring(end); + + _progress_time(300) + for (let i = 0; i < start.length; i++) { + expect(spring()[i]!.foo).toBeGreaterThan(end[i]!.foo/2); + } + + dispose(); + }); +}); + +describe("createDerivedSpring", () => { + it("updates toward accessor target", () => { + const [signal, setSignal] = createSignal(0); + const [spring, dispose] = createRoot(d => [createDerivedSpring(signal), d]); + + expect(spring()).toBe(0); + setSignal(50); // Set to 100 here. + expect(spring()).toBe(0); + + _progress_time(300) + + // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) + expect(spring()).not.toBe(50); + expect(spring()).toBeGreaterThan(50 / 2); + dispose(); + }); +}); diff --git a/packages/spring/test/server.test.ts b/packages/spring/test/server.test.ts new file mode 100644 index 000000000..2637cca72 --- /dev/null +++ b/packages/spring/test/server.test.ts @@ -0,0 +1,18 @@ +import { describe, test, expect } from "vitest"; +import { createDerivedSpring, createSpring } from "../src/index.js"; +import { createSignal } from "solid-js"; + +describe("createSpring", () => { + test("doesn't break in SSR", () => { + const [value, setValue] = createSpring({ progress: 0 }); + expect(value().progress, "initial value should be { progress: 0 }").toBe(0); + }); +}); + +describe("createDerivedSpring", () => { + test("doesn't break in SSR", () => { + const [signal, setSignal] = createSignal({ progress: 0 }); + const value = createDerivedSpring(signal); + expect(value().progress, "initial value should be { progress: 0 }").toBe(0); + }); +}); diff --git a/packages/spring/tsconfig.json b/packages/spring/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/packages/spring/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55770e416..00b14cb42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -843,6 +843,12 @@ importers: specifier: ^1.8.7 version: 1.8.20 + packages/spring: + dependencies: + solid-js: + specifier: ^1.6.12 + version: 1.8.22 + packages/start: devDependencies: solid-js: