zod-collection-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # zod-collection-ui
2
+
3
+ Declare once, render anywhere. Define a Zod schema and get auto-generated table columns, form fields, filters, state management, and data provider — with zero configuration.
4
+
5
+ ```typescript
6
+ import { z } from 'zod';
7
+ import { defineCollection, toColumnDefs, toFormConfig, toFilterConfig } from 'zod-collection-ui';
8
+
9
+ const products = defineCollection(z.object({
10
+ id: z.string().uuid(),
11
+ name: z.string().min(1),
12
+ status: z.enum(['draft', 'active', 'archived']),
13
+ price: z.number().min(0),
14
+ tags: z.array(z.string()),
15
+ createdAt: z.date(),
16
+ }));
17
+
18
+ // Auto-generated from the schema:
19
+ const columns = toColumnDefs(products); // TanStack Table column defs
20
+ const form = toFormConfig(products); // Form field configs (create/edit)
21
+ const filters = toFilterConfig(products); // Filter panel configs
22
+ ```
23
+
24
+ That's it. No annotations needed. The library infers that `id` is hidden, `name` is searchable, `status` is a select filter, `price` is a range filter, and `createdAt` is not editable — all from the Zod types and field names.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install zod-collection-ui zod
30
+ ```
31
+
32
+ Requires Zod v4+.
33
+
34
+ ## Why
35
+
36
+ Every schema-driven UI tool solves half the problem:
37
+
38
+ - **Form generators** (RJSF, AutoForm) handle data shape → form widgets, but not collections
39
+ - **Table libraries** (TanStack Table, AG Grid) handle column config → table features, but not forms
40
+ - **CRUD frameworks** (React Admin, Refine) handle both, but imperatively — not from a schema
41
+
42
+ `zod-collection-ui` bridges the gap: one Zod schema produces table columns, form fields, filter configs, state management, and a data provider interface. Headless — bring your own renderer.
43
+
44
+ ## Features
45
+
46
+ - **Zero config**: A plain Zod schema produces a working collection with sensible defaults
47
+ - **4-layer inference**: Zod type → validation checks → field name heuristics → `.meta()` annotations
48
+ - **Headless**: Produces data structures, not React components — works with any framework
49
+ - **TanStack Table compatible**: `toColumnDefs()` outputs column definitions with sort/filter/group config
50
+ - **Form generation**: `toFormConfig()` outputs field configs for create and edit forms
51
+ - **Filter panels**: `toFilterConfig()` outputs filter configs with enum options and numeric bounds
52
+ - **State management**: `createCollectionStore()` produces framework-agnostic state + actions + selectors
53
+ - **Data provider**: `DataProvider<T>` interface with in-memory adapter for prototyping
54
+ - **AI-ready**: `toPrompt()` generates structured descriptions for LLM consumption
55
+ - **Custom operations**: Declare item/selection/collection actions with confirmation dialogs
56
+ - **TypeScript-first**: Full type inference from your Zod schema
57
+
58
+ ## Quick Start
59
+
60
+ ### Zero Config
61
+
62
+ ```typescript
63
+ import { z } from 'zod';
64
+ import { defineCollection, toColumnDefs } from 'zod-collection-ui';
65
+
66
+ // Just a Zod schema — no annotations needed
67
+ const contacts = defineCollection(z.object({
68
+ id: z.string(),
69
+ name: z.string(),
70
+ email: z.string(),
71
+ role: z.enum(['customer', 'partner', 'lead']),
72
+ isActive: z.boolean().default(true),
73
+ createdAt: z.date(),
74
+ }));
75
+
76
+ // The library auto-detects:
77
+ contacts.idField; // 'id'
78
+ contacts.labelField; // 'name' (detected from name pattern)
79
+ contacts.getSearchableFields(); // ['name', 'email']
80
+ contacts.getGroupableFields(); // [{ key: 'role', ... }, { key: 'isActive', ... }]
81
+
82
+ // Generate table columns
83
+ const columns = toColumnDefs(contacts);
84
+ // → name: sortable, searchable
85
+ // → email: sortable, searchable, email widget
86
+ // → role: sortable, select filter, groupable
87
+ // → isActive: boolean filter, groupable
88
+ // → id, createdAt: auto-hidden/non-editable
89
+ ```
90
+
91
+ ### With Overrides
92
+
93
+ ```typescript
94
+ const products = defineCollection(ProductSchema, {
95
+ affordances: {
96
+ bulkDelete: true,
97
+ export: ['csv', 'json'],
98
+ pagination: { defaultPageSize: 50 },
99
+ defaultSort: { field: 'createdAt', direction: 'desc' },
100
+ },
101
+ fields: {
102
+ name: { inlineEditable: true, columnWidth: 250 },
103
+ status: { badge: { draft: 'secondary', active: 'default', archived: 'outline' } },
104
+ price: { displayFormat: 'currency' },
105
+ description: { detailOnly: true },
106
+ },
107
+ operations: [
108
+ { name: 'archive', label: 'Archive', scope: 'item', confirm: true },
109
+ { name: 'bulkArchive', label: 'Archive Selected', scope: 'selection' },
110
+ { name: 'exportReport', label: 'Export', scope: 'collection' },
111
+ ],
112
+ });
113
+ ```
114
+
115
+ ### With Zod `.meta()`
116
+
117
+ ```typescript
118
+ const TaskSchema = z.object({
119
+ id: z.string().uuid(),
120
+ title: z.string().meta({
121
+ title: 'Task Title',
122
+ inlineEditable: true,
123
+ summaryField: true,
124
+ }),
125
+ status: z.enum(['todo', 'in_progress', 'done']).meta({
126
+ badge: { todo: 'secondary', in_progress: 'default', done: 'success' },
127
+ }),
128
+ priority: z.enum(['low', 'medium', 'high']).meta({
129
+ badge: { low: 'ghost', medium: 'secondary', high: 'destructive' },
130
+ }),
131
+ });
132
+ ```
133
+
134
+ ## API
135
+
136
+ ### `defineCollection(schema, config?)`
137
+
138
+ Main entry point. Takes a Zod object schema and optional config, returns a `CollectionDefinition`.
139
+
140
+ ```typescript
141
+ const collection = defineCollection(MySchema, {
142
+ affordances?: CollectionAffordances, // Collection-level capabilities
143
+ fields?: Record<string, FieldAffordance>, // Per-field overrides
144
+ operations?: OperationDefinition[], // Custom actions
145
+ idField?: string, // Default: auto-detected
146
+ labelField?: string, // Default: auto-detected
147
+ });
148
+ ```
149
+
150
+ **Returns** a `CollectionDefinition` with:
151
+ - `schema` — The source Zod schema
152
+ - `affordances` — Resolved collection-level affordances
153
+ - `fieldAffordances` — Per-field affordances (inferred + merged)
154
+ - `operations` — Custom operations
155
+ - `idField` / `labelField` — Identity fields
156
+ - `getVisibleFields()` — Fields for table view
157
+ - `getSearchableFields()` — Fields for global search
158
+ - `getFilterableFields()` — Fields with filter config
159
+ - `getSortableFields()` — Sortable fields
160
+ - `getGroupableFields()` — Groupable fields
161
+ - `getOperations(scope)` — Operations by scope
162
+ - `describe()` — Human-readable description
163
+
164
+ ### `toColumnDefs(collection)`
165
+
166
+ Generates TanStack Table-compatible column definitions.
167
+
168
+ ```typescript
169
+ const columns = toColumnDefs(collection);
170
+ // Each column has: id, header, accessorKey, enableSorting, enableColumnFilter,
171
+ // enableGlobalFilter, enableGrouping, sortingFn, filterFn, size, meta
172
+ ```
173
+
174
+ ### `toFormConfig(collection, mode)`
175
+
176
+ Generates form field configurations for create or edit forms.
177
+
178
+ ```typescript
179
+ const createFields = toFormConfig(collection, 'create');
180
+ const editFields = toFormConfig(collection, 'edit');
181
+ // Each field has: name, label, type, required, disabled, options, placeholder, helpText
182
+ ```
183
+
184
+ ### `toFilterConfig(collection)`
185
+
186
+ Generates filter panel configurations.
187
+
188
+ ```typescript
189
+ const filters = toFilterConfig(collection);
190
+ // Each filter has: name, label, filterType, options (for enums), bounds (for ranges)
191
+ ```
192
+
193
+ ### `createCollectionStore(collection)`
194
+
195
+ Creates a framework-agnostic state factory with pure functions.
196
+
197
+ ```typescript
198
+ const store = createCollectionStore<Product>(collection);
199
+
200
+ // Initial state (derived from collection affordances)
201
+ let state = store.initialState;
202
+
203
+ // Pure actions: (state, args) → newState
204
+ state = store.actions.setItems(state, products, totalCount);
205
+ state = store.actions.setSorting(state, [{ id: 'price', desc: true }]);
206
+ state = store.actions.setColumnFilters(state, [{ id: 'status', value: 'active' }]);
207
+ state = store.actions.setGlobalFilter(state, 'search term');
208
+ state = store.actions.selectAll(state);
209
+ state = store.actions.reset(state);
210
+
211
+ // Selectors
212
+ store.selectors.getSelectedItems(state); // T[]
213
+ store.selectors.getSelectedCount(state); // number
214
+ store.selectors.getPageCount(state); // number
215
+ store.selectors.isAllSelected(state); // boolean
216
+ store.selectors.getVisibleItems(state); // T[] (current page)
217
+ ```
218
+
219
+ Works with any state library:
220
+
221
+ ```typescript
222
+ // With Zustand
223
+ const useStore = create(() => store.initialState);
224
+
225
+ // With React useReducer
226
+ const [state, dispatch] = useReducer(reducer, store.initialState);
227
+
228
+ // With plain variables
229
+ let state = store.initialState;
230
+ state = store.actions.setItems(state, data);
231
+ ```
232
+
233
+ ### `createInMemoryProvider(data, options?)`
234
+
235
+ Creates a `DataProvider<T>` backed by an in-memory array. Supports sorting, filtering, search, and pagination.
236
+
237
+ ```typescript
238
+ const provider = createInMemoryProvider(products, {
239
+ idField: 'id', // default
240
+ simulateDelay: 100, // ms, for testing loading states
241
+ searchFields: ['name'], // default: all string fields
242
+ });
243
+
244
+ const { data, total } = await provider.getList({
245
+ sort: [{ id: 'price', desc: true }],
246
+ filter: [{ id: 'status', value: ['active'] }],
247
+ search: 'widget',
248
+ pagination: { page: 1, pageSize: 25 },
249
+ });
250
+
251
+ await provider.create({ name: 'New Product', ... });
252
+ await provider.update('id-123', { price: 29.99 });
253
+ await provider.delete('id-123');
254
+ await provider.deleteMany(['id-1', 'id-2']);
255
+ ```
256
+
257
+ ### `toPrompt(collection)`
258
+
259
+ Generates a structured markdown description for LLM/AI consumption.
260
+
261
+ ```typescript
262
+ const prompt = toPrompt(collection);
263
+ // Returns markdown with: data shape table, capabilities, filter config,
264
+ // custom operations, and UI generation hints
265
+ ```
266
+
267
+ ## Inference Rules
268
+
269
+ The library infers affordances from four layers (later overrides earlier):
270
+
271
+ | Layer | Source | Example |
272
+ |-------|--------|---------|
273
+ | 1. Type | Zod type | `z.string()` → sortable, searchable, text filter |
274
+ | 2. Validation | Zod checks | `z.string().email()` → email widget |
275
+ | 3. Name | Field name | `createdAt` → not editable, range filter |
276
+ | 4. Meta | `.meta()` | `.meta({ sortable: false })` → override |
277
+
278
+ **Type defaults:**
279
+
280
+ | Zod Type | Sortable | Filterable | Searchable | Editable |
281
+ |----------|----------|------------|------------|----------|
282
+ | `string` | yes | search | yes | yes |
283
+ | `number` | yes | range | no | yes |
284
+ | `boolean` | yes | boolean | no | yes |
285
+ | `enum` | yes | select | no | yes |
286
+ | `date` | yes | range | no | yes |
287
+ | `array` | no | contains | no | yes |
288
+ | `object` | no | no | no | yes |
289
+
290
+ **Name heuristics:**
291
+
292
+ | Pattern | Inference |
293
+ |---------|-----------|
294
+ | `id`, `_id`, `uuid` | hidden, not editable, exact filter |
295
+ | `createdAt`, `created_at` | not editable, range filter |
296
+ | `updatedAt` | hidden, not editable |
297
+ | `password`, `secret`, `token` | hidden, not readable, not searchable |
298
+ | `email` | searchable, email widget |
299
+ | `name`, `title` | searchable, summary field |
300
+ | `description`, `notes` | textarea, truncated, not sortable |
301
+ | `status`, `state` | groupable, select filter |
302
+ | `imageUrl`, `avatar` | not sortable, not filterable |
303
+
304
+ ## Field Affordances
305
+
306
+ Every field can declare these capabilities:
307
+
308
+ ```typescript
309
+ interface FieldAffordance {
310
+ // Query
311
+ sortable?: boolean | 'asc' | 'desc' | 'both' | 'none';
312
+ filterable?: boolean | 'exact' | 'search' | 'select' | 'multiSelect' | 'range' | 'contains' | 'boolean';
313
+ searchable?: boolean;
314
+ groupable?: boolean;
315
+ aggregatable?: boolean | ('sum' | 'avg' | 'min' | 'max' | 'count')[];
316
+
317
+ // CRUD
318
+ editable?: boolean;
319
+ inlineEditable?: boolean;
320
+ immutableAfterCreate?: boolean;
321
+
322
+ // Display
323
+ visible?: boolean;
324
+ hidden?: boolean;
325
+ detailOnly?: boolean;
326
+ summaryField?: boolean;
327
+ columnWidth?: number;
328
+ badge?: Record<string, string>;
329
+ copyable?: boolean;
330
+ truncate?: number;
331
+ editWidget?: string;
332
+ // ... and more
333
+ }
334
+ ```
335
+
336
+ ## Collection Affordances
337
+
338
+ ```typescript
339
+ interface CollectionAffordances {
340
+ create?: boolean;
341
+ delete?: boolean;
342
+ bulkDelete?: boolean;
343
+ bulkEdit?: boolean | string[];
344
+ search?: boolean | { debounce?: number; placeholder?: string };
345
+ pagination?: boolean | { defaultPageSize?: number; style?: 'pages' | 'infinite' };
346
+ defaultSort?: { field: string; direction: 'asc' | 'desc' };
347
+ selectable?: boolean | 'single' | 'multi';
348
+ export?: boolean | string[];
349
+ views?: ('table' | 'grid' | 'list' | 'kanban')[];
350
+ // ... and more
351
+ }
352
+ ```
353
+
354
+ ## Examples
355
+
356
+ Run the included examples:
357
+
358
+ ```bash
359
+ npx tsx examples/01-basic-usage.ts # Simplest usage
360
+ npx tsx examples/02-ecommerce-catalog.ts # Full pipeline with overrides
361
+ npx tsx examples/03-task-tracker.ts # .meta() annotations, state management
362
+ npx tsx examples/04-zero-config.ts # Zero config demo
363
+ ```
364
+
365
+ ## Design Philosophy
366
+
367
+ 1. **Thin glue, not a framework.** ~1500 lines that reads Zod schemas and produces config objects for existing renderers.
368
+ 2. **Convention over configuration.** A plain Zod schema produces a working collection — zero annotations needed.
369
+ 3. **Escape hatches everywhere.** Any auto-generated config can be overridden per-field, per-collection, or per-view.
370
+ 4. **Zod-native.** The schema IS the source of truth. Affordances are metadata ON the schema.
371
+ 5. **Headless first.** Produces data structures, not React components. Renderers are separate.
372
+
373
+ ## Background
374
+
375
+ This library fills a gap in the JS/TS ecosystem: no single library lets you declare both the **data shape** and the **available operations** (sort, filter, edit, bulk delete, search, create) in a unified schema that a renderer consumes.
376
+
377
+ The concept draws from [affordance theory](https://en.wikipedia.org/wiki/Affordance) (Gibson/Norman), [HATEOAS](https://en.wikipedia.org/wiki/HATEOAS), OData's [Capabilities Vocabulary](https://github.com/oasis-tcs/odata-vocabularies), and 30 years of [model-based UI development](https://www.w3.org/2007/uwa/editors-drafts/mbui/latest/Model-Based-UI-XG-FinalReport.html) research.
378
+
379
+ See the full [landscape analysis](https://github.com/thorwhalen/zod-collection-ui/blob/main/schema_affordance_ui_report.md) for details.
380
+
381
+ ## License
382
+
383
+ MIT
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Collection Definition: The main entry point.
3
+ *
4
+ * `defineCollection` takes a Zod object schema + optional configuration and produces
5
+ * a CollectionDefinition with resolved affordances, generators, and utilities.
6
+ */
7
+ import { z } from 'zod';
8
+ import type { CollectionConfig, CollectionAffordances, FieldAffordance, OperationDefinition } from './types.js';
9
+ export interface CollectionDefinition<TSchema extends z.ZodObject<any>> {
10
+ /** The source Zod schema. */
11
+ schema: TSchema;
12
+ /** Resolved collection-level affordances. */
13
+ affordances: CollectionAffordances;
14
+ /** Resolved per-field affordances (after inference + merge). */
15
+ fieldAffordances: Record<string, FieldAffordance & {
16
+ title: string;
17
+ zodType: string;
18
+ }>;
19
+ /** Custom operations. */
20
+ operations: OperationDefinition[];
21
+ /** Which field is the unique identifier. */
22
+ idField: string;
23
+ /** Which field is the human-readable label. */
24
+ labelField: string;
25
+ /** Get ordered list of visible fields for the collection view. */
26
+ getVisibleFields(): string[];
27
+ /** Get fields that are searchable (for global search). */
28
+ getSearchableFields(): string[];
29
+ /** Get fields that are filterable (for filter panel). */
30
+ getFilterableFields(): {
31
+ key: string;
32
+ affordance: FieldAffordance & {
33
+ title: string;
34
+ };
35
+ }[];
36
+ /** Get fields that are sortable (for sort controls). */
37
+ getSortableFields(): {
38
+ key: string;
39
+ affordance: FieldAffordance & {
40
+ title: string;
41
+ };
42
+ }[];
43
+ /** Get fields that are groupable. */
44
+ getGroupableFields(): {
45
+ key: string;
46
+ affordance: FieldAffordance & {
47
+ title: string;
48
+ };
49
+ }[];
50
+ /** Get operations by scope. */
51
+ getOperations(scope: 'item' | 'selection' | 'collection'): OperationDefinition[];
52
+ /** Generate a human-readable description of affordances. */
53
+ describe(): string;
54
+ }
55
+ /**
56
+ * Define a collection from a Zod object schema and optional configuration.
57
+ *
58
+ * This is the main entry point for the collection affordance library.
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const projectCollection = defineCollection(
63
+ * z.object({
64
+ * id: z.string().uuid(),
65
+ * name: z.string().min(1),
66
+ * status: z.enum(['draft', 'active', 'archived']),
67
+ * priority: z.number().int().min(1).max(5),
68
+ * }),
69
+ * {
70
+ * affordances: { bulkDelete: true, export: ['csv', 'json'] },
71
+ * fields: { name: { inlineEditable: true } },
72
+ * operations: [{ name: 'archive', label: 'Archive', scope: 'item' }],
73
+ * }
74
+ * );
75
+ * ```
76
+ */
77
+ export declare function defineCollection<TSchema extends z.ZodObject<any>>(schema: TSchema, config?: CollectionConfig<z.infer<TSchema>>): CollectionDefinition<TSchema>;