diff --git a/.changeset/sortable-initial.md b/.changeset/sortable-initial.md new file mode 100644 index 000000000..7b8b402dc --- /dev/null +++ b/.changeset/sortable-initial.md @@ -0,0 +1,41 @@ +--- +"@solid-primitives/sortable": major +--- + +Initial release of `@solid-primitives/sortable` + +Reactive sorting primitives, combining ideas from VueUse's `useSorted` and d3-array's comparator +utilities, built directly on Solid 2.0's `mapArray` and `createProjection` rather than a bespoke +diffing engine. + +### `ascending` / `descending` / `by` / `combine` / `reverse` + +Comparator building blocks. `ascending`/`descending` always sort `null`/`undefined`/`NaN` to the +end, regardless of direction — unlike a naive `a < b ? -1 : a > b ? 1 : 0`, which silently treats +them as equal to everything. `by` derives a comparator from a key accessor; `combine` composes +comparators for multi-key tie-breaking; `reverse` flips any comparator. + +### `makeSorted` / `createSorted` + +Non-reactive and reactive sort. `createSorted`'s default path (a static comparator, non-`dirty`) +delegates directly to `@solid-primitives/signal-builders`'s existing `sort()`. It adds a reactive +comparator (an accessor, so toggling sort direction/column doesn't rebuild the primitive) and a +`dirty: true` option that sorts the source array in place and reuses its reference — mirroring +VueUse's `useSorted`'s `dirty` option under Solid's signal model. + +### `sortedIndex` / `sortedIndexBy` / `insertSorted` + +Binary search over an already-sorted array, and an O(log n) immutable insert — versus an +O(n log n) full re-sort for a single insertion. + +### `createSortedIndex` + +Per-item reactive rank tracking: an item's index accessor only updates when *that item's* position +actually changes, never for unrelated moves elsewhere in the list. Built on `mapArray` — the same +core primitive that powers `` — rather than a hand-rolled diffing engine. + +### `createSortedProjection` + +A store-shaped sorted view, reconciled by key via `createProjection`, so unrelated rows don't +notify when one row's data changes. Pairs naturally with `` for +move-not-recreate DOM behavior with no bespoke tracking code. diff --git a/packages/sortable/LICENSE b/packages/sortable/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/sortable/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/sortable/README.md b/packages/sortable/README.md new file mode 100644 index 000000000..dc9100fd0 --- /dev/null +++ b/packages/sortable/README.md @@ -0,0 +1,265 @@ +

+ Solid Primitives sortable +

+ +# @solid-primitives/sortable + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/sortable?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/sortable) +[![version](https://img.shields.io/npm/v/@solid-primitives/sortable?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/sortable) +[![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) + +Reactive sorting primitives — comparators, reactive sort (with an in-place `dirty` mode), sorted +search/insert, and two flavors of granular per-item rank tracking built directly on `mapArray` and +`createProjection`. + +- **`ascending` / `descending`** — comparators where `null`/`undefined`/`NaN` always sort to the + end, regardless of direction. +- **`by`** — builds a comparator from a derived key. +- **`combine`** — composes comparators, later ones breaking ties left by earlier ones. +- **`reverse`** — flips any comparator. +- **`makeSorted`** — non-reactive sorted copy. +- **`createSorted`** — reactive sort, with a reactive comparator and an in-place `dirty` mode. +- **`sortedIndex` / `sortedIndexBy`** — binary search for the insertion point in a sorted array. +- **`insertSorted`** — immutable O(log n) insert into an already-sorted array. +- **`createSortedIndex`** — per-item reactive rank; an item's accessor only updates when *that + item's* position actually changes, built on `mapArray` (the same primitive behind ``). +- **`createSortedProjection`** — a store-shaped sorted view, reconciled by key via + `createProjection`, so unrelated rows don't notify when one row changes. + +## Installation + +```bash +npm install @solid-primitives/sortable +# or +yarn add @solid-primitives/sortable +# or +pnpm add @solid-primitives/sortable +``` + +## `ascending` / `descending` + +Default comparators for `Array.prototype.sort` and every primitive in this package. +`null`/`undefined`/`NaN` always sort to the end — unlike a naive `a < b ? -1 : a > b ? 1 : 0`, +which silently treats them as equal to everything they're compared against. + +```ts +// Type +type Comparator = (a: T, b: T) => number; +const ascending: Comparator; +const descending: Comparator; + +// Example +import { ascending, descending } from "@solid-primitives/sortable"; + +[3, 1, 2].sort(ascending); // [1, 2, 3] +[3, 1, 2].sort(descending); // [3, 2, 1] +[2, undefined, 1].sort(ascending); // [1, 2, undefined] +``` + +## `by` + +Builds a comparator that orders items by a derived key. + +```ts +// Type +function by(accessor: (item: T) => K, comparator?: Comparator): Comparator; + +// Example +import { by, descending } from "@solid-primitives/sortable"; + +const byPrice = by((item: Product) => item.price); +products.sort(byPrice); + +const byPriceDesc = by((item: Product) => item.price, descending); +``` + +## `combine` + +Composes comparators so later ones break ties left by earlier ones. + +```ts +// Type +function combine(...comparators: Comparator[]): Comparator; + +// Example +import { combine, by, descending } from "@solid-primitives/sortable"; + +const cmp = combine(by((p: Product) => p.category), by((p: Product) => p.price, descending)); +products.sort(cmp); +``` + +## `reverse` + +Flips the order of any comparator — composes with `by` and `combine`. + +```ts +// Type +function reverse(comparator: Comparator): Comparator; + +// Example +import { reverse, ascending } from "@solid-primitives/sortable"; + +[1, 3, 2].sort(reverse(ascending)); // [3, 2, 1] +``` + +## `makeSorted` + +Non-reactive: returns a new sorted copy of a plain array, leaving the original untouched. + +```ts +// Type +function makeSorted(list: T[], comparator?: Comparator): T[]; + +// Example +import { makeSorted } from "@solid-primitives/sortable"; + +makeSorted([3, 1, 2]); // [1, 2, 3] +``` + +## `createSorted` + +Reactively sorts `list`, re-sorting whenever `list` or `comparator` changes. `comparator` may +itself be reactive (an accessor), so toggling sort direction/column doesn't require rebuilding the +primitive. + +```ts +// Type +type CreateSortedOptions = { + /** + * Sort the array in place and return that same reference, instead of allocating a new sorted + * copy on every recompute. Only meaningful when `list` is a mutable array you own (e.g. paired + * with a signal set via `setList(list => (list.sort(cmp), list))`). + * @default false + */ + dirty?: boolean; +}; + +function createSorted( + list: MaybeAccessor, + comparator?: MaybeAccessor>, + options?: CreateSortedOptions, +): Accessor; + +// Example +import { createSorted, by } from "@solid-primitives/sortable"; + +const [column, setColumn] = createSignal<"name" | "price">("name"); +const comparator = createMemo(() => by((item: Product) => item[column()])); +const sorted = createSorted(products, comparator); + +{product => }; +``` + +### Notes + +- The default (`dirty: false`) path with a static comparator delegates directly to + `@solid-primitives/signal-builders`'s `sort()` — no reason to re-implement a `createMemo` wrapper + that already exists and is tested. +- `dirty: true` mutates and reuses the same array reference — mirrors VueUse's `useSorted`'s + `dirty` option under Solid's signal model. Only use it on an array you own and don't share. + +## `sortedIndex` / `sortedIndexBy` + +Binary search for the leftmost index at which a value could be inserted into an already-sorted +array while keeping it sorted. + +```ts +// Type +function sortedIndex(list: readonly T[], value: T, comparator?: Comparator): number; +function sortedIndexBy( + list: readonly T[], + value: T, + accessor: (item: T) => K, + comparator?: Comparator, +): number; + +// Example +import { sortedIndex } from "@solid-primitives/sortable"; + +sortedIndex([1, 3, 5, 7], 4); // 2 +``` + +## `insertSorted` + +Immutably inserts a value into an already-sorted array at the position the comparator dictates — +an O(log n) search plus an O(n) copy, versus an O(n log n) full re-sort for a single insertion. + +```ts +// Type +function insertSorted(list: readonly T[], value: T, comparator?: Comparator): T[]; + +// Example +import { insertSorted } from "@solid-primitives/sortable"; + +insertSorted([1, 3, 5], 4); // [1, 3, 4, 5] +``` + +## `createSortedIndex` + +Tracks each item's index within the sorted view of `list`, without invalidating every consumer on +every reorder. Returns a function that, given an item, returns a reactive accessor for its current +index (`-1` if the item isn't present). + +```ts +// Type +function createSortedIndex( + list: MaybeAccessor, + comparator?: MaybeAccessor>, +): (item: T) => Accessor; + +// Example +import { createSortedIndex, by } from "@solid-primitives/sortable"; + +const indexOf = createSortedIndex(rows, by((r: Row) => r.name)); +const rank = indexOf(row); // only updates when `row`'s own position changes + +{row => }; +``` + +### Notes + +- Built on `mapArray` — the same primitive that powers ``. `mapArray` keys by reference + identity and reuses the same computation (and its `index` signal) for an item across reorders, + so an item's index accessor only updates when *that item's* position actually changes; unrelated + moves elsewhere in the list never notify it. This is Solid core's own keyed-diff algorithm, not a + bespoke one. +- Each call to the returned function does an O(n) scan (untracked) to find the item's stable + per-item accessor — cheap relative to the granular reactivity it buys you. + +## `createSortedProjection` + +A store-shaped sorted view of `list`, reconciled by `key` so surviving items keep their store +identity across recomputes — reordering only touches the slots that actually changed, instead of +treating a reorder as "everything changed". + +```ts +// Type +function createSortedProjection( + list: MaybeAccessor, + comparator?: MaybeAccessor>, + key?: string | ((item: T) => unknown), +): Refreshable>; + +// Example +import { createSortedProjection, by } from "@solid-primitives/sortable"; + +const sortedUsers = createSortedProjection(users, by((u: User) => u.name), "id"); + + u.id}> + {user => } +; +``` + +### Notes + +- `key` identifies an item across recomputes — same contract as `reconcile`'s `key` argument. + Defaults to `"id"`. +- Prefer this over `createSorted` when consumers read individual item fields through the store + proxy, or render with ` item[key]}>` — both get + move-not-recreate behavior for free from the store's own reconciliation, with no bespoke tracking + code in this package at all. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/sortable/deno.jsonc b/packages/sortable/deno.jsonc new file mode 100644 index 000000000..93e87fe6e --- /dev/null +++ b/packages/sortable/deno.jsonc @@ -0,0 +1,24 @@ +{ + "name": "@solid-primitives/sortable", + "version": "0.0.100", + "description": "Reactive sorting primitives — comparators, reactive sort, sorted search/insert, and granular per-item rank tracking.", + "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/sortable/dev/index.tsx b/packages/sortable/dev/index.tsx new file mode 100644 index 000000000..f9950834e --- /dev/null +++ b/packages/sortable/dev/index.tsx @@ -0,0 +1,48 @@ +import { type Component, createSignal } from "solid-js"; +import { For } from "@solidjs/web"; +import { createSorted, createSortedIndex, by, descending } from "../src/index.js"; + +type Player = { id: number; name: string; score: number }; + +const NAMES = ["Ada", "Grace", "Alan", "Linus", "Barbara", "Margaret"]; + +function randomScore() { + return Math.floor(Math.random() * 100); +} + +const App: Component = () => { + const [players, setPlayers] = createSignal( + NAMES.map((name, id) => ({ id, name, score: randomScore() })), + ); + const sorted = createSorted(players, by((p: Player) => p.score, descending)); + const indexOf = createSortedIndex(players, by((p: Player) => p.score, descending)); + + const shuffleOne = () => { + const list = players(); + const target = list[Math.floor(Math.random() * list.length)]!; + setPlayers(list.map(p => (p.id === target.id ? { ...p, score: randomScore() } : p))); + }; + + return ( +
+
+

Sortable Primitive

+

Leaderboard sorted by score — each row tracks its own rank

+ +
    + + {player => ( +
  • + #{indexOf(player)() + 1} — {player.name} ({player.score}) +
  • + )} +
    +
+
+
+ ); +}; + +export default App; diff --git a/packages/sortable/package.json b/packages/sortable/package.json new file mode 100644 index 000000000..29a837af2 --- /dev/null +++ b/packages/sortable/package.json @@ -0,0 +1,83 @@ +{ + "name": "@solid-primitives/sortable", + "version": "0.0.100", + "description": "Reactive sorting primitives — comparators, reactive sort, sorted search/insert, and granular per-item rank tracking.", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/sortable", + "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": "sortable", + "stage": 0, + "list": [ + "ascending", + "descending", + "by", + "combine", + "reverse", + "makeSorted", + "createSorted", + "sortedIndex", + "sortedIndexBy", + "insertSorted", + "createSortedIndex", + "createSortedProjection" + ], + "category": "Reactivity" + }, + "keywords": [ + "solid", + "primitives", + "sort", + "sortable", + "comparator", + "reactive" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "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" + } + }, + "typesVersions": {}, + "tsdown": { + "entry": "src/**/*.{ts,tsx}", + "outDir": "dist" + }, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "pnpm -w build", + "vitest": "vitest -c ../../configs/vitest.config.solid2.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "dependencies": { + "@solid-primitives/signal-builders": "workspace:^", + "@solid-primitives/utils": "workspace:^" + }, + "peerDependencies": { + "@solidjs/web": "catalog:peer", + "solid-js": "catalog:peer" + }, + "devDependencies": { + "@solidjs/web": "catalog:", + "solid-js": "catalog:" + } +} diff --git a/packages/sortable/src/comparators.ts b/packages/sortable/src/comparators.ts new file mode 100644 index 000000000..b4f8deaf5 --- /dev/null +++ b/packages/sortable/src/comparators.ts @@ -0,0 +1,66 @@ +export type Comparator = (a: T, b: T) => number; + +function isMissing(value: unknown): boolean { + return value == null || (typeof value === "number" && Number.isNaN(value)); +} + +/** + * Default ascending comparator for `Array.prototype.sort` and every primitive in this package. + * `null`, `undefined`, and `NaN` always sort to the end, regardless of direction — unlike a naive + * `a < b ? -1 : a > b ? 1 : 0`, which silently treats them as equal to everything they're compared + * against (their relative order then depends on sort stability rather than being well-defined). + */ +export const ascending: Comparator = (a, b) => { + const aMissing = isMissing(a); + const bMissing = isMissing(b); + if (aMissing && bMissing) return 0; + if (aMissing) return 1; + if (bMissing) return -1; + return a < b ? -1 : a > b ? 1 : 0; +}; + +/** + * Descending comparator. `null`/`undefined`/`NaN` still sort to the end — reversing the order of + * comparable values doesn't move missing values to the front. + */ +export const descending: Comparator = (a, b) => { + const aMissing = isMissing(a); + const bMissing = isMissing(b); + if (aMissing && bMissing) return 0; + if (aMissing) return 1; + if (bMissing) return -1; + return a > b ? -1 : a < b ? 1 : 0; +}; + +/** + * Builds a comparator that orders items by a derived key. + * @example + * const byPrice = by((item: Product) => item.price); + * const byPriceDesc = by((item: Product) => item.price, descending); + */ +export const by = (accessor: (item: T) => K, comparator: Comparator = ascending): Comparator => (a, b) => comparator(accessor(a), accessor(b)); + +/** + * Composes comparators so later ones break ties left by earlier ones. + * @example + * const cmp = combine(by((p: Product) => p.category), by((p: Product) => p.price, descending)); + */ +export const combine = (...comparators: Comparator[]): Comparator => (a, b) => { + for (const comparator of comparators) { + const result = comparator(a, b); + if (result !== 0) return result; + } + return 0; +}; + +/** + * Flips the order of any comparator (composes with {@link by} and {@link combine}). + * + * Swapping the arguments also flips where a missing-aware comparator like {@link ascending} puts + * `null`/`NaN` — its "always sort missing values last" logic is tied to argument order, not + * direction, so `reverse(ascending)` sorts them *first* instead (`undefined` is unaffected: the + * JS engine always places it last, never passing it to the comparator at all). Use + * {@link descending}, or a comparator that handles missing values explicitly, instead of + * `reverse(ascending)` when missing values must stay last. + */ +export const reverse = (comparator: Comparator): Comparator => (a, b) => comparator(b, a); diff --git a/packages/sortable/src/index-of.ts b/packages/sortable/src/index-of.ts new file mode 100644 index 000000000..c3b0c87c2 --- /dev/null +++ b/packages/sortable/src/index-of.ts @@ -0,0 +1,34 @@ +import { type Accessor, mapArray, untrack } from "solid-js"; +import { type MaybeAccessor } from "@solid-primitives/utils"; +import { createSorted } from "./sort.ts"; +import type { Comparator } from "./comparators.ts"; + +/** + * Tracks each item's index within the sorted view of `list`, without invalidating every + * consumer on every reorder. + * + * `mapArray` (the same primitive that powers ``) keys by reference identity and reuses the + * same computation — and its `index` signal — for an item across recomputes, so an item's index + * accessor only updates when *that item's* position actually changes; unrelated moves elsewhere + * in the list never notify it. This is Solid core's own keyed-diff algorithm, not a bespoke one. + * + * @returns a function that, given an item, returns a reactive accessor for its current index + * (`-1` if the item isn't present). Each call does an O(n) scan (untracked) to find the item's + * stable per-item accessor — cheap relative to the granular reactivity it buys you. + * + * @example + * const indexOf = createSortedIndex(rows, by(r => r.name)); + * const rank = indexOf(row); // only updates when `row`'s own position changes + */ +export function createSortedIndex( + list: MaybeAccessor, + comparator?: MaybeAccessor>, +): (item: T) => Accessor { + const sorted = createSorted(list, comparator); + const entries = mapArray(sorted, (value, index) => [value, index] as const); + + return item => () => { + const pair = untrack(entries).find(([value]) => value === item); + return pair ? pair[1]() : -1; + }; +} diff --git a/packages/sortable/src/index.ts b/packages/sortable/src/index.ts new file mode 100644 index 000000000..9979737eb --- /dev/null +++ b/packages/sortable/src/index.ts @@ -0,0 +1,5 @@ +export { ascending, descending, by, combine, reverse, type Comparator } from "./comparators.ts"; +export { makeSorted, createSorted, type CreateSortedOptions } from "./sort.ts"; +export { sortedIndex, sortedIndexBy, insertSorted } from "./search.ts"; +export { createSortedIndex } from "./index-of.ts"; +export { createSortedProjection } from "./projection.ts"; diff --git a/packages/sortable/src/projection.ts b/packages/sortable/src/projection.ts new file mode 100644 index 000000000..0e37875b5 --- /dev/null +++ b/packages/sortable/src/projection.ts @@ -0,0 +1,33 @@ +import { createProjection, type Refreshable, type Store } from "solid-js"; +import { access, type MaybeAccessor } from "@solid-primitives/utils"; +import { ascending, type Comparator } from "./comparators.ts"; + +/** + * A store-shaped sorted view of `list`, reconciled by `key` so surviving items keep their store + * identity across recomputes — Solid 2.0's `createProjection`'s keyed reconcile diffs the + * previous and next sorted arrays and only touches the slots that actually changed, instead of + * treating a reorder as "everything changed". + * + * Prefer this over {@link createSorted} when consumers read individual item fields through the + * store proxy, or render with ` item[key]}>` — both get + * move-not-recreate behavior for free from the store's own reconciliation, with no bespoke + * tracking code in this package at all. + * + * @param key property name (or extractor) identifying an item across recomputes — same contract + * as `reconcile`'s `key` argument. Defaults to `"id"`. + * + * @example + * const sortedUsers = createSortedProjection(users, by(u => u.name), "id"); + * u.id}>{user => } + */ +export function createSortedProjection( + list: MaybeAccessor, + comparator?: MaybeAccessor>, + key: string | ((item: T) => unknown) = "id", +): Refreshable> { + return createProjection( + () => [...access(list)].sort(access(comparator) ?? ascending), + [], + { key }, + ); +} diff --git a/packages/sortable/src/search.ts b/packages/sortable/src/search.ts new file mode 100644 index 000000000..3f66de289 --- /dev/null +++ b/packages/sortable/src/search.ts @@ -0,0 +1,38 @@ +import { ascending, by, type Comparator } from "./comparators.ts"; + +/** + * Binary search for the leftmost index at which `value` could be inserted into `list` while + * keeping it sorted per `comparator`. `list` is assumed to already be sorted by that comparator — + * behavior is undefined otherwise. + */ +export function sortedIndex(list: readonly T[], value: T, comparator: Comparator = ascending): number { + let low = 0; + let high = list.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (comparator(list[mid]!, value) < 0) low = mid + 1; + else high = mid; + } + return low; +} + +/** Like {@link sortedIndex}, but comparing by a derived key instead of the items themselves. */ +export function sortedIndexBy( + list: readonly T[], + value: T, + accessor: (item: T) => K, + comparator: Comparator = ascending, +): number { + return sortedIndex(list, value, by(accessor, comparator)); +} + +/** + * Immutably inserts `value` into an already-sorted `list` at the position `comparator` dictates — + * an O(log n) search plus an O(n) copy, versus an O(n log n) full re-sort for a single insertion. + */ +export function insertSorted(list: readonly T[], value: T, comparator: Comparator = ascending): T[] { + const index = sortedIndex(list, value, comparator); + const result = list.slice(); + result.splice(index, 0, value); + return result; +} diff --git a/packages/sortable/src/sort.ts b/packages/sortable/src/sort.ts new file mode 100644 index 000000000..48b6c456c --- /dev/null +++ b/packages/sortable/src/sort.ts @@ -0,0 +1,58 @@ +import { type Accessor, createMemo } from "solid-js"; +import { access, type MaybeAccessor } from "@solid-primitives/utils"; +import { sort as reactiveSort } from "@solid-primitives/signal-builders"; +import { ascending, type Comparator } from "./comparators.ts"; + +/** Non-reactive: returns a new sorted copy of `list`, leaving the original untouched. */ +export function makeSorted(list: T[], comparator: Comparator = ascending): T[] { + return list.slice().sort(comparator); +} + +export type CreateSortedOptions = { + /** + * Sort `list`'s current value in place and return that same array reference, instead of + * allocating a new sorted copy on every recompute. Only meaningful when `list` is a mutable + * array you own (e.g. paired with a signal set via `setList(list => (list.sort(cmp), list))`) — + * mirrors VueUse's `useSorted`'s `dirty` option under Solid's signal model. + * @default false + */ + dirty?: boolean; +}; + +/** + * Reactively sorts `list`, re-sorting whenever `list` or `comparator` changes. `comparator` may + * itself be reactive (an accessor), so toggling sort direction/column doesn't require rebuilding + * the primitive. + * + * The default (`dirty: false`) path delegates directly to `@solid-primitives/signal-builders`'s + * `sort()` when the comparator is static, returning a new array each recompute. Pass + * `{ dirty: true }` to sort in place and reuse the same array reference instead. + * + * @example + * const [column, setColumn] = createSignal<"name" | "price">("name"); + * const comparator = createMemo(() => by(item => item[column()])); + * const sorted = createSorted(items, comparator); + */ +export function createSorted( + list: MaybeAccessor, + comparator?: MaybeAccessor>, + options?: CreateSortedOptions, +): Accessor { + if (options?.dirty) { + return createMemo(() => { + const arr = access(list); + arr.sort(access(comparator) ?? ascending); + return arr; + }); + } + + // `access()` only invokes zero-arg functions — a real comparator has arity 2, so this branch + // correctly treats it (and `undefined`) as static, delegating to signal-builders' `sort`. + if (comparator === undefined || (typeof comparator === "function" && comparator.length !== 0)) { + return reactiveSort(list, (comparator as Comparator | undefined) ?? ascending); + } + + // reactive comparator (a zero-arg accessor) — signal-builders' `sort` doesn't track this, so + // read it ourselves inside the memo. + return createMemo(() => access(list).slice().sort(access(comparator) ?? ascending)); +} diff --git a/packages/sortable/stories/sortable.stories.tsx b/packages/sortable/stories/sortable.stories.tsx new file mode 100644 index 000000000..0625c6c91 --- /dev/null +++ b/packages/sortable/stories/sortable.stories.tsx @@ -0,0 +1,277 @@ +import { createSignal, createEffect, createMemo, For } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { + createSorted, + createSortedIndex, + createSortedProjection, + insertSorted, + by, + combine, + reverse, + descending, +} from "../src/index.js"; +import readme from "../README.md?raw"; +import { Badge, Button, ButtonRow, Container, Section } from "../../../.storybook/ui/index.js"; + +type Player = { id: number; name: string; score: number }; + +const NAMES = ["Ada", "Grace", "Alan", "Linus", "Barbara", "Margaret"]; + +function randomScore() { + return Math.floor(Math.random() * 100); +} + +const meta = preview.meta({ + title: "Reactivity/Sortable", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +export const GranularRankTracking = meta.story({ + name: "Granular rank tracking", + parameters: { + docs: { + description: { + story: + "`createSortedIndex` gives each row a per-item reactive rank, built on `mapArray`'s keyed diff. Click the button to randomize a single row's score — only the row(s) whose rank actually changes flashes, not the whole leaderboard.", + }, + }, + }, + render: () => { + const [players, setPlayers] = createSignal( + NAMES.map((name, id) => ({ id, name, score: randomScore() })), + ); + const sorted = createSorted(players, by((p: Player) => p.score, descending)); + const indexOf = createSortedIndex(players, by((p: Player) => p.score, descending)); + + const [flashed, setFlashed] = createSignal>(new Set()); + + const shuffleOne = () => { + const list = players(); + const target = list[Math.floor(Math.random() * list.length)]!; + setPlayers(list.map(p => (p.id === target.id ? { ...p, score: randomScore() } : p))); + }; + + return ( + + + + +
+
+ + {player => { + const rank = indexOf(player); + + createEffect( + () => rank(), + () => { + setFlashed(prev => new Set(prev).add(player.id)); + const timer = setTimeout(() => { + setFlashed(prev => { + const next = new Set(prev); + next.delete(player.id); + return next; + }); + }, 600); + return () => clearTimeout(timer); + }, + { defer: true }, + ); + + return ( +
+ #{rank() + 1} + {player.name} + {player.score} +
+ ); + }} +
+
+
+
+ ); + }, +}); + +type Product = { id: number; category: string; name: string; price: number }; + +const PRODUCTS: Product[] = [ + { id: 1, category: "Fruit", name: "Apple", price: 1.2 }, + { id: 2, category: "Fruit", name: "Banana", price: 0.5 }, + { id: 3, category: "Veg", name: "Carrot", price: 0.8 }, + { id: 4, category: "Veg", name: "Potato", price: 0.3 }, + { id: 5, category: "Dairy", name: "Milk", price: 2.5 }, + { id: 6, category: "Dairy", name: "Cheese", price: 4.0 }, +]; + +export const MultiColumnSort = meta.story({ + name: "Multi-column sort", + parameters: { + docs: { + description: { + story: + "`combine` chains comparators so a later one breaks ties left by an earlier one. Category is always the primary key here; toggling the button swaps the price comparator (the tie-breaker) between ascending and `reverse`d, without touching the category ordering at all.", + }, + }, + }, + render: () => { + const [priceDesc, setPriceDesc] = createSignal(false); + const comparator = createMemo(() => { + const byPrice = by((p: Product) => p.price); + return combine(by((p: Product) => p.category), priceDesc() ? reverse(byPrice) : byPrice); + }); + const sorted = createSorted(() => PRODUCTS, comparator); + + return ( + + + + +
+
+ + {p => ( +
+ {p.category} + {p.name} + ${p.price.toFixed(2)} +
+ )} +
+
+
+
+ ); + }, +}); + +type Task = { id: number; label: string; order: number }; + +function initialTasks(): Task[] { + return ["Design", "Build", "Test", "Ship"].map((label, order) => ({ id: order, label, order })); +} + +export const DragToReorder = meta.story({ + name: "Drag to reorder", + parameters: { + docs: { + description: { + story: + "A lightweight drag-and-drop list backed by `createSortedProjection`, sorted by each row's `order` field. Dropping a row only reassigns `order` on the rows between its old and new position — the store's keyed reconcile (by `id`) means every untouched row keeps its DOM node and reactive identity across the drop, not just its data.", + }, + }, + }, + render: () => { + const [tasks, setTasks] = createSignal(initialTasks()); + const sorted = createSortedProjection(tasks, by((t: Task) => t.order), "id"); + + let draggedId: number | null = null; + + const onDrop = (targetId: number) => { + if (draggedId === null || draggedId === targetId) return; + const current = tasks(); + const from = current.findIndex(t => t.id === draggedId); + const to = current.findIndex(t => t.id === targetId); + const reordered = current.slice(); + const [moved] = reordered.splice(from, 1); + reordered.splice(to, 0, moved!); + setTasks(reordered.map((t, order) => (t.order === order ? t : { ...t, order }))); + draggedId = null; + }; + + return ( + +
+
+ + {task => ( +
(draggedId = task.id)} + onDragOver={e => e.preventDefault()} + onDrop={() => onDrop(task.id)} + style={{ + padding: "0.5rem 0.75rem", + "border-radius": "0.5rem", + background: "rgba(148, 163, 184, 0.15)", + cursor: "grab", + "user-select": "none", + }} + > + ⠿ {task.label} +
+ )} +
+
+
+
+ ); + }, +}); + +type Score = { id: number; player: string; points: number }; + +const PLAYERS = ["Nova", "Kai", "Zed", "Mira", "Rex"]; +let nextScoreId = 0; + +function randomScoreEntry(): Score { + return { + id: nextScoreId++, + player: PLAYERS[Math.floor(Math.random() * PLAYERS.length)]!, + points: Math.floor(Math.random() * 1000), + }; +} + +export const LiveSortedFeed = meta.story({ + name: "Live sorted feed", + parameters: { + docs: { + description: { + story: + '`insertSorted` places each incoming score at its correct position with a binary search — an O(log n) search plus an O(n) copy — instead of re-sorting the whole feed on every arrival. Click "Add score" a few times to watch new entries drop straight into place.', + }, + }, + }, + render: () => { + const byPointsDesc = by((s: Score) => s.points, descending); + const [scores, setScores] = createSignal( + Array.from({ length: 5 }, randomScoreEntry).sort(byPointsDesc), + ); + + const addScore = () => setScores(prev => insertSorted(prev, randomScoreEntry(), byPointsDesc)); + + return ( + + + + +
+
+ + {(s, i) => ( +
+ #{i() + 1} + {s.player} + {s.points} +
+ )} +
+
+
+
+ ); + }, +}); diff --git a/packages/sortable/test/comparators.test.ts b/packages/sortable/test/comparators.test.ts new file mode 100644 index 000000000..08e2eb786 --- /dev/null +++ b/packages/sortable/test/comparators.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect } from "vitest"; +import { ascending, descending, by, combine, reverse } from "../src/index.ts"; + +describe("ascending", () => { + test("orders numbers/strings naturally", () => { + expect([3, 1, 2].sort(ascending)).toEqual([1, 2, 3]); + expect(["b", "a", "c"].sort(ascending)).toEqual(["a", "b", "c"]); + }); + + test("undefined and null always sort to the end", () => { + // `undefined` is moved to the very end by the JS engine itself, unconditionally, before our + // comparator ever runs — `null` is a normal value as far as the engine is concerned, so it's + // ordered by our comparator (which also treats it as "missing", placing it after every real + // value) and lands just before the unconditionally-appended `undefined`. + expect([2, undefined, 1, null, 3].sort(ascending)).toEqual([1, 2, 3, null, undefined]); + }); + + test("NaN sorts to the end, like undefined", () => { + expect([2, NaN, 1].sort(ascending)).toEqual([1, 2, NaN]); + }); +}); + +describe("descending", () => { + test("reverses comparable order", () => { + expect([1, 3, 2].sort(descending)).toEqual([3, 2, 1]); + }); + + test("still sorts undefined/NaN to the end", () => { + // Same caveat as above: `undefined` is moved to the end by the engine, unconditionally; + // `NaN` is a normal number to the engine, so our comparator places it last among the + // comparator-ordered values, just before `undefined`. + expect([2, undefined, 1, NaN, 3].sort(descending)).toEqual([3, 2, 1, NaN, undefined]); + }); +}); + +describe("by", () => { + test("orders by a derived key, ascending by default", () => { + const items = [{ price: 3 }, { price: 1 }, { price: 2 }]; + expect(items.sort(by(item => item.price)).map(i => i.price)).toEqual([1, 2, 3]); + }); + + test("accepts a custom comparator for the derived key", () => { + const items = [{ price: 1 }, { price: 3 }, { price: 2 }]; + expect(items.sort(by(item => item.price, descending)).map(i => i.price)).toEqual([3, 2, 1]); + }); +}); + +describe("combine", () => { + test("breaks ties with later comparators", () => { + const items = [ + { category: "b", price: 2 }, + { category: "a", price: 3 }, + { category: "a", price: 1 }, + ]; + const sorted = items.sort( + combine( + by((i: (typeof items)[number]) => i.category), + by((i: (typeof items)[number]) => i.price), + ), + ); + expect(sorted).toEqual([ + { category: "a", price: 1 }, + { category: "a", price: 3 }, + { category: "b", price: 2 }, + ]); + }); +}); + +describe("reverse", () => { + test("flips any comparator", () => { + expect([1, 3, 2].sort(reverse(ascending))).toEqual([3, 2, 1]); + expect([1, 3, 2].sort(reverse(descending))).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/sortable/test/index-of.test.ts b/packages/sortable/test/index-of.test.ts new file mode 100644 index 000000000..ec43f4806 --- /dev/null +++ b/packages/sortable/test/index-of.test.ts @@ -0,0 +1,112 @@ +import { describe, test, expect } from "vitest"; +import { createRoot, createSignal, createEffect, flush } from "solid-js"; +import { createSortedIndex, ascending } from "../src/index.ts"; + +describe("createSortedIndex", () => { + test("reflects each item's position in the sorted list", () => { + createRoot(dispose => { + const a = { name: "b" }; + const b = { name: "a" }; + const [list] = createSignal([a, b]); + const indexOf = createSortedIndex(list, (x: typeof a, y: typeof a) => ascending(x.name, y.name)); + flush(); + expect(indexOf(a)()).toBe(1); + expect(indexOf(b)()).toBe(0); + dispose(); + }); + }); + + test("returns -1 for an item that isn't present", () => { + createRoot(dispose => { + const a = { name: "a" }; + const missing = { name: "z" }; + const [list] = createSignal([a]); + const indexOf = createSortedIndex(list, (x: typeof a, y: typeof a) => ascending(x.name, y.name)); + flush(); + expect(indexOf(missing)()).toBe(-1); + dispose(); + }); + }); + + test("an item's index accessor only re-runs when that item's own position changes", () => { + // Sorted by name: a(0), b(1), c(2), d(3), e(4) + const a = { name: "b" }; + const b = { name: "c" }; + const c = { name: "d" }; + const d = { name: "e" }; + const e = { name: "f" }; + const [items, setItems] = createSignal([a, b, c, d, e]); + + let runsA = 0; + let runsB = 0; + let runsC = 0; + let runsD = 0; + let runsE = 0; + + const [dispose, idxA, idxB, idxC, idxD, idxE] = createRoot(dispose => { + const indexOf = createSortedIndex(items, (x: typeof a, y: typeof a) => ascending(x.name, y.name)); + const idxA = indexOf(a); + const idxB = indexOf(b); + const idxC = indexOf(c); + const idxD = indexOf(d); + const idxE = indexOf(e); + + createEffect( + () => { + runsA++; + return idxA(); + }, + () => {}, + ); + createEffect( + () => { + runsB++; + return idxB(); + }, + () => {}, + ); + createEffect( + () => { + runsC++; + return idxC(); + }, + () => {}, + ); + createEffect( + () => { + runsD++; + return idxD(); + }, + () => {}, + ); + createEffect( + () => { + runsE++; + return idxE(); + }, + () => {}, + ); + + return [dispose, idxA, idxB, idxC, idxD, idxE] as const; + }); + + flush(); + expect([idxA(), idxB(), idxC(), idxD(), idxE()]).toEqual([0, 1, 2, 3, 4]); + expect([runsA, runsB, runsC, runsD, runsE]).toEqual([1, 1, 1, 1, 1]); + + // Swap the two ends: `a` moves to the back, `e` moves to the front. + // `b`, `c`, `d` keep their relative order and their indices unchanged. + a.name = "z"; + e.name = "a"; + setItems(prev => [...prev]); + flush(); + + expect([idxA(), idxB(), idxC(), idxD(), idxE()]).toEqual([4, 1, 2, 3, 0]); + expect(runsA).toBe(2); + expect(runsE).toBe(2); + expect(runsB).toBe(1); + expect(runsC).toBe(1); + expect(runsD).toBe(1); + dispose(); + }); +}); diff --git a/packages/sortable/test/projection.test.ts b/packages/sortable/test/projection.test.ts new file mode 100644 index 000000000..61071aed3 --- /dev/null +++ b/packages/sortable/test/projection.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect } from "vitest"; +import { createRoot, createSignal, createEffect, flush } from "solid-js"; +import { createSortedProjection, by } from "../src/index.ts"; + +type Row = { id: number; name: string; note: string }; + +describe("createSortedProjection", () => { + test("returns a store-shaped sorted view", () => { + createRoot(dispose => { + const [items] = createSignal<{ id: number; name: string }[]>([ + { id: 1, name: "b" }, + { id: 2, name: "a" }, + ]); + const sorted = createSortedProjection(items, by((i: { id: number; name: string }) => i.name), "id"); + flush(); + expect(sorted.map(i => i.id)).toEqual([2, 1]); + dispose(); + }); + }); + + test("reconciles by key: an unrelated row's field change doesn't notify other rows", () => { + const [items, setItems] = createSignal([ + { id: 1, name: "a", note: "" }, + { id: 2, name: "b", note: "" }, + ]); + + let runs = 0; + + const dispose = createRoot(dispose => { + const sorted = createSortedProjection(items, by((i: Row) => i.name), "id"); + createEffect( + () => { + runs++; + return sorted.find(i => i.id === 2)!.name; + }, + () => {}, + ); + return dispose; + }); + + flush(); + expect(runs).toBe(1); + + // Change row 1's `note` only — its `name`/order is untouched, and row 2 (`id: 2`) is + // completely unrelated to the change. + setItems([ + { id: 1, name: "a", note: "edited" }, + { id: 2, name: "b", note: "" }, + ]); + flush(); + expect(runs).toBe(1); + dispose(); + }); +}); diff --git a/packages/sortable/test/search.test.ts b/packages/sortable/test/search.test.ts new file mode 100644 index 000000000..b05e9c33d --- /dev/null +++ b/packages/sortable/test/search.test.ts @@ -0,0 +1,43 @@ +import { describe, test, expect } from "vitest"; +import { sortedIndex, sortedIndexBy, insertSorted, descending } from "../src/index.ts"; + +describe("sortedIndex", () => { + test("finds the insertion point in a sorted array", () => { + expect(sortedIndex([1, 3, 5, 7], 4)).toBe(2); + expect(sortedIndex([1, 3, 5, 7], 0)).toBe(0); + expect(sortedIndex([1, 3, 5, 7], 8)).toBe(4); + }); + + test("returns the leftmost index among duplicates", () => { + expect(sortedIndex([1, 2, 2, 2, 3], 2)).toBe(1); + }); + + test("handles an empty array", () => { + expect(sortedIndex([], 1)).toBe(0); + }); + + test("respects a custom comparator", () => { + expect(sortedIndex([5, 3, 1], 4, descending)).toBe(1); + }); +}); + +describe("sortedIndexBy", () => { + test("finds the insertion point by a derived key", () => { + const items = [{ price: 1 }, { price: 3 }, { price: 5 }]; + expect(sortedIndexBy(items, { price: 4 }, i => i.price)).toBe(2); + }); +}); + +describe("insertSorted", () => { + test("inserts maintaining order without mutating the input", () => { + const original = [1, 3, 5]; + const result = insertSorted(original, 4); + expect(result).toEqual([1, 3, 4, 5]); + expect(original).toEqual([1, 3, 5]); + }); + + test("inserts at the start and end", () => { + expect(insertSorted([1, 3, 5], 0)).toEqual([0, 1, 3, 5]); + expect(insertSorted([1, 3, 5], 6)).toEqual([1, 3, 5, 6]); + }); +}); diff --git a/packages/sortable/test/server.test.ts b/packages/sortable/test/server.test.ts new file mode 100644 index 000000000..625de9eee --- /dev/null +++ b/packages/sortable/test/server.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { + ascending, + by, + makeSorted, + createSorted, + sortedIndex, + insertSorted, + createSortedIndex, + createSortedProjection, +} from "../src/index.ts"; + +describe("sortable — SSR", () => { + test("works without a browser environment", () => + createRoot(dispose => { + expect(makeSorted([3, 1, 2])).toEqual([1, 2, 3]); + expect(sortedIndex([1, 3, 5], 4)).toBe(2); + expect(insertSorted([1, 3, 5], 4)).toEqual([1, 3, 4, 5]); + + const sorted = createSorted(() => [3, 1, 2]); + expect(sorted()).toEqual([1, 2, 3]); + + const item = { id: 1, name: "a" }; + const indexOf = createSortedIndex(() => [item], ascending); + expect(indexOf(item)()).toBe(0); + + const projected = createSortedProjection(() => [item], by((i: typeof item) => i.name), "id"); + expect(projected.map(i => i.id)).toEqual([1]); + + dispose(); + })); +}); diff --git a/packages/sortable/test/sort.test.ts b/packages/sortable/test/sort.test.ts new file mode 100644 index 000000000..107bbf095 --- /dev/null +++ b/packages/sortable/test/sort.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from "vitest"; +import { createRoot, createSignal, flush } from "solid-js"; +import { makeSorted, createSorted, ascending, descending, by } from "../src/index.ts"; + +describe("makeSorted", () => { + test("returns a new sorted copy without mutating the input", () => { + const original = [3, 1, 2]; + const result = makeSorted(original); + expect(result).toEqual([1, 2, 3]); + expect(original).toEqual([3, 1, 2]); + }); +}); + +describe("createSorted", () => { + test("sorts reactively and returns a new array each recompute (non-dirty)", () => { + const [list, setList] = createSignal([3, 1, 2]); + const [dispose, sorted] = createRoot(dispose => [dispose, createSorted(list)] as const); + + flush(); + expect(sorted()).toEqual([1, 2, 3]); + const first = sorted(); + + setList([5, 4, 6]); + flush(); + expect(sorted()).toEqual([4, 5, 6]); + expect(sorted()).not.toBe(first); + dispose(); + }); + + test("does not mutate the source array (non-dirty)", () => { + const original = [3, 1, 2]; + const [list] = createSignal(original); + const [dispose, sorted] = createRoot(dispose => [dispose, createSorted(list)] as const); + + flush(); + sorted(); + expect(original).toEqual([3, 1, 2]); + dispose(); + }); + + test("dirty mode mutates the source array in place and reuses its reference", () => { + const original = [3, 1, 2]; + const [list] = createSignal(original); + const [dispose, sorted] = createRoot( + dispose => [dispose, createSorted(list, ascending, { dirty: true })] as const, + ); + + flush(); + expect(sorted()).toBe(original); + expect(original).toEqual([1, 2, 3]); + dispose(); + }); + + test("supports a reactive comparator (direction toggle)", () => { + const [list] = createSignal([3, 1, 2]); + const [dir, setDir] = createSignal<"asc" | "desc">("asc"); + const comparator = () => (dir() === "asc" ? ascending : descending); + const [dispose, sorted] = createRoot(dispose => [dispose, createSorted(list, comparator)] as const); + + flush(); + expect(sorted()).toEqual([1, 2, 3]); + + setDir("desc"); + flush(); + expect(sorted()).toEqual([3, 2, 1]); + dispose(); + }); + + test("supports a `by` comparator", () => { + const [list] = createSignal([{ price: 3 }, { price: 1 }, { price: 2 }]); + const [dispose, sorted] = createRoot( + dispose => [dispose, createSorted(list, by((i: { price: number }) => i.price))] as const, + ); + + flush(); + expect(sorted().map(i => i.price)).toEqual([1, 2, 3]); + dispose(); + }); +}); diff --git a/packages/sortable/tsconfig.json b/packages/sortable/tsconfig.json new file mode 100644 index 000000000..596e2cf72 --- /dev/null +++ b/packages/sortable/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d19d212b..51dd99046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1243,6 +1243,22 @@ importers: specifier: 'catalog:' version: 2.0.0-beta.29 + packages/sortable: + dependencies: + '@solid-primitives/signal-builders': + specifier: workspace:^ + version: link:../signal-builders + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 'catalog:' + version: 2.0.0-beta.29(solid-js@2.0.0-beta.29) + solid-js: + specifier: 'catalog:' + version: 2.0.0-beta.29 + packages/spring: devDependencies: '@solid-primitives/utils':