@rozie-ui/data-table-lit 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rozie-ui/data-table-lit
2
2
 
3
- Idiomatic **lit** `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.
3
+ Idiomatic **lit** `DataTable` — a headless, fully-accessible (WAI-ARIA) data table (sorting, global + per-column filtering, pagination, row selection, column visibility / resize / pinning (plus a programmatic `columnOrder` slice, no drag affordance), 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. The table's primary surfaces are themed through 18 `--rozie-data-table-*` custom properties, so it re-skins to any design system. This package is generated; do not edit `src/` by hand.
4
4
 
5
5
  ## Install
6
6
 
@@ -14,29 +14,32 @@ Also installed: `@rozie/runtime-lit` — Rozie's small, tree-shaken runtime help
14
14
 
15
15
  ## Usage
16
16
 
17
- 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.
17
+ 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.
18
18
 
19
19
  ### Columns as a config array
20
20
 
21
21
  ```ts
22
22
  import '@rozie-ui/data-table-lit';
23
+ import type { DataTable } from '@rozie-ui/data-table-lit';
23
24
 
24
25
  // <rozie-data-table> is a custom element. Set `data`/`columns` as properties
25
26
  // and listen for the change events (`sort-change`, `filter-change`, …).
26
- const el = document.querySelector('rozie-data-table');
27
- el.data = [
27
+ const el = document.querySelector<DataTable>('rozie-data-table');
28
+ if (el) {
29
+ el.data = [
28
30
  { id: 1, name: 'Ada Lovelace', email: 'ada@analytical.engine', status: 'active' },
29
31
  { id: 2, name: 'Alan Turing', email: 'alan@bletchley.park', status: 'active' },
30
32
  { id: 3, name: 'Grace Hopper', email: 'grace@navy.mil', status: 'away' },
31
33
  ];
32
- el.columns = [
34
+ el.columns = [
33
35
  { field: 'name', header: 'Name', sortable: true, filterable: true },
34
36
  { field: 'email', header: 'Email' },
35
37
  { field: 'status', header: 'Status', sortable: true },
36
38
  ];
37
- el.addEventListener('sort-change', (e) => {
38
- console.log('sorting', e.detail);
39
- });
39
+ el.addEventListener('sort-change', (e) => {
40
+ console.log('sorting', e.detail);
41
+ });
42
+ }
40
43
  ```
41
44
 
42
45
  ### Declarative `<Column>` children + a custom cell
@@ -56,8 +59,8 @@ const rows = [
56
59
  render(html`
57
60
  <rozie-data-table
58
61
  .data=${rows}
59
- selection-mode="multiple"
60
- sticky-header
62
+ selectionmode="multiple"
63
+ stickyheader
61
64
  .cell=${({ columnId, value }) =>
62
65
  columnId === 'status' ? html`<span class="badge">${value}</span>` : value}
63
66
  >
@@ -81,9 +84,9 @@ const rows = Array.from({ length: 10_000 }, (_, i) => ({
81
84
  status: i % 2 ? 'active' : 'away',
82
85
  }));
83
86
 
84
- // PROP form — the `max-height` attribute sizes the scroll container.
87
+ // PROP form — the `maxheight` attribute sizes the scroll container.
85
88
  render(html`
86
- <rozie-data-table .data=${rows} virtual max-height="400px">
89
+ <rozie-data-table .data=${rows} virtual maxheight="400px">
87
90
  <rozie-column field="name" header="Name"></rozie-column>
88
91
  <rozie-column field="email" header="Email"></rozie-column>
89
92
  <rozie-column field="status" header="Status"></rozie-column>
@@ -91,8 +94,8 @@ render(html`
91
94
  `, document.body);
92
95
 
93
96
  // TOKEN form — the same height via the CSS custom property (the prop wins when
94
- // both are set; the token is the fallback). `estimate-row-height` tunes the seed:
95
- // <rozie-data-table .data=${rows} virtual estimate-row-height="48"
97
+ // both are set; the token is the fallback). `estimaterowheight` tunes the seed:
98
+ // <rozie-data-table .data=${rows} virtual estimaterowheight="48"
96
99
  // style="--rozie-data-table-max-height: 400px"> … </rozie-data-table>
97
100
  ```
98
101
 
@@ -116,7 +119,7 @@ const validateQty = (value: unknown) => Number(value) >= 0 || 'must be >= 0';
116
119
 
117
120
  render(html`
118
121
  <rozie-data-table
119
- interaction-mode="grid"
122
+ interactionmode="grid"
120
123
  .data=${rows}
121
124
  @data-change=${(e: CustomEvent) => { rows = e.detail; }}
122
125
  @cell-edit-commit=${(e: CustomEvent) => console.log('cell commit', e.detail)}
@@ -260,21 +263,21 @@ const statusOptions = [
260
263
 
261
264
  // The #editor slot is the `.editor` property — a function returning a Lit template,
262
265
  // dispatched by columnId. Pass the slot scope ({ columnId, column, row, value, commit,
263
- // cancel }) as element properties; <rozie-editor-select> also takes `.options`.
266
+ // cancel, autofocus }) as element properties; <rozie-editor-select> also takes `.options`.
264
267
  render(html`
265
268
  <rozie-data-table
266
- interaction-mode="grid"
269
+ interactionmode="grid"
267
270
  .data=${rows}
268
271
  @data-change=${(e: CustomEvent) => { rows = e.detail; }}
269
272
  @cell-edit-commit=${(e: CustomEvent) => console.log('cell commit', e.detail)}
270
- .editor=${({ columnId, column, row, value, commit, cancel }) => {
271
- const p = { columnId, column, row, value, commit, cancel };
273
+ .editor=${({ columnId, column, row, value, commit, cancel, autofocus }) => {
274
+ const p = { columnId, column, row, value, commit, cancel, autofocus };
272
275
  switch (columnId) {
273
- case 'name': return html`<rozie-editor-text .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-text>`;
274
- case 'qty': return html`<rozie-editor-number .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-number>`;
275
- case 'status': return html`<rozie-editor-select .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .options=${statusOptions}></rozie-editor-select>`;
276
- case 'active': return html`<rozie-editor-checkbox .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-checkbox>`;
277
- case 'score': return html`<rozie-editor-date .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-date>`;
276
+ case 'name': return html`<rozie-editor-text .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .autofocus=${p.autofocus}></rozie-editor-text>`;
277
+ case 'qty': return html`<rozie-editor-number .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .autofocus=${p.autofocus}></rozie-editor-number>`;
278
+ case 'status': return html`<rozie-editor-select .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .autofocus=${p.autofocus} .options=${statusOptions}></rozie-editor-select>`;
279
+ case 'active': return html`<rozie-editor-checkbox .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .autofocus=${p.autofocus}></rozie-editor-checkbox>`;
280
+ case 'score': return html`<rozie-editor-date .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .autofocus=${p.autofocus}></rozie-editor-date>`;
278
281
  default: return null;
279
282
  }
280
283
  }}
@@ -306,19 +309,19 @@ let rows = [
306
309
  let columnFilters: { id: string; value: unknown }[] = [];
307
310
 
308
311
  // The #filter slot is the `.filter` property — a function returning a Lit template,
309
- // dispatched by columnId. Pass the slot scope ({ columnId, column, value, setFilter,
310
- // uniqueValues, minMax }) as element properties.
312
+ // dispatched by columnId. Pass the slot scope ({ columnId, value, uniqueValues, minMax,
313
+ // setFilter }) as element properties.
311
314
  render(html`
312
315
  <rozie-data-table
313
316
  .data=${rows}
314
317
  .columnFilters=${columnFilters}
315
318
  @filter-change=${(e: CustomEvent) => { columnFilters = e.detail.columnFilters; }}
316
- .filter=${({ columnId, column, value, setFilter, uniqueValues, minMax }) => {
317
- const p = { columnId, column, value, setFilter, uniqueValues, minMax };
319
+ .filter=${({ columnId, value, setFilter, uniqueValues, minMax }) => {
320
+ const p = { columnId, value, setFilter, uniqueValues, minMax };
318
321
  switch (columnId) {
319
- case 'name': return html`<rozie-filter-text .columnId=${p.columnId} .column=${p.column} .value=${p.value} .setFilter=${p.setFilter}></rozie-filter-text>`;
320
- case 'category': return html`<rozie-filter-select .columnId=${p.columnId} .column=${p.column} .value=${p.value} .setFilter=${p.setFilter} .uniqueValues=${p.uniqueValues}></rozie-filter-select>`;
321
- case 'price': return html`<rozie-filter-number-range .columnId=${p.columnId} .column=${p.column} .value=${p.value} .setFilter=${p.setFilter} .minMax=${p.minMax}></rozie-filter-number-range>`;
322
+ case 'name': return html`<rozie-filter-text .columnId=${p.columnId} .value=${p.value} .setFilter=${p.setFilter}></rozie-filter-text>`;
323
+ case 'category': return html`<rozie-filter-select .columnId=${p.columnId} .value=${p.value} .setFilter=${p.setFilter} .uniqueValues=${p.uniqueValues}></rozie-filter-select>`;
324
+ case 'price': return html`<rozie-filter-number-range .columnId=${p.columnId} .value=${p.value} .setFilter=${p.setFilter} .minMax=${p.minMax}></rozie-filter-number-range>`;
322
325
  default: return null;
323
326
  }
324
327
  }}
@@ -405,7 +408,7 @@ render(html`
405
408
 
406
409
  ## Theming
407
410
 
408
- 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):
411
+ The table's primary surfaces are themed through 18 `--rozie-data-table-*` CSS custom properties — override any of them at any ancestor scope (on Lit, set them at or above the `<rozie-data-table>` element — `:root`, a wrapper, or the element itself — never on the `.rozie-data-table` classes, which render inside the shadow root where a document stylesheet cannot reach them). Some internal details (the grid active-cell ring, range highlight, fill handle, the per-header column ⋯ menu, and most of the pagination bar beyond its control border/radius/disabled-opacity) are not yet part of the public token surface. Ready-made design-system bridges ship in the package (import `base.css` first, then a bridge):
409
412
 
410
413
  ```ts
411
414
  import '@rozie-ui/data-table-lit/themes/base.css';
@@ -416,8 +419,8 @@ import '@rozie-ui/data-table-lit/themes/shadcn.css'; // or material.css, boot
416
419
 
417
420
  | Name | Type | Default | Two-way (model) | Required | Description |
418
421
  | --- | --- | --- | :---: | :---: | --- |
419
- | `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). |
420
- | `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). |
422
+ | `data` | `Array` | `—` | ✓ | ✓ | The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through the two-way `data` binding (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher). |
423
+ | `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). A `pinned` value of `'left'` or `'right'` is applied once as the table's initial `columnPinning` state — identical to `<Column pinned>` — which also REORDERS that column, since table-core orders visible cells `[left-pinned, center, right-pinned]`. A consumer who has already expressed a pin (an initial two-way `columnPinning` model value or an interactive pin) owns the slice and this declaration is ignored. |
421
424
  | `selectionMode` | `String` | `"none"` | | | Row-selection mode: `'none'` \| `'single'` \| `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header. |
422
425
  | `sorting` | `Array` | `[]` | ✓ | | `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding. |
423
426
  | `globalFilter` | `String` | `''` | ✓ | | The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding. |
@@ -426,18 +429,18 @@ import '@rozie-ui/data-table-lit/themes/shadcn.css'; // or material.css, boot
426
429
  | `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). |
427
430
  | `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. |
428
431
  | `pageCount` | `Number` | `null` | | | Explicit total page count for `manual` pagination; overrides rowCount-derived count. |
429
- | `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). |
432
+ | `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). Pass `expandable` as a real boolean — a valueless attribute only coerces to `true` on Vue and Lit. |
430
433
  | `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. |
431
434
  | `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. |
432
435
  | `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). |
433
- | `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. |
436
+ | `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 the two-way `grouping` model (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array. |
434
437
  | `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. |
435
438
  | `columnVisibility` | `Object` | `{}` | ✓ | | `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`. |
436
439
  | `columnSizing` | `Object` | `{}` | ✓ | | `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`. |
437
440
  | `columnOrder` | `Array` | `[]` | ✓ | | `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`. |
438
441
  | `columnPinning` | `Object` | `{…}` | ✓ | | `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`. |
439
442
  | `stickyHeader` | `Boolean` | `false` | | | Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container. |
440
- | `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. |
443
+ | `interactionMode` | `String` | `"table"` | | | `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) \| `'grid'` — 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. |
441
444
  | `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. |
442
445
  | `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. |
443
446
  | `undoLimit` | `Number` | `100` | | | The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`. |
@@ -451,20 +454,20 @@ import '@rozie-ui/data-table-lit/themes/shadcn.css'; // or material.css, boot
451
454
  | Event | Description |
452
455
  | --- | --- |
453
456
  | `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 }]`. |
454
- | `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). |
455
- | `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). |
457
+ | `expand-change` | Fired 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) and REGARDLESS of whether the two-way `expanded` model is bound. Payload is the fresh `ExpandedState` — a `{ [rowId]: true }` object, or the `true` literal after `expandAll` (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). |
458
+ | `group-change` | Fired 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) and REGARDLESS of whether the two-way `grouping` model 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). |
456
459
  | `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. |
457
460
  | `page-change` | Fired when pagination changes (prev/next, a page-size change, or a `setPage`/`setRowsPerPage` call). Payload is the fresh `{ pageIndex, pageSize }` object. |
458
461
  | `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. |
459
462
  | `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. |
460
463
  | `resize-change` | Fired live during a column resize drag (`columnResizeMode: "onChange"`). Payload is the fresh `ColumnSizingState` `{ [colId]: number }` object. |
461
- | `reorder-change` | Fired when the column order changes (an `applyColumnOrder` call or a header reorder). Payload is the fresh `ColumnOrderState` `string[]`. |
464
+ | `reorder-change` | Fired when the column order changes. Column reorder is programmatic only — an `applyColumnOrder` call or a two-way `columnOrder` write; the component ships NO drag or menu affordance for it. Payload is the fresh `ColumnOrderState` `string[]`. |
462
465
  | `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. |
463
- | `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. |
464
- | `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). |
465
- | `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. |
466
- | `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. |
467
- | `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. |
466
+ | `history-change` | Fired when undo/redo AVAILABILITY changes (one unified grid-wide undo/redo stack, not scoped per-cell or per-row) — 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. |
467
+ | `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. |
468
+ | `range-change` | Fired whenever the rectangular cell-range selection changes — extended by `Shift+Arrow` / `Shift+Click` from the single `extendRange`/`setRangeFocus` call sites (React multi-emit dedup). One-way notification (the range is NOT a `model:true` slice — the model:true count stays at 12, 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 `{ anchor: null, focus: null }` when no range is set. |
469
+ | `cell-edit-commit` | Fired 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). Pairs with the two-way `data` write (one fresh-array replace per commit). Payload is `{ rowId, columnId, oldValue, newValue }`. `cancel`/`Escape` and a validation failure emit nothing. |
470
+ | `row-edit-commit` | Fired when a FULL-ROW edit commits — once per row save, from the single `commitRow` call site (React multi-emit dedup). 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 (a single failure blocks the whole row and emits nothing) then writes the two-way `data` model 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. |
468
471
 
469
472
  ## Imperative handle
470
473
 
@@ -474,15 +477,15 @@ Beyond props, the component exposes imperative methods (declared once in the Roz
474
477
  | --- | --- |
475
478
  | `sortColumn` | Toggle (or set) the sort for a column — `sortColumn(colId, desc?)`. Drives table-core so `sort-change` fires with the fresh `SortingState`. |
476
479
  | `clearSorting` | Clear all sorting — `clearSorting()`. Resets to the unsorted core row model and fires `sort-change`. |
477
- | `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. |
478
- | `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). |
479
- | `collapseAll` | Collapse every row (phase 50 req-3) — `collapseAll()`. Resets the expanded set to a blank state (`resetExpanded(true)` → `{}`) and fires `expand-change` with `{}`. |
480
- | `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. |
480
+ | `toggleRowExpanded` | Toggle ONE row's expanded state — `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. |
481
+ | `expandAll` | Open every expandable row — `expandAll()`. Drives table-core (`toggleAllRowsExpanded(true)`) so `expand-change` fires; the payload may be the `true` expand-all literal. |
482
+ | `collapseAll` | Collapse every row — `collapseAll()`. Resets the expanded set to a blank state (`resetExpanded(true)` → `{}`) and fires `expand-change` with `{}`. |
483
+ | `getExpandedRows` | Return the original row data for the currently-expanded rows — `getExpandedRows()` → `unknown[]` (empty when nothing is expanded). The read-verb twin of the `expand-change` event. |
481
484
  | `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.) |
482
485
  | `clearGrouping` | Clear all grouping — `clearGrouping()`. Resets to the ungrouped (flat) row model (`table.setGrouping([])`) and fires `group-change` with `[]`. |
483
- | `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. |
484
- | `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. |
485
- | `getColumnDefs` | Return the resolved `ColumnDef[]` (the id-keyed LWW union of the `:columns` config array and the `<Column>` children) — `getColumnDefs()`. |
486
+ | `getFacetedUniqueValues` | Return a column's CROSS-FILTERED distinct values — `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. |
487
+ | `getFacetedMinMaxValues` | Return a numeric column's CROSS-FILTERED `[min, max]` range — `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. |
488
+ | `getColumnDefs` | Return the resolved `ColumnDef[]` (the id-keyed LWW union of the `columns` config array and the `<Column>` children) — `getColumnDefs()`. |
486
489
  | `toggleAllRows` | Select or clear all (filtered) rows — `toggleAllRows(value)`. Drives table-core so `selection-change` fires with the fresh `RowSelectionState`. |
487
490
  | `clearSelection` | Clear the row selection — `clearSelection()`. Fires `selection-change` with `{}`. |
488
491
  | `getSelectedRows` | Return the original row data for the currently-selected rows — `getSelectedRows()` → `unknown[]` (empty when nothing is selected). |
@@ -492,32 +495,36 @@ Beyond props, the component exposes imperative methods (declared once in the Roz
492
495
  | `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.) |
493
496
  | `resetColumnSizing` | Reset all column widths to their defaults — `resetColumnSizing()`. Fires `resize-change`. |
494
497
  | `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`. |
495
- | `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.) |
496
- | `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). |
497
- | `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.) |
498
- | `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.) |
499
- | `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.) |
500
- | `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`.) |
501
- | `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.) |
502
- | `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.) |
503
- | `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`.) |
504
- | `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. |
505
- | `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. |
506
- | `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. |
507
- | `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). |
508
- | `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. |
498
+ | `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: 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.) |
499
+ | `getActiveCell` | Return the current active-cell position — `getActiveCell()` → `{ rowIndex, colIndex, isHeader }`. For a body cell `rowIndex` is the ABSOLUTE display-order index (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). |
500
+ | `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. (Named `clearActiveCell`, not `clear`: distinct from the listbox `clear` selection verb.) |
501
+ | `getRowIndexRelativeToPage` | Convert an ABSOLUTE display-order row index (the `focusCell` / `getActiveCell` / `activecell-change` space) 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.) |
502
+ | `editCell` | Programmatically open the editor on a cell — `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.) |
503
+ | `commitEditing` | Programmatically commit the open editor — `commitEditing()`. Runs the column validator; on success writes the two-way `data` model and fires one `cell-edit-commit`; on a validation failure keeps the editor open. No-op when no cell is editing. (Named `commitEditing`, not `commit`.) |
504
+ | `editRow` | Programmatically enter FULL-ROW edit on a body row — `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 two-way `data` model 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.) |
505
+ | `getSelectedRange` | Return the current rectangular cell-range selection — `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 `{ 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. (Named `getSelectedRange`, not `getRange`/`getSelection`: collision-clean against `getSelectedRows`, the verb/event/prop, and the Lit ROZ137 reserved sets.) |
506
+ | `cut` | Cut the current cell-range selection — `cut()`. Copies the range to the clipboard as TSV (the same escaped serialization `Ctrl+C` produces) THEN clears the source cells through the two-way `data` model 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 reusable 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`.) |
507
+ | `undo` | Undo the most recent data mutation (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 `data` model 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. |
508
+ | `redo` | Redo the most recently undone data mutation — `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. |
509
+ | `canUndo` | Return whether an undo step is available — `canUndo()` → `boolean`. Reflects the undo stack depth; use alongside the `history-change` event to drive a toolbar button`s disabled state without polling. |
510
+ | `canRedo` | Return whether a redo step is available — `canRedo()` → `boolean`. Reflects the redo stack depth; cleared by any new edit after an undo (standard undo semantics). |
511
+ | `clearHistory` | Discard all undo/redo history — `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. |
509
512
 
510
513
  ```ts
511
514
  // The custom element IS the handle — exposed methods are public element
512
515
  // methods.
513
- const el = document.querySelector('rozie-data-table');
514
- el.toggleAllRows(true);
515
- const selected = el.getSelectedRows();
516
- el.editRow(0); // full-row edit on row 0
517
- const range = el.getSelectedRange(); // the active cell-range rectangle
518
- el.expandAll(); // collapseAll / toggleRowExpanded / getExpandedRows
519
- el.applyGrouping(['region']); // clearGrouping to reset
520
- const cats = el.getFacetedUniqueValues('category'); // getFacetedMinMaxValues too
516
+ import type { DataTable } from '@rozie-ui/data-table-lit';
517
+
518
+ const el = document.querySelector<DataTable>('rozie-data-table');
519
+ if (el) {
520
+ el.toggleAllRows(true);
521
+ const selected = el.getSelectedRows();
522
+ el.editRow(0); // full-row edit on row 0
523
+ const range = el.getSelectedRange(); // the active cell-range rectangle
524
+ el.expandAll(); // collapseAll / toggleRowExpanded / getExpandedRows
525
+ el.applyGrouping(['region']); // clearGrouping to reset
526
+ const cats = el.getFacetedUniqueValues('category'); // getFacetedMinMaxValues too
527
+ }
521
528
  ```
522
529
 
523
530
  ## Slots
package/dist/index.cjs CHANGED
@@ -443,6 +443,34 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
443
443
  left: rail.concat(deduped)
444
444
  };
445
445
  };
446
+ this.pinSeedApplied = false;
447
+ this.seedColumnPinning = () => {
448
+ if (this.pinSeedApplied) return;
449
+ const live = this.columnPinning != null ? this.columnPinning : this._columnPinningDefault.value;
450
+ const isRealPin = (id) => id !== this.SELECT_COL_ID && id !== this.EXPANDER_COL_ID;
451
+ if ((live && live.left ? live.left : []).filter(isRealPin).length > 0 || (live && live.right ? live.right : []).filter(isRealPin).length > 0) {
452
+ this.pinSeedApplied = true;
453
+ return;
454
+ }
455
+ const defs = this.columnDefs();
456
+ if (!defs.length) return;
457
+ const left = [];
458
+ const right = [];
459
+ for (let i = 0; i < defs.length; i++) {
460
+ const def = defs[i];
461
+ if (!def) continue;
462
+ if (def.pinned === "left") left.push(def.id);
463
+ else if (def.pinned === "right") right.push(def.id);
464
+ }
465
+ if (!left.length && !right.length) return;
466
+ this.pinSeedApplied = true;
467
+ const seeded = {
468
+ left,
469
+ right
470
+ };
471
+ this._columnPinningDefault.value = seeded;
472
+ this._columnPinningControllable.write(seeded);
473
+ };
446
474
  this.currentState = () => ({
447
475
  sorting: this.sorting != null ? this.sorting : this._sortingDefault.value,
448
476
  globalFilter: this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value,
@@ -839,9 +867,12 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
839
867
  this.onColumnPinningChangeCb = (updater) => {
840
868
  this.writeColumnPinning(applyUpdater(updater, this.currentState().columnPinning));
841
869
  };
870
+ this.columnSizingInfoSync = null;
842
871
  this.onColumnSizingInfoChangeCb = (updater) => {
843
- const next = applyUpdater(updater, this._columnSizingInfo.value);
844
- this._columnSizingInfo.value = next != null ? next : this._columnSizingInfo.value;
872
+ const next = applyUpdater(updater, this.columnSizingInfoSync != null ? this.columnSizingInfoSync : this._columnSizingInfo.value);
873
+ if (next == null) return;
874
+ this.columnSizingInfoSync = next;
875
+ this._columnSizingInfo.value = next;
845
876
  };
846
877
  this.resolveVirtual = () => {
847
878
  const v = this.virtual;
@@ -2980,6 +3011,11 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
2980
3011
  const tryFocus = () => {
2981
3012
  const el = this.resolveCellEl(String(row), col);
2982
3013
  if (el) {
3014
+ const ae = this.gridRoot && this.gridRoot.getRootNode ? this.gridRoot.getRootNode().activeElement : null;
3015
+ if (ae && ae !== el && this.gridRoot.contains && this.gridRoot.contains(ae) && ae.closest) {
3016
+ const aeCell = ae.closest("[data-grid-cell]");
3017
+ if (aeCell && aeCell !== el) return;
3018
+ }
2983
3019
  el.focus();
2984
3020
  return;
2985
3021
  }
@@ -3600,6 +3636,34 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
3600
3636
  static {
3601
3637
  this.styles = lit.css`
3602
3638
  :host{display:contents}
3639
+ :host {
3640
+ --rdt-font: var(--rozie-data-table-font);
3641
+ --rdt-color: var(--rozie-data-table-fg);
3642
+ --rdt-cell-padding: var(--rozie-data-table-cell-padding);
3643
+ --rdt-border: 1px solid var(--rozie-data-table-border-color);
3644
+ --rdt-header-bg: var(--rozie-data-table-header-bg);
3645
+ --rdt-header-weight: var(--rozie-data-table-header-weight);
3646
+ --rdt-sort-ind-opacity: var(--rozie-data-table-sort-indicator-opacity);
3647
+ --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color);
3648
+ --rdt-filter-radius: var(--rozie-data-table-radius);
3649
+ --rdt-page-btn-border: 1px solid var(--rozie-data-table-control-border-color);
3650
+ --rdt-page-btn-radius: var(--rozie-data-table-radius);
3651
+ --rdt-page-btn-disabled-opacity: var(--rozie-data-table-disabled-opacity);
3652
+ --rdt-page-size-border: 1px solid var(--rozie-data-table-control-border-color);
3653
+ --rdt-page-size-radius: var(--rozie-data-table-radius);
3654
+ --rdt-resize-grip-color: var(--rozie-data-table-resize-grip-color);
3655
+ --rdt-resize-grip-active: var(--rozie-data-table-resize-grip-active);
3656
+ --rdt-pin-btn-border: 1px solid var(--rozie-data-table-control-border-color);
3657
+ --rdt-pin-btn-radius: var(--rozie-data-table-radius);
3658
+ --rdt-pin-btn-active-bg: var(--rozie-data-table-pin-active-bg);
3659
+ --rdt-colvis-summary-border: 1px solid var(--rozie-data-table-control-border-color);
3660
+ --rdt-colvis-summary-radius: var(--rozie-data-table-radius);
3661
+ --rdt-colvis-menu-border: 1px solid var(--rozie-data-table-border-color);
3662
+ --rdt-colvis-menu-radius: var(--rozie-data-table-radius);
3663
+ --rdt-colvis-menu-bg: var(--rozie-data-table-menu-bg);
3664
+ --rdt-colvis-menu-shadow: var(--rozie-data-table-menu-shadow);
3665
+ --rdt-select-accent: var(--rozie-data-table-select-accent);
3666
+ }
3603
3667
  .rozie-data-table[data-rozie-s-d5dcab4c] {
3604
3668
  border-collapse: collapse;
3605
3669
  width: 100%;
@@ -4111,6 +4175,7 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
4111
4175
  return;
4112
4176
  }
4113
4177
  (() => {
4178
+ this.seedColumnPinning();
4114
4179
  this.reFeed();
4115
4180
  this.maybeClearHistoryOnExternalSwap();
4116
4181
  })();
@@ -4150,6 +4215,7 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
4150
4215
  }))(this));
4151
4216
  }));
4152
4217
  this._dataDefault.value = this.data || [];
4218
+ this.seedColumnPinning();
4153
4219
  this.table = (0, _tanstack_table_core.createTable)({
4154
4220
  data: this.currentData(),
4155
4221
  columns: this.tableColumns(),
@@ -4244,6 +4310,7 @@ let DataTable = class DataTable extends (0, _lit_labs_preact_signals.SignalWatch
4244
4310
  }
4245
4311
  if (this.colsWindowed()) {
4246
4312
  if (!(this.gridScrollEl ? this.gridScrollEl.clientWidth : 0)) console.warn("[rozie-data-table] virtual is on for columns but the scroll container has no bounded width; set a CSS width on an ancestor so the column window can be measured");
4313
+ this.bumpWindowVer();
4247
4314
  }
4248
4315
  };
4249
4316
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));
package/dist/index.d.cts CHANGED
@@ -6,14 +6,14 @@ declare class DataTable extends DataTable_base {
6
6
  static shadowRootOptions: ShadowRootInit;
7
7
  static styles: _$lit.CSSResult;
8
8
  /**
9
- * 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).
9
+ * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through the two-way `data` binding (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
10
10
  * @example
11
11
  * <rozie-data-table .data=${rows} @data-change=${…} .columns=${cols}></rozie-data-table>
12
12
  */
13
13
  _data_attr: any[];
14
14
  private _dataControllable;
15
15
  /**
16
- * 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).
16
+ * 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). A `pinned` value of `'left'` or `'right'` is applied once as the table's initial `columnPinning` state — identical to `<Column pinned>` — which also REORDERS that column, since table-core orders visible cells `[left-pinned, center, right-pinned]`. A consumer who has already expressed a pin (an initial two-way `columnPinning` model value or an interactive pin) owns the slice and this declaration is ignored.
17
17
  */
18
18
  columns: any[];
19
19
  /**
@@ -53,7 +53,7 @@ declare class DataTable extends DataTable_base {
53
53
  */
54
54
  pageCount: number | null;
55
55
  /**
56
- * 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).
56
+ * 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). Pass `expandable` as a real boolean — a valueless attribute only coerces to `true` on Vue and Lit.
57
57
  */
58
58
  expandable: boolean;
59
59
  /**
@@ -70,7 +70,7 @@ declare class DataTable extends DataTable_base {
70
70
  */
71
71
  groupable: boolean;
72
72
  /**
73
- * `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.
73
+ * `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 the two-way `grouping` model (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
74
74
  */
75
75
  _grouping_attr: any[] | null;
76
76
  private _groupingControllable;
@@ -104,7 +104,7 @@ declare class DataTable extends DataTable_base {
104
104
  */
105
105
  stickyHeader: boolean;
106
106
  /**
107
- * `'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.
107
+ * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` — 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.
108
108
  */
109
109
  interactionMode: string;
110
110
  /**
@@ -305,6 +305,8 @@ declare class DataTable extends DataTable_base {
305
305
  expandedTouched: boolean;
306
306
  groupingActiveDefault: () => boolean;
307
307
  effectiveColumnPinning: () => any;
308
+ pinSeedApplied: boolean;
309
+ seedColumnPinning: () => void;
308
310
  currentState: () => any;
309
311
  currentData: () => any;
310
312
  buildConfigDef: (c: any) => {
@@ -385,6 +387,7 @@ declare class DataTable extends DataTable_base {
385
387
  onColumnSizingChangeCb: (updater: any) => void;
386
388
  onColumnOrderChangeCb: (updater: any) => void;
387
389
  onColumnPinningChangeCb: (updater: any) => void;
390
+ columnSizingInfoSync: any;
388
391
  onColumnSizingInfoChangeCb: (updater: any) => void;
389
392
  resolveVirtual: () => "rows" | "columns" | "both" | "off";
390
393
  rowsWindowed: () => boolean;
@@ -779,7 +782,7 @@ declare class Column extends Column_base {
779
782
  */
780
783
  filterable: boolean;
781
784
  /**
782
- * Pin side: `''` (unpinned) | `'left'` | `'right'`. Reserved metadata carried into the parent's column pinning state.
785
+ * Initial pin side: `''` (unpinned) | `'left'` | `'right'`. Applied once as the table's starting `columnPinning` state, so `getIsPinned()` reports it and the column joins the matching sticky rail — which also reorders it, since visible cells are ordered `[left-pinned, center, right-pinned]`. Ignored if the consumer has already pinned something; an interactive unpin is never re-applied.
783
786
  */
784
787
  pinned: string;
785
788
  /**
@@ -803,7 +806,7 @@ declare class Column extends Column_base {
803
806
  */
804
807
  editable: boolean;
805
808
  /**
806
- * Built-in editor type for this column: `'text'` | `'number'` | `'select'` | `'checkbox'`. Ignored when a custom `#editor` scoped slot handles the column. Default `'text'`.
809
+ * Editor type for this column: `'text'` | `'number'` | `'select'` | `'checkbox'` (built-in inputs), or `'custom'` to hand rendering to the `#editor` scoped slot (or a per-column `editor-<columnId>` fill). Default `'text'`. **`'custom'` is the gate:** a column left on a built-in type ignores any `#editor` fill and renders the built-in input. Requires `editable: true` — and, for any pointer or keyboard edit entry, `interactionMode="grid"` on the table.
807
810
  */
808
811
  editor: string;
809
812
  /**