@rozie-ui/data-table-solid 0.1.0

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 One Learning Community LTD
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,533 @@
1
+ # @rozie-ui/data-table-solid
2
+
3
+ Idiomatic **solid** `DataTable` — a headless, fully-accessible (WAI-ARIA) data table (sorting, global + per-column filtering, pagination, row selection, column visibility / resize / reorder / pinning, sticky header) compiled from one [Rozie](https://github.com/One-Learning-Community/rozie.js) source. The state engine is `@tanstack/table-core` — the SAME framework-agnostic core behind TanStack Table, wired to this framework's reactivity with NO per-framework adapter. Every visual value is a CSS custom property, so it re-skins to any design system. This package is generated; do not edit `src/` by hand.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i @rozie-ui/data-table-solid
9
+ ```
10
+
11
+ Peer dependencies: `solid-js + @tanstack/table-core + @tanstack/virtual-core + @floating-ui/dom`. Install them alongside this package.
12
+
13
+ ## Usage
14
+
15
+ Columns may be declared as a `:columns` config array **or** as `<Column>` children (or both — an id-keyed last-write-wins union). Per-cell rendering is one parent `#cell` / `#colHeader` renderer on `<DataTable>`, dispatched by `columnId`, so it works the same with either column form.
16
+
17
+ ### Columns as a config array
18
+
19
+ ```tsx
20
+ import { createSignal } from 'solid-js';
21
+ import { DataTable } from '@rozie-ui/data-table-solid';
22
+
23
+ export function Demo() {
24
+ const rows = [
25
+ { id: 1, name: 'Ada Lovelace', email: 'ada@analytical.engine', status: 'active' },
26
+ { id: 2, name: 'Alan Turing', email: 'alan@bletchley.park', status: 'active' },
27
+ { id: 3, name: 'Grace Hopper', email: 'grace@navy.mil', status: 'away' },
28
+ ];
29
+ const columns = [
30
+ { field: 'name', header: 'Name', sortable: true, filterable: true },
31
+ { field: 'email', header: 'Email' },
32
+ { field: 'status', header: 'Status', sortable: true },
33
+ ];
34
+ const [sorting, setSorting] = createSignal<{ id: string; desc: boolean }[]>([]);
35
+ return (
36
+ <DataTable data={rows} columns={columns} sorting={sorting()} onSortChange={setSorting} selectionMode="multiple" stickyHeader />
37
+ );
38
+ }
39
+ ```
40
+
41
+ ### Declarative `<Column>` children + a custom cell
42
+
43
+ ```tsx
44
+ import { createSignal } from 'solid-js';
45
+ import { DataTable, Column } from '@rozie-ui/data-table-solid';
46
+
47
+ export function Demo() {
48
+ const rows = [
49
+ { id: 1, name: 'Ada Lovelace', email: 'ada@analytical.engine', status: 'active' },
50
+ { id: 2, name: 'Alan Turing', email: 'alan@bletchley.park', status: 'active' },
51
+ { id: 3, name: 'Grace Hopper', email: 'grace@navy.mil', status: 'away' },
52
+ ];
53
+ const [sorting, setSorting] = createSignal<{ id: string; desc: boolean }[]>([]);
54
+ return (
55
+ <DataTable
56
+ data={rows}
57
+ sorting={sorting()}
58
+ onSortChange={setSorting}
59
+ selectionMode="multiple"
60
+ stickyHeader
61
+ cellSlot={({ columnId, value }) =>
62
+ columnId === 'status' ? <StatusBadge status={value} /> : value
63
+ }
64
+ >
65
+ <Column field="name" header="Name" sortable filterable />
66
+ <Column field="email" header="Email" />
67
+ <Column field="status" header="Status" sortable />
68
+ </DataTable>
69
+ );
70
+ }
71
+ ```
72
+
73
+ ### Virtualized rows (windowing)
74
+
75
+ ```tsx
76
+ import { DataTable, Column } from '@rozie-ui/data-table-solid';
77
+
78
+ // PROP form — bound maxHeight sizes the scroll container.
79
+ export function Demo() {
80
+ const rows = Array.from({ length: 10_000 }, (_, i) => ({
81
+ id: i + 1,
82
+ name: `Row ${i + 1}`,
83
+ email: `user${i + 1}@example.com`,
84
+ status: i % 2 ? 'active' : 'away',
85
+ }));
86
+ return (
87
+ <DataTable data={rows} virtual maxHeight="400px">
88
+ <Column field="name" header="Name" />
89
+ <Column field="email" header="Email" />
90
+ <Column field="status" header="Status" />
91
+ </DataTable>
92
+ );
93
+ }
94
+
95
+ // TOKEN form — the same height via the CSS custom property (the prop wins when
96
+ // both are set; the token is the fallback). estimateRowHeight tunes the seed:
97
+ // <DataTable data={rows} virtual estimateRowHeight={48}
98
+ // style={{ '--rozie-data-table-max-height': '400px' }} />
99
+ ```
100
+
101
+ ### Editable cells (inline edit + validation)
102
+
103
+ ```tsx
104
+ import { createSignal } from 'solid-js';
105
+ import { DataTable, Column } from '@rozie-ui/data-table-solid';
106
+
107
+ export function Demo() {
108
+ // The component OWNS edit state — bind ONE model ('data') + listen for commits.
109
+ const [rows, setRows] = createSignal([
110
+ { id: 1, name: 'Alpha', qty: 3, status: 'active', active: true, score: 41 },
111
+ { id: 2, name: 'Beta', qty: 7, status: 'archived', active: false, score: 92 },
112
+ ]);
113
+ const statusOptions = [
114
+ { value: 'active', label: 'Active' },
115
+ { value: 'archived', label: 'Archived' },
116
+ { value: 'pending', label: 'Pending' },
117
+ ];
118
+ return (
119
+ <DataTable
120
+ interactionMode="grid"
121
+ data={rows()}
122
+ onDataChange={setRows}
123
+ onCellEditCommit={(p) => console.log('cell commit', p)}
124
+ onRowEditCommit={(p) => console.log('row commit', p)}
125
+ // The #editor scoped slot is a render prop on Solid (the documented edge).
126
+ editorSlot={({ columnId, value, commit, cancel }) =>
127
+ columnId === 'score' ? (
128
+ <span>
129
+ <button onClick={() => commit(Number(value) - 1)}>−</button>
130
+ <button onClick={() => commit(Number(value) + 1)}>+</button>
131
+ <button onClick={() => cancel()}>esc</button>
132
+ </span>
133
+ ) : null
134
+ }
135
+ >
136
+ <Column field="name" header="Name" editable editor="text" />
137
+ <Column field="qty" header="Qty" editable editor="number"
138
+ validate={(value) => Number(value) >= 0 || 'must be >= 0'} />
139
+ <Column field="status" header="Status" editable editor="select" editorOptions={statusOptions} />
140
+ <Column field="active" header="Active" editable editor="checkbox" />
141
+ <Column field="score" header="Score" editable editor="custom" />
142
+ </DataTable>
143
+ );
144
+ }
145
+ ```
146
+
147
+ ### Expandable rows (`#detail` slot + nested sub-rows)
148
+
149
+ ```tsx
150
+ import { createSignal } from 'solid-js';
151
+ import { DataTable, Column } from '@rozie-ui/data-table-solid';
152
+
153
+ export function Demo() {
154
+ // expandable opts in; the two-way `expanded` set keeps MULTIPLE rows open; getSubRows
155
+ // yields depth-indented child rows; the #detail slot renders a panel under any open row.
156
+ const rows = [
157
+ { id: 1, name: 'Engineering', headcount: 12, children: [
158
+ { id: 11, name: 'Frontend', headcount: 5 },
159
+ { id: 12, name: 'Backend', headcount: 7 },
160
+ ] },
161
+ { id: 2, name: 'Sales', headcount: 8 },
162
+ ];
163
+ const [expanded, setExpanded] = createSignal<Record<string, boolean>>({});
164
+ return (
165
+ <DataTable
166
+ data={rows}
167
+ expandable
168
+ expanded={expanded()}
169
+ onExpandChange={setExpanded} // the event is `expand-change` → onExpandChange
170
+ getSubRows={(row) => row.children} // depth-indented nested rows
171
+ // The #detail scoped slot is a render prop on Solid (the documented edge).
172
+ detailSlot={({ row }) => <aside class="detail">More about {row.name}</aside>}
173
+ >
174
+ <Column field="name" header="Name" />
175
+ <Column field="headcount" header="Headcount" />
176
+ </DataTable>
177
+ );
178
+ }
179
+ ```
180
+
181
+ ### Grouping + aggregation (headless `#groupBar`)
182
+
183
+ ```tsx
184
+ import { createSignal } from 'solid-js';
185
+ import { DataTable, Column } from '@rozie-ui/data-table-solid';
186
+
187
+ export function Demo() {
188
+ // groupable enables grouping; the `grouping` model is an ORDERED column-id list;
189
+ // aggregationFn rolls leaf values into the group header. #groupBar is HEADLESS (no drag).
190
+ const rows = [
191
+ { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
192
+ { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
193
+ { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
194
+ { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
195
+ ];
196
+ const [grouping, setGrouping] = createSignal<string[]>([]);
197
+ const scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
198
+ const v = leafRows.map((r) => Number(r.getValue(columnId)));
199
+ return v.length ? Math.max(...v) - Math.min(...v) : 0;
200
+ };
201
+ return (
202
+ <DataTable
203
+ data={rows}
204
+ groupable
205
+ grouping={grouping()}
206
+ onGroupChange={setGrouping} // the event is `group-change` → onGroupChange
207
+ // The #groupBar scoped slot is a render prop on Solid (the documented edge).
208
+ groupBarSlot={({ grouping, groupableColumns, applyGrouping, clearGrouping }) => (
209
+ <div>
210
+ <button onClick={() => applyGrouping(['region', 'category'])}>Group region → category</button>
211
+ <button onClick={() => clearGrouping()}>Clear</button>
212
+ <span>{grouping.join(' → ') || 'ungrouped'} ({groupableColumns.length} groupable)</span>
213
+ </div>
214
+ )}
215
+ >
216
+ <Column field="region" header="Region" />
217
+ <Column field="category" header="Category" />
218
+ <Column field="units" header="Units" aggregationFn="sum" />
219
+ <Column field="score" header="Score" aggregationFn={scoreRange} />
220
+ </DataTable>
221
+ );
222
+ }
223
+ ```
224
+
225
+ ### Faceted filtering exposure (headless `#filter`)
226
+
227
+ ```tsx
228
+ import { createSignal } from 'solid-js';
229
+ import { DataTable, Column } from '@rozie-ui/data-table-solid';
230
+
231
+ export function Demo() {
232
+ // Faceting is HEADLESS + read-only (NO event, NO built-in control). The #filter slot
233
+ // hands you `uniqueValues` (keys, cross-filtered) + numeric `minMax`.
234
+ const rows = [
235
+ { id: 1, name: 'Alpha', category: 'Hardware', price: 30 },
236
+ { id: 2, name: 'Beta', category: 'Software', price: 90 },
237
+ { id: 3, name: 'Gamma', category: 'Hardware', price: 10 },
238
+ { id: 4, name: 'Delta', category: 'Service', price: 50 },
239
+ ];
240
+ const [columnFilters, setColumnFilters] = createSignal<{ id: string; value: unknown }[]>([]);
241
+ return (
242
+ <DataTable
243
+ data={rows}
244
+ columnFilters={columnFilters()}
245
+ onFilterChange={(p) => p.columnFilters && setColumnFilters(p.columnFilters)}
246
+ // The #filter scoped slot is a render prop on Solid (the documented edge).
247
+ filterSlot={({ columnId, uniqueValues, minMax }) =>
248
+ columnId === 'category' ? (
249
+ <fieldset>
250
+ {uniqueValues.map((v) => <label><input type="checkbox" /> {String(v)}</label>)}
251
+ </fieldset>
252
+ ) : (
253
+ <input type="range" min={minMax?.[0]} max={minMax?.[1]} />
254
+ )
255
+ }
256
+ >
257
+ <Column field="name" header="Name" />
258
+ <Column field="category" header="Category" filterable />
259
+ <Column field="price" header="Price" filterable />
260
+ </DataTable>
261
+ );
262
+ }
263
+ ```
264
+
265
+ ### Drop-in editor components (`#editor`)
266
+
267
+ ```tsx
268
+ import { createSignal, Switch, Match } from 'solid-js';
269
+ import {
270
+ DataTable, Column,
271
+ EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate,
272
+ } from '@rozie-ui/data-table-solid';
273
+
274
+ export function Demo() {
275
+ // OPT-IN drop-in editors fill the #editor slot — DataTable stays the headless
276
+ // default; the editors are additive named exports. Spread the slot scope through to
277
+ // each drop-in ({ columnId, column, row, value, commit, cancel }); EditorSelect also
278
+ // takes `options`. Use them as-is, or fork one as a template.
279
+ const [rows, setRows] = createSignal([
280
+ { id: 1, name: 'Alpha', qty: 3, status: 'active', active: true, score: 41 },
281
+ { id: 2, name: 'Beta', qty: 7, status: 'archived', active: false, score: 92 },
282
+ ]);
283
+ const statusOptions = [
284
+ { value: 'active', label: 'Active' },
285
+ { value: 'archived', label: 'Archived' },
286
+ { value: 'pending', label: 'Pending' },
287
+ ];
288
+ return (
289
+ <DataTable
290
+ interactionMode="grid"
291
+ data={rows()}
292
+ onDataChange={setRows}
293
+ onCellEditCommit={(p) => console.log('cell commit', p)}
294
+ // The #editor scoped slot is a render prop on Solid (the documented edge).
295
+ editorSlot={(scope) => (
296
+ <Switch>
297
+ <Match when={scope.columnId === 'name'}><EditorText {...scope} /></Match>
298
+ <Match when={scope.columnId === 'qty'}><EditorNumber {...scope} /></Match>
299
+ <Match when={scope.columnId === 'status'}><EditorSelect {...scope} options={statusOptions} /></Match>
300
+ <Match when={scope.columnId === 'active'}><EditorCheckbox {...scope} /></Match>
301
+ <Match when={scope.columnId === 'score'}><EditorDate {...scope} /></Match>
302
+ </Switch>
303
+ )}
304
+ >
305
+ <Column field="name" header="Name" editable editor="custom" />
306
+ <Column field="qty" header="Qty" editable editor="custom" />
307
+ <Column field="status" header="Status" editable editor="custom" />
308
+ <Column field="active" header="Active" editable editor="custom" />
309
+ <Column field="score" header="Score" editable editor="custom" />
310
+ </DataTable>
311
+ );
312
+ }
313
+ ```
314
+
315
+ ### Drop-in filter components (`#filter`)
316
+
317
+ ```tsx
318
+ import { createSignal, Switch, Match } from 'solid-js';
319
+ import {
320
+ DataTable, Column,
321
+ FilterText, FilterNumberRange, FilterSelect,
322
+ } from '@rozie-ui/data-table-solid';
323
+
324
+ export function Demo() {
325
+ // OPT-IN drop-in filters fill the #filter slot — DataTable stays the headless
326
+ // default; the filters are additive named exports. Mark each column filterable (the
327
+ // #filter slot only renders for filterable columns) and spread the slot scope through
328
+ // to each drop-in ({ columnId, column, value, setFilter, uniqueValues, minMax }).
329
+ const rows = [
330
+ { id: 1, name: 'Alpha', category: 'Hardware', price: 30 },
331
+ { id: 2, name: 'Beta', category: 'Software', price: 90 },
332
+ { id: 3, name: 'Gamma', category: 'Hardware', price: 10 },
333
+ { id: 4, name: 'Delta', category: 'Service', price: 50 },
334
+ ];
335
+ const [columnFilters, setColumnFilters] = createSignal<{ id: string; value: unknown }[]>([]);
336
+ return (
337
+ <DataTable
338
+ data={rows}
339
+ columnFilters={columnFilters()}
340
+ onFilterChange={(p) => p.columnFilters && setColumnFilters(p.columnFilters)}
341
+ // The #filter scoped slot is a render prop on Solid (the documented edge).
342
+ filterSlot={(scope) => (
343
+ <Switch>
344
+ <Match when={scope.columnId === 'name'}><FilterText {...scope} /></Match>
345
+ <Match when={scope.columnId === 'category'}><FilterSelect {...scope} /></Match>
346
+ <Match when={scope.columnId === 'price'}><FilterNumberRange {...scope} /></Match>
347
+ </Switch>
348
+ )}
349
+ >
350
+ <Column field="name" header="Name" filterable />
351
+ <Column field="category" header="Category" filterable />
352
+ <Column field="price" header="Price" filterable />
353
+ </DataTable>
354
+ );
355
+ }
356
+ ```
357
+
358
+ ### Drop-in group bar + detail panel (`#groupBar` / `#detail`)
359
+
360
+ ```tsx
361
+ import { createSignal } from 'solid-js';
362
+ import {
363
+ DataTable, Column, GroupBar, DetailPanel,
364
+ } from '@rozie-ui/data-table-solid';
365
+
366
+ export function Demo() {
367
+ // OPT-IN drop-ins fill the #groupBar + #detail slots — DataTable stays the headless
368
+ // default; both are additive named exports. Spread the slot scope through to each
369
+ // (GroupBar gets { grouping, groupableColumns, applyGrouping, clearGrouping };
370
+ // DetailPanel gets { row }). Use them as-is, or fork either as a starter.
371
+ const rows = [
372
+ { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
373
+ { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
374
+ { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
375
+ { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
376
+ ];
377
+ const [grouping, setGrouping] = createSignal<string[]>([]);
378
+ const [expanded, setExpanded] = createSignal<Record<string, boolean>>({});
379
+ return (
380
+ <DataTable
381
+ data={rows}
382
+ groupable
383
+ expandable
384
+ grouping={grouping()}
385
+ onGroupChange={setGrouping}
386
+ expanded={expanded()}
387
+ onExpandChange={setExpanded}
388
+ // The #groupBar / #detail scoped slots are render props on Solid (the documented edge).
389
+ groupBarSlot={(scope) => <GroupBar {...scope} />}
390
+ detailSlot={(scope) => <DetailPanel {...scope} />}
391
+ >
392
+ <Column field="region" header="Region" />
393
+ <Column field="category" header="Category" />
394
+ <Column field="units" header="Units" aggregationFn="sum" />
395
+ </DataTable>
396
+ );
397
+ }
398
+ ```
399
+
400
+ ## Theming
401
+
402
+ Every visual value is a `--rozie-data-table-*` CSS custom property — override any of them at any ancestor scope. Ready-made design-system bridges ship in the package (import `base.css` first, then a bridge):
403
+
404
+ ```tsx
405
+ import '@rozie-ui/data-table-solid/themes/base.css';
406
+ import '@rozie-ui/data-table-solid/themes/shadcn.css'; // or material.css, bootstrap.css
407
+ ```
408
+
409
+ ## Props
410
+
411
+ | Name | Type | Default | Two-way (model) | Required | Description |
412
+ | --- | --- | --- | :---: | :---: | --- |
413
+ | `data` | `Array` | `—` | ✓ | ✓ | The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher). |
414
+ | `columns` | `Array` | `[]` | | | Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union). |
415
+ | `selectionMode` | `String` | `"none"` | | | Row-selection mode: `'none'` \| `'single'` \| `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header. |
416
+ | `sorting` | `Array` | `[]` | ✓ | | `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding. |
417
+ | `globalFilter` | `String` | `''` | ✓ | | The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding. |
418
+ | `columnFilters` | `Array` | `[]` | ✓ | | `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`. |
419
+ | `pagination` | `Object` | `{…}` | ✓ | | `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`. |
420
+ | `manual` | `Boolean` | `false` | | | Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page). |
421
+ | `rowCount` | `Number` | `null` | | | Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset. |
422
+ | `pageCount` | `Number` | `null` | | | Explicit total page count for `manual` pagination; overrides rowCount-derived count. |
423
+ | `expandable` | `Boolean` | `false` | | | Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit). |
424
+ | `expanded` | `any` | `null` | ✓ | | `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand. |
425
+ | `getSubRows` | `Function` | `null` | | | Table-level child-row accessor `(originalRow, index) => TData[] \| undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode. |
426
+ | `groupable` | `Boolean` | `false` | | | Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI). |
427
+ | `grouping` | `Array` | `null` | ✓ | | `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array. |
428
+ | `rowSelection` | `Object` | `{}` | ✓ | | `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding. |
429
+ | `columnVisibility` | `Object` | `{}` | ✓ | | `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`. |
430
+ | `columnSizing` | `Object` | `{}` | ✓ | | `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`. |
431
+ | `columnOrder` | `Array` | `[]` | ✓ | | `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`. |
432
+ | `columnPinning` | `Object` | `{…}` | ✓ | | `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`. |
433
+ | `stickyHeader` | `Boolean` | `false` | | | Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container. |
434
+ | `interactionMode` | `String` | `"table"` | | | `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) \| `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support. |
435
+ | `singleClickEdit` | `Boolean` | `false` | | | Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected. |
436
+ | `undoable` | `Boolean` | `false` | | | Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert. |
437
+ | `undoLimit` | `Number` | `100` | | | The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`. |
438
+ | `virtual` | `Boolean` | `false` | | | Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table. |
439
+ | `estimateRowHeight` | `Number` | `40` | | | Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on. |
440
+ | `maxHeight` | `String` | `''` | | | A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback. |
441
+
442
+ ## Events
443
+
444
+ | Event | Description |
445
+ | --- | --- |
446
+ | `sort-change` | Fired when the sort state changes (header click / shift-click multi-sort / a `sortColumn`/`clearSorting` call). Payload is the fresh `SortingState` array `[{ id, desc }]`. |
447
+ | `expand-change` | Fired (phase 50) when the expanded-row set changes (an expander chevron toggle — click / Enter / Space — or a `toggleRowExpanded`/`expandAll`/`collapseAll` call). Fires exactly once per change (the echo-guarded write funnel dedups the React multi-render re-entry, D-07) and REGARDLESS of whether `r-model:expanded` is bound. Payload is the fresh `ExpandedState` — a `{ [rowId]: true }` object, or the `true` literal after `expandAll` (Pitfall 2: it is passed through verbatim — never `Object.keys` it without a `=== true` guard). Named `expand-change` (not `expanded-change`): the model:true `expanded` prop owns the `onExpandedChange` callback on the React/Solid Props interface, so the event stems off a distinct name to avoid a duplicate-identifier collision (the house convention every slice follows). |
448
+ | `group-change` | Fired (phase 50) when the grouping state changes (a consumer-built `#groupBar` apply/clear, mutating the `grouping` model, or an `applyGrouping`/`clearGrouping` call). Fires exactly once per change (the echo-guarded write funnel dedups the React multi-render re-entry, D-07) and REGARDLESS of whether `r-model:grouping` is bound. Payload is the fresh `GroupingState` — an ORDERED `string[]` of column ids (e.g. `["region","category"]` for nested groups), or `[]` when cleared. Named `group-change` (not `grouping-change`): the model:true `grouping` prop owns the `onGroupingChange` callback on the React/Solid Props interface, so the event stems off a distinct name to avoid a duplicate-identifier collision (the house convention every slice follows — sorting→sort-change, expanded→expand-change). |
449
+ | `filter-change` | Fired when a filter changes. Payload is `{ globalFilter }` for the global search box or `{ columnFilters }` (the fresh `ColumnFiltersState` `[{ id, value }]`) for a per-column filter — both surface through this one event. |
450
+ | `page-change` | Fired when pagination changes (prev/next, a page-size change, or a `setPage`/`setRowsPerPage` call). Payload is the fresh `{ pageIndex, pageSize }` object. |
451
+ | `selection-change` | Fired when the row selection changes (a row/select-all checkbox toggle or a `toggleAllRows`/`clearSelection` call). Payload is the fresh `RowSelectionState` `{ [rowId]: true }` object. |
452
+ | `visibility-change` | Fired when a column is shown/hidden (the column-toggle menu or a `toggleColumnVisibility` call). Payload is the fresh `VisibilityState` `{ [colId]: boolean }` object. |
453
+ | `resize-change` | Fired live during a column resize drag (`columnResizeMode: "onChange"`). Payload is the fresh `ColumnSizingState` `{ [colId]: number }` object. |
454
+ | `reorder-change` | Fired when the column order changes (an `applyColumnOrder` call or a header reorder). Payload is the fresh `ColumnOrderState` `string[]`. |
455
+ | `pin-change` | Fired when a column is pinned/unpinned (the per-header pin buttons or a `pinColumn` call). Payload is the fresh `ColumnPinningState` `{ left: string[], right: string[] }` object. |
456
+ | `history-change` | Fired (260709-8ct, grid-wide undo/redo) when undo/redo AVAILABILITY changes — EDGE-triggered (only when `canUndo`/`canRedo` actually flip, not on every recorded edit) so a toolbar can enable/disable its buttons without polling. Only meaningful when `undoable` is `true`. Payload is `{ canUndo, canRedo }` booleans. No `history` model prop exists, so there is no model-prop==emit-name duplicate-identifier collision. |
457
+ | `activecell-change` | Fired (grid interaction mode only) whenever the active cell moves — by keyboard navigation or a `focusCell` call. One-way notification; payload is `{ rowIndex, colIndex }` integers over the visible model. Fires on every move including arrow keypresses (D-02). |
458
+ | `range-change` | Fired (Phase 51 req-7 / D-07) whenever the rectangular cell-range selection changes — extended by `Shift+Arrow` / `Shift+Click` from the single `extendRange`/`setRangeFocus` call sites (React multi-emit dedup, D-07). One-way notification (the range is NOT a `model:true` slice — the model:true count stays at 10, leaving the Angular multi-model-CVA condition untouched); it is a SEPARATE layer from the row-selection slice and the two never corrupt each other. Payload is `getSelectedRange()` → `{ anchor, focus }` where each corner is a `{ rowIndex, colIndex }` index pair over the visible model (integers only — no row data, no DOM node), or `null` when no range is set. |
459
+ | `cell-edit-commit` | Fired (Phase 51) when an editable cell commits a NEW, validated value — once per committed cell, from the single `commitEdit` call site (a rejected/invalid commit fires nothing — D-01). Pairs with the `r-model:data` write (one fresh-array replace per commit). Payload is `{ rowId, columnId, oldValue, newValue }`. `cancel`/`Escape` and a validation failure emit nothing. |
460
+ | `row-edit-commit` | Fired (Phase 51 req-6 / D-06) when a FULL-ROW edit commits — once per row save, from the single `commitRow` call site (React multi-emit dedup, D-07). A row enters full-row edit via `Shift+F2` or the `editRow` verb (every editable cell edits at once); one save validates every edited cell (D-01 — a single failure blocks the whole row and emits nothing) then writes the bound `r-model:data` ONCE (a single fresh-array row-object replace with all changes applied) and fires this event ONCE. `Escape` reverts the whole row as a unit and emits nothing. Payload is `{ rowId, changes }` where `changes` is `[{ columnId, oldValue, newValue }]` for ONLY the columns whose value actually changed. |
461
+
462
+ ## Imperative handle
463
+
464
+ Beyond props, the component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly:
465
+
466
+ | Method | Description |
467
+ | --- | --- |
468
+ | `sortColumn` | Toggle (or set) the sort for a column — `sortColumn(colId, desc?)`. Drives table-core so `sort-change` fires with the fresh `SortingState`. |
469
+ | `clearSorting` | Clear all sorting — `clearSorting()`. Resets to the unsorted core row model and fires `sort-change`. |
470
+ | `toggleRowExpanded` | Toggle ONE row's expanded state (phase 50 req-3) — `toggleRowExpanded(rowId)` where `rowId` is the consumer's row `id` (the data field) OR the table-core row id (both resolve). Scans the core flat-row set so a collapsed parent is still resolvable. Drives table-core so `expand-change` fires with the fresh `ExpandedState`. Multi-expand: it does not collapse other open rows. |
471
+ | `expandAll` | Open every expandable row (phase 50 req-3) — `expandAll()`. Drives table-core (`toggleAllRowsExpanded(true)`) so `expand-change` fires; the payload may be the `true` expand-all literal (Pitfall 2). |
472
+ | `collapseAll` | Collapse every row (phase 50 req-3) — `collapseAll()`. Resets the expanded set to a blank state (`resetExpanded(true)` → `{}`) and fires `expand-change` with `{}`. |
473
+ | `getExpandedRows` | Return the original row data for the currently-expanded rows (phase 50 req-3) — `getExpandedRows()` → `unknown[]` (empty when nothing is expanded). The read-verb twin of the `expand-change` event. |
474
+ | `applyGrouping` | Set the full grouping — `applyGrouping(cols)` where `cols` is a fresh ORDERED `string[]` of column ids (multi-column → nested groups, e.g. `["region","category"]`). Drives table-core (`table.setGrouping`) so `group-change` fires with the fresh `GroupingState`. (Named `applyGrouping`, not `setGrouping`: a `set<ModelProp>` verb collides with React`s auto-generated `grouping` useState setter and an $expose verb is rename-protected — ROZ524; same `applyColumnOrder` precedent.) |
475
+ | `clearGrouping` | Clear all grouping — `clearGrouping()`. Resets to the ungrouped (flat) row model (`table.setGrouping([])`) and fires `group-change` with `[]`. |
476
+ | `getFacetedUniqueValues` | Return a column's CROSS-FILTERED distinct values (phase 50 reqs 8-9, D-03) — `getFacetedUniqueValues(colId)` → `unknown[]` of distinct values (KEYS ONLY — occurrence counts are deliberately NOT exposed). Resolves the column via `table.getColumn(colId)` and reads table-core's faceted unique-value map, returning `Array.from(map.keys())`. Cross-filtered: the values reflect rows passing all OTHER active column filters and update when an upstream filter changes. Empty array when the column/table is missing. Inert (the faceted models stay off-path) until this verb or the `#filter` slot reads a facet. |
477
+ | `getFacetedMinMaxValues` | Return a numeric column's CROSS-FILTERED `[min, max]` range (phase 50 reqs 8-9, D-03) — `getFacetedMinMaxValues(colId)` → `[number, number] | null`. Resolves the column via `table.getColumn(colId)` and reads table-core's faceted min/max. Cross-filtered (reflects rows passing all OTHER active column filters) and updates when an upstream filter changes. `null` when unavailable. The read twin handed to the `#filter` scoped slot so a consumer builds a numeric range slider purely from exposed values. |
478
+ | `getColumnDefs` | Return the resolved `ColumnDef[]` (the id-keyed LWW union of the `:columns` config array and the `<Column>` children) — `getColumnDefs()`. |
479
+ | `toggleAllRows` | Select or clear all (filtered) rows — `toggleAllRows(value)`. Drives table-core so `selection-change` fires with the fresh `RowSelectionState`. |
480
+ | `clearSelection` | Clear the row selection — `clearSelection()`. Fires `selection-change` with `{}`. |
481
+ | `getSelectedRows` | Return the original row data for the currently-selected rows — `getSelectedRows()` → `unknown[]` (empty when nothing is selected). |
482
+ | `setPage` | Go to a 0-based page index — `setPage(idx)`. Drives table-core so `page-change` fires with the fresh `{ pageIndex, pageSize }`. |
483
+ | `setRowsPerPage` | Set the page size — `setRowsPerPage(size)`. Fires `page-change` with the fresh pagination object. |
484
+ | `toggleColumnVisibility` | Show/hide a column — `toggleColumnVisibility(colId)`. Drives table-core so `visibility-change` fires with the fresh `VisibilityState`. |
485
+ | `applyColumnOrder` | Set the full column order — `applyColumnOrder(order)` where `order` is a fresh `string[]`. Fires `reorder-change`. (Named `applyColumnOrder`, not `setColumnOrder`: a `set<ModelProp>` verb collides with React`s auto-generated `columnOrder` setter and an $expose verb is rename-protected — ROZ524.) |
486
+ | `resetColumnSizing` | Reset all column widths to their defaults — `resetColumnSizing()`. Fires `resize-change`. |
487
+ | `pinColumn` | Pin a column to a side or unpin it — `pinColumn(colId, side)` where `side` is `'left'` | `'right'` | `false`. Fires `pin-change` with the fresh `ColumnPinningState`. |
488
+ | `focusCell` | Move + focus the active cell (grid interaction mode) — `focusCell(rowIndex, colIndex)`. `rowIndex` is the ABSOLUTE display-order position in `getPrePaginationRowModel().rows` (filter+sort+expand applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes (C1): a paginated `focusCell(abs)` switches to `Math.floor(abs/pageSize)` then focuses; a virtual `focusCell(abs)` scrolls-then-focuses. Args coerced to integers + clamped to bounds. Fires `activecell-change` with the absolute `rowIndex`. (Named `focusCell`, not `focus`: a bare `focus` verb shadows the inherited `HTMLElement.focus` on Lit — ROZ137.) |
489
+ | `getActiveCell` | Return the current active-cell position — `getActiveCell()` → `{ rowIndex, colIndex, isHeader }`. For a body cell `rowIndex` is the ABSOLUTE display-order index (C1, matching `focusCell` / `activecell-change`) and `isHeader` is `false`; for a header-active cell `rowIndex` is `null` and `isHeader` is `true` (integers only — no row data, no DOM node). |
490
+ | `clearActiveCell` | Reset the roving active-cell position to the entry cell and exit interaction mode — `clearActiveCell()`. The next Tab-in re-enters at the entry cell (D-01). (Named `clearActiveCell`, not `clear`: distinct from the listbox `clear` selection verb.) |
491
+ | `getRowIndexRelativeToPage` | Convert an ABSOLUTE display-order row index (the `focusCell` / `getActiveCell` / `activecell-change` space, C1) to the PAGE-RELATIVE index — `getRowIndexRelativeToPage(absRow?)` → `number`. With no argument it converts the current active cell (`abs - pageIndex*pageSize`). In virtual mode (no pagination) it returns the absolute index unchanged. Mirrors MUI `getRowIndexRelativeToVisibleRows`. (Named `getRowIndexRelativeToPage`: collision-clean against the verb/event/prop and Lit ROZ137 reserved sets.) |
492
+ | `editCell` | Programmatically open the editor on a cell (Phase 51) — `editCell(rowIndex, colIndex)`, addressed by index over the visible model (args coerced to integers + clamped). No-op on a non-editable cell. (Named `editCell`, not `edit`: collision-clean against the verb/event/prop and Lit ROZ137 reserved sets.) |
493
+ | `commitEditing` | Programmatically commit the open editor (Phase 51) — `commitEditing()`. Runs the column validator; on success writes the bound `r-model:data` and fires one `cell-edit-commit`; on a validation failure keeps the editor open (D-01). No-op when no cell is editing. (Named `commitEditing`, not `commit`.) |
494
+ | `editRow` | Programmatically enter FULL-ROW edit on a body row (Phase 51 req-6 / D-06) — `editRow(rowIndex)`, addressed by index over the visible model (args coerced to integers + clamped). The API twin of the `Shift+F2` shortcut: every editable cell in the row enters edit at once. A later save commits the whole row in one `r-model:data` write + one `row-edit-commit`; `Escape` reverts the row as a unit. No-op on a row with no editable columns. (Named `editRow`, not `edit`/`editColumn`: collision-clean against the verb/event/prop and Lit ROZ137 reserved sets.) |
495
+ | `getSelectedRange` | Return the current rectangular cell-range selection (Phase 51 req-7 / D-07) — `getSelectedRange()` → `{ anchor, focus }` where each corner is a `{ rowIndex, colIndex }` index pair over the visible model (integers only — no row data, no DOM node, T-49-02), or `{ anchor: null, focus: null }` when no range is set. The range is extended by `Shift+Arrow` / `Shift+Click` and is ONE-WAY (this read verb + the `range-change` event), NOT a `model:true` slice (D-07). (Named `getSelectedRange`, not `getRange`/`getSelection`: collision-clean against `getSelectedRows`, the verb/event/prop, and the Lit ROZ137 reserved sets.) |
496
+ | `cut` | Cut the current cell-range selection (Phase 63 C3) — `cut()`. Copies the range to the clipboard as TSV (the same escaped serialization `Ctrl+C` produces) THEN clears the source cells through the bound `r-model:data` write-funnel in a single `writeData` (each cell coerced to its column empty — `null` on a numeric column, the empty string on text — under the editable + validator skip rule, firing one `cell-edit-commit` per cleared cell). The API twin of the `Ctrl/Cmd+X` shortcut. A no-op while a header cell is active (the B11 clipboard guard). (Named `cut`, the spreadsheet-standard verb: collision-clean against the verb/event/prop, the React auto-setter set, and the Lit ROZ137 reserved DOM members — `cut` is not on `HTMLElement`.) |
497
+ | `undo` | Undo the most recent data mutation (260709-8ct, grid-wide undo/redo) — `undo()`. Only meaningful when `undoable` is `true`. Pops the most recent pre-mutation snapshot, pushes the current data onto the redo stack, and replays the snapshot through the SAME `writeData` funnel every edit/paste/fill/cut/clear commits through — so the two-way `r-model:data` writeback keeps working with zero new code. Fires `history-change` once per call. A no-op when nothing is undoable (`canUndo()` is `false`). The API twin of the `Ctrl/Cmd+Z` shortcut. |
498
+ | `redo` | Redo the most recently undone data mutation (260709-8ct) — `redo()`. Symmetric to `undo()`: pops the redo stack, pushes the current data back onto the undo stack, replays through the same `writeData` seam, fires `history-change` once. A no-op when nothing is redoable (`canRedo()` is `false`). The API twin of the `Ctrl/Cmd+Y` / `Ctrl/Cmd+Shift+Z` shortcuts. |
499
+ | `canUndo` | Return whether an undo step is available (260709-8ct) — `canUndo()` → `boolean`. Reflects the undo stack depth; use alongside the `history-change` event to drive a toolbar button`s disabled state without polling. |
500
+ | `canRedo` | Return whether a redo step is available (260709-8ct) — `canRedo()` → `boolean`. Reflects the redo stack depth; cleared by any new edit after an undo (standard undo semantics). |
501
+ | `clearHistory` | Discard all undo/redo history (260709-8ct) — `clearHistory()`. Empties both stacks (`canUndo()`/`canRedo()` become `false`). Also called internally on an external data-reference swap (a dataset the component itself did not write), since undoing across a dataset boundary is incoherent. |
502
+
503
+ ```tsx
504
+ import { DataTable, type DataTableHandle } from '@rozie-ui/data-table-solid';
505
+
506
+ let handle: DataTableHandle | undefined;
507
+ // The ref callback receives the HANDLE object (not the DOM node).
508
+ <DataTable ref={(h) => (handle = h)} data={rows} />;
509
+ handle?.toggleAllRows(true);
510
+ handle?.editRow(0); // full-row edit on row 0
511
+ const range = handle?.getSelectedRange(); // the active cell-range rectangle
512
+ handle?.expandAll(); // collapseAll / toggleRowExpanded / getExpandedRows
513
+ handle?.applyGrouping(['region']); // clearGrouping to reset
514
+ const cats = handle?.getFacetedUniqueValues('category'); // getFacetedMinMaxValues too
515
+ ```
516
+
517
+ ## Slots
518
+
519
+ All rendering slots live on the parent `<DataTable>` (a `<Column>` carries metadata only). The `cell` / `colHeader` slots are single renderers dispatched by `columnId` — switch on it to vary the render per column; a column the slot does not render shows the plain accessor value. (On React/Solid these are render-prop props — `renderCell` / `renderColHeader` / `cellSlot` / `colHeaderSlot`; on Lit they are the `.cell` / `.colHeader` properties — the documented cross-framework divergence.)
520
+
521
+ The `detail` (expandable rows), `groupBar` (grouping) and `filter` (faceted filtering) scoped slots follow the SAME render-prop convention: on React they are `renderDetail` / `renderGroupBar` / `renderFilter`; on Solid they are `detailSlot` / `groupBarSlot` / `filterSlot`; on Lit they are the `.detail` / `.groupBar` / `.filter` properties — the documented React render-prop edge (per the cross-framework compatibility bar). On Vue / Svelte / Angular they are ordinary named scoped slots (`#detail` / `#groupBar` / `#filter`). The `groupBar` and `filter` slots are HEADLESS — the component ships NO built-in group-bar / facet control, so the consumer builds the UI purely from the exposed slot props.
522
+
523
+ | Slot | Params |
524
+ | --- | --- |
525
+ | (default) | |
526
+ | groupBar | grouping, groupableColumns, applyGrouping, clearGrouping |
527
+ | selectAll | checked, indeterminate, toggle |
528
+ | colHeader | columnId, column, label |
529
+ | filter | columnId, value, uniqueValues, minMax, setFilter |
530
+ | selectCell | row, checked, toggle |
531
+ | cell | columnId, column, row, value |
532
+ | editor | columnId, column, row, value, commit, cancel |
533
+ | detail | row |