diff --git a/.changeset/signal-builders-solid2-migration.md b/.changeset/signal-builders-solid2-migration.md new file mode 100644 index 000000000..91892463c --- /dev/null +++ b/.changeset/signal-builders-solid2-migration.md @@ -0,0 +1,12 @@ +--- +"@solid-primitives/signal-builders": major +--- + +Migrate to Solid.js v2.0 (beta.13) + +## Breaking Changes + +**Peer dependency**: `solid-js@^2.0.0-beta.13` is now required. + +- The `on` helper from `solid-js` (used internally by `capitalize`) is removed in Solid 2.0; `capitalize` now uses a plain `createMemo` which is equivalent +- `get` and `merge` now correctly return reactive `Accessor` values via `createMemo` — previously they returned plain (non-reactive) values despite their type signatures claiming otherwise; any code that was working around this bug by calling the result as a plain value will break diff --git a/packages/signal-builders/README.md b/packages/signal-builders/README.md index baccc5c17..383a32fb6 100644 --- a/packages/signal-builders/README.md +++ b/packages/signal-builders/README.md @@ -8,21 +8,23 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/signal-builders?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/signal-builders) [![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-2.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -A collection of chainable and composable reactive signal calculations, _AKA_ **Signal Builders**. +A collection of chainable, composable reactive computations — **Signal Builders** — for common array, object, number, string, and type-conversion operations. ## Installation ```bash npm install @solid-primitives/signal-builders # or -yarn add @solid-primitives/signal-builders +pnpm add @solid-primitives/signal-builders ``` -## How to use it +Requires `solid-js@^2.0.0-beta.13` as a peer dependency. -Signal builders create computations when used, so they need to be used under a reactive root. +## Usage -Note, since all of the signal builders use [`createMemo`](https://www.solidjs.com/docs/latest/api#creatememo) to wrap the calculation, updates will be caused only when the calculated value changes. Also the calculations should stay 'pure' – try to not cause side effects inside them. +Each builder wraps its computation in `createMemo`, so results only update when the computed value actually changes. Builders must be called inside a reactive owner (a component body or `createRoot`), and computations should be kept pure — avoid side effects inside them. + +Because each builder returns an `Accessor`, the output of one can be passed directly as input to another: ### Array @@ -56,10 +58,10 @@ modifiedUser(); // { name: { first: "John", last: "Solid" }, age: 21 } import { add, multiply, clamp, int } from "@solid-primitives/signal-builders"; const [input, setInput] = createSignal("123"); -const [ing, setIng] = createSignal(-45); +const [offset, setOffset] = createSignal(-45); const [max, setMax] = createSignal(1000); -const value = clamp(multiply(int(input), add(ing, 54, 9)), 0, max); +const value = clamp(multiply(int(input), add(offset, 54, 9)), 0, max); ``` ### String @@ -71,74 +73,67 @@ const [greeting, setGreeting] = createSignal("Hello"); const [target, setTarget] = createSignal("World"); const message = template`${greeting}, ${target}!`; -message(); // => Hello, World! +message(); // => "Hello, World!" const solidMessage = lowercase(add(substring(message, 0, 7), "Solid")); -solidMessage(); // => hello, solid +solidMessage(); // => "hello, solid" ``` -## List of builders +## Builder Reference ### Array -- **`push`** - basically `Array.prototype.push()` -- **`drop`** - drop n items from the array start -- **`dropRight`** - drop n items from the end of the array -- **`filter`** - basically `Array.prototype.filter()` -- **`filterOut`** - filter out passed item from an array -- **`remove`** - removes passed item from an array (first one from the start) -- **`removeItems`** - removes multiple items from an array -- **`splice`** - signal-builder `Array.prototype.splice()` -- **`slice`** - signal-builder `Array.prototype.slice()` -- **`map`** - signal-builder `Array.prototype.map()` -- **`sort`** - signal-builder `Array.prototype.sort()` -- **`concat`** - Append multiple arrays together -- **`flatten`** - Flattens a nested array into a one-level array -- **`filterInstance`** - filter list: only leave items that are instances of specified Classes -- **`filterOutInstance`** - filter list: remove items that are instances of specified Classes - -### Object/Array - -- **`get`** - Get a single property value of an object by specifying a path to it. -- **`update`** - Change single value in an object by key, or series of recursing keys. +- **`push`** — append items to an array +- **`drop`** — remove n items from the start +- **`dropRight`** — remove n items from the end +- **`filter`** — `Array.prototype.filter()` +- **`filterOut`** — remove all occurrences of a specific item +- **`remove`** — remove the first occurrence of a specific item +- **`removeItems`** — remove multiple specific items +- **`splice`** — `Array.prototype.splice()` +- **`slice`** — `Array.prototype.slice()` +- **`map`** — `Array.prototype.map()` +- **`sort`** — `Array.prototype.sort()` +- **`concat`** — concatenate multiple arrays +- **`flatten`** — flatten one level of nesting +- **`filterInstance`** — keep only items that are instances of the specified classes +- **`filterOutInstance`** — remove items that are instances of the specified classes ### Object -- **`omit`** - get an object copy without the provided keys -- **`pick`** - get an object copy with only the provided keys -- **`merge`** - Merges multiple objects into a single one. +- **`omit`** — copy an object without the specified keys +- **`pick`** — copy an object with only the specified keys +- **`get`** — read a value at a key path (up to 6 levels deep) +- **`merge`** — shallow merge of multiple objects +- **`update`** — immutably set a value at a key path; the last argument can be a new value or a setter function `(prev) => next` ### Convert -- **`string`** - turns passed value to a string -- **`float`** - turns passed string to an float number -- **`int`** - turns passed string to an intiger -- **`join`** - join array with a separator to a string +- **`string`** — convert a value to a string +- **`float`** — parse a string as a float (`Number.parseFloat`) +- **`int`** — parse a string as an integer (`Number.parseInt`) +- **`join`** — join an array into a string with a separator ### Number -- **`add`** - `a + b + c + ...` -- **`substract`** - `a - b - c - ...` -- **`multiply`** - `a * b * c * ...` -- **`divide`** - `a / b / c / ...` -- **`power`** - `a ** b ** c ** ...` -- **`clamp`** - clamp a number value between two other values -- **`round`** - `Math.round()` -- **`ceil`** - `Math.ceil()` -- **`floor`** - `Math.floor()` +- **`add`** — `a + b + c + ...` +- **`substract`** — `a - b - c - ...` +- **`multiply`** — `a * b * c * ...` +- **`divide`** — `a / b / c / ...` +- **`power`** — `a ** b ** c ** ...` +- **`clamp`** — constrain a value between min and max +- **`round`** — `Math.round()` +- **`ceil`** — `Math.ceil()` +- **`floor`** — `Math.floor()` ### String -- **`add`** - `a + b + c + ...` -- **`lowercase`** - signal builder `String.prototype.toLowerCase()` -- **`uppercase`** - signal builder `String.prototype.toUpperCase()` -- **`capitalize`** - capitalize a string input e.g. `"solidJS"` -> `"Solidjs"` -- **`substring`** - signal builder `String.prototype.substring()` -- **`template`** - Create reactive string templates - -## A call for feedback - -`signal-builders` package is now a proof of concept of a fresh and experimental idea. Therefore all feedback/ideas/issues are highly welcome! :) +- **`lowercase`** — `String.prototype.toLowerCase()` +- **`uppercase`** — `String.prototype.toUpperCase()` +- **`capitalize`** — capitalize the first character and lowercase the rest +- **`substring`** — `String.prototype.substring()` +- **`add`** — `a + b + c + ...` (string concatenation) +- **`template`** — reactive tagged template literal ## Changelog diff --git a/packages/signal-builders/package.json b/packages/signal-builders/package.json index f60da7b7a..737910f47 100644 --- a/packages/signal-builders/package.json +++ b/packages/signal-builders/package.json @@ -2,7 +2,7 @@ "name": "@solid-primitives/signal-builders", "version": "0.2.3", "description": "A collection of chainable and composable reactive signal calculations, aka Signal Builders.", - "author": "Your Name ", + "author": "Damian Tarnawski ", "license": "MIT", "homepage": "https://primitives.solidjs.community/package/signal-builders", "repository": { @@ -13,14 +13,26 @@ "name": "signal-builders", "stage": 2, "list": [ - "List of builders" + "push", + "filter", + "sort", + "map", + "get", + "merge", + "update", + "add", + "clamp", + "template" ], "category": "Reactivity" }, "keywords": [ "solid", "primitives", - "fp" + "signal", + "reactive", + "fp", + "functional" ], "files": [ "dist" @@ -49,10 +61,10 @@ "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "solid-js": "^2.0.0-beta.14" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "solid-js": "2.0.0-beta.14" } } diff --git a/packages/signal-builders/src/array.ts b/packages/signal-builders/src/array.ts index 7c63f62f8..87e32f109 100644 --- a/packages/signal-builders/src/array.ts +++ b/packages/signal-builders/src/array.ts @@ -11,9 +11,7 @@ import * as _ from "@solid-primitives/utils/immutable"; import { type Accessor, createMemo } from "solid-js"; import type { MappingFn, Predicate, FlattenArray } from "@solid-primitives/utils/immutable"; -/** - * signal-builder `Array.prototype.push()` - */ +/** Reactively appends items to an array, returning a new array without mutating the original. */ export const push = < A extends MaybeAccessor, V extends ItemsOf>, @@ -25,52 +23,51 @@ export const push = < createMemo(() => _.push(access(list), ...accessArray(items))); /** - * signal-builder that drops n items from the array start. + * Reactively drops the first `n` elements from an array (default: 1). + * @example + * const [list, setList] = createSignal([1, 2, 3, 4]); + * drop(list, 2)(); // => [3, 4] */ export const drop = (list: MaybeAccessor, n?: number): Accessor => createMemo(() => _.drop(access(list), n) as T); /** - * signal-builder that drops n items from the end of the array. + * Reactively drops the last `n` elements from an array (default: 1). + * @example + * const [list, setList] = createSignal([1, 2, 3, 4]); + * dropRight(list, 2)(); // => [1, 2] */ export const dropRight = (list: MaybeAccessor, n?: number): Accessor => createMemo(() => _.dropRight(access(list), n) as T); -/** - * signal-builder `Array.prototype.filter()` - */ +/** Reactively filters array items by a predicate function. */ export const filter = , V extends ItemsOf>>( list: A, predicate: Predicate, ): Accessor => createMemo(() => _.filter(access(list), predicate)); -/** - * signal-builder `Array.prototype.filter()` that filters out passed item - */ +/** Reactively filters out all occurrences of `item` from the array. */ export const filterOut = , V extends ItemsOf>>( list: A, item: MaybeAccessor, ): Accessor => createMemo(() => _.filterOut(access(list), access(item))); /** - * signal-builder `Array.prototype.sort()` + * Reactively sorts an array, returning a new sorted copy without mutating the original. + * Without a `compareFn`, items are sorted lexicographically (coerced to strings). */ export const sort = , V extends ItemsOf>>( list: A, compareFn?: (a: V, b: V) => number, ): Accessor => createMemo(() => _.sort(access(list), compareFn)); -/** - * signal-builder `Array.prototype.map()` - */ +/** Reactively maps array items to a new value via `mapFn`. */ export const map = , T>( list: A, mapFn: MappingFn>, T>, ): Accessor => createMemo(() => _.map(access(list), mapFn)); -/** - * signal-builder `Array.prototype.slice()` - */ +/** Reactive `Array.prototype.slice()` — extracts a portion of the array without mutating it. */ export const slice = ( list: MaybeAccessor, start?: number, @@ -78,7 +75,8 @@ export const slice = ( ): Accessor => createMemo(() => _.slice(access(list), start, end) as T); /** - * signal-builder `Array.prototype.splice()` + * Reactively removes/replaces elements and optionally inserts new ones, returning a new array. + * Immutable equivalent of `Array.prototype.splice()`. */ export const splice = < A extends MaybeAccessor, @@ -94,16 +92,18 @@ export const splice = < _.splice(access(list), access(start), access(deleteCount), ...accessArray(items)), ); -/** - * signal-builder removing passed item from an array (first one from the start) - */ +/** Reactively removes the first occurrence of `item` from the array. */ export const remove = , V extends ItemsOf>>( list: A, item: MaybeAccessor, ): Accessor => createMemo(() => _.remove(access(list), access(item))); /** - * signal-builder removing multiple items from an array + * Reactively removes the first occurrence of each provided item from the array. + * Once an item is matched, subsequent occurrences of that item in the list are kept. + * @example + * const [list, setList] = createSignal([1, 2, 3, 2, 1]); + * removeItems(list, 1, 2)(); // => [3, 2, 1] (first 1 and first 2 removed) */ export const removeItems = ( list: MaybeAccessor, @@ -111,21 +111,26 @@ export const removeItems = ( ): Accessor => createMemo(() => _.removeItems(access(list), ...accessArray(items)) as T); /** - * signal-builder appending multiple arrays together + * Reactively concatenates multiple arrays or values into a single flat array. + * @example + * const [a, setA] = createSignal([1, 2]); + * const [b, setB] = createSignal([3, 4]); + * concat(a, b)(); // => [1, 2, 3, 4] */ export const concat = [], V extends MaybeAccessorValue>>( ...a: A ): Accessor : V>> => createMemo(() => _.concat(...accessArray(a))); -/** - * Signal builder: Flattens a nested array into a one-level array - */ +/** Reactively deep-flattens a nested array (fully recursive, not just one level). */ export const flatten = (list: MaybeAccessor): Accessor> => createMemo(() => _.flatten(access(list)) as FlattenArray); /** - * Signal builder: filter list: only leave items that are instances of specified Classes + * Reactively keeps only items that are instances of any of the provided classes. + * @example + * const [nodes, setNodes] = createSignal([new Text("hi"), new Comment("x"), new Text("bye")]); + * filterInstance(nodes, Text)(); // => [Text("hi"), Text("bye")] */ export const filterInstance = (list: MaybeAccessor, ...classes: I) => (classes.length === 1 @@ -135,7 +140,10 @@ export const filterInstance = (list: MaybeAccessor )) as Accessor>>[]>; /** - * Signal builder: filter list: remove items that are instances of specified Classes + * Reactively removes items that are instances of any of the provided classes. + * @example + * const [nodes, setNodes] = createSignal([new Text("hi"), new Comment("x"), new Text("bye")]); + * filterOutInstance(nodes, Comment)(); // => [Text("hi"), Text("bye")] */ export const filterOutInstance = ( list: MaybeAccessor, diff --git a/packages/signal-builders/src/convert.ts b/packages/signal-builders/src/convert.ts index 15c48a7a2..eed991a4c 100644 --- a/packages/signal-builders/src/convert.ts +++ b/packages/signal-builders/src/convert.ts @@ -1,25 +1,26 @@ import { access, type MaybeAccessor } from "@solid-primitives/utils"; import { type Accessor, createMemo } from "solid-js"; -/** - * signal-builder turning passed value to a string - */ +/** Reactively coerces any value or signal to a string. */ export const string = (from: any): Accessor => createMemo(() => access(from) + ""); -/** - * signal-builder turning passed string to an float number - */ +/** Reactively parses a string signal or value as a floating-point number. Wraps `Number.parseFloat`. */ export const float = (input: MaybeAccessor): Accessor => createMemo(() => Number.parseFloat(access(input))); /** - * signal-builder turning passed string to an intiger + * Reactively parses a string signal or value as an integer. Wraps `Number.parseInt`. + * @param radix Base for parsing: 2 for binary, 8 for octal, 16 for hex. Defaults to 10. */ export const int = (input: MaybeAccessor, radix?: number): Accessor => createMemo(() => Number.parseInt(access(input), radix)); /** - * signal-builder joining array with a separator to a string + * Reactively joins an array signal or value into a string. + * @example + * const [items, setItems] = createSignal(["a", "b", "c"]); + * const csv = join(items, ","); + * csv(); // => "a,b,c" */ export const join = ( list: MaybeAccessor, diff --git a/packages/signal-builders/src/number.ts b/packages/signal-builders/src/number.ts index 674597054..d6a07dda4 100644 --- a/packages/signal-builders/src/number.ts +++ b/packages/signal-builders/src/number.ts @@ -2,38 +2,50 @@ import { access, accessArray, type MaybeAccessor } from "@solid-primitives/utils import * as _ from "@solid-primitives/utils/immutable"; import { type Accessor, createMemo } from "solid-js"; -/** signal-builder `a + b + c + ...` */ +/** + * Reactive `a + b + c + ...` — also concatenates strings when passed string values. + * @example + * const [x, setX] = createSignal(2); + * const sum = add(x, 3); + * sum(); // => 5 + */ export function add(...a: MaybeAccessor[]): Accessor; export function add(...a: MaybeAccessor[]): Accessor; export function add(...a: MaybeAccessor[]): Accessor { return createMemo(() => _.add(...accessArray(a))); } -/** signal-builder `a - b - c - ...` */ +/** `a - b - c - ...` */ export const substract = (a: MaybeAccessor, ...b: MaybeAccessor[]) => createMemo(() => _.substract(access(a), ...accessArray(b))); -/** signal-builder `a * b * c * ...` */ +/** `a * b * c * ...` */ export const multiply = (a: MaybeAccessor, ...b: MaybeAccessor[]) => createMemo(() => _.multiply(access(a), ...accessArray(b))); -/** signal-builder `a / b / c / ...` */ +/** `a / b / c / ...` */ export const divide = (a: MaybeAccessor, ...b: MaybeAccessor[]) => createMemo(() => _.divide(access(a), ...accessArray(b))); -/** signal-builder `a ** b ** c ** ...` */ +/** `a ** b ** c ** ...` */ export const power = (a: MaybeAccessor, ...b: MaybeAccessor[]) => createMemo(() => _.power(access(a), ...accessArray(b))); -/** Signal Builder: `Math.round()` */ +/** Reactive `Math.round()`. */ export const round = (a: MaybeAccessor) => createMemo(() => Math.round(access(a))); -/** Signal Builder: `Math.ceil()` */ + +/** Reactive `Math.ceil()`. */ export const ceil = (a: MaybeAccessor) => createMemo(() => Math.ceil(access(a))); -/** Signal Builder: `Math.floor()` */ + +/** Reactive `Math.floor()`. */ export const floor = (a: MaybeAccessor) => createMemo(() => Math.floor(access(a))); /** - * Signal builder: clamps a number value between two other values + * Reactively clamps a value between `min` and `max` (inclusive). + * @example + * const [val, setVal] = createSignal(15); + * const clamped = clamp(val, 0, 10); + * clamped(); // => 10 */ export const clamp = ( value: MaybeAccessor, diff --git a/packages/signal-builders/src/object.ts b/packages/signal-builders/src/object.ts index b32888495..4462ab08e 100644 --- a/packages/signal-builders/src/object.ts +++ b/packages/signal-builders/src/object.ts @@ -9,7 +9,10 @@ import { type Accessor, createMemo } from "solid-js"; import * as _ from "@solid-primitives/utils/immutable"; /** - * Signal Builder: Create a new subset object without the provided keys + * Reactively creates a new object with the specified keys omitted. + * @example + * const [obj, setObj] = createSignal({ a: 1, b: 2, c: 3 }); + * omit(obj, "b")(); // => { a: 1, c: 3 } */ export const omit = < A extends MaybeAccessor, @@ -21,7 +24,10 @@ export const omit = < ): Accessor> => createMemo(() => _.omit(access(object), ...accessArray(keys))); /** - * Signal Builder: Create a new subset object with only the provided keys + * Reactively creates a new object containing only the specified keys. + * @example + * const [obj, setObj] = createSignal({ a: 1, b: 2, c: 3 }); + * pick(obj, "a", "c")(); // => { a: 1, c: 3 } */ export const pick = < A extends MaybeAccessor, @@ -33,7 +39,11 @@ export const pick = < ): Accessor> => createMemo(() => _.pick(access(object), ...accessArray(keys))); /** - * Signal Builder: Get a single property value of an object by specifying a path to it. + * Reactively reads a property at a key path. Supports up to 6 levels deep with full type inference. + * Any argument can be a signal or a plain value. + * @example + * const [user, setUser] = createSignal({ profile: { name: "Alice" } }); + * get(user, "profile", "name")(); // => "Alice" */ export function get( obj: MaybeAccessor, @@ -101,11 +111,16 @@ export function get< k6: MaybeAccessor, ): Accessor; export function get(obj: any, ...keys: any[]) { - return _.get(access(obj), ...(accessArray(keys) as [any, any])); + return createMemo(() => _.get(access(obj), ...(accessArray(keys) as [any, any]))); } /** - * Signal Builder: Merges multiple objects into a single one. Only the first level of properties is merged. + * Reactively shallow-merges objects — later arguments override earlier ones for matching keys. + * Only top-level properties are combined; nested objects are replaced, not merged. + * @example + * const [defaults, setDefaults] = createSignal({ color: "blue", size: 12 }); + * const [overrides, setOverrides] = createSignal({ size: 16 }); + * merge(defaults, overrides)(); // => { color: "blue", size: 16 } */ export function merge( a: MaybeAccessor, @@ -151,5 +166,5 @@ export function merge< f: MaybeAccessor, ): Accessor, C>, D>, E>, F>>; export function merge(...objects: object[]) { - return _.merge(...(accessArray(objects) as [object, object])); + return createMemo(() => _.merge(...(accessArray(objects) as [object, object]))); } diff --git a/packages/signal-builders/src/string.ts b/packages/signal-builders/src/string.ts index bfe676940..2ca8d98ce 100644 --- a/packages/signal-builders/src/string.ts +++ b/packages/signal-builders/src/string.ts @@ -1,23 +1,27 @@ import { access, type MaybeAccessor } from "@solid-primitives/utils"; -import { type Accessor, createMemo, on } from "solid-js"; +import { type Accessor, createMemo } from "solid-js"; -/** - * Signal builder: `String.prototype.toLowerCase()` - */ +/** Reactive `String.prototype.toLowerCase()`. */ export const lowercase = (string: Accessor) => createMemo(() => string().toLowerCase()); -/** - * Signal builder: `String.prototype.toUpperCase()` - */ + +/** Reactive `String.prototype.toUpperCase()`. */ export const uppercase = (string: Accessor) => createMemo(() => string().toUpperCase()); + /** - * Signal builder: capitalize a string input + * Reactively capitalizes a string — uppercases the first character, lowercases the rest. + * @example + * const [s, setS] = createSignal("hELLO wORLD"); + * capitalize(s)(); // => "Hello world" */ export const capitalize = (string: Accessor) => - createMemo(on(string, s => s[0]!.toUpperCase() + s.substring(1).toLowerCase())); + createMemo(() => { const s = string(); return s.length === 0 ? s : s[0]!.toUpperCase() + s.substring(1).toLowerCase(); }); + /** - * Signal builder: `String.prototype.substring()` - * @param start The zero-based index number indicating the beginning of the substring. - * @param end Zero-based index number indicating the end of the substring. The substring includes the characters up to, but not including, the character indicated by end. If end is omitted, the characters from start through the end of the original string are returned. + * Reactive `String.prototype.substring()`. + * @param end Exclusive upper bound; omit to extend through the end of the string. + * @example + * const [s, setS] = createSignal("Hello, world!"); + * substring(s, 7, 12)(); // => "world" */ export const substring = ( string: MaybeAccessor, @@ -25,14 +29,13 @@ export const substring = ( end?: MaybeAccessor, ) => createMemo(() => access(string).substring(access(start), access(end))); -// a string primitive harvested from @lxsmnsyc's solid-use: /** - * Signal builder: Create reactive string templates + * Reactive tagged template literal — interpolated values can be signals or plain values. * @example * const [greeting, setGreeting] = createSignal('Hello'); * const [target, setTarget] = createSignal('Solid'); * const message = template`${greeting}, ${target}!`; - * message() // => Hello, Solid! + * message() // => "Hello, Solid!" */ export function template( strings: TemplateStringsArray, diff --git a/packages/signal-builders/src/update.ts b/packages/signal-builders/src/update.ts index 5eea48323..99a0d3cc9 100644 --- a/packages/signal-builders/src/update.ts +++ b/packages/signal-builders/src/update.ts @@ -95,7 +95,8 @@ export type Update = { }; /** - * Signal Builder: Change single value in an object by key. Allows accessign nested objects by passing multiple keys. + * Immutably sets a value at a key path. The last argument is either a new value or a setter + * function `(prev) => next`. Pass multiple keys to reach into nested objects. */ export const update: Update = (...args: any[]) => createMemo(() => _update(...(accessArray(args) as [any, any, any]))); diff --git a/packages/signal-builders/stories/signal-builders.stories.tsx b/packages/signal-builders/stories/signal-builders.stories.tsx new file mode 100644 index 000000000..f20bc9535 --- /dev/null +++ b/packages/signal-builders/stories/signal-builders.stories.tsx @@ -0,0 +1,363 @@ +import { createSignal, For, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { + push, + filter, + sort, + capitalize, + lowercase, + template, + multiply, + divide, + substract, + round, + clamp, + update, + merge, + get, + int, + add, +} from "../src/index.js"; +import readme from "../README.md?raw"; +import { + Button, + ButtonRow, + Container, + Section, + Separator, + StatRow, + ValueDisplay, + Badge, + TextField, + colors, + font, +} from "../../../.storybook/ui/index.js"; +import { compare } from "@solid-primitives/utils"; + +const meta = preview.meta({ + title: "Reactivity/Signal Builders", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +// ── Story 1: Array pipeline ─────────────────────────────────────────────────── + +export const ArrayPipeline = meta.story({ + name: "Sort & filter pipeline", + parameters: { + docs: { + description: { + story: + "Array builders compose like pipes — `filter` keeps numbers at or above the threshold, `sort` orders the result. The `.removed` count is attached to the return value of `filter`, giving a tally of how many items were dropped.", + }, + }, + }, + render: () => { + const POOL = [7, 3, 9, 1, 5, 8, 2, 6, 4]; + const [list, setList] = createSignal([7, 3, 9, 1, 5]); + const [min, setMin] = createSignal(3); + + // Pipeline: filter → sort + const filtered = filter(list, (n: number) => n >= min()); + const sorted = sort(filtered, compare); + + return ( + +
+ + + {n => ( + + )} + + +
+
+ + + {t => ( + + )} + + +
+ +
+ + {n => {n}} + + + no items + +
+ + +
+ ); + }, +}); + +// ── Story 2: String pipeline ────────────────────────────────────────────────── + +export const StringPipeline = meta.story({ + name: "Name badge formatter", + parameters: { + docs: { + description: { + story: + "`capitalize` normalizes each name part, then `template` assembles them into a display name. A second pipeline wraps `template` with `lowercase` to derive a username slug — two reactive outputs from the same two source signals.", + }, + }, + }, + render: () => { + const [first, setFirst] = createSignal("jane"); + const [last, setLast] = createSignal("doe"); + + // Pipeline A: capitalize each part → assemble + const displayName = template`${capitalize(first)} ${capitalize(last)}`; + // Pipeline B: lowercase both → dot-separated slug + const username = lowercase(template`${first}.${last}`); + + return ( + + + + + + + + ); + }, +}); + +// ── Story 3: Number pipeline ────────────────────────────────────────────────── + +export const NumberPipeline = meta.story({ + name: "Price calculator", + parameters: { + docs: { + description: { + story: + "Number builders form an arithmetic pipeline — `multiply` computes the subtotal, `divide` calculates the discount fraction, `round` snaps it to whole dollars, `substract` applies it, and `clamp` enforces a budget ceiling. Each node is a separate `createMemo` that only re-evaluates when its own inputs change.", + }, + }, + }, + render: () => { + const MAX = 50; + const [qty, setQty] = createSignal(3); + const [price, setPrice] = createSignal(12); + const [discPct, setDiscPct] = createSignal(20); + + // Pipeline: multiply → divide/round (discount amt) → substract → clamp + const subtotal = multiply(qty, price); + const discount = round(divide(multiply(subtotal, discPct), 100)); + const unclamped = substract(subtotal, discount); + const total = clamp(unclamped, 0, MAX); + + return ( + +
+ + + {n => ( + + )} + + +
+
+ + + {n => ( + + )} + + +
+
+ + + {n => ( + + )} + + +
+ + + + + MAX}> + Capped at ${MAX} + +
+ ); + }, +}); + +// ── Story 4: Object pipeline ────────────────────────────────────────────────── + +export const ObjectPipeline = meta.story({ + name: "Profile update chain", + parameters: { + docs: { + description: { + story: + "`update` immutably sets a nested field, `merge` overlays additional keys on top of the result, and `get` reads a typed key path out of the final object. Editing the last name or switching the role flows through the entire chain in one reactive flush.", + }, + }, + }, + render: () => { + const base = { name: { first: "Jane", last: "" }, role: "viewer" }; + const [lastName, setLastName] = createSignal("Doe"); + const [role, setRole] = createSignal("editor"); + + // Pipeline: update nested key → merge in role override → read back via get + const withLast = update(() => base, "name", "last", lastName); + const merged = merge(withLast, () => ({ role: role() })); + const fullName = template`${get(merged, "name", "first")} ${get(merged, "name", "last")}`; + const currentRole = get(merged, "role"); + + return ( + + +
+ + {(["viewer", "editor", "admin"]).map(r => ( + + ))} + +
+ + + +
+ ); + }, +}); + +// ── Story 5: Cross-category chain ───────────────────────────────────────────── + +export const ConvertAndCompute = meta.story({ + name: "Text input → clamped score", + parameters: { + docs: { + description: { + story: + "Builders from different categories compose seamlessly: `int` parses the text input into a number, `multiply` scales it, `add` applies a flat bonus, and `clamp` constrains the result. The full chain re-evaluates any time a single input changes.", + }, + }, + }, + render: () => { + const [raw, setRaw] = createSignal("20"); + const [multiplier, setMultiplier] = createSignal(3); + const [cap, setCap] = createSignal(100); + + // Cross-category pipeline: int (convert) → multiply → add → clamp (number) + const base = multiply(int(raw), multiplier); + const withBonus = add(base, 10); // flat +10 bonus; add starts from 0 so 0+base+10 = base+10 + const score = clamp(withBonus, 0, cap); + + return ( + + +
+ + + {n => ( + + )} + + +
+
+ + + {n => ( + + )} + + +
+ + + + + + cap()}> + Capped at {cap()} + +
+ ); + }, +}); diff --git a/packages/signal-builders/test/index.test.ts b/packages/signal-builders/test/index.test.ts index dc293e6d8..e988b096a 100644 --- a/packages/signal-builders/test/index.test.ts +++ b/packages/signal-builders/test/index.test.ts @@ -1,48 +1,1061 @@ import { describe, it, expect } from "vitest"; -import { filterInstance, filterOutInstance, push, sort, template } from "../src/index.js"; -import { createRoot, createSignal } from "solid-js"; +import { + string, + float, + int, + join, + lowercase, + uppercase, + capitalize, + substring, + template, + add, + substract, + multiply, + divide, + power, + round, + ceil, + floor, + clamp, + push, + drop, + dropRight, + filter, + filterOut, + sort, + map, + slice, + splice, + remove, + removeItems, + concat, + flatten, + filterInstance, + filterOutInstance, + omit, + pick, + get, + merge, + update, +} from "../src/index.js"; +import { createRoot, createSignal, flush } from "solid-js"; import { compare } from "@solid-primitives/utils"; -describe("signal builders", () => { - it("push + sort", () => +// ─── CONVERT ──────────────────────────────────────────────────────────────── + +describe("string()", () => { + it("coerces a number to string", () => + createRoot(dispose => { + expect(string(42)()).toBe("42"); + dispose(); + })); + + it("coerces a boolean to string", () => + createRoot(dispose => { + expect(string(true)()).toBe("true"); + dispose(); + })); + + it("updates when signal changes", () => { + const [val, setVal] = createSignal(1); + const { result, dispose } = createRoot(d => ({ result: string(val), dispose: d })); + expect(result()).toBe("1"); + setVal(null); + flush(); + expect(result()).toBe("null"); + dispose(); + }); +}); + +describe("float()", () => { + it("parses a float string", () => + createRoot(dispose => { + expect(float(() => "3.14")()).toBe(3.14); + dispose(); + })); + + it("returns NaN for a non-numeric string", () => + createRoot(dispose => { + expect(float(() => "abc")()).toBeNaN(); + dispose(); + })); + + it("updates when signal changes", () => { + const [val, setVal] = createSignal("1.5"); + const { result, dispose } = createRoot(d => ({ result: float(val), dispose: d })); + expect(result()).toBe(1.5); + setVal("2.75"); + flush(); + expect(result()).toBe(2.75); + dispose(); + }); +}); + +describe("int()", () => { + it("parses a decimal integer", () => + createRoot(dispose => { + expect(int(() => "42")()).toBe(42); + dispose(); + })); + + it("truncates fractional digits", () => + createRoot(dispose => { + expect(int(() => "3.9")()).toBe(3); + dispose(); + })); + + it("parses hex with radix 16", () => + createRoot(dispose => { + expect(int(() => "ff", 16)()).toBe(255); + dispose(); + })); + + it("updates when signal changes", () => { + const [val, setVal] = createSignal("10"); + const { result, dispose } = createRoot(d => ({ result: int(val), dispose: d })); + expect(result()).toBe(10); + setVal("99"); + flush(); + expect(result()).toBe(99); + dispose(); + }); +}); + +describe("join()", () => { + it("joins with a separator", () => + createRoot(dispose => { + expect(join(() => ["a", "b", "c"], ",")()).toBe("a,b,c"); + dispose(); + })); + + it("defaults to comma when separator is omitted", () => + createRoot(dispose => { + expect(join(() => [1, 2, 3])()).toBe("1,2,3"); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal(["x", "y"]); + const { result, dispose } = createRoot(d => ({ result: join(list, "-"), dispose: d })); + expect(result()).toBe("x-y"); + setList(["a", "b", "c"]); + flush(); + expect(result()).toBe("a-b-c"); + dispose(); + }); + + it("updates when separator signal changes", () => { + const [sep, setSep] = createSignal(","); + const { result, dispose } = createRoot(d => ({ + result: join(() => ["a", "b"], sep), + dispose: d, + })); + expect(result()).toBe("a,b"); + setSep(" | "); + flush(); + expect(result()).toBe("a | b"); + dispose(); + }); +}); + +// ─── STRING ───────────────────────────────────────────────────────────────── + +describe("lowercase()", () => { + it("lowercases a string", () => + createRoot(dispose => { + expect(lowercase(() => "HELLO WORLD")()).toBe("hello world"); + dispose(); + })); + + it("updates when signal changes", () => { + const [s, setS] = createSignal("HELLO"); + const { result, dispose } = createRoot(d => ({ result: lowercase(s), dispose: d })); + expect(result()).toBe("hello"); + setS("WORLD"); + flush(); + expect(result()).toBe("world"); + dispose(); + }); +}); + +describe("uppercase()", () => { + it("uppercases a string", () => + createRoot(dispose => { + expect(uppercase(() => "hello world")()).toBe("HELLO WORLD"); + dispose(); + })); + + it("updates when signal changes", () => { + const [s, setS] = createSignal("hello"); + const { result, dispose } = createRoot(d => ({ result: uppercase(s), dispose: d })); + expect(result()).toBe("HELLO"); + setS("world"); + flush(); + expect(result()).toBe("WORLD"); + dispose(); + }); +}); + +describe("capitalize()", () => { + it("uppercases first char and lowercases the rest", () => + createRoot(dispose => { + expect(capitalize(() => "hELLO wORLD")()).toBe("Hello world"); + dispose(); + })); + + it("returns empty string unchanged", () => + createRoot(dispose => { + expect(capitalize(() => "")()).toBe(""); + dispose(); + })); + + it("updates when signal changes", () => { + const [s, setS] = createSignal("fOO"); + const { result, dispose } = createRoot(d => ({ result: capitalize(s), dispose: d })); + expect(result()).toBe("Foo"); + setS("bAR"); + flush(); + expect(result()).toBe("Bar"); + dispose(); + }); +}); + +describe("substring()", () => { + it("extracts a range with start and end", () => + createRoot(dispose => { + expect(substring(() => "Hello, world!", () => 7, () => 12)()).toBe("world"); + dispose(); + })); + + it("extracts to end when end is omitted", () => + createRoot(dispose => { + expect(substring(() => "Hello, world!", () => 7)()).toBe("world!"); + dispose(); + })); + + it("updates when string signal changes", () => { + const [s, setS] = createSignal("abcdef"); + const { result, dispose } = createRoot(d => ({ + result: substring(s, () => 1, () => 4), + dispose: d, + })); + expect(result()).toBe("bcd"); + setS("xyz123"); + flush(); + expect(result()).toBe("yz1"); + dispose(); + }); +}); + +describe("template`...`", () => { + it("interpolates plain values", () => + createRoot(dispose => { + expect(template`Hello, ${"World"}!`()).toBe("Hello, World!"); + dispose(); + })); + + it("interpolates signals and updates reactively", () => { + const [name, setName] = createSignal("Solid"); + const [ver, setVer] = createSignal(2); + const { result, dispose } = createRoot(d => ({ + result: template`${name} v${ver}`, + dispose: d, + })); + expect(result()).toBe("Solid v2"); + setName("World"); + setVer(3); + flush(); + expect(result()).toBe("World v3"); + dispose(); + }); +}); + +// ─── NUMBER ───────────────────────────────────────────────────────────────── + +describe("add()", () => { + it("adds two numbers", () => + createRoot(dispose => { + expect(add(() => 2, () => 3)()).toBe(5); + dispose(); + })); + + it("adds multiple numbers", () => + createRoot(dispose => { + expect(add(() => 1, () => 2, () => 3, () => 4)()).toBe(10); + dispose(); + })); + + it("coerces to string when a string arg is present (starts from 0)", () => + createRoot(dispose => { + // add() starts accumulating from 0, so number args sum first, then string coercion kicks in + expect(add(() => 5, () => " items")()).toBe("5 items"); + dispose(); + })); + + it("updates when signal changes", () => { + const [a, setA] = createSignal(1); + const [b, setB] = createSignal(2); + const { result, dispose } = createRoot(d => ({ result: add(a, b), dispose: d })); + expect(result()).toBe(3); + setA(10); + flush(); + expect(result()).toBe(12); + dispose(); + }); +}); + +describe("substract()", () => { + it("subtracts two values", () => + createRoot(dispose => { + expect(substract(() => 10, () => 3)()).toBe(7); + dispose(); + })); + + it("subtracts multiple values left-to-right", () => + createRoot(dispose => { + expect(substract(() => 10, () => 3, () => 2)()).toBe(5); + dispose(); + })); + + it("updates when signal changes", () => { + const [a, setA] = createSignal(10); + const { result, dispose } = createRoot(d => ({ + result: substract(a, () => 4), + dispose: d, + })); + expect(result()).toBe(6); + setA(20); + flush(); + expect(result()).toBe(16); + dispose(); + }); +}); + +describe("multiply()", () => { + it("multiplies two values", () => + createRoot(dispose => { + expect(multiply(() => 3, () => 4)()).toBe(12); + dispose(); + })); + + it("multiplies multiple values", () => + createRoot(dispose => { + expect(multiply(() => 2, () => 3, () => 4)()).toBe(24); + dispose(); + })); + + it("updates when signal changes", () => { + const [a, setA] = createSignal(3); + const { result, dispose } = createRoot(d => ({ result: multiply(a, () => 5), dispose: d })); + expect(result()).toBe(15); + setA(2); + flush(); + expect(result()).toBe(10); + dispose(); + }); +}); + +describe("divide()", () => { + it("divides two values", () => + createRoot(dispose => { + expect(divide(() => 12, () => 4)()).toBe(3); + dispose(); + })); + + it("divides multiple values left-to-right", () => + createRoot(dispose => { + expect(divide(() => 100, () => 5, () => 4)()).toBe(5); + dispose(); + })); + + it("updates when signal changes", () => { + const [a, setA] = createSignal(20); + const { result, dispose } = createRoot(d => ({ result: divide(a, () => 4), dispose: d })); + expect(result()).toBe(5); + setA(40); + flush(); + expect(result()).toBe(10); + dispose(); + }); +}); + +describe("power()", () => { + it("raises to a power", () => + createRoot(dispose => { + expect(power(() => 2, () => 10)()).toBe(1024); + dispose(); + })); + + it("applies exponent chain left-to-right: (2**3)**2 = 64", () => + createRoot(dispose => { + expect(power(() => 2, () => 3, () => 2)()).toBe(64); + dispose(); + })); + + it("updates when signal changes", () => { + const [exp, setExp] = createSignal(2); + const { result, dispose } = createRoot(d => ({ result: power(() => 3, exp), dispose: d })); + expect(result()).toBe(9); + setExp(3); + flush(); + expect(result()).toBe(27); + dispose(); + }); +}); + +describe("round()", () => { + it("rounds to nearest integer", () => + createRoot(dispose => { + expect(round(() => 2.7)()).toBe(3); + expect(round(() => 2.3)()).toBe(2); + dispose(); + })); + + it("updates when signal changes", () => { + const [v, setV] = createSignal(2.4); + const { result, dispose } = createRoot(d => ({ result: round(v), dispose: d })); + expect(result()).toBe(2); + setV(2.6); + flush(); + expect(result()).toBe(3); + dispose(); + }); +}); + +describe("ceil()", () => { + it("rounds up to next integer", () => + createRoot(dispose => { + expect(ceil(() => 2.1)()).toBe(3); + expect(ceil(() => 3.0)()).toBe(3); + dispose(); + })); + + it("updates when signal changes", () => { + const [v, setV] = createSignal(1.1); + const { result, dispose } = createRoot(d => ({ result: ceil(v), dispose: d })); + expect(result()).toBe(2); + setV(3.2); + flush(); + expect(result()).toBe(4); + dispose(); + }); +}); + +describe("floor()", () => { + it("rounds down to previous integer", () => + createRoot(dispose => { + expect(floor(() => 2.9)()).toBe(2); + expect(floor(() => 3.0)()).toBe(3); + dispose(); + })); + + it("updates when signal changes", () => { + const [v, setV] = createSignal(3.9); + const { result, dispose } = createRoot(d => ({ result: floor(v), dispose: d })); + expect(result()).toBe(3); + setV(4.1); + flush(); + expect(result()).toBe(4); + dispose(); + }); +}); + +describe("clamp()", () => { + it("clamps a value above max to max", () => + createRoot(dispose => { + expect(clamp(() => 15, () => 0, () => 10)()).toBe(10); + dispose(); + })); + + it("clamps a value below min to min", () => + createRoot(dispose => { + expect(clamp(() => -5, () => 0, () => 10)()).toBe(0); + dispose(); + })); + + it("keeps a value already within range unchanged", () => + createRoot(dispose => { + expect(clamp(() => 7, () => 0, () => 10)()).toBe(7); + dispose(); + })); + + it("updates when value signal changes", () => { + const [val, setVal] = createSignal(5); + const { result, dispose } = createRoot(d => ({ + result: clamp(val, () => 0, () => 10), + dispose: d, + })); + expect(result()).toBe(5); + setVal(15); + flush(); + expect(result()).toBe(10); + setVal(-3); + flush(); + expect(result()).toBe(0); + dispose(); + }); + + it("updates when max signal changes", () => { + const [max, setMax] = createSignal(10); + const { result, dispose } = createRoot(d => ({ + result: clamp(() => 5, () => 0, max), + dispose: d, + })); + expect(result()).toBe(5); + setMax(3); // value 5 now above new max + flush(); + expect(result()).toBe(3); + dispose(); + }); + + it("updates when min signal changes", () => { + const [min, setMin] = createSignal(0); + const { result, dispose } = createRoot(d => ({ + result: clamp(() => 5, min, () => 10), + dispose: d, + })); + expect(result()).toBe(5); + setMin(8); // value 5 now below new min + flush(); + expect(result()).toBe(8); + dispose(); + }); +}); + +// ─── ARRAY ────────────────────────────────────────────────────────────────── + +describe("push()", () => { + it("appends items to the array", () => + createRoot(dispose => { + expect(push(() => [1, 2], () => 3, () => 4)()).toEqual([1, 2, 3, 4]); + dispose(); + })); + + it("does not mutate the original array", () => + createRoot(dispose => { + const original = [1, 2, 3]; + push(() => original, () => 4)(); + expect(original).toEqual([1, 2, 3]); + dispose(); + })); + + it("updates when list or item signals change", () => { + const [list, setList] = createSignal([4, 3, 2, 1]); + const [item, setItem] = createSignal(0); + const { result, dispose } = createRoot(d => ({ + result: sort(push(list, item), compare), + dispose: d, + })); + expect(result()).toEqual([0, 1, 2, 3, 4]); + setList([1, 2, 3, 5, 4]); + setItem(1); + flush(); + expect(result()).toEqual([1, 1, 2, 3, 4, 5]); + dispose(); + }); +}); + +describe("drop()", () => { + it("drops the first element by default", () => + createRoot(dispose => { + expect(drop(() => [1, 2, 3, 4])()).toEqual([2, 3, 4]); + dispose(); + })); + + it("drops n elements from the front", () => createRoot(dispose => { - const [list, setList] = createSignal([4, 3, 2, 1]); - const [item, setItem] = createSignal(0); - const res = sort(push(list, item), compare); - expect(res()).toEqual([0, 1, 2, 3, 4]); - setList([1, 2, 3, 5, 4]); - setItem(1); - expect(res()).toEqual([1, 1, 2, 3, 4, 5]); + expect(drop(() => [1, 2, 3, 4], 2)()).toEqual([3, 4]); dispose(); })); - it("filter instances", () => + it("updates when list signal changes", () => { + const [list, setList] = createSignal([1, 2, 3, 4, 5]); + const { result, dispose } = createRoot(d => ({ result: drop(list, 2), dispose: d })); + expect(result()).toEqual([3, 4, 5]); + setList([10, 20, 30]); + flush(); + expect(result()).toEqual([30]); + dispose(); + }); +}); + +describe("dropRight()", () => { + it("drops the last element by default", () => + createRoot(dispose => { + expect(dropRight(() => [1, 2, 3, 4])()).toEqual([1, 2, 3]); + dispose(); + })); + + it("drops n elements from the end", () => + createRoot(dispose => { + expect(dropRight(() => [1, 2, 3, 4], 2)()).toEqual([1, 2]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([1, 2, 3, 4, 5]); + const { result, dispose } = createRoot(d => ({ result: dropRight(list, 2), dispose: d })); + expect(result()).toEqual([1, 2, 3]); + setList([10, 20, 30]); + flush(); + expect(result()).toEqual([10]); + dispose(); + }); +}); + +describe("filter()", () => { + // The underlying immutable filter attaches a `.removed` count to the returned array. + // Spread into a plain array before comparing to avoid property mismatch in toEqual. + it("keeps items that match a predicate", () => + createRoot(dispose => { + const result = filter(() => [1, 2, 3, 4, 5], n => n % 2 === 0)(); + expect([...result]).toEqual([2, 4]); + expect(result.removed).toBe(3); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([1, 2, 3, 4, 5, 6]); + const { result, dispose } = createRoot(d => ({ + result: filter(list, n => n > 3), + dispose: d, + })); + expect([...result()]).toEqual([4, 5, 6]); + setList([1, 2, 10, 20]); + flush(); + expect([...result()]).toEqual([10, 20]); + dispose(); + }); +}); + +describe("filterOut()", () => { + // filterOut delegates to filter, so it also attaches a `.removed` property. + it("removes all occurrences of the item", () => + createRoot(dispose => { + const result = filterOut(() => [1, 2, 3, 2, 1], () => 2)(); + expect([...result]).toEqual([1, 3, 1]); + expect(result.removed).toBe(2); + dispose(); + })); + + it("updates when item signal changes", () => { + const [item, setItem] = createSignal(2); + const { result, dispose } = createRoot(d => ({ + result: filterOut(() => [1, 2, 3, 2, 1], item), + dispose: d, + })); + expect([...result()]).toEqual([1, 3, 1]); + setItem(1); + flush(); + expect([...result()]).toEqual([2, 3, 2]); + dispose(); + }); +}); + +describe("sort()", () => { + it("sorts with a compareFn", () => + createRoot(dispose => { + expect(sort(() => [3, 1, 4, 1, 5], compare)()).toEqual([1, 1, 3, 4, 5]); + dispose(); + })); + + it("does not mutate the original array", () => + createRoot(dispose => { + const original = [3, 1, 2]; + sort(() => original, compare)(); + expect(original).toEqual([3, 1, 2]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([3, 1, 2]); + const { result, dispose } = createRoot(d => ({ result: sort(list, compare), dispose: d })); + expect(result()).toEqual([1, 2, 3]); + setList([5, 4, 3, 2, 1]); + flush(); + expect(result()).toEqual([1, 2, 3, 4, 5]); + dispose(); + }); +}); + +describe("map()", () => { + it("maps items to new values", () => + createRoot(dispose => { + expect(map(() => [1, 2, 3], n => n * 2)()).toEqual([2, 4, 6]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([1, 2, 3]); + const { result, dispose } = createRoot(d => ({ + result: map(list, n => n * 10), + dispose: d, + })); + expect(result()).toEqual([10, 20, 30]); + setList([4, 5]); + flush(); + expect(result()).toEqual([40, 50]); + dispose(); + }); +}); + +describe("slice()", () => { + it("extracts a range with start and end", () => + createRoot(dispose => { + expect(slice(() => [1, 2, 3, 4, 5], 1, 4)()).toEqual([2, 3, 4]); + dispose(); + })); + + it("slices to end when end is omitted", () => + createRoot(dispose => { + expect(slice(() => [1, 2, 3, 4, 5], 2)()).toEqual([3, 4, 5]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([10, 20, 30, 40, 50]); + const { result, dispose } = createRoot(d => ({ result: slice(list, 1, 3), dispose: d })); + expect(result()).toEqual([20, 30]); + setList([1, 2, 3, 4, 5]); + flush(); + expect(result()).toEqual([2, 3]); + dispose(); + }); +}); + +describe("splice()", () => { + it("removes elements at a position", () => + createRoot(dispose => { + expect(splice(() => [1, 2, 3, 4, 5], () => 1, () => 2)()).toEqual([1, 4, 5]); + dispose(); + })); + + it("replaces elements", () => + createRoot(dispose => { + expect(splice(() => [1, 2, 3], () => 1, () => 1, () => 99)()).toEqual([1, 99, 3]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([1, 2, 3, 4, 5]); + const { result, dispose } = createRoot(d => ({ + result: splice(list, () => 0, () => 2), + dispose: d, + })); + expect(result()).toEqual([3, 4, 5]); + setList([10, 20, 30, 40]); + flush(); + expect(result()).toEqual([30, 40]); + dispose(); + }); +}); + +describe("remove()", () => { + it("removes the first occurrence of the item", () => + createRoot(dispose => { + expect(remove(() => [1, 2, 3, 2, 1], () => 2)()).toEqual([1, 3, 2, 1]); + dispose(); + })); + + it("updates when item signal changes", () => { + const [item, setItem] = createSignal(1); + const { result, dispose } = createRoot(d => ({ + result: remove(() => [1, 2, 1, 3], item), + dispose: d, + })); + expect(result()).toEqual([2, 1, 3]); + setItem(2); + flush(); + expect(result()).toEqual([1, 1, 3]); + dispose(); + }); +}); + +describe("removeItems()", () => { + // removeItems removes the FIRST occurrence of each provided item (not all occurrences). + // Once a provided item is matched once, subsequent occurrences in the list are kept. + it("removes the first occurrence of each provided item", () => + createRoot(dispose => { + expect(removeItems(() => [1, 2, 3, 2, 1], () => 1, () => 2)()).toEqual([3, 2, 1]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([1, 2, 3, 4, 5]); + const { result, dispose } = createRoot(d => ({ + result: removeItems(list, () => 2, () => 4), + dispose: d, + })); + expect(result()).toEqual([1, 3, 5]); + setList([2, 2, 4, 6]); + flush(); + expect(result()).toEqual([2, 6]); // first 2 removed, first 4 removed; second 2 kept + dispose(); + }); +}); + +describe("concat()", () => { + it("concatenates two arrays", () => + createRoot(dispose => { + expect(concat(() => [1, 2], () => [3, 4])()).toEqual([1, 2, 3, 4]); + dispose(); + })); + + it("concatenates more than two arrays", () => + createRoot(dispose => { + expect(concat(() => [1], () => [2], () => [3])()).toEqual([1, 2, 3]); + dispose(); + })); + + it("updates when any signal changes", () => { + const [a, setA] = createSignal([1, 2]); + const [b, setB] = createSignal([3, 4]); + const { result, dispose } = createRoot(d => ({ result: concat(a, b), dispose: d })); + expect(result()).toEqual([1, 2, 3, 4]); + setA([10]); + flush(); + expect(result()).toEqual([10, 3, 4]); + setB([20, 30]); + flush(); + expect(result()).toEqual([10, 20, 30]); + dispose(); + }); +}); + +describe("flatten()", () => { + it("flattens one level deep", () => + createRoot(dispose => { + expect(flatten(() => [[1, 2], [3, 4]])()).toEqual([1, 2, 3, 4]); + dispose(); + })); + + it("flattens deeply (recursive, not just one level)", () => + createRoot(dispose => { + expect(flatten(() => [[1, [2]], [3]])()).toEqual([1, 2, 3]); + dispose(); + })); + + it("updates when list signal changes", () => { + const [list, setList] = createSignal([[1, 2], [3, 4]]); + const { result, dispose } = createRoot(d => ({ result: flatten(list), dispose: d })); + expect(result()).toEqual([1, 2, 3, 4]); + setList([[5, 6], [7]]); + flush(); + expect(result()).toEqual([5, 6, 7]); + dispose(); + }); +}); + +describe("filterInstance()", () => { + it("keeps only instances of the specified class", () => + createRoot(dispose => { + const el = document.createElement("div"); + const list = [1, "hello", el, "world"]; + expect(filterInstance(() => list, Element)()).toEqual([el]); + dispose(); + })); + + it("keeps instances of any of multiple classes", () => + createRoot(dispose => { + const num = 12345; + const el = document.createElement("div"); + const svg = document.createElement("svg"); + const list = [num, "hello", el, svg, "world", null, undefined, NaN]; + const result = filterInstance(() => list, Element, Number)(); + expect(result).toEqual([num, el, svg]); + dispose(); + })); + + it("does not mutate the original array", () => + createRoot(dispose => { + const list = [1, "two", 3]; + const copy = [...list]; + filterInstance(() => list, Number)(); + expect(list).toEqual(copy); + dispose(); + })); +}); + +describe("filterOutInstance()", () => { + it("removes instances of the specified class", () => + createRoot(dispose => { + const el = document.createElement("div"); + const list = [1, "hello", el, 2, "world"]; + expect(filterOutInstance(() => list, Element)()).toEqual([1, "hello", 2, "world"]); + dispose(); + })); + + it("removes instances of any of multiple classes", () => createRoot(dispose => { const num = 12345; - const string = "hello"; const el = document.createElement("div"); const svg = document.createElement("svg"); - const list = [num, string, el, svg, string, null, undefined, NaN]; - const copy = [num, string, el, svg, string, null, undefined, NaN]; + const list = [num, "hello", el, svg, "world", null, undefined, NaN]; + const result = filterOutInstance(() => list, Element, Number)(); + expect(result).toEqual(["hello", "world"]); + dispose(); + })); +}); + +// ─── OBJECT ───────────────────────────────────────────────────────────────── + +describe("omit()", () => { + it("omits a single key", () => + createRoot(dispose => { + expect(omit(() => ({ a: 1, b: 2, c: 3 }), "b")()).toEqual({ a: 1, c: 3 }); + dispose(); + })); + + it("omits multiple keys", () => + createRoot(dispose => { + expect(omit(() => ({ a: 1, b: 2, c: 3, d: 4 }), "a", "c")()).toEqual({ b: 2, d: 4 }); + dispose(); + })); + + it("updates when object signal changes", () => { + const [obj, setObj] = createSignal({ x: 1, y: 2, z: 3 }); + const { result, dispose } = createRoot(d => ({ result: omit(obj, "y"), dispose: d })); + expect(result()).toEqual({ x: 1, z: 3 }); + setObj({ x: 10, y: 20, z: 30 }); + flush(); + expect(result()).toEqual({ x: 10, z: 30 }); + dispose(); + }); +}); + +describe("pick()", () => { + it("picks specified keys", () => + createRoot(dispose => { + expect(pick(() => ({ a: 1, b: 2, c: 3 }), "a", "c")()).toEqual({ a: 1, c: 3 }); + dispose(); + })); + + it("updates when object signal changes", () => { + const [obj, setObj] = createSignal({ x: 1, y: 2, z: 3 }); + const { result, dispose } = createRoot(d => ({ result: pick(obj, "x", "z"), dispose: d })); + expect(result()).toEqual({ x: 1, z: 3 }); + setObj({ x: 10, y: 20, z: 30 }); + flush(); + expect(result()).toEqual({ x: 10, z: 30 }); + dispose(); + }); +}); + +describe("get()", () => { + it("reads a top-level key", () => + createRoot(dispose => { + expect(get(() => ({ a: 1, b: 2 }), "a")()).toBe(1); + dispose(); + })); + + it("reads a two-level key path", () => + createRoot(dispose => { + expect(get(() => ({ user: { name: "Alice" } }), "user", "name")()).toBe("Alice"); + dispose(); + })); + + it("reads a three-level key path", () => + createRoot(dispose => { + const obj = { a: { b: { c: 42 } } }; + expect(get(() => obj, "a", "b", "c")()).toBe(42); + dispose(); + })); + + it("updates when object signal changes", () => { + const [obj, setObj] = createSignal({ name: "Alice", age: 30 }); + const { result, dispose } = createRoot(d => ({ result: get(obj, "name"), dispose: d })); + expect(result()).toBe("Alice"); + setObj({ name: "Bob", age: 25 }); + flush(); + expect(result()).toBe("Bob"); + dispose(); + }); +}); - const a = filterInstance(() => list, Element, Number); - expect(a()).toEqual([num, el, svg]); - const b = filterOutInstance(() => list, Element, Number); - expect(b()).toEqual([string, string]); - expect(list, "nonmutable").toEqual(copy); +describe("merge()", () => { + it("merges two objects, later keys override earlier", () => + createRoot(dispose => { + expect(merge(() => ({ a: 1, b: 2 }), () => ({ b: 99, c: 3 }))()).toEqual({ + a: 1, + b: 99, + c: 3, + }); dispose(); })); - it("template", () => + it("merges three objects", () => createRoot(dispose => { - const [a, _setA] = createSignal("Hello"); - const [b, setB] = createSignal("World"); - const result = template`${a} ${b}!!!`; - expect(result()).toBe("Hello World!!!"); + expect(merge(() => ({ a: 1 }), () => ({ b: 2 }), () => ({ c: 3 }))()).toEqual({ + a: 1, + b: 2, + c: 3, + }); + dispose(); + })); + + it("updates when any signal changes", () => { + const [overrides, setOverrides] = createSignal({ size: 16 }); + const { result, dispose } = createRoot(d => ({ + result: merge(() => ({ color: "blue", size: 12 }), overrides), + dispose: d, + })); + expect(result()).toEqual({ color: "blue", size: 16 }); + setOverrides({ size: 24 }); + flush(); + expect(result()).toEqual({ color: "blue", size: 24 }); + dispose(); + }); +}); - setB("Solid"); - expect(result()).toBe("Hello Solid!!!"); +// ─── UPDATE ───────────────────────────────────────────────────────────────── +describe("update()", () => { + it("sets a top-level key to a plain value", () => + createRoot(dispose => { + expect(update(() => ({ a: 1, b: 2 }), "a", 99)()).toEqual({ a: 99, b: 2 }); dispose(); })); + + it("sets a nested key path", () => + createRoot(dispose => { + expect( + update(() => ({ user: { name: "Alice", age: 30 } }), "user", "name", "Bob")(), + ).toEqual({ user: { name: "Bob", age: 30 } }); + dispose(); + })); + + it("does not mutate the original object", () => + createRoot(dispose => { + const original = { a: 1, b: 2 }; + update(() => original, "a", 99)(); + expect(original).toEqual({ a: 1, b: 2 }); + dispose(); + })); + + it("updates when the object signal changes", () => { + const [obj, setObj] = createSignal({ x: 10, y: 2 }); + const { result, dispose } = createRoot(d => ({ + result: update(obj, "y", 99), + dispose: d, + })); + expect(result()).toEqual({ x: 10, y: 99 }); + setObj({ x: 20, y: 5 }); + flush(); + expect(result()).toEqual({ x: 20, y: 99 }); + dispose(); + }); + + it("supports a key accessor that updates reactively", () => { + const [key, setKey] = createSignal<"a" | "b">("a"); + const { result, dispose } = createRoot(d => ({ + result: update(() => ({ a: 1, b: 2 }), key, 99), + dispose: d, + })); + expect(result()).toEqual({ a: 99, b: 2 }); + setKey("b"); + flush(); + expect(result()).toEqual({ a: 1, b: 99 }); + dispose(); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d65aaa793..ed77c10ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1034,8 +1034,8 @@ importers: version: link:../utils devDependencies: solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/spring: devDependencies: @@ -3280,6 +3280,11 @@ packages: '@solidjs/signals@2.0.0-beta.14': resolution: {integrity: sha512-y72nYtD7ogwX/UR5g2Y+meyeO6Q/xbQGtmvVTQX6USkMwEGOMnytqDnHj5amUzD7Fzqg32svwtCSx/q8hsOXAA==} + '@solidjs/web@2.0.0-beta.10': + resolution: {integrity: sha512-Ox7MBv19kuxHoHhWoLCCcc6aykSgaqzWvWT7RB66VqlFnQ8Lid2ncd30g5L4XC0GB+MN/WZVb68tiYrAFUDIAg==} + peerDependencies: + solid-js: ^2.0.0-beta.10 + '@solidjs/web@2.0.0-beta.13': resolution: {integrity: sha512-ugSnWcNc18osJZ24+op7mQpm6LlyHSgTnvSaYqEwL9PVmLxXpmAS7/dt5nc7MLLZtwgf1J1rmRfZb7mT8fTL2w==} peerDependencies: @@ -8933,6 +8938,12 @@ snapshots: '@solidjs/signals@2.0.0-beta.14': {} + '@solidjs/web@2.0.0-beta.10(solid-js@2.0.0-beta.10)': + dependencies: + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + solid-js: 2.0.0-beta.10 + '@solidjs/web@2.0.0-beta.13(solid-js@2.0.0-beta.13)': dependencies: seroval: 1.5.4