From a402fce13e980253230f565e3ab0bbaa76dc2824 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:49:58 -0400 Subject: [PATCH 1/2] Initial commit --- .changeset/sortable-initial.md | 41 ++ deno.jsonc | 4 +- deno.lock | 534 +++++++++--------- packages/sortable/LICENSE | 21 + packages/sortable/README.md | 265 +++++++++ packages/sortable/deno.jsonc | 24 + packages/sortable/dev/index.tsx | 48 ++ packages/sortable/package.json | 83 +++ packages/sortable/src/comparators.ts | 57 ++ packages/sortable/src/index-of.ts | 34 ++ packages/sortable/src/index.ts | 5 + packages/sortable/src/projection.ts | 33 ++ packages/sortable/src/search.ts | 38 ++ packages/sortable/src/sort.ts | 58 ++ .../sortable/stories/sortable.stories.tsx | 277 +++++++++ packages/sortable/test/comparators.test.ts | 74 +++ packages/sortable/test/index-of.test.ts | 112 ++++ packages/sortable/test/projection.test.ts | 54 ++ packages/sortable/test/search.test.ts | 43 ++ packages/sortable/test/server.test.ts | 33 ++ packages/sortable/test/sort.test.ts | 79 +++ packages/sortable/tsconfig.json | 4 + pnpm-lock.yaml | 16 + 23 files changed, 1672 insertions(+), 265 deletions(-) create mode 100644 .changeset/sortable-initial.md create mode 100644 packages/sortable/LICENSE create mode 100644 packages/sortable/README.md create mode 100644 packages/sortable/deno.jsonc create mode 100644 packages/sortable/dev/index.tsx create mode 100644 packages/sortable/package.json create mode 100644 packages/sortable/src/comparators.ts create mode 100644 packages/sortable/src/index-of.ts create mode 100644 packages/sortable/src/index.ts create mode 100644 packages/sortable/src/projection.ts create mode 100644 packages/sortable/src/search.ts create mode 100644 packages/sortable/src/sort.ts create mode 100644 packages/sortable/stories/sortable.stories.tsx create mode 100644 packages/sortable/test/comparators.test.ts create mode 100644 packages/sortable/test/index-of.test.ts create mode 100644 packages/sortable/test/projection.test.ts create mode 100644 packages/sortable/test/search.test.ts create mode 100644 packages/sortable/test/server.test.ts create mode 100644 packages/sortable/test/sort.test.ts create mode 100644 packages/sortable/tsconfig.json 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/deno.jsonc b/deno.jsonc index da5ac623c..d7a7002b1 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -30,8 +30,8 @@ ] }, "catalog": { - "solid-js": "2.0.0-beta.26", - "@solidjs/web": "2.0.0-beta.26", + "solid-js": "2.0.0-beta.29", + "@solidjs/web": "2.0.0-beta.29", "tsdown": "^0.22.3", "unplugin-solid": "^1.0.0" }, diff --git a/deno.lock b/deno.lock index 96c323743..03497c099 100644 --- a/deno.lock +++ b/deno.lock @@ -11,13 +11,13 @@ "npm:@graphql-typed-document-node/core@^3.2.0": "3.2.0_graphql@16.14.2", "npm:@nothing-but/node-resolve-ts@^1.0.1": "1.0.1", "npm:@reduxjs/toolkit@^1.9.7": "1.9.7_react@19.2.7", - "npm:@solidjs/web@2.0.0-beta.26": "2.0.0-beta.26_solid-js@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29": "2.0.0-beta.29_solid-js@2.0.0-beta.29", "npm:@storybook/addon-docs@^10.4.6": "10.4.6_@types+react@19.2.17_storybook@10.4.6__@types+react@19.2.17__prettier@3.9.4__@testing-library+dom@10.4.1__react@19.2.7_@testing-library+dom@10.4.1_@types+node@22.20.0_esbuild@0.28.1_prettier@3.9.4_sass@1.101.0_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0", "npm:@supabase/supabase-js@^2.48.1": "2.110.0", "npm:@tailwindcss/container-queries@~0.1.1": "0.1.1_tailwindcss@3.3.3", "npm:@tailwindcss/typography@~0.5.20": "0.5.20_tailwindcss@3.3.3", - "npm:@tanstack/solid-router@2.0.0-beta.23": "2.0.0-beta.23_@solidjs+web@2.0.0-beta.15__solid-js@2.0.0-beta.26__seroval@1.5.6_solid-js@2.0.0-beta.26_seroval@1.5.6", - "npm:@tanstack/solid-start@2.0.0-beta.24": "2.0.0-beta.24_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_seroval@1.5.6", + "npm:@tanstack/solid-router@2.0.0-beta.23": "2.0.0-beta.23_@solidjs+web@2.0.0-beta.15__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29", + "npm:@tanstack/solid-start@2.0.0-beta.24": "2.0.0-beta.24_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0", "npm:@tauri-apps/api@^1.6.0": "1.6.0", "npm:@testing-library/jest-dom@^6.9.1": "6.9.1", "npm:@types/jsdom@^21.1.7": "21.1.7", @@ -57,10 +57,10 @@ "npm:remark-parse@11": "11.0.0", "npm:remark-rehype@^11.1.2": "11.1.2", "npm:sass@^1.101.0": "1.101.0", - "npm:solid-js@2.0.0-beta.26": "2.0.0-beta.26", + "npm:solid-js@2.0.0-beta.29": "2.0.0-beta.29", "npm:solid-js@^1.9.7": "1.9.14", - "npm:solid-transition-group@~0.2.3": "0.2.3_solid-js@2.0.0-beta.26", - "npm:storybook-solidjs-vite@^10.5.2": "10.5.2_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26_storybook@10.4.6__@types+react@19.2.17__prettier@3.9.4__@testing-library+dom@10.4.1__react@19.2.7_typescript@6.0.3_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_vite-plugin-solid@3.0.0-next.5__@solidjs+web@2.0.0-beta.26___solid-js@2.0.0-beta.26__@testing-library+jest-dom@6.9.1__solid-js@2.0.0-beta.26__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___sass@1.101.0", + "npm:solid-transition-group@~0.2.3": "0.2.3_solid-js@2.0.0-beta.29", + "npm:storybook-solidjs-vite@^10.5.2": "10.5.2_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29_storybook@10.4.6__@types+react@19.2.17__prettier@3.9.4__@testing-library+dom@10.4.1__react@19.2.7_typescript@6.0.3_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_vite-plugin-solid@3.0.0-next.5__@solidjs+web@2.0.0-beta.29___solid-js@2.0.0-beta.29__@testing-library+jest-dom@6.9.1__solid-js@2.0.0-beta.29__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___sass@1.101.0", "npm:storybook@^10.4.6": "10.4.6_@types+react@19.2.17_prettier@3.9.4_@testing-library+dom@10.4.1_react@19.2.7", "npm:tailwindcss-dir@4": "4.0.0", "npm:tailwindcss@3.3.3": "3.3.3", @@ -68,8 +68,8 @@ "npm:typescript@^7.0.2": "7.0.2", "npm:undici@^5.29.0": "5.29.0", "npm:unified@^11.0.5": "11.0.5", - "npm:unplugin-solid@1": "1.0.0_solid-js@2.0.0-beta.26", - "npm:vite-plugin-solid@3.0.0-next.5": "3.0.0-next.5_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_@testing-library+jest-dom@6.9.1_solid-js@2.0.0-beta.26_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0", + "npm:unplugin-solid@1": "1.0.0_solid-js@2.0.0-beta.29", + "npm:vite-plugin-solid@3.0.0-next.5": "3.0.0-next.5_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_@testing-library+jest-dom@6.9.1_solid-js@2.0.0-beta.29_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0", "npm:vite@^6.4.3": "6.4.3_@types+node@22.20.0_sass@1.101.0", "npm:vite@^8.1.3": "8.1.3_@types+node@22.20.0_esbuild@0.28.1_sass@1.101.0", "npm:vitest@^2.1.9": "2.1.9_@types+node@22.20.0_jsdom@25.0.1_sass@1.101.0" @@ -2645,7 +2645,7 @@ "@sindresorhus/is@4.6.0": { "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" }, - "@solid-devtools/debugger@0.28.1_solid-js@2.0.0-beta.26": { + "@solid-devtools/debugger@0.28.1_solid-js@2.0.0-beta.29": { "integrity": "sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg==", "dependencies": [ "@nothing-but/utils", @@ -2657,20 +2657,20 @@ "@solid-primitives/scheduled", "@solid-primitives/static-store", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-devtools/logger@0.9.11_solid-js@2.0.0-beta.26": { + "@solid-devtools/logger@0.9.11_solid-js@2.0.0-beta.29": { "integrity": "sha512-THbiY1iQlieL6vdgJc4FIsLe7V8a57hod/Thm8zdKrTkWL88UPZjkBBfM+mVNGusd4OCnAN20tIFBhNnuT1Dew==", "dependencies": [ "@nothing-but/utils", "@solid-devtools/debugger", "@solid-devtools/shared", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-devtools/shared@0.20.0_solid-js@2.0.0-beta.26": { + "@solid-devtools/shared@0.20.0_solid-js@2.0.0-beta.29": { "integrity": "sha512-o5TACmUOQsxpzpOKCjbQqGk8wL8PMi+frXG9WNu4Lh3PQVUB6hs95Kl/S8xc++zwcMguUKZJn8h5URUiMOca6Q==", "dependencies": [ "@nothing-but/utils", @@ -2682,124 +2682,124 @@ "@solid-primitives/static-store", "@solid-primitives/styles", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/bounds@0.1.6_solid-js@2.0.0-beta.26": { + "@solid-primitives/bounds@0.1.6_solid-js@2.0.0-beta.29": { "integrity": "sha512-aAjjllsB1hlkyWPYdP+AiavRNhJDUl1jBj08HGgZp2wdX6VsbJBik9+Y1JoNnbtFl/wls+8Z31hAemhsZ1bR6Q==", "dependencies": [ "@solid-primitives/event-listener", "@solid-primitives/resize-observer", "@solid-primitives/static-store", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/event-listener@2.4.6_solid-js@2.0.0-beta.26": { + "@solid-primitives/event-listener@2.4.6_solid-js@2.0.0-beta.29": { "integrity": "sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==", "dependencies": [ "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/keyboard@1.3.7_solid-js@2.0.0-beta.26": { + "@solid-primitives/keyboard@1.3.7_solid-js@2.0.0-beta.29": { "integrity": "sha512-558RPNYnXx4nGh537DSqAn4xMrC8iFipl/5+xzgzWoTNFst4RnUN3BOLmtDjJ0UGGoQXVMALYR3bNOHM0xnt1Q==", "dependencies": [ "@solid-primitives/event-listener", "@solid-primitives/rootless", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/media@2.3.6_solid-js@2.0.0-beta.26": { + "@solid-primitives/media@2.3.6_solid-js@2.0.0-beta.29": { "integrity": "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw==", "dependencies": [ "@solid-primitives/event-listener", "@solid-primitives/rootless", "@solid-primitives/static-store", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/refs@1.1.4_solid-js@2.0.0-beta.26": { + "@solid-primitives/refs@1.1.4_solid-js@2.0.0-beta.29": { "integrity": "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A==", "dependencies": [ "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/resize-observer@2.1.6_solid-js@2.0.0-beta.26": { + "@solid-primitives/resize-observer@2.1.6_solid-js@2.0.0-beta.29": { "integrity": "sha512-GFj6b81KIahl5SBf35VlyClHshDhVEREeZz2WYkHHKBvam66mE5H5f1g3EralabAAjmcU1bBd18YYYETPv0Knw==", "dependencies": [ "@solid-primitives/event-listener", "@solid-primitives/rootless", "@solid-primitives/static-store", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/rootless@1.5.4_solid-js@2.0.0-beta.26": { + "@solid-primitives/rootless@1.5.4_solid-js@2.0.0-beta.29": { "integrity": "sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==", "dependencies": [ "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/scheduled@1.5.3_solid-js@2.0.0-beta.26": { + "@solid-primitives/scheduled@1.5.3_solid-js@2.0.0-beta.29": { "integrity": "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g==", "dependencies": [ - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/static-store@0.1.4_solid-js@2.0.0-beta.26": { + "@solid-primitives/static-store@0.1.4_solid-js@2.0.0-beta.29": { "integrity": "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==", "dependencies": [ "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/styles@0.1.4_solid-js@2.0.0-beta.26": { + "@solid-primitives/styles@0.1.4_solid-js@2.0.0-beta.29": { "integrity": "sha512-siDvuK9mBpswjUdJCpr3ZUhBIHghNjHsBhZ9p3mIH1sDgwVr5gQGfFmESIvtb4Ka4Oq6B6hLOqi4QES/i8u4YA==", "dependencies": [ "@solid-primitives/rootless", "@solid-primitives/utils", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/transition-group@1.1.2_solid-js@2.0.0-beta.26": { + "@solid-primitives/transition-group@1.1.2_solid-js@2.0.0-beta.29": { "integrity": "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==", "dependencies": [ - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solid-primitives/utils@6.4.1_solid-js@2.0.0-beta.26": { + "@solid-primitives/utils@6.4.1_solid-js@2.0.0-beta.29": { "integrity": "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==", "dependencies": [ - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solidjs/meta@0.29.4_solid-js@2.0.0-beta.26": { + "@solidjs/meta@0.29.4_solid-js@2.0.0-beta.29": { "integrity": "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==", "dependencies": [ - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solidjs/signals@2.0.0-beta.26": { - "integrity": "sha512-ocwCOuqNbV6R1swAuNP+AF0FoRhxCK8/vhG+XszM0rP8htyYOqsLSXfUy3hVRJHJ/wh7ajN3pZXpv7SPujzD/w==" + "@solidjs/signals@2.0.0-beta.29": { + "integrity": "sha512-/W4goRwA/t9SqA0acIcD008pBJr2p5BMxVpzcdvWMYNswyE88se1ecJKQO/QTa6DjVRZvplOOtKEtDRHpEo3rQ==" }, - "@solidjs/web@2.0.0-beta.15_solid-js@2.0.0-beta.26_seroval@1.5.6": { + "@solidjs/web@2.0.0-beta.15_solid-js@2.0.0-beta.29": { "integrity": "sha512-+LvmzzN5CHxQ+TeCGgA8DKuB8S4t0EHVlmDUlAp4hkLnh7My3ZGuKYHKWsFMd8w08nPgF6LdBZkH+c5iNcmcSA==", "dependencies": [ "seroval-plugins", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26": { - "integrity": "sha512-t0GkzS7wZCuPej8OIQN0+6/uBOHRRhottvAIqnXK4QwiVhCiXr51Bx+sR194INQvvURiP50eprt9TusgKh/Taw==", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29": { + "integrity": "sha512-Bsb1JP1n6OIuiCQiYlE1B3+cNY0UlDPyA1UUYC3trFudUMs0aDCxGmY/B5KXnmw4NNcRIUA2bW46h3FC9qhWXA==", "dependencies": [ "seroval", "seroval-plugins", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, "@storybook/addon-docs@10.4.6_@types+react@19.2.17_storybook@10.4.6__@types+react@19.2.17__prettier@3.9.4__@testing-library+dom@10.4.1__react@19.2.7_@testing-library+dom@10.4.1_@types+node@22.20.0_esbuild@0.28.1_prettier@3.9.4_sass@1.101.0_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0": { @@ -2964,7 +2964,7 @@ "zod" ] }, - "@tanstack/router-plugin@1.168.18_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_vite-plugin-solid@3.0.0-next.5__@solidjs+web@2.0.0-beta.26___solid-js@2.0.0-beta.26__@testing-library+jest-dom@6.9.1__solid-js@2.0.0-beta.26__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___sass@1.101.0_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26": { + "@tanstack/router-plugin@1.168.18_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_vite-plugin-solid@3.0.0-next.5__@solidjs+web@2.0.0-beta.29___solid-js@2.0.0-beta.29__@testing-library+jest-dom@6.9.1__solid-js@2.0.0-beta.29__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___sass@1.101.0_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29": { "integrity": "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg==", "dependencies": [ "@babel/core", @@ -2997,43 +2997,43 @@ "tinyglobby" ] }, - "@tanstack/solid-router@2.0.0-beta.23_@solidjs+web@2.0.0-beta.15__solid-js@2.0.0-beta.26__seroval@1.5.6_solid-js@2.0.0-beta.26_seroval@1.5.6": { + "@tanstack/solid-router@2.0.0-beta.23_@solidjs+web@2.0.0-beta.15__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29": { "integrity": "sha512-0gwLWmeFuT6Euaqi0qSOq5jtJHAZJsmQhCZs+knwiLIsuhjUV4cRyhgEVI0eAVVTDlFfBSzA0tTBdFe5diPCoA==", "dependencies": [ "@solid-devtools/logger", "@solid-primitives/refs", "@solidjs/meta", - "@solidjs/web@2.0.0-beta.15_solid-js@2.0.0-beta.26_seroval@1.5.6", + "@solidjs/web@2.0.0-beta.15_solid-js@2.0.0-beta.29", "@tanstack/history", "@tanstack/router-core", "isbot", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@tanstack/solid-start-client@2.0.0-beta.23_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26_seroval@1.5.6": { + "@tanstack/solid-start-client@2.0.0-beta.23_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29": { "integrity": "sha512-EPYRD+arREV7r4PR0Mx4gnFZUfM+5ALIKltRdomhLBjmjmm6k9CFh5Ctqjj2lEdA6M5yvZlMgR9vNE14r1Knqg==", "dependencies": [ - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29", "@tanstack/router-core", "@tanstack/solid-router", "@tanstack/start-client-core", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@tanstack/solid-start-server@2.0.0-beta.23_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26_seroval@1.5.6": { + "@tanstack/solid-start-server@2.0.0-beta.23_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29": { "integrity": "sha512-EOJHHdIcRb9WkDE6PUJbn7Wh/BVvm7OUPGn0JzVZIObnXDU/BLd9yRDYSCIgC7JK1IH43dxlktV4eEb3TC86/Q==", "dependencies": [ - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29", "@tanstack/router-core", "@tanstack/solid-router", "@tanstack/start-server-core", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "@tanstack/solid-start@2.0.0-beta.24_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_seroval@1.5.6": { + "@tanstack/solid-start@2.0.0-beta.24_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0": { "integrity": "sha512-mtF9MP/zw/0IZ2qhZCcl5dUPB+bL7Fp7ftjHcrJ9m8zw6TDbnUSiC65W/xiymiLV/J1KssVklEs0sPIxdcvOtw==", "dependencies": [ - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29", "@tanstack/solid-router", "@tanstack/solid-start-client", "@tanstack/solid-start-server", @@ -3041,7 +3041,7 @@ "@tanstack/start-plugin-core", "@tanstack/start-server-core", "pathe@2.0.3", - "solid-js@2.0.0-beta.26", + "solid-js@2.0.0-beta.29", "vite@8.1.3_@types+node@22.20.0_esbuild@0.28.1_sass@1.101.0" ], "optionalPeers": [ @@ -3060,7 +3060,7 @@ "@tanstack/start-fn-stubs@1.162.0": { "integrity": "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==" }, - "@tanstack/start-plugin-core@1.171.17_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26": { + "@tanstack/start-plugin-core@1.171.17_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29": { "integrity": "sha512-ngKkp3wn/U3nyeqZl7KcMzjbgTbcypC5ES7O92JpA5/tz4PufFOf5l+eX3pY+4Z6jE6Jb6ekQgnryG7XMjpK7Q==", "dependencies": [ "@babel/code-frame@7.27.1", @@ -3798,26 +3798,26 @@ "parse5" ] }, - "babel-preset-solid@1.9.12_@babel+core@7.29.7_solid-js@2.0.0-beta.26": { + "babel-preset-solid@1.9.12_@babel+core@7.29.7_solid-js@2.0.0-beta.29": { "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", "dependencies": [ "@babel/core", "babel-plugin-jsx-dom-expressions", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ], "optionalPeers": [ - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "babel-preset-solid@2.0.0-beta.17_@babel+core@7.29.7_solid-js@2.0.0-beta.26": { + "babel-preset-solid@2.0.0-beta.17_@babel+core@7.29.7_solid-js@2.0.0-beta.29": { "integrity": "sha512-4v+lLgo+UGFZSF9Dw+1se1xE9L06ZCyRXVW7O1YhDj+41tdZH+Aa+3FyzYgTKoR/LbEFELgpKZExalOA6MIgPQ==", "dependencies": [ "@babel/core", "@dom-expressions/babel-plugin-jsx", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ], "optionalPeers": [ - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, "bail@2.0.2": { @@ -7028,8 +7028,8 @@ "seroval-plugins" ] }, - "solid-js@2.0.0-beta.26": { - "integrity": "sha512-ffP6D49w3uL7xoh6Pj3GReRjuAIayw9q2lOeeqnRXDuFPMrhdcMlHQ2P0whmXaXTrMTsI2yngRR1nszJIJHwyA==", + "solid-js@2.0.0-beta.29": { + "integrity": "sha512-Jv0jRw7joECw63cRfeqo5lnEVNVhAcH1hWt1nSvyp5npTgk6eyCmP5UDLTNWm6ox5G5LmBJzFkBulm+hp9+vmA==", "dependencies": [ "@solidjs/signals", "csstype", @@ -7037,28 +7037,28 @@ "seroval-plugins" ] }, - "solid-refresh@0.7.8_solid-js@2.0.0-beta.26": { + "solid-refresh@0.7.8_solid-js@2.0.0-beta.29": { "integrity": "sha512-iG442T3HXJp5jEebCy8okETrWnmX7CnxNOKrJQVeufh6WXSn+xtLXaptUXXMHTWcRqTJOJwQ8UwzxrRtVFSIzA==", "dependencies": [ "@babel/generator@7.29.7", "@babel/types@7.29.7", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "solid-refresh@0.8.0-next.7_solid-js@2.0.0-beta.26": { + "solid-refresh@0.8.0-next.7_solid-js@2.0.0-beta.29": { "integrity": "sha512-fqkPRAeiE0tqfo2ZljeQBIXwfYssU2w1FmaWFrXmnV33B/CfGfez7BjtOF0Y1/orUNRXI/DZcJlJThHllcCMsA==", "dependencies": [ "@babel/generator@7.29.7", "@babel/types@7.29.7", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, - "solid-transition-group@0.2.3_solid-js@2.0.0-beta.26": { + "solid-transition-group@0.2.3_solid-js@2.0.0-beta.29": { "integrity": "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==", "dependencies": [ "@solid-primitives/refs", "@solid-primitives/transition-group", - "solid-js@2.0.0-beta.26" + "solid-js@2.0.0-beta.29" ] }, "source-map-js@1.2.1": { @@ -7099,23 +7099,23 @@ "std-env@3.10.0": { "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==" }, - "storybook-solidjs-vite@10.5.2_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_solid-js@2.0.0-beta.26_storybook@10.4.6__@types+react@19.2.17__prettier@3.9.4__@testing-library+dom@10.4.1__react@19.2.7_typescript@6.0.3_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_vite-plugin-solid@3.0.0-next.5__@solidjs+web@2.0.0-beta.26___solid-js@2.0.0-beta.26__@testing-library+jest-dom@6.9.1__solid-js@2.0.0-beta.26__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___sass@1.101.0": { + "storybook-solidjs-vite@10.5.2_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_solid-js@2.0.0-beta.29_storybook@10.4.6__@types+react@19.2.17__prettier@3.9.4__@testing-library+dom@10.4.1__react@19.2.7_typescript@6.0.3_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0_vite-plugin-solid@3.0.0-next.5__@solidjs+web@2.0.0-beta.29___solid-js@2.0.0-beta.29__@testing-library+jest-dom@6.9.1__solid-js@2.0.0-beta.29__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___sass@1.101.0": { "integrity": "sha512-u+XSoTxE8JoVZ4IdI+gJRuNMQL00PCLA1BinBPdwHRKSSSVczf+JQhcfBJ2xxODs430RJp+JjAajKZeaSIHj9Q==", "dependencies": [ - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29", "@storybook/builder-vite", "@storybook/global", "@volar/language-core", "@volar/typescript", "semver@7.8.1", - "solid-js@2.0.0-beta.26", + "solid-js@2.0.0-beta.29", "storybook", "typescript@6.0.3", "vite@8.1.3_@types+node@22.20.0_esbuild@0.28.1_sass@1.101.0", "vite-plugin-solid" ], "optionalPeers": [ - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29", "typescript@6.0.3" ] }, @@ -7586,15 +7586,15 @@ "normalize-path@2.1.1" ] }, - "unplugin-solid@1.0.0_solid-js@2.0.0-beta.26": { + "unplugin-solid@1.0.0_solid-js@2.0.0-beta.29": { "integrity": "sha512-pv1CS3XMtf3WwX8Dq9Bvo4qH6mfjN2xOgbaPcnqW1dLhyP/JQCvueGEsN0dYIZ4JvxaD/G/Ot1JnBzNQGHkfeA==", "dependencies": [ "@babel/core", "@rollup/pluginutils", - "babel-preset-solid@1.9.12_@babel+core@7.29.7_solid-js@2.0.0-beta.26", + "babel-preset-solid@1.9.12_@babel+core@7.29.7_solid-js@2.0.0-beta.29", "merge-anything@6.0.6", - "solid-js@2.0.0-beta.26", - "solid-refresh@0.7.8_solid-js@2.0.0-beta.26", + "solid-js@2.0.0-beta.29", + "solid-refresh@0.7.8_solid-js@2.0.0-beta.29", "unplugin@2.3.11", "vitefu" ] @@ -7691,17 +7691,17 @@ ], "bin": true }, - "vite-plugin-solid@3.0.0-next.5_@solidjs+web@2.0.0-beta.26__solid-js@2.0.0-beta.26_@testing-library+jest-dom@6.9.1_solid-js@2.0.0-beta.26_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0": { + "vite-plugin-solid@3.0.0-next.5_@solidjs+web@2.0.0-beta.29__solid-js@2.0.0-beta.29_@testing-library+jest-dom@6.9.1_solid-js@2.0.0-beta.29_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__sass@1.101.0": { "integrity": "sha512-hcn3mzevQDv6Oyo/Zv5LXdOrlWwKGeGVxNhc9fUq3AcN9aO6KABy52yq5cvnPDo3qaxmvOJVbNS1H4V5rx7AQg==", "dependencies": [ "@babel/core", - "@solidjs/web@2.0.0-beta.26_solid-js@2.0.0-beta.26", + "@solidjs/web@2.0.0-beta.29_solid-js@2.0.0-beta.29", "@testing-library/jest-dom", "@types/babel__core", - "babel-preset-solid@2.0.0-beta.17_@babel+core@7.29.7_solid-js@2.0.0-beta.26", + "babel-preset-solid@2.0.0-beta.17_@babel+core@7.29.7_solid-js@2.0.0-beta.29", "merge-anything@5.1.7", - "solid-js@2.0.0-beta.26", - "solid-refresh@0.8.0-next.7_solid-js@2.0.0-beta.26", + "solid-js@2.0.0-beta.29", + "solid-refresh@0.8.0-next.7_solid-js@2.0.0-beta.29", "vite@8.1.3_@types+node@22.20.0_esbuild@0.28.1_sass@1.101.0", "vitefu" ], @@ -7970,7 +7970,7 @@ "npm:@babel/preset-typescript@^7.27.1", "npm:@changesets/cli@^2.31.0", "npm:@nothing-but/node-resolve-ts@^1.0.1", - "npm:@solidjs/web@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", "npm:@storybook/addon-docs@^10.4.6", "npm:@testing-library/jest-dom@^6.9.1", "npm:@types/jsdom@^21.1.7", @@ -7990,7 +7990,7 @@ "npm:rehype-highlight@^7.0.2", "npm:rehype-slug@6", "npm:remark-gfm@^4.0.1", - "npm:solid-js@2.0.0-beta.26", + "npm:solid-js@2.0.0-beta.29", "npm:storybook-solidjs-vite@^10.5.2", "npm:storybook@^10.4.6", "npm:tsdown@~0.22.3", @@ -8005,119 +8005,119 @@ "packages/a11y": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/active-element": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/analytics": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/async": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/audio": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/bounds": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/broadcast-channel": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/clipboard": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/connectivity": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/context": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/controlled-props": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/controlled-signal": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/cookies": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/cursor": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/date": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", "npm:date-fns@^2.30.0", - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, @@ -8132,61 +8132,61 @@ "packages/deep": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/destructure": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/devices": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/event-bus": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/event-dispatcher": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/event-listener": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/event-props": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/favicon": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.20", - "npm:solid-js@2.0.0-beta.20" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, @@ -8201,59 +8201,59 @@ "packages/filesystem": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", "npm:@tauri-apps/api@^1.6.0", "npm:@types/wicg-file-system-access@2023.10.7", "npm:chokidar@^3.6.0", - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/flux-store": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/focus": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/form": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/fullscreen": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/geolocation": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", "npm:@types/leaflet@^1.9.21", "npm:leaflet@^1.9.4", - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/gestures": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, @@ -8273,16 +8273,16 @@ "packages/history": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/i18n": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29", "npm:tsdown@~0.22.3", "npm:unplugin-solid@1" ] @@ -8291,8 +8291,8 @@ "packages/idle": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, @@ -8309,258 +8309,258 @@ "packages/input-mask": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/interaction": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/intersection-observer": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/jsx-tokenizer": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/keyboard": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/keyed": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/lifecycle": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/list": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/list-state": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/map": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/marker": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/masonry": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/match": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/media": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/mediastream": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/memo": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/mouse": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/mutable": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/mutation-observer": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/notification": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/orientation": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/page-utilities": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/pagination": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/permission": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/platform": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/pointer": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/presence": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/promise": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/props": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/queue": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/raf": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/range": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/refs": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29", "npm:solid-transition-group@~0.2.3" ] } @@ -8568,8 +8568,8 @@ "packages/resize-observer": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, @@ -8583,213 +8583,221 @@ "packages/rootless": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/scheduled": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/script-loader": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/scroll": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/selection": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/sensors": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/set": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/share": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/signal-builders": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" + ] + } + }, + "packages/sortable": { + "packageJson": { + "dependencies": [ + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/spring": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/sse": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/state-machine": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/static-store": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/storage": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/styles": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/timer": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/transition-group": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/trigger": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/tween": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/upload": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/url": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/utils": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/vibrate": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/video": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/virtual": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/websocket": { "packageJson": { "dependencies": [ - "npm:solid-js@2.0.0-beta.26" + "npm:solid-js@2.0.0-beta.29" ] } }, "packages/workers": { "packageJson": { "dependencies": [ - "npm:@solidjs/web@2.0.0-beta.26", - "npm:solid-js@2.0.0-beta.26" + "npm:@solidjs/web@2.0.0-beta.29", + "npm:solid-js@2.0.0-beta.29" ] } }, @@ -8797,7 +8805,7 @@ "packageJson": { "dependencies": [ "npm:@floating-ui/dom@^1.7.6", - "npm:@solidjs/web@2.0.0-beta.26", + "npm:@solidjs/web@2.0.0-beta.29", "npm:@tailwindcss/container-queries@~0.1.1", "npm:@tailwindcss/typography@~0.5.20", "npm:@tanstack/solid-router@2.0.0-beta.23", 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..c08e10b76 --- /dev/null +++ b/packages/sortable/src/comparators.ts @@ -0,0 +1,57 @@ +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}). */ +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': From 125ef0e0f2ea830dfb2f20bbf0250726288ecfc6 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:12:05 -0400 Subject: [PATCH 2/2] reverse inverts missing-value placement instead of preserving it --- packages/sortable/src/comparators.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/sortable/src/comparators.ts b/packages/sortable/src/comparators.ts index c08e10b76..b4f8deaf5 100644 --- a/packages/sortable/src/comparators.ts +++ b/packages/sortable/src/comparators.ts @@ -53,5 +53,14 @@ export const combine = (...comparators: Comparator[]): Comparator => (a return 0; }; -/** Flips the order of any comparator (composes with {@link by} and {@link combine}). */ +/** + * 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);