From 8b1a8ee5c08a441599fc1a8f30a92c31bb3b7edd Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 16 Aug 2026 14:51:05 +0700 Subject: [PATCH] feat!: remove TanStack, and own the form engine Three TanStack packages were the last things pinning this library to Solid 1, and none of them was earning its place. `@tanstack/solid-table` backed seven public hooks that **nothing in this library used**. `createDataGrid` already answers the same questions natively - its rows derive `filteredRows -> sortedRows -> pageRows` with selection alongside - so the hooks were a second table engine kept alive by their own exports. `@tanstack/solid-virtual` was the same story in one file. `useAnchoredOverlayPosition` stays: it never touched TanStack and a grid still needs to position an overlay against a cell. `@tanstack/solid-form` was different, and it is the work here. It was load bearing: `FormField` rendered ``, `FormSubmitButton` rendered ``, and `FormApi` was literally `{ _tsForm: any }`, so twelve erased generics were part of this library's public type. The replacement is small because the field components already carry the hard part - `Input`, `Select` and the rest own their presentation, `state="invalid"` and error slot. What was missing was bookkeeping: values, touched, errors, and when to run the schema. Standard Schema is called directly, which is what TanStack was doing on our behalf anyway. Two behaviours are now explicit rather than inherited. Errors are gated on `isTouched` so a form does not open covered in red, and a failed `submit()` touches every field, so the errors it refused on all become visible at once rather than one blur at a time. There is a test for the second, which the old suite had no way to express. Also drops ten `@solid-primitives/*` packages. Every one was declared in `package.json` and imported from nowhere - not in `src`, `tests`, `scripts` or `playground`. 28 packages leave the install. BREAKING CHANGE: `useTableModel`, `useTableSorting`, `useTablePagination`, `useTableFiltering`, `useTableSelection`, `useTableExpansion`, `toSortDescriptor`, `toSortingState` and `useVirtualRows` are removed, with their option and result types. Use `createDataGrid`, which covers filtering, sorting, pagination and selection; row expansion has no replacement yet. `FormApi._tsForm` is gone, replaced by `values()`, `getFieldValue`, `getFieldMeta`, `setFieldValue`, `validateField`, `submit()`, `isSubmitting()` and `isValid()`. --- README.md | 7 +- bun.lock | 129 +------ docs/ui-usage.md | 8 +- package.json | 25 -- src/components/form/Form.layout.tsx | 2 +- src/components/form/FormField.layout.tsx | 67 ++-- .../form/FormSubmitButton.layout.tsx | 26 +- src/components/table/index.ts | 30 +- src/hooks/form/FormContext.ts | 7 +- src/hooks/form/createForm.ts | 345 +++++++++--------- src/hooks/form/useFieldNew.ts | 33 +- src/hooks/table/helpers.ts | 42 --- src/hooks/table/index.ts | 45 +-- src/hooks/table/useTableExpansion.ts | 41 --- src/hooks/table/useTableFiltering.ts | 138 ------- src/hooks/table/useTableModel.ts | 98 ----- src/hooks/table/useTablePagination.ts | 69 ---- src/hooks/table/useTableSelection.ts | 42 --- src/hooks/table/useTableSorting.ts | 56 --- src/index.ts | 38 +- src/primitives/virtualizer/index.ts | 6 - src/primitives/virtualizer/useVirtualRows.ts | 80 ---- tests/hooks/form/createForm.test.ts | 50 ++- 23 files changed, 288 insertions(+), 1096 deletions(-) delete mode 100644 src/hooks/table/helpers.ts delete mode 100644 src/hooks/table/useTableExpansion.ts delete mode 100644 src/hooks/table/useTableFiltering.ts delete mode 100644 src/hooks/table/useTableModel.ts delete mode 100644 src/hooks/table/useTablePagination.ts delete mode 100644 src/hooks/table/useTableSelection.ts delete mode 100644 src/hooks/table/useTableSorting.ts delete mode 100644 src/primitives/virtualizer/index.ts delete mode 100644 src/primitives/virtualizer/useVirtualRows.ts diff --git a/README.md b/README.md index 3bbbdfba..0aa0e457 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,8 @@ The rules that hold across every component — worth two minutes before your fir flat. Parts are styleable via `data-slot` and state attributes such as `data-open`. - There is **no polymorphic `as` prop**. -Forms are TanStack Form plus any Standard Schema validator; Table is headless and assembled -from hooks. Both, with toasts, icons and dates, are covered in +Forms are built in and take any Standard Schema validator; DataGrid derives filtering, +sorting, pagination and selection itself. Both, with toasts, icons and dates, are covered in **[docs/ui-usage.md](https://github.com/pathscale/ui/blob/master/docs/ui-usage.md)**. ## Requirements @@ -137,8 +137,7 @@ line and must not be used. The complete compiler contract, hard-error behavior, commands, and porting analyzer are documented in [docs/layouts.md](docs/layouts.md). -Peers include `@solid-primitives/*`, `@tanstack/solid-form` and `@tanstack/solid-table`. -Two are optional and only needed for the features they back: `popmotion` (JS animation +Two peers are optional and only needed for the features they back: `popmotion` (JS animation driver) and `@standard-schema/spec` (schema validation). ## Subpath exports diff --git a/bun.lock b/bun.lock index 53b1c804..f2d1281a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,7 @@ "": { "name": "@pathscale/ui", "dependencies": { - "@iconify/tailwind4": "^1.2.3", - "@pathscale/rsbuild-plugin-iconify": "^1.0.4", - "@tanstack/solid-virtual": "^3.13.27", + "clsx": "^2.1.1", "tailwind-merge": "^3.6.0", }, "devDependencies": { @@ -19,23 +17,10 @@ "@rsbuild/core": "^2.0.9", "@rsbuild/plugin-solid": "^1.2.1", "@rslib/core": "^0.22.0", - "@solid-primitives/event-listener": "^2.4.5", - "@solid-primitives/intersection-observer": "^2.2.4", - "@solid-primitives/keyboard": "^1.3.5", - "@solid-primitives/media": "^2.3.5", - "@solid-primitives/props": "^3.2.3", - "@solid-primitives/resize-observer": "^2.1.5", - "@solid-primitives/scheduled": "^1.5.3", - "@solid-primitives/scroll": "^2.1.5", - "@solid-primitives/storage": "^2.1.4", - "@solid-primitives/utils": "^6.4.0", "@standard-schema/spec": "^1.1.0", - "@tanstack/solid-form": "^1.33.0", - "@tanstack/solid-table": "^8.21.3", "@types/bun": "^1.3.14", "babel-preset-solid": "^1.9.12", "cally": "^0.8.0", - "clsx": "^2.1.1", "cssnano": "^7.1.9", "postcss": "^8.5.15", "postcss-cli": "^11.0.1", @@ -48,19 +33,7 @@ "typescript": "^6.0.3", }, "peerDependencies": { - "@solid-primitives/event-listener": "^2.3.0", - "@solid-primitives/intersection-observer": "^2.1.3", - "@solid-primitives/keyboard": "^1.2.5", - "@solid-primitives/media": "^2.2.5", - "@solid-primitives/props": "^3.1.8", - "@solid-primitives/resize-observer": "^2.0.22", - "@solid-primitives/scheduled": "^1.4.1", - "@solid-primitives/scroll": "^2.0.20", - "@solid-primitives/storage": "^2.1.1", - "@solid-primitives/utils": "^6.2.1", "@standard-schema/spec": "^1.0.0", - "@tanstack/solid-form": "^1.29.0", - "@tanstack/solid-table": "^8.0.0", "popmotion": "^11.0.5", "solid-js": "^1.9", "solid-layouts": "^0.1.3", @@ -178,8 +151,6 @@ "@colordx/core": ["@colordx/core@5.4.3", "", {}, "sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ=="], - "@cyberalien/svg-utils": ["@cyberalien/svg-utils@1.2.15", "", { "dependencies": { "@iconify/types": "^2.0.0" } }, "sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -188,10 +159,6 @@ "@iconify/json": ["@iconify/json@2.2.481", "", { "dependencies": { "@iconify/types": "*", "pathe": "^2.0.3" } }, "sha512-H/bCPJN+JawFnEgSFtBGQMz2nAc3KNsLZ6fKG6KfJX7X9H0blv0Acbt6umCBVRxZ0xBM/9FcUYZst4/Zv6SB/A=="], - "@iconify/tailwind4": ["@iconify/tailwind4@1.2.3", "", { "dependencies": { "@iconify/tools": "^5.0.5", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.0" }, "peerDependencies": { "tailwindcss": ">= 4.0.0" } }, "sha512-z8SKiMHRASJKF/IY//87MF88lcB7ulxh8vlhQXXLWsBkNtOh6ese9R41MyGpQeqXdRvQVt+/fX2glQtHFjQ+MA=="], - - "@iconify/tools": ["@iconify/tools@5.0.12", "", { "dependencies": { "@cyberalien/svg-utils": "^1.2.15", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "fflate": "^0.8.3", "modern-tar": "^0.7.6", "pathe": "^2.0.3", "svgo": "^4.0.1" } }, "sha512-aFPwSFmFphUPVjNLUkgxgUPSPVgTEEjv0mE7NgvfoQBuE5TtsMrKa4HTY65cM5oXZ2a1xKQcW/RKhiOKEiAarw=="], - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], @@ -220,8 +187,6 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@pathscale/rsbuild-plugin-iconify": ["@pathscale/rsbuild-plugin-iconify@1.0.4", "", { "dependencies": { "@iconify/json": "^2.2.481", "@iconify/utils": "^2.3.0", "@rspack/core": "^1.7.11", "cssnano": "^7.1.9", "postcss": "^8.5.15", "postcss-cli": "^11.0.1", "svgo": "^3.3.3" }, "peerDependencies": { "@rsbuild/core": "^1.3.22", "@rslib/core": "^0.9.1" } }, "sha512-KrCuzAa9AzbXojh7ne+yfldfipWZ39d/BFfwDhLNlDvQxpena5I/0h4eoa4x4RlaCux6h+Ay81KmHGrdR0tpyw=="], - "@pathscale/rsbuild-plugin-ui-css-purge": ["@pathscale/rsbuild-plugin-ui-css-purge@0.9.11", "", { "dependencies": { "@swc/core": "^1.15.40", "lightningcss": "^1.32.0", "postcss": "^8.5.15" }, "bin": { "rsbuild-plugin-ui-css-purge": "dist/postbuild-purge.js", "generate-manifest": "src/generate-manifest.ts" } }, "sha512-iIyScjlJ1/yfbtD+vPWQP+S5WSQp6tBtppDAMGImBPCxzoSnpQPwhvsyFGpNyVwGUZj/8QxhbpY34ev0FXKpng=="], "@rsbuild/core": ["@rsbuild/core@2.0.9", "", { "dependencies": { "@rspack/core": "~2.0.5", "@swc/helpers": "^0.5.23" }, "peerDependencies": { "core-js": ">= 3.0.0" }, "optionalPeers": ["core-js"], "bin": { "rsbuild": "./bin/rsbuild.js" } }, "sha512-lK2bMNuwh3TuLXLskS7nG3fnQk+6eaLeeZiquJWcna4JZx9iaI59JSd+iLcg5TeZLjEVygkwn/HcE+yuYDQRAw=="], @@ -256,32 +221,6 @@ "@rspack/core": ["@rspack/core@2.0.5", "", { "dependencies": { "@rspack/binding": "2.0.5" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-9tv2HAnSiTote5WPH2tmz1hLZ1zKbzkiZc1eYp7LP/8jcsiJBuf40ihiWidAgbbuYtJo3kWET6q+qOm5UhNiGA=="], - "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="], - - "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], - - "@solid-primitives/intersection-observer": ["@solid-primitives/intersection-observer@2.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-3+QaAT/TUdEaeKkqiTf+sk8g1/vE+QM9PucG2/JI8hLI8ALpBYBnlhtvcwIdpjaKMSpPN/1L4hneLvwqnx4YRA=="], - - "@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ=="], - - "@solid-primitives/media": ["@solid-primitives/media@2.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA=="], - - "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], - - "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="], - - "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - - "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="], - - "@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-G7t4Kt1E+f4d7iZ2dGDWsra2k1WQ5VOtXrTcg7MC+DrR5QMq8YCj9wB/C9yxHJ1lGi7Sv+mzjNLtVCFO5ECzUg=="], - - "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], - - "@solid-primitives/storage": ["@solid-primitives/storage@2.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12", "solid-start": ">=0.2.26" }, "optionalPeers": ["solid-start"] }, "sha512-ziGRMSPi2PDnY6L6YPGWYnzmUEPIwO0PaDT9N1WNW9P2tUMMhgqKLXBrYfjytpjwY3UM6kIrbONMRADjyGtzAw=="], - - "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@swc/core": ["@swc/core@1.15.40", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.40", "@swc/core-darwin-x64": "1.15.40", "@swc/core-linux-arm-gnueabihf": "1.15.40", "@swc/core-linux-arm64-gnu": "1.15.40", "@swc/core-linux-arm64-musl": "1.15.40", "@swc/core-linux-ppc64-gnu": "1.15.40", "@swc/core-linux-s390x-gnu": "1.15.40", "@swc/core-linux-x64-gnu": "1.15.40", "@swc/core-linux-x64-musl": "1.15.40", "@swc/core-win32-arm64-msvc": "1.15.40", "@swc/core-win32-ia32-msvc": "1.15.40", "@swc/core-win32-x64-msvc": "1.15.40" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg=="], @@ -316,26 +255,6 @@ "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], - "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - - "@tanstack/form-core": ["@tanstack/form-core@1.33.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-AV4Pw9Dk4orFsuPBcDssfWMJFs+yMYBae7zZ4oTqrCf4ftNGQKxvrQRZeqKHG6A4TkiLeSvf2kzIjcVkrW7E6w=="], - - "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], - - "@tanstack/solid-form": ["@tanstack/solid-form@1.33.0", "", { "dependencies": { "@tanstack/form-core": "1.33.0", "@tanstack/solid-store": "^0.11.0" }, "peerDependencies": { "solid-js": ">=1.9.9" } }, "sha512-+KXp+T/fD+nO5JarDj1W5GctEm4aFuz13DkHVJrdIHGo0evMXlFNWJT57n+TOyvoLoKehknKjM1o41rrKClSTQ=="], - - "@tanstack/solid-store": ["@tanstack/solid-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-2isL0ZnnyI1iN0V+QPrxE3OcPndohBgVlBcHZYoAOIAiU1WoWjVy0q5gb0suPu1Id0h5cKC23JnwzQTxWDZD0w=="], - - "@tanstack/solid-table": ["@tanstack/solid-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "solid-js": ">=1.3" } }, "sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A=="], - - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.27", "", { "dependencies": { "@tanstack/virtual-core": "3.16.1" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-pQZJa2IOR1rr76A1l4wV4iY1mrku6B8otNGw2hg1ywKCrbl74y50AbpPYR06ziMOjGjrGZBBAdTk+VY9IJON6w=="], - - "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], - - "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.16.1", "", {}, "sha512-br4gmJ3eI2IXth0fAAVWNdi/Iqb5ZDIUG9409Q17qnXegeHHc9vPEeMsSwpGf29GqayfmqFPtv9S4rxcZOub3Q=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -444,8 +363,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -464,8 +381,6 @@ "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -524,8 +439,6 @@ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - "modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -662,8 +575,6 @@ "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], - "thenby": ["thenby@1.4.1", "", {}, "sha512-D5a/bO0KdalOE3q8MlrRmSxjbKZHT3MQmXkJP+r97Vw8MMwOZKOwUSEyTtK7eSMj2y0kyAjpYMRMZmmLw1FtNQ=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], @@ -698,14 +609,6 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "@iconify/tailwind4/@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], - - "@iconify/tools/@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], - - "@iconify/tools/svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], @@ -720,12 +623,6 @@ "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "@iconify/tools/svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], - - "@iconify/tools/svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], @@ -734,30 +631,6 @@ "postcss-svgo/svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], - "@iconify/tools/svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="], - "postcss-svgo/svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - - "@pathscale/rsbuild-plugin-iconify/@rspack/core/@rspack/binding/@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], } } diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 71295963..eadca7b8 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -146,7 +146,7 @@ the same normalized hex that it displays. Surface strength, softness and accent remain consumer theme concerns; use the literal selected hex as their input rather than making the wheel silently transform it. -## Forms (TanStack Form + Standard Schema) +## Forms (built in, plus Standard Schema) ```tsx import { createForm, Form, FormField, FormSubmitButton } from "@pathscale/ui"; @@ -165,8 +165,8 @@ const form = createForm({ ``` - `Form` without a `form` prop = plain styled `
` (`FormRoot`). With `form` = context provider + wired submit. -- Inside a ``: `useField(name)` → `{value, error, touched, invalid, handleChange, handleBlur}`. **Errors are touch-gated** — `error()` is `undefined` until the field blurs. `FormSubmitButton` disables on `!canSubmit` (not touch-gated), so the button can be disabled with no visible error. -- Escape hatch: `form._tsForm` is the raw TanStack form API (typed `any` on purpose). +- Inside a ``: `useField(name)` → `{value, error, touched, invalid, handleChange, handleBlur}`. **Errors are touch-gated** — `error()` is `undefined` until the field blurs. `FormSubmitButton` disables on `!form.isValid()` (not touch-gated), so the button can be disabled with no visible error. A failed `submit()` touches every field, so the errors it refused on all become visible at once. +- The form API is the library's own: `values()`, `getFieldValue`, `getFieldMeta`, `setFieldValue`, `validateField`, `submit()`, `isSubmitting()`, `isValid()`. There is no longer a `_tsForm` escape hatch, because there is no longer a wrapped library to escape to. - Schema validation runs on change+blur+submit; blur errors clear immediately on change once valid. ## DataGrid (assembled) @@ -250,7 +250,7 @@ const table = useTableModel({ - State-slice hooks (all controlled-or-uncontrolled): `useTableSorting`, `useTableSelection`, `useTableFiltering` (per-column popovers + `getColumnFilterProps`), `useTablePagination` (⚠️ `nextPage(max)`/`lastPage(max)` need caller-supplied max page index), `useTableExpansion`. - Parts: TableRoot/ScrollContainer/Content/Header/Column/Body/Row/Cell/ExpandedRow/Footer/PageSize/ResizableContainer/ColumnResizer/LoadMore(+Content), plus SortIcon, ExpandToggle, InlineConfirm, MobileListView (responsive card fallback), VirtualSpacerRow. -- **Virtualization is not built in**: combine `useVirtualRows` (wraps @tanstack/solid-virtual) + `VirtualSpacerRow` yourself. See the Table section above. +- **Virtualization is not built in.** `useVirtualRows` was a thin wrapper over `@tanstack/solid-virtual` that nothing in the library used; it was removed with the rest of TanStack. `VirtualSpacerRow` is still here for a caller that brings its own windowing. ## Motion diff --git a/package.json b/package.json index 9461f423..8ea74e12 100644 --- a/package.json +++ b/package.json @@ -73,19 +73,7 @@ "@rsbuild/core": "^2.0.9", "@rsbuild/plugin-solid": "^1.2.1", "@rslib/core": "^0.22.0", - "@solid-primitives/event-listener": "^2.4.5", - "@solid-primitives/intersection-observer": "^2.2.4", - "@solid-primitives/keyboard": "^1.3.5", - "@solid-primitives/media": "^2.3.5", - "@solid-primitives/props": "^3.2.3", - "@solid-primitives/resize-observer": "^2.1.5", - "@solid-primitives/scheduled": "^1.5.3", - "@solid-primitives/scroll": "^2.1.5", - "@solid-primitives/storage": "^2.1.4", - "@solid-primitives/utils": "^6.4.0", "@standard-schema/spec": "^1.1.0", - "@tanstack/solid-form": "^1.33.0", - "@tanstack/solid-table": "^8.21.3", "@types/bun": "^1.3.14", "babel-preset-solid": "^1.9.12", "cally": "^0.8.0", @@ -102,23 +90,10 @@ }, "dependencies": { "clsx": "^2.1.1", - "@tanstack/solid-virtual": "^3.13.27", "tailwind-merge": "^3.6.0" }, "peerDependencies": { - "@solid-primitives/event-listener": "^2.3.0", - "@solid-primitives/intersection-observer": "^2.1.3", - "@solid-primitives/keyboard": "^1.2.5", - "@solid-primitives/media": "^2.2.5", - "@solid-primitives/props": "^3.1.8", - "@solid-primitives/resize-observer": "^2.0.22", - "@solid-primitives/scheduled": "^1.4.1", - "@solid-primitives/scroll": "^2.0.20", - "@solid-primitives/storage": "^2.1.1", - "@solid-primitives/utils": "^6.2.1", "@standard-schema/spec": "^1.0.0", - "@tanstack/solid-form": "^1.29.0", - "@tanstack/solid-table": "^8.0.0", "popmotion": "^11.0.5", "solid-js": "^1.9", "solid-layouts": "^0.1.3" diff --git a/src/components/form/Form.layout.tsx b/src/components/form/Form.layout.tsx index 19759b91..a7668f6b 100644 --- a/src/components/form/Form.layout.tsx +++ b/src/components/form/Form.layout.tsx @@ -57,7 +57,7 @@ const FormWithContext: Layout = () const handleSubmit: JSX.EventHandlerUnion = (e) => { e.preventDefault(); e.stopPropagation(); - local.form._tsForm.handleSubmit(); + void local.form.submit(); if (typeof local.onSubmit === "function") { local.onSubmit(e); } diff --git a/src/components/form/FormField.layout.tsx b/src/components/form/FormField.layout.tsx index 19158f38..653033cb 100644 --- a/src/components/form/FormField.layout.tsx +++ b/src/components/form/FormField.layout.tsx @@ -1,6 +1,5 @@ import { Show, splitProps, type Component, type JSX } from "solid-js"; import { twMerge } from "tailwind-merge"; -import type { FieldApi } from "@tanstack/solid-form"; import Input from "../input"; import type { InputFieldProps } from "../input"; @@ -75,49 +74,37 @@ const FormField: Layout = () => { }; const form = resolveForm(); - const tsForm = form._tsForm; - return ( - FieldApi) => { - const errorMessage = () => - field().state.meta.isTouched - ? getFirstFieldError((field().state.meta.errors ?? []) as unknown[]) - : undefined; + // Read straight off the form rather than through a render-prop child. The + // engine is ours now, so field state is just three accessors, and a wrapper + // component that exists only to hand them down would be a layer with no job. + const meta = () => form.getFieldMeta(local.name); + const errorMessage = () => + meta().isTouched ? getFirstFieldError(meta().errors) : undefined; - return ( -
- - - + return ( +
+ + + - { - field().handleChange(e.currentTarget.value as never); - }} - onBlur={() => field().handleBlur()} - aria-invalid={Boolean(errorMessage()) ? true : undefined} - issues={errorMessage() ? [{ code: "invalid", message: String(errorMessage()) }] : undefined} - /> + { + form.setFieldValue(local.name, e.currentTarget.value); + }} + onBlur={() => form.validateField(local.name, "blur")} + aria-invalid={errorMessage() ? true : undefined} + issues={errorMessage() ? [{ code: "invalid", message: String(errorMessage()) }] : undefined} + /> - -
- ); - }} - /> + +
); }; diff --git a/src/components/form/FormSubmitButton.layout.tsx b/src/components/form/FormSubmitButton.layout.tsx index ec17ad13..6e7efa95 100644 --- a/src/components/form/FormSubmitButton.layout.tsx +++ b/src/components/form/FormSubmitButton.layout.tsx @@ -45,24 +45,18 @@ const FormSubmitButton: Layout = }; const form = resolveForm(); - const tsForm = form._tsForm; + // `` was a render prop whose only job was to deliver two + // booleans. Both are accessors on the form now, so the component reads them + // directly and Solid tracks them the same way. return ( - ({ - canSubmit: s.canSubmit, - isSubmitting: s.isSubmitting, - })} - children={(state: () => { canSubmit: boolean; isSubmitting: boolean }) => ( - - )} - /> + ); }; diff --git a/src/components/table/index.ts b/src/components/table/index.ts index 3e271e53..5dea56b2 100644 --- a/src/components/table/index.ts +++ b/src/components/table/index.ts @@ -1,33 +1,7 @@ export type { ExpandToggleProps } from "./ExpandToggle.generated"; export { default as ExpandToggle } from "./ExpandToggle.generated"; -export type { - HookSortDescriptor, - HookSortDirection, - TableSelectionState, - UseAnchoredOverlayPositionOptions, - UseTableExpansionOptions, - UseTableExpansionResult, - UseTableFilteringOptions, - UseTableFilteringResult, - UseTableModelOptions, - UseTablePaginationOptions, - UseTablePaginationResult, - UseTableSelectionOptions, - UseTableSelectionResult, - UseTableSortingOptions, - UseTableSortingResult, -} from "./hooks"; -export { - toSortDescriptor, - toSortingState, - useAnchoredOverlayPosition, - useTableExpansion, - useTableFiltering, - useTableModel, - useTablePagination, - useTableSelection, - useTableSorting, -} from "./hooks"; +export type { UseAnchoredOverlayPositionOptions } from "./hooks"; +export { useAnchoredOverlayPosition } from "./hooks"; export type { InlineConfirmProps, InlineConfirmVariant, diff --git a/src/hooks/form/FormContext.ts b/src/hooks/form/FormContext.ts index b8d057cc..832aa74b 100644 --- a/src/hooks/form/FormContext.ts +++ b/src/hooks/form/FormContext.ts @@ -5,9 +5,8 @@ import type { FormApi } from "./createForm"; // Context // --------------------------------------------------------------------------- -// We use `any` here because the form's generic parameters are deeply nested -// TanStack types — the context just needs to hold the opaque FormApi wrapper. -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// The context holds a form whose value type the provider knows and the +// consumer does not, so the stored type erases it. export type AnyFormApi = FormApi; export const FormContext = createContext(null); @@ -26,7 +25,7 @@ export const FormContext = createContext(null); * @example * // Inside a component rendered as a child of * const form = useFormContext(); - * const tsForm = form._tsForm; + * const value = form.getFieldValue("email"); */ export const useFormContext = (): AnyFormApi => { const ctx = useContext(FormContext); diff --git a/src/hooks/form/createForm.ts b/src/hooks/form/createForm.ts index cdb1ace1..3d4bd874 100644 --- a/src/hooks/form/createForm.ts +++ b/src/hooks/form/createForm.ts @@ -1,11 +1,9 @@ -import { createForm as createTSForm } from "@tanstack/solid-form"; +import { createStore, produce } from "solid-js/store"; import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { getFirstFieldError } from "./getFirstFieldError"; -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -type AnyValues = Record; +// biome-ignore lint/suspicious/noExplicitAny: a form's values are the caller's shape +type AnyValues = Record; export type AsyncValidatorFn = (context: { value: TValues; @@ -20,8 +18,7 @@ export type CreateFormOptions = { /** * Any Standard Schema-compatible schema (Zod, Valibot, Arktype, ...). - * Applied as `validators.onChange`, `validators.onBlur` and - * `validators.onSubmit`. + * Runs on change, on blur and on submit. */ schema?: StandardSchemaV1; @@ -40,189 +37,197 @@ export type CreateFormOptions = { onSubmit?: (value: TValues) => void | Promise; }; +/** What a field tracks besides its value. */ +type FieldMeta = { + isTouched: boolean; + errors: string[]; +}; + /** * The form API returned by `createForm`. * - * Consumers should use `` to wire it into the component tree. - * For advanced use, access the raw TanStack Form instance via `api._tsForm`. + * Consumers should use `` to wire it into the component tree, + * and read individual fields with `useField(name)`. */ export type FormApi = { - /** - * Raw TanStack Form instance. Intended as an escape hatch for cases not - * covered by the library's abstractions (custom field rendering, ``, etc.). - * Prefixed with `_` to signal that it's an internal detail. - * - * TanStack Form's type has 12 generic parameters — we intentionally erase - * them here to keep the public API simple. Use `form._tsForm` to access the - * full typed instance in advanced scenarios. - */ - // biome-ignore lint/suspicious/noExplicitAny: TanStack Form has 12 generic params; erased for public API simplicity - _tsForm: any; + /** Every field's current value. */ + values: () => TValues; + /** One field's current value. */ + getFieldValue: (name: string) => unknown; + /** One field's touched flag and errors. */ + getFieldMeta: (name: string) => FieldMeta; + /** Write a value and re-run the synchronous schema. */ + setFieldValue: (name: string, value: unknown) => void; + /** Mark touched and validate, which is what a blur means. */ + validateField: (name: string, cause: "change" | "blur") => void | Promise; + /** Validate everything, run async validators, then `onSubmit` if clean. */ + submit: () => Promise; + /** True while `submit()` is awaiting. */ + isSubmitting: () => boolean; + /** True when no field holds an error. */ + isValid: () => boolean; /** Phantom field: preserves TValues for type inference in consuming hooks. */ _values?: TValues; }; -// --------------------------------------------------------------------------- -// Internal validation logic -// --------------------------------------------------------------------------- - -type ValidationCause = "change" | "blur" | "submit" | "mount" | "server"; - -type ValidationLogicProps = { - // biome-ignore lint/suspicious/noExplicitAny: TanStack Form validator map shape is internal - validators?: any; - // biome-ignore lint/suspicious/noExplicitAny: TanStack Form API type is intentionally erased in wrapper - form: any; - event: { - type: ValidationCause; - async: boolean; - }; - runValidation: (props: { - validators: Array< - | { - // biome-ignore lint/suspicious/noExplicitAny: validator function types are internal - fn: any; - cause: ValidationCause; - } - | undefined - >; - // biome-ignore lint/suspicious/noExplicitAny: TanStack Form API type is intentionally erased in wrapper - form: any; - }) => void; -}; - -const createValidationLogic = (props: ValidationLogicProps) => { - if (!props.validators) { - return props.runValidation({ - validators: [], - form: props.form, - }); - } - - const isAsync = props.event.async; - - const onMountValidator = isAsync - ? undefined - : ({ fn: props.validators.onMount, cause: "mount" as const }); - - const onChangeValidator = { - fn: isAsync ? props.validators.onChangeAsync : props.validators.onChange, - cause: "change" as const, - }; - - const onBlurValidator = { - fn: isAsync ? props.validators.onBlurAsync : props.validators.onBlur, - cause: "blur" as const, - }; - - const onSubmitValidator = { - fn: isAsync ? props.validators.onSubmitAsync : props.validators.onSubmit, - cause: "submit" as const, - }; - - const onServerValidator = isAsync - ? undefined - : ({ - fn: () => undefined, - cause: "server" as const, - }); - - switch (props.event.type) { - case "mount": { - return props.runValidation({ - validators: [onMountValidator], - form: props.form, - }); - } - case "submit": { - return props.runValidation({ - validators: [ - onChangeValidator, - onBlurValidator, - onSubmitValidator, - onServerValidator, - ], - form: props.form, - }); - } - case "server": { - return props.runValidation({ - validators: [], - form: props.form, - }); - } - case "blur": { - return props.runValidation({ - validators: [onBlurValidator, onServerValidator], - form: props.form, - }); - } - case "change": { - // Re-run blur validators on each change so blur-origin errors are cleared - // immediately when the value becomes valid again. - return props.runValidation({ - validators: [onChangeValidator, onBlurValidator, onServerValidator], - form: props.form, - }); - } - default: { - throw new Error(`Unknown validation event type: ${props.event.type}`); - } +/** + * Read a Standard Schema's issues into a per-field error map. + * + * The spec puts the field on `issue.path`, which is a list of keys or of + * `{ key }` segments depending on the library. Only the first segment is used: + * this form is flat, and a nested path would key an error to a field name that + * does not exist rather than to its parent. + */ +const issuesToErrors = (issues: readonly StandardSchemaV1.Issue[]): Record => { + const errors: Record = {}; + for (const issue of issues) { + const head = issue.path?.[0]; + const name = + typeof head === "object" && head !== null && "key" in head ? String(head.key) : String(head ?? ""); + if (!name) continue; + (errors[name] ??= []).push(issue.message); } + return errors; }; -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - /** - * Creates a new form instance backed by TanStack Form. + * A form, without TanStack. + * + * The engine is deliberately small because the field components already carry + * the hard parts: `Input`, `Select`, `Checkbox` and the rest own their own + * presentation, `state="invalid"` and error slot. What was missing is + * bookkeeping — values, touched, errors, and when to run the schema — and that + * is what this is. * - * ```tsx - * const form = createForm({ - * defaultValues: { email: "", password: "" }, - * schema: loginSchema, // Zod or Valibot — both work - * onSubmit: async (values) => { await login(values); }, - * }); + * `@tanstack/solid-form` used to supply it, through a `_tsForm` escape hatch + * that leaked its twelve erased generics into this library's public type. It + * also peer-pinned Solid 1, which made it one of three packages standing + * between this library and Solid 2. * - * return ( - * - * - * - * Log in - * - * ); - * ``` + * Validation runs on change and on blur, and errors are only *shown* once a + * field is touched — the display gate lives in `useField`, so a submit can + * surface every error at once by touching every field. */ export const createForm = ( options: CreateFormOptions, ): FormApi => { - const hasSchema = Boolean(options.schema); - const hasAsyncValidators = Boolean(options.asyncValidators); - - // biome-ignore lint/suspicious/noExplicitAny: TanStack Form generics are erased at our wrapper boundary - const tsForm: any = createTSForm(() => ({ - defaultValues: options.defaultValues as Record, - - validators: hasSchema || hasAsyncValidators - ? { - // biome-ignore lint/suspicious/noExplicitAny: Standard Schema bridges Zod/Valibot/etc. - onChange: options.schema as any, - // biome-ignore lint/suspicious/noExplicitAny: Standard Schema bridges Zod/Valibot/etc. - onBlur: options.schema as any, - // biome-ignore lint/suspicious/noExplicitAny: Standard Schema bridges Zod/Valibot/etc. - onSubmit: options.schema as any, - onBlurAsync: options.asyncValidators?.onBlur as any, - onSubmitAsync: options.asyncValidators?.onSubmit as any, + const [values, setValues] = createStore({ ...options.defaultValues }); + const [meta, setMeta] = createStore>({}); + const [status, setStatus] = createStore({ isSubmitting: false }); + + const metaFor = (name: string): FieldMeta => meta[name] ?? { isTouched: false, errors: [] }; + + /** Run the schema and replace every field's errors with its verdict. */ + const runSchema = (): boolean => { + const schema = options.schema; + if (!schema) return true; + const result = schema["~standard"].validate(values as TValues); + // A Standard Schema may validate asynchronously. The synchronous callers + // here (change, blur) cannot wait for one, and treating a pending result as + // "no errors" would clear real ones, so it is left to `submit()`. + if (result instanceof Promise) return true; + const errors = result.issues ? issuesToErrors(result.issues) : {}; + setMeta( + produce((draft) => { + const names = new Set([...Object.keys(draft), ...Object.keys(errors)]); + for (const name of names) { + draft[name] = { isTouched: draft[name]?.isTouched ?? false, errors: errors[name] ?? [] }; } - : undefined, + }), + ); + return !result.issues?.length; + }; + + const setFieldValue = (name: string, value: unknown): void => { + // Cast at the boundary: the store is typed by the caller's values, and a + // field name is a string the caller chose, so this is the one place the two + // cannot be related without making `name` a keyof and losing dotted paths. + (setValues as unknown as (fn: (draft: AnyValues) => void) => void)( + produce((draft: AnyValues) => { + draft[name] = value; + }), + ); + runSchema(); + }; - validationLogic: createValidationLogic, + const validateField = (name: string, cause: "change" | "blur"): void => { + if (cause === "blur") { + setMeta( + produce((draft) => { + draft[name] = { isTouched: true, errors: draft[name]?.errors ?? [] }; + }), + ); + } + runSchema(); + }; - onSubmit: async ({ value }: { value: unknown }) => { - await options.onSubmit?.(value as TValues); - }, - })); + const submit = async (): Promise => { + // Touch everything first: an untouched field hides its error, and a submit + // that silently does nothing because of an error you cannot see is the + // worst version of this. + setMeta( + produce((draft) => { + for (const name of Object.keys(values as AnyValues)) { + draft[name] = { isTouched: true, errors: draft[name]?.errors ?? [] }; + } + }), + ); + + let ok = runSchema(); + + // An async schema is resolved here, where there is somewhere to await it. + const schema = options.schema; + if (schema) { + const result = schema["~standard"].validate(values as TValues); + if (result instanceof Promise) { + const resolved = await result; + const errors = resolved.issues ? issuesToErrors(resolved.issues) : {}; + setMeta( + produce((draft) => { + for (const name of new Set([...Object.keys(draft), ...Object.keys(errors)])) { + draft[name] = { isTouched: true, errors: errors[name] ?? [] }; + } + }), + ); + ok = !resolved.issues?.length; + } + } - return { _tsForm: tsForm }; + if (!ok) return; + + setStatus("isSubmitting", true); + try { + const async = options.asyncValidators?.onSubmit; + if (async) { + const serverErrors = await async({ value: values as TValues }); + if (serverErrors && Object.keys(serverErrors).length > 0) { + setMeta( + produce((draft) => { + for (const [name, message] of Object.entries(serverErrors)) { + if (!message) continue; + draft[name] = { isTouched: true, errors: [String(message)] }; + } + }), + ); + return; + } + } + await options.onSubmit?.(values as TValues); + } finally { + setStatus("isSubmitting", false); + } + }; + + return { + values: () => values as TValues, + getFieldValue: (name) => (values as AnyValues)[name], + getFieldMeta: metaFor, + setFieldValue, + validateField, + submit, + isSubmitting: () => status.isSubmitting, + isValid: () => Object.values(meta).every((m) => (m?.errors.length ?? 0) === 0), + }; }; + +export { getFirstFieldError }; diff --git a/src/hooks/form/useFieldNew.ts b/src/hooks/form/useFieldNew.ts index 27b613c1..1fde17fe 100644 --- a/src/hooks/form/useFieldNew.ts +++ b/src/hooks/form/useFieldNew.ts @@ -29,8 +29,8 @@ export type UseFieldResult = { * Reads live field state for `name` from the nearest `
` context. * * Must be called **inside** a `` descendant component. - * For fields that need fine-grained rendering, prefer using `form._tsForm.Field` - * render prop directly. + * Field components own their own presentation; this supplies the state they + * need to show it. * * ```tsx * // Inside a child of @@ -46,29 +46,22 @@ export type UseFieldResult = { */ export const useField = (name: string): UseFieldResult => { const form = useFormContext(); - const tsForm = form._tsForm; - const value = createMemo(() => tsForm.getFieldValue(name as never)); + const value = createMemo(() => form.getFieldValue(name)); + const meta = createMemo(() => form.getFieldMeta(name)); + const touched = createMemo(() => meta().isTouched); - const meta = createMemo(() => tsForm.getFieldMeta(name as never)); - - const touched = createMemo(() => Boolean(meta()?.isTouched)); - - const error = createMemo((): string | undefined => { - const m = meta(); - if (!m?.isTouched) return undefined; - return getFirstFieldError((m.errors ?? []) as unknown[]); - }); + // Gated on `isTouched` so a form does not open covered in errors for fields + // nobody has reached yet. `submit()` touches everything, which is what makes + // a failed submit show all of them at once. + const error = createMemo((): string | undefined => + meta().isTouched ? getFirstFieldError(meta().errors) : undefined, + ); const invalid = createMemo(() => Boolean(error())); - const handleChange = (value: unknown) => { - tsForm.setFieldValue(name as never, value as never); - }; - - const handleBlur = () => { - tsForm.validateField(name as never, "blur"); - }; + const handleChange = (next: unknown) => form.setFieldValue(name, next); + const handleBlur = () => form.validateField(name, "blur"); return { value, error, touched, invalid, handleChange, handleBlur }; }; diff --git a/src/hooks/table/helpers.ts b/src/hooks/table/helpers.ts deleted file mode 100644 index 74ef3b36..00000000 --- a/src/hooks/table/helpers.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Accessor } from "solid-js"; -import type { SortingState, Updater } from "@tanstack/solid-table"; -import type { HookSortDescriptor } from "./useTableSorting"; - -export const resolveUpdater = (previous: T, updater: Updater): T => { - if (typeof updater === "function") { - return (updater as (prev: T) => T)(previous); - } - return updater; -}; - -export const asAccessor = (value: Accessor | T): Accessor => { - if (typeof value === "function") { - return value as Accessor; - } - return () => value; -}; - -export const toSortDescriptor = ( - sorting: SortingState, -): HookSortDescriptor | undefined => { - const activeSort = sorting[0]; - if (!activeSort) return undefined; - - return { - column: activeSort.id, - direction: activeSort.desc ? "descending" : "ascending", - }; -}; - -export const toSortingState = ( - descriptor: HookSortDescriptor | undefined, -): SortingState => { - if (!descriptor) return []; - - return [ - { - id: descriptor.column, - desc: descriptor.direction === "descending", - }, - ]; -}; diff --git a/src/hooks/table/index.ts b/src/hooks/table/index.ts index 61e6c5b5..07e48e26 100644 --- a/src/hooks/table/index.ts +++ b/src/hooks/table/index.ts @@ -1,35 +1,14 @@ -export { - useTableSorting, - type UseTableSortingOptions, - type UseTableSortingResult, - type HookSortDirection, - type HookSortDescriptor, -} from "./useTableSorting"; -export { - useTablePagination, - type UseTablePaginationOptions, - type UseTablePaginationResult, -} from "./useTablePagination"; -export { - useTableFiltering, - type UseTableFilteringOptions, - type UseTableFilteringResult, -} from "./useTableFiltering"; -export { - useTableSelection, - type UseTableSelectionOptions, - type UseTableSelectionResult, - type TableSelectionState, -} from "./useTableSelection"; -export { - useTableExpansion, - type UseTableExpansionOptions, - type UseTableExpansionResult, -} from "./useTableExpansion"; -export { - useTableModel, - type UseTableModelOptions, -} from "./useTableModel"; -export { toSortDescriptor, toSortingState } from "./helpers"; +/* + * What is left of `hooks/table` after TanStack. + * + * The sorting, filtering, pagination, selection, expansion and model hooks were + * a thin public shim over `@tanstack/solid-table`. Nothing inside this library + * used one, and `createDataGrid` answers the same questions natively — its rows + * derive `filteredRows -> sortedRows -> pageRows`, with selection alongside — so + * the shim was a second table engine kept alive only by its own exports. + * + * `useAnchoredOverlayPosition` stays because it never had anything to do with + * TanStack: it positions an overlay against a cell, which a grid still needs. + */ export { useAnchoredOverlayPosition } from "./useAnchoredOverlayPosition"; export type { UseAnchoredOverlayPositionOptions } from "./useAnchoredOverlayPosition"; diff --git a/src/hooks/table/useTableExpansion.ts b/src/hooks/table/useTableExpansion.ts deleted file mode 100644 index 71d055aa..00000000 --- a/src/hooks/table/useTableExpansion.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createSignal, type Accessor } from "solid-js"; -import type { ExpandedState, OnChangeFn } from "@tanstack/solid-table"; -import { resolveUpdater } from "./helpers"; - -export interface UseTableExpansionOptions { - expanded?: Accessor; - setExpanded?: OnChangeFn; - initialExpanded?: ExpandedState; -} - -export interface UseTableExpansionResult { - expanded: Accessor; - setExpanded: OnChangeFn; - collapseAll: () => void; -} - -export const useTableExpansion = ( - options: UseTableExpansionOptions = {}, -): UseTableExpansionResult => { - const [internalExpanded, setInternalExpanded] = createSignal( - options.initialExpanded ?? {}, - ); - - const expanded = () => options.expanded?.() ?? internalExpanded(); - - const setExpanded: OnChangeFn = (updater) => { - if (options.setExpanded) { - options.setExpanded(updater); - return; - } - setInternalExpanded((prev) => resolveUpdater(prev, updater)); - }; - - const collapseAll = () => setExpanded({}); - - return { - expanded, - setExpanded, - collapseAll, - }; -}; diff --git a/src/hooks/table/useTableFiltering.ts b/src/hooks/table/useTableFiltering.ts deleted file mode 100644 index b9c66888..00000000 --- a/src/hooks/table/useTableFiltering.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createMemo, createSignal, type Accessor, type JSX } from "solid-js"; -import type { - ColumnFiltersState, - OnChangeFn, -} from "@tanstack/solid-table"; -import { resolveUpdater } from "./helpers"; - -export interface UseTableFilteringOptions { - columnFilters?: Accessor; - setColumnFilters?: OnChangeFn; - initialColumnFilters?: ColumnFiltersState; - globalFilter?: Accessor; - setGlobalFilter?: OnChangeFn; - initialGlobalFilter?: string; -} - -export interface UseTableFilteringResult { - columnFilters: Accessor; - setColumnFilters: OnChangeFn; - globalFilter: Accessor; - setGlobalFilter: OnChangeFn; - getColumnFilterValue: (columnId: string) => string; - setColumnFilterValue: (columnId: string, value: string) => void; - getColumnFilterProps: ( - columnId: string, - options?: { - afterChange?: (value: string) => void; - }, - ) => { - readonly value: string; - onInput: JSX.EventHandler; - onChange: JSX.EventHandler< - HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, - Event - >; - }; - openFilterFor: Accessor; - closeFilter: () => void; - toggleFilter: (columnId: string) => void; - anyFilterActive: Accessor; -} - -export const useTableFiltering = ( - options: UseTableFilteringOptions = {}, -): UseTableFilteringResult => { - const [internalColumnFilters, setInternalColumnFilters] = - createSignal(options.initialColumnFilters ?? []); - const [internalGlobalFilter, setInternalGlobalFilter] = createSignal( - options.initialGlobalFilter ?? "", - ); - const [openFilterFor, setOpenFilterFor] = createSignal(null); - - const columnFilters = () => - options.columnFilters?.() ?? internalColumnFilters(); - const globalFilter = () => options.globalFilter?.() ?? internalGlobalFilter(); - - const setColumnFilters: OnChangeFn = (updater) => { - if (options.setColumnFilters) { - options.setColumnFilters(updater); - return; - } - setInternalColumnFilters((prev) => resolveUpdater(prev, updater)); - }; - - const setGlobalFilter: OnChangeFn = (updater) => { - if (options.setGlobalFilter) { - options.setGlobalFilter(updater); - return; - } - setInternalGlobalFilter((prev) => resolveUpdater(prev, updater)); - }; - - const closeFilter = () => setOpenFilterFor(null); - const toggleFilter = (columnId: string) => { - setOpenFilterFor((current) => (current === columnId ? null : columnId)); - }; - - const getColumnFilterValue = (columnId: string) => { - const filter = columnFilters().find((entry) => entry.id === columnId); - return typeof filter?.value === "string" ? filter.value : ""; - }; - - const setColumnFilterValue = (columnId: string, value: string) => { - setColumnFilters((previous) => { - const next = previous.filter((entry) => entry.id !== columnId); - if (value.trim().length > 0) { - next.push({ id: columnId, value }); - } - return next; - }); - }; - - const getColumnFilterProps = ( - columnId: string, - bindingOptions?: { - afterChange?: (value: string) => void; - }, - ) => { - const applyValue = (value: string) => { - setColumnFilterValue(columnId, value); - bindingOptions?.afterChange?.(value); - }; - - return { - get value() { - return getColumnFilterValue(columnId); - }, - onInput: (event: InputEvent & { - currentTarget: HTMLInputElement | HTMLTextAreaElement; - target: Element; - }) => { - applyValue(event.currentTarget.value); - }, - onChange: (event: Event & { - currentTarget: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; - target: Element; - }) => { - applyValue(event.currentTarget.value); - }, - }; - }; - - const anyFilterActive = createMemo(() => columnFilters().length > 0); - - return { - columnFilters, - setColumnFilters, - globalFilter, - setGlobalFilter, - getColumnFilterValue, - setColumnFilterValue, - getColumnFilterProps, - openFilterFor, - closeFilter, - toggleFilter, - anyFilterActive, - }; -}; diff --git a/src/hooks/table/useTableModel.ts b/src/hooks/table/useTableModel.ts deleted file mode 100644 index 6e67742c..00000000 --- a/src/hooks/table/useTableModel.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Accessor } from "solid-js"; -import { - createSolidTable, - getCoreRowModel, - getExpandedRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - type ColumnDef, - type ColumnFiltersState, - type ExpandedState, - type OnChangeFn, - type PaginationState, - type Row, - type SortingState, - type Table, -} from "@tanstack/solid-table"; -import { asAccessor } from "./helpers"; -import type { TableSelectionState } from "./useTableSelection"; - -export interface UseTableModelOptions { - data: Accessor | TData[]; - columns: Accessor[]> | ColumnDef[]; - getRowId?: (originalRow: TData, index: number, parent?: Row) => string; - sorting?: Accessor; - setSorting?: OnChangeFn; - columnFilters?: Accessor; - setColumnFilters?: OnChangeFn; - pagination?: Accessor; - setPagination?: OnChangeFn; - globalFilter?: Accessor; - setGlobalFilter?: OnChangeFn; - rowSelection?: Accessor; - setRowSelection?: OnChangeFn; - expanded?: Accessor; - setExpanded?: OnChangeFn; - enableSorting?: boolean; - enableFilters?: boolean; - enablePagination?: boolean; - enableRowSelection?: boolean; - enableExpanding?: boolean; - getRowCanExpand?: (row: Row) => boolean; -} - -export const useTableModel = ( - options: UseTableModelOptions, -): Table => { - const dataAccessor = asAccessor(options.data); - const columnsAccessor = asAccessor(options.columns); - - return createSolidTable({ - get data() { - return dataAccessor(); - }, - get columns() { - return columnsAccessor(); - }, - getRowId: options.getRowId, - state: { - get sorting() { - return options.sorting?.() ?? []; - }, - get columnFilters() { - return options.columnFilters?.() ?? []; - }, - get pagination() { - return options.pagination?.() ?? { pageIndex: 0, pageSize: 10 }; - }, - get globalFilter() { - return options.globalFilter?.() ?? ""; - }, - get rowSelection() { - return options.rowSelection?.() ?? {}; - }, - get expanded() { - return options.expanded?.() ?? {}; - }, - }, - onSortingChange: options.setSorting, - onColumnFiltersChange: options.setColumnFilters, - onPaginationChange: options.setPagination, - onGlobalFilterChange: options.setGlobalFilter, - onRowSelectionChange: options.setRowSelection, - onExpandedChange: options.setExpanded, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getExpandedRowModel: getExpandedRowModel(), - enableSorting: options.enableSorting, - enableFilters: options.enableFilters, - manualPagination: - options.enablePagination === undefined ? false : !options.enablePagination, - enableRowSelection: options.enableRowSelection, - enableExpanding: options.enableExpanding, - getRowCanExpand: options.getRowCanExpand, - }); -}; diff --git a/src/hooks/table/useTablePagination.ts b/src/hooks/table/useTablePagination.ts deleted file mode 100644 index 30e1d55b..00000000 --- a/src/hooks/table/useTablePagination.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createSignal, type Accessor } from "solid-js"; -import type { OnChangeFn, PaginationState } from "@tanstack/solid-table"; -import { resolveUpdater } from "./helpers"; - -const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; - -export interface UseTablePaginationOptions { - pagination?: Accessor; - setPagination?: OnChangeFn; - initialPagination?: PaginationState; - pageSizeOptions?: readonly number[]; -} - -export interface UseTablePaginationResult { - pagination: Accessor; - setPagination: OnChangeFn; - pageSizeOptions: Accessor; - setPageIndex: (index: number) => void; - setPageSize: (size: number) => void; - firstPage: () => void; - previousPage: () => void; - nextPage: (maxPageIndex: number) => void; - lastPage: (maxPageIndex: number) => void; -} - -export const useTablePagination = ( - options: UseTablePaginationOptions = {}, -): UseTablePaginationResult => { - const [internalPagination, setInternalPagination] = createSignal( - options.initialPagination ?? { pageIndex: 0, pageSize: 10 }, - ); - - const pagination = () => options.pagination?.() ?? internalPagination(); - const pageSizeOptions = () => options.pageSizeOptions ?? DEFAULT_PAGE_SIZE_OPTIONS; - - const setPagination: OnChangeFn = (updater) => { - if (options.setPagination) { - options.setPagination(updater); - return; - } - setInternalPagination((prev) => resolveUpdater(prev, updater)); - }; - - const setPageIndex = (index: number) => { - setPagination((prev) => ({ ...prev, pageIndex: Math.max(0, index) })); - }; - - const setPageSize = (size: number) => { - setPagination((prev) => ({ ...prev, pageSize: size })); - }; - - const firstPage = () => setPageIndex(0); - const previousPage = () => setPageIndex(pagination().pageIndex - 1); - const nextPage = (maxPageIndex: number) => - setPageIndex(Math.min(maxPageIndex, pagination().pageIndex + 1)); - const lastPage = (maxPageIndex: number) => setPageIndex(maxPageIndex); - - return { - pagination, - setPagination, - pageSizeOptions, - setPageIndex, - setPageSize, - firstPage, - previousPage, - nextPage, - lastPage, - }; -}; diff --git a/src/hooks/table/useTableSelection.ts b/src/hooks/table/useTableSelection.ts deleted file mode 100644 index 175a38e5..00000000 --- a/src/hooks/table/useTableSelection.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSignal, type Accessor } from "solid-js"; -import type { OnChangeFn } from "@tanstack/solid-table"; -import { resolveUpdater } from "./helpers"; - -export type TableSelectionState = Record; - -export interface UseTableSelectionOptions { - rowSelection?: Accessor; - setRowSelection?: OnChangeFn; - initialRowSelection?: TableSelectionState; -} - -export interface UseTableSelectionResult { - rowSelection: Accessor; - setRowSelection: OnChangeFn; - clearSelection: () => void; -} - -export const useTableSelection = ( - options: UseTableSelectionOptions = {}, -): UseTableSelectionResult => { - const [internalRowSelection, setInternalRowSelection] = - createSignal(options.initialRowSelection ?? {}); - - const rowSelection = () => options.rowSelection?.() ?? internalRowSelection(); - - const setRowSelection: OnChangeFn = (updater) => { - if (options.setRowSelection) { - options.setRowSelection(updater); - return; - } - setInternalRowSelection((prev) => resolveUpdater(prev, updater)); - }; - - const clearSelection = () => setRowSelection({}); - - return { - rowSelection, - setRowSelection, - clearSelection, - }; -}; diff --git a/src/hooks/table/useTableSorting.ts b/src/hooks/table/useTableSorting.ts deleted file mode 100644 index 86a27eb3..00000000 --- a/src/hooks/table/useTableSorting.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createMemo, createSignal, type Accessor } from "solid-js"; -import type { OnChangeFn, SortingState } from "@tanstack/solid-table"; -import { resolveUpdater, toSortDescriptor, toSortingState } from "./helpers"; - -export type HookSortDirection = "ascending" | "descending"; - -export type HookSortDescriptor = { - column: string; - direction: HookSortDirection; -}; - -export interface UseTableSortingOptions { - sorting?: Accessor; - setSorting?: OnChangeFn; - initialSorting?: SortingState; -} - -export interface UseTableSortingResult { - sorting: Accessor; - setSorting: OnChangeFn; - sortDescriptor: Accessor; - setSortDescriptor: (descriptor: HookSortDescriptor) => void; -} - -export const useTableSorting = ( - options: UseTableSortingOptions = {}, -): UseTableSortingResult => { - const [internalSorting, setInternalSorting] = createSignal( - options.initialSorting ?? [], - ); - - const sorting = () => options.sorting?.() ?? internalSorting(); - - const setSorting: OnChangeFn = (updater) => { - if (options.setSorting) { - options.setSorting(updater); - return; - } - setInternalSorting((prev) => resolveUpdater(prev, updater)); - }; - - const sortDescriptor = createMemo(() => { - return toSortDescriptor(sorting()); - }); - - const setSortDescriptor = (descriptor: HookSortDescriptor) => { - setSorting(toSortingState(descriptor)); - }; - - return { - sorting, - setSorting, - sortDescriptor, - setSortDescriptor, - }; -}; diff --git a/src/index.ts b/src/index.ts index 974451a6..1da2bc4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -152,7 +152,7 @@ export type { FieldsetActionsProps, } from "./components/fieldset"; // --------------------------------------------------------------------------- -// Form components and hooks (TanStack-based API) +// Form components and hooks // --------------------------------------------------------------------------- export { default as Form, @@ -389,34 +389,8 @@ export type { InlineConfirmProps as TableInlineConfirmProps, InlineConfirmVariant as TableInlineConfirmVariant, } from "./components/table"; -export { - useTableModel, - useTableSorting, - useTablePagination, - useTableFiltering, - useTableSelection, - useTableExpansion, - toSortDescriptor, - toSortingState, - useAnchoredOverlayPosition, -} from "./hooks/table"; -export type { - UseTableModelOptions, - UseTableSortingOptions, - UseTableSortingResult, - HookSortDirection, - HookSortDescriptor, - UseTablePaginationOptions, - UseTablePaginationResult, - UseTableFilteringOptions, - UseTableFilteringResult, - UseTableSelectionOptions, - UseTableSelectionResult, - TableSelectionState, - UseTableExpansionOptions, - UseTableExpansionResult, - UseAnchoredOverlayPositionOptions, -} from "./hooks/table"; +export { useAnchoredOverlayPosition } from "./hooks/table"; +export type { UseAnchoredOverlayPositionOptions } from "./hooks/table"; export { default as Tabs } from "./components/tabs"; export type { @@ -513,12 +487,6 @@ export type { // Motion export * from "./motion"; -export { useVirtualRows } from "./primitives/virtualizer"; -export type { - UseVirtualRowsOptions, - UseVirtualRowsResult, - VirtualRowsRange, -} from "./primitives/virtualizer"; export { useStreamingBuffer, useStreamingSubscription, diff --git a/src/primitives/virtualizer/index.ts b/src/primitives/virtualizer/index.ts deleted file mode 100644 index 385935ce..00000000 --- a/src/primitives/virtualizer/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { useVirtualRows } from "./useVirtualRows"; -export type { - UseVirtualRowsOptions, - UseVirtualRowsResult, - VirtualRowsRange, -} from "./useVirtualRows"; diff --git a/src/primitives/virtualizer/useVirtualRows.ts b/src/primitives/virtualizer/useVirtualRows.ts deleted file mode 100644 index 9579e1c3..00000000 --- a/src/primitives/virtualizer/useVirtualRows.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createEffect, createMemo, type Accessor } from "solid-js"; -import { - createVirtualizer, - type PartialKeys, - type VirtualItem, - type Virtualizer, - type VirtualizerOptions, -} from "@tanstack/solid-virtual"; - -type OmittedVirtualizerOptions = - | "observeElementRect" - | "observeElementOffset" - | "scrollToFn"; - -export type UseVirtualRowsOptions< - TScrollElement extends Element, - TItemElement extends Element, -> = PartialKeys< - VirtualizerOptions, - OmittedVirtualizerOptions ->; - -export type VirtualRowsRange = { - startIndex: number; - endIndex: number; -}; - -export type UseVirtualRowsResult< - TScrollElement extends Element, - TItemElement extends Element, -> = { - virtualizer: Virtualizer; - virtualItems: Accessor; - totalSize: Accessor; - range: Accessor; - startIndex: Accessor; - endIndex: Accessor; -}; - -export const useVirtualRows = < - TScrollElement extends Element, - TItemElement extends Element, ->( - options: UseVirtualRowsOptions, -): UseVirtualRowsResult => { - const virtualizer = createVirtualizer(options); - - createEffect(() => { - const scrollElement = options.getScrollElement(); - if (!scrollElement) return; - virtualizer.measure(); - }); - - const virtualItems = () => virtualizer.getVirtualItems(); - const totalSize = () => virtualizer.getTotalSize(); - - const range = createMemo(() => { - const items = virtualItems(); - if (items.length === 0) { - return undefined; - } - - return { - startIndex: items[0].index, - endIndex: items[items.length - 1].index, - }; - }); - - const startIndex = createMemo(() => range()?.startIndex ?? -1); - const endIndex = createMemo(() => range()?.endIndex ?? -1); - - return { - virtualizer, - virtualItems, - totalSize, - range, - startIndex, - endIndex, - }; -}; diff --git a/tests/hooks/form/createForm.test.ts b/tests/hooks/form/createForm.test.ts index c06ae993..ca3b598a 100644 --- a/tests/hooks/form/createForm.test.ts +++ b/tests/hooks/form/createForm.test.ts @@ -50,19 +50,19 @@ const createPasswordForm = (onSubmit?: (value: PasswordValues) => void | Promise describe("createForm", () => { it("clears blur-origin errors after valid change", async () => { const { form, dispose } = createPasswordForm(); - const tsForm = form._tsForm; try { - tsForm.setFieldValue("password", "abc"); - await tsForm.validateField("password", "blur"); + form.setFieldValue("password", "abc"); + await form.validateField("password", "blur"); - expect(tsForm.state.canSubmit).toBeFalse(); - expect(tsForm.getFieldMeta("password")?.errorMap?.onBlur).toBeDefined(); + expect(form.isValid()).toBeFalse(); + expect(form.getFieldMeta("password").errors.length).toBeGreaterThan(0); + expect(form.getFieldMeta("password").isTouched).toBeTrue(); - tsForm.setFieldValue("password", "abcd"); + form.setFieldValue("password", "abcd"); - expect(tsForm.state.canSubmit).toBeTrue(); - expect(tsForm.getFieldMeta("password")?.errorMap?.onBlur).toBeUndefined(); + expect(form.isValid()).toBeTrue(); + expect(form.getFieldMeta("password").errors).toEqual([]); } finally { dispose(); } @@ -71,24 +71,42 @@ describe("createForm", () => { it("allows submit after correcting a blur-invalid value", async () => { const onSubmit = mock(async (_value: PasswordValues) => {}); const { form, dispose } = createPasswordForm(onSubmit); - const tsForm = form._tsForm; try { - tsForm.setFieldValue("password", "abc"); - await tsForm.validateField("password", "blur"); - await tsForm.handleSubmit(); + form.setFieldValue("password", "abc"); + await form.validateField("password", "blur"); + await form.submit(); - expect(tsForm.state.canSubmit).toBeFalse(); + expect(form.isValid()).toBeFalse(); expect(onSubmit).toHaveBeenCalledTimes(0); - tsForm.setFieldValue("password", "abcd"); - await tsForm.handleSubmit(); + form.setFieldValue("password", "abcd"); + await form.submit(); - expect(tsForm.state.canSubmit).toBeTrue(); + expect(form.isValid()).toBeTrue(); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toEqual({ password: "abcd" }); } finally { dispose(); } }); + + it("shows every error on a failed submit, not only the touched ones", async () => { + const onSubmit = mock(async (_value: PasswordValues) => {}); + const { form, dispose } = createPasswordForm(onSubmit); + + try { + // Nothing blurred, so nothing is touched and nothing displays yet. + expect(form.getFieldMeta("password").isTouched).toBeFalse(); + + form.setFieldValue("password", "abc"); + await form.submit(); + + // A submit that refuses has to say why, so it touches everything. + expect(form.getFieldMeta("password").isTouched).toBeTrue(); + expect(onSubmit).toHaveBeenCalledTimes(0); + } finally { + dispose(); + } + }); });