Appearance
DataTable — live demo
This is the real @rozie-ui/data-table-vue package running on this page (VitePress is itself a Vue app). Click a header to sort (shift-click to add a secondary sort), type in the search box to filter, page through the rows, tick the checkboxes to select, drag a column edge to resize, or open the Columns menu to hide one — then watch the two-way bound state below update. Everything is driven by the same DataTable.rozie source that compiles to all six frameworks, built on @tanstack/table-core with no per-framework adapter and a tokenised skin that ships inside the component.
Batteries included — the drop-in components
DataTable is headless by default: it owns state, filtering, grouping, sorting, and editing logic, but hands you the slots and lets you render the chrome. That's the right default for a design system — but it isn't the fastest way to get a good-looking table on screen. So the package also ships opt-in, same-package drop-in components — FilterText, FilterSelect, FilterNumberRange, GroupBar, EditorText, EditorSelect, DetailPanel — that fill the headless slots so you get filtering, grouping, inline editing, and detail rows with zero custom code. This is the shadcn-style "here's what you get out of the box" moment.
They're tree-shakeable named exports from the same @rozie-ui/data-table-vue package — not a separate -defaults package, not a heavier "batteries" build. Import only the drop-ins you use; the rest never enters your bundle. Each one is presentational only (no engine, no extra deps) and styles itself automatically from the descendant selectors already shipped in themes/base.css.
The table below is one <DataTable> on a small team dataset with every drop-in wired in: a drag-to-group GroupBar, per-column filter widgets (text / faceted select / numeric range), expandable detail rows, and custom cell editors.
Every widget above is a named export you opt into — bind a slice, drop the component into its slot, done. They're starters, not a wall: fork DetailPanel into a bespoke panel, swap FilterSelect for your own faceted control, keep the headless built-ins where you want them. See the API reference for every slot scope and prop, and the <Column> reference.
Headless by default — the raw building blocks
The same package, now with no drop-ins — just the headless <DataTable> and a single #cell slot you render yourself. This is what you reach for when you want full control of the chrome.
Each v-model:<slice> is a two-way bind — the readout updates the instant you change the state, and a consumer write flows back in. The four slices bound here (sorting, globalFilter, rowSelection, pagination) are four of the twelve independent state slices; bind a slice only when you want to own it. The header buttons drive the imperative handle (toggleAllRows, clearSelection, clearSorting) grabbed through Vue's ref. A single #cell slot on <DataTable>, dispatched by columnId, renders the Status badge; every other column falls through to the plain accessor value (the fast path). See the API reference for every prop, slice, event, slot, and handle verb, plus the <Column> API, theming, and accessibility reference.
Row windowing (virtualization)
The same real @rozie-ui/data-table-vue package, now over 50,000 rows with virtual + maxHeight="400px". Only the visible slice renders inside the bounded scroll container — scroll the table below and watch the row count stay tiny while the scrollbar spans the full 50,000-row height. Row windowing is GA on all six targets and tested to 100,000 rows by a DOM/behavioral VR matrix; the default virtual="false" is byte-identical to a non-virtual table.
Set virtual to opt in; bound maxHeight (or the --rozie-data-table-max-height CSS custom property — the prop wins, the token is the fallback) sizes the scroll container, and estimateRowHeight seeds the row estimate before measureElement refines actual heights. Windowing runs over the full filtered + sorted (pre-pagination) model and suppresses the client pagination chrome. See the comparison page for the published support boundary (and the orthogonal pieces — column virtualization + dynamic auto-measure — that remain deferred).
One source, six outputs
You author the component once as a .rozie file (the parent DataTable.rozie plus the declarative Column.rozie child):
html
<!--
DataTable.rozie — the CORE @rozie-ui/data-table parent component (Phase 48 core
wave). A headless, accessible, cross-framework data table built on a SINGLE inline
@tanstack/table-core bridge (NO per-framework adapter — the whole point of the
family) marrying table-core's pull-based state machine to all six reactivity
systems. table-core owns NO DOM (it is a pure `createTable → setOptions →
getRowModel` state machine), so this is the controlled-state HALF of the rete
FlowCanvas bridge with none of the DOM-mutation half.
<DataTable :data="rows" r-model:sorting="$data.sorting" sticky-header>
<Column field="name" header="Name" sortable />
<Column field="email" header="Email" />
<Column field="status" header="Status" sortable>
<template #cell="{ value }"><StatusBadge :status="value" /></template>
</Column>
</DataTable>
COLUMN DECLARATION — TWO coexisting forms (req-2), id-keyed LWW union:
- `:columns` config array → lower precedence
- `<Column>` children → override by id (last-write-wins)
Both resolve to the SAME internal ColumnDef set via columnDefs() below.
CELL / HEADER RENDERING (req-3) — the Wave-0 probe locked
`plain-scoped-slot-in-keyed-r-for`: the <td>/<th> hosts are FRAMEWORK-OWNED by the
keyed r-for (NOT engine-created like rete), so a per-column render template is a PLAIN
SCOPED SLOT rendered DIRECTLY inside the keyed <td>/<th> — NO portal, NO projection,
NO host-span, NO rAF defer. The slot moves with the keyed DOM (so a reorder carries
the cell content) and sits under normal component-scoped CSS (so a styled cell is
styled on every target, Lit included). Because slots are statically positioned in the
DECLARING component's template, a per-<Column>-child template CANNOT plain-render into
the parent's <td> across the component boundary (that only works via $portals, the rete
NodeType mechanism D-A deletes). So the single #cell / #colHeader scoped slot is
declared on DATATABLE and DISPATCHES by columnId: the consumer writes ONE
`<template #cell="{ columnId, value, row, column }">` and switches on columnId (the
Wave-0 DataTableProbe design). NOTE the header slot is named `#colHeader` (NOT
`#header`) — a `#header` slot lowers to a Svelte snippet prop named `header` that
COLLIDES with the pervasive `const header`/loop-var `header` in the script (Svelte 5
unifies snippets+props into one scope) → the whole component fails to mount (empty
body). NEW collision class: slot-name == a common local-const name on Svelte.
<Column> children carry only metadata (no templates).
A column the slot does not render (the slot's fallback / a config-array column) shows
the PLAIN accessor value (the fast path — `{{ value }}` slot fallback).
STATE (req-4..req-11 — all NINE slices now wired: sorting, globalFilter, columnFilters,
pagination, rowSelection, columnVisibility, columnSizing, columnOrder, columnPinning).
currentState() assembles the live state object and each slice gets its OWN STATIC-KEY
write funnel (A4: a generic dynamic-key funnel
`$data[k]=…`/`$model[k]=…` is ROZ106 on all six — never one parametric indexed
funnel). onSortingChange → writeSorting(next) emits a FRESH sorting array + fires
`sort-change` REGARDLESS of binding (echo-guarded).
STICKY HEADER (req-12): a pure-CSS `position: sticky; top: 0` on the header row,
token-driven and gated by the `stickyHeader` prop.
Multi-model Angular CVA: with the NINE model:true slices now wired (req-14), ROZ125
fires as a WARNING — not an error — on the Angular target and NO ControlValueAccessor
is auto-emitted (the multi-model condition disables it; do NOT add an
`angular:{cva:false}` source workaround — the condition already disables CVA). The
nine slices' two-way r-models all still work via the standard per-prop
`valueChange.emit(...)` outputs.
ACCESSIBILITY (req-13, D-01): `role="table"`/`role="rowgroup"`/`role="row"`/
`role="columnheader"`/`role="cell"`; `aria-sort` (string-safe 'ascending'|'descending'|
'none') on sortable headers; EVERY interactive control is a NATIVE focusable element
(sort buttons, select-all + per-row checkboxes, pagination prev/next + page-size
select, global + per-column filter inputs, the column-visibility `<details>` toggle,
per-header pin buttons, and edge resize handles) with an accessible name — no
div-with-click-only control. The keyboard/focus surface is the table-oriented default;
the reserved `interactionMode='grid'` seam (D-02) stays INERT in v1 (full APG grid
arrow-key cell navigation is a future additive layer, not baked into the core).
NINE-SLICE SURFACE COMPLETE: sorting, globalFilter, columnFilters, pagination,
rowSelection, columnVisibility, columnSizing, columnOrder, columnPinning — each an
INDEPENDENT optional two-way r-model with its own $data.<slice>Default uncontrolled
fallback, its own STATIC-KEY write funnel, and its own change event firing REGARDLESS
of binding (the uncontrolled fallback works when the consumer does not bind the model).
ZERO emitter/core change.
-->
<rozie name="DataTable" inherit-attrs="false" inherit-listeners="false">
<props>
{
// The row data (required). Stable reference per Rozie's setup-once model — re-feed
// it directly into table-core, never map/clone it in the watcher (Pitfall 3
// infinite re-render). `required: true` is the sole optionality determinant (a
// `default:` would be incoherent → ROZ014); the bridge guards `$props.data || []`
// defensively anyway.
//
// Phase 51 (req-4): `data` is now the 10TH two-way slice — a committed cell edit
// writes a FRESH array back through the `writeData` funnel (consumer binds ONE
// `r-model:data`; component owns state, Dan's principle). required + model is
// coherent (no default). The uncontrolled fallback is $data.dataDefault (so editing
// still works when the consumer passes a one-way `:data`). A1/A2: the 10th two-way
// slice keeps Angular CVA disabled (already disabled at 9 — ROZ125 warns, never an
// error); no CVA regression.
data: {
type: Array,
required: true,
model: true,
docs: {
description:
'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).',
example: '<DataTable r-model:data="rows" :columns="cols" />',
},
},
// Config-array column fallback (lower precedence than <Column> children). Each:
// { id?, field, header?, sortable?, filterable?, pinned?, width? }. Columns may be
// declared via THIS array OR via <Column> children OR both (id-keyed LWW union).
columns: {
type: Array,
default: () => [],
docs: {
description:
'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).',
},
},
// Row-selection mode chrome seam: 'none' | 'single' | 'multiple'. Reserved here;
// the rowSelection state slice is wired in a later wave. Named distinctly from any
// future `rowSelection` model prop (no ROZ127 / no slot-name clash).
selectionMode: {
type: String,
default: 'none',
docs: {
description:
"Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.",
},
},
// ── State slices wired so far ────────────────────────────────────────────────
// sorting (SortingState = [{ id, desc }]). model:true → six native two-way
// expansions; uncontrolled fallback is $data.sortingDefault. Each slice has its own
// model prop, $data.<slice>Default fallback, and a STATIC-KEY write funnel (A4:
// never a generic indexed funnel — $data[k]=… is ROZ106 on all six).
sorting: {
type: Array,
default: () => [],
model: true,
docs: {
description:
'`SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.',
},
},
// globalFilter (string) — feeds getFilteredRowModel(); narrows ALL columns.
// Uncontrolled fallback $data.globalFilterDefault. onGlobalFilterChange funnels a
// fresh value through writeGlobalFilter → filter-change (regardless of binding).
globalFilter: {
type: String,
default: '',
model: true,
docs: {
description:
'The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.',
},
},
// columnFilters (ColumnFiltersState = [{ id, value }]) — per-column narrowing.
// Each <Column> may opt in via its `filterable` flag (read from the resolved
// ColumnDef). Whole-array-replace on write (never in-place). filter-change fires.
columnFilters: {
type: Array,
default: () => [],
model: true,
docs: {
description:
'`ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column\'s `filterable`). Two-way: whole-array replace on write, fires `filter-change`.',
},
},
// pagination ({ pageIndex, pageSize }) — feeds getPaginationRowModel(); prev/next +
// page-size chrome below. onPaginationChange funnels a fresh object → page-change.
pagination: {
type: Object,
default: () => ({ pageIndex: 0, pageSize: 10 }),
model: true,
docs: {
description:
'`{ 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`.',
},
},
// Server-side hook (req-6): when true, sets manualPagination/manualFiltering/
// manualSorting on the table instance — table-core then trusts the consumer-supplied
// rows verbatim and only emits the change events (the consumer fetches the page).
manual: {
type: Boolean,
default: false,
docs: {
description:
'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).',
},
},
// ── Expandable rows (phase 50 reqs 1-3, D-04) ────────────────────────────────
// expandable: opt-in gate. Default false → byte-identical-OFF (req-10): no expander
// column injects and getExpandedRowModel is inert (empty `expanded` + no getSubRows →
// getRowModel().rows unchanged). When true, a LEADING chevron expander column auto-
// injects (after the select column) and every row can expand (the #detail seam) unless
// getSubRows is supplied (then only rows with children expand). Bare `<DataTable
// expandable>` only coerces "" → true on Vue+Lit, so consumers bind `:expandable="true"`.
expandable: {
type: Boolean,
default: false,
docs: {
description:
'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).',
},
},
// expanded (ExpandedState = { [rowId]: true } | true) — req-1/3. model:true → two-way.
// `true` is the expand-ALL literal (Pitfall 2 — pass through verbatim, never Object.keys
// without a `=== true` guard). Multi-expand by default (multiple rows open at once).
// onExpandedChange funnels a FRESH value → expanded-change (regardless of binding).
// Uncontrolled fallback $data.expandedDefault. The 11th two-way slice — Angular CVA stays
// disabled (already disabled at ≥2 models, ROZ125 warns, never errors); no CVA regression.
// Default NULL (not `{}`): the `$props.expanded != null ? … : <uncontrolled>` idiom in
// currentState() needs an UNBOUND expanded to read as null so the uncontrolled fallback
// ($data.expandedDefault) AND the grouping auto-expand default (req-4) are reachable. A
// non-null `{}` default would make $props.expanded always non-null → the first ternary
// branch always wins → the auto-expand (and uncontrolled expand) path is dead code (the
// post-mount grouping subtrees stayed collapsed). Bound consumers are unaffected (the bound
// value is non-null). Uncontrolled expand now correctly funnels through $data.expandedDefault.
expanded: {
type: [Object, Boolean],
default: null,
model: true,
docs: {
description:
'`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.',
},
},
// getSubRows (TABLE option, NOT a per-Column field — RESEARCH anti-pattern): an accessor
// `(originalRow, index) => TData[] | undefined` that returns a row's child rows. When
// supplied (with expandable), table-core flattens the hierarchy into getRowModel().rows
// and the expand seam reveals depth-indented child rows off the SAME D-04 branch (no
// nested r-for — Pitfall 1). Null (default) → the #detail scoped slot is the expand mode.
getSubRows: {
type: Function,
default: null,
docs: {
description:
'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.',
},
},
// ── Grouping (phase 50 reqs 4-7, D-01/D-05/D-06) ─────────────────────────────
// groupable: opt-in gate for the HEADLESS #groupBar host region. Default false →
// byte-identical-OFF (req-10): the #groupBar host <div> never renders and no group
// chrome appears. getGroupedRowModel is supplied UNCONDITIONALLY (inert when
// `grouping` is empty — mirrors getExpandedRowModel), so grouping is driven purely by
// the `grouping` model slice; `groupable` only gates the consumer-facing group-bar
// surface (no built-in drag — D-02 REVISED).
groupable: {
type: Boolean,
default: false,
docs: {
description:
'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).',
},
},
// grouping (GroupingState = string[]) — reqs 4/7. model:true → two-way. An ORDERED
// list of column ids (multi-column → nested groups, e.g. ['region','category']).
// onGroupingChange funnels a FRESH array → `group-change` (NOT `grouping-change`: the
// model:true `grouping` prop auto-generates an `onGroupingChange` callback on the
// React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate identifier TS2300 — the model-prop==emit-name collision
// 50-02 hit with expanded/expanded-change → expand-change. The house convention stems the
// event off a DISTINCT name: sorting→sort-change, rowSelection→selection-change,
// grouping→group-change).
// Default NULL (not `[]`), mirroring the `expanded` slice (WR-04, 50 review): the
// `$props.grouping != null ? … : $data.groupingDefault` idiom in currentState() /
// groupingActiveDefault() needs an UNBOUND grouping to read as null so the uncontrolled
// fallback ($data.groupingDefault, default []) is reachable and the grouping auto-expand
// default (req-4) can activate when a consumer applies grouping WITHOUT binding
// r-model:grouping. A non-null `[]` default would make $props.grouping always non-null →
// the first ternary branch always wins → the uncontrolled fallback is dead code. Bound
// consumers are unaffected (the bound value is a non-null array); all reads go through the
// null-guarded currentState().grouping / groupingActiveDefault() (`|| []`), so table-core
// still receives an array. The 12th two-way slice — Angular CVA stays disabled (already
// disabled at ≥2 models, ROZ125 warns, never errors); no CVA regression.
grouping: {
type: Array,
default: null,
model: true,
docs: {
description:
'`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.',
},
},
// rowSelection (RowSelectionState = { [rowId]: true }) — req-7. model:true → two-way.
// selectionMode drives the chrome: 'none' injects no column; 'single' keeps ≤1
// (enableMultiRowSelection:false); 'multiple' auto-injects a LEADING checkbox column
// (D-04) with a select-all header (indeterminate when partial, D-06: filtered rows).
// selection-change fires regardless of binding. Checkbox-only toggle (D-05) — the
// row body does NOT select.
rowSelection: {
type: Object,
default: () => ({}),
model: true,
docs: {
description:
'`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.',
},
},
// ── Column-management slices (feature wave b, req-8/9/10/11) ──────────────────
// columnVisibility (VisibilityState = { [colId]: boolean }) — req-8. model:true →
// two-way. Hidden columns drop AUTOMATICALLY from header + body (the body iterates
// row.getVisibleCells() and the header groups expose only visible leaf columns).
// onColumnVisibilityChange funnels a FRESH object → visibility-change (regardless of
// binding). Uncontrolled fallback $data.columnVisibilityDefault.
columnVisibility: {
type: Object,
default: () => ({}),
model: true,
docs: {
description:
'`VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.',
},
},
// columnSizing (ColumnSizingState = { [colId]: number }) — req-9. model:true →
// two-way. A pointer-drag resize handle on resizable headers (table-core's
// header.getResizeHandler() under columnResizeMode:'onChange') writes a FRESH sizing
// object; the <th> applies header.getSize(). onColumnSizingChange → resize-change
// regardless of binding. Behavioral assertion = width delta. Fallback
// $data.columnSizingDefault.
columnSizing: {
type: Object,
default: () => ({}),
model: true,
docs: {
description:
'`ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: \'onChange\'`). Two-way: fires `resize-change`.',
},
},
// columnOrder (ColumnOrderState = string[]) — req-10. model:true → two-way. A header
// drag writes a FRESH order array (immutable — never an in-place splice → silent on
// React/Solid/Angular/Lit). onColumnOrderChange → reorder-change regardless of
// binding. Fallback $data.columnOrderDefault.
columnOrder: {
type: Array,
default: () => [],
model: true,
docs: {
description:
'`ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.',
},
},
// columnPinning (ColumnPinningState = { left: string[], right: string[] }) — req-11.
// model:true → two-way. A per-header pin control writes columnPinning; pinned columns
// get position:sticky + computed left/right offsets (column.getStart('left') /
// getAfter('right')) so they stay during horizontal scroll. onColumnPinningChange →
// pin-change regardless of binding. Fallback $data.columnPinningDefault.
columnPinning: {
type: Object,
default: () => ({ left: [], right: [] }),
model: true,
docs: {
description:
'`ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.',
},
},
// Pure-CSS sticky header gate (req-12). When true the <thead> sticks to the top of
// the scroll container.
stickyHeader: {
type: Boolean,
default: false,
docs: {
description:
'Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.',
},
},
// FORWARD-COMPAT SEAM (D-02): 'table' (default, row-oriented) | 'grid' (cell
// keyboard navigation). RESERVED only — grid cell-nav is NOT implemented in this
// plan. Declaring it now keeps the public prop shape stable for the later wave.
interactionMode: {
type: String,
default: 'table',
docs: {
description:
"`'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role=\"grid\"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.",
deprecated:
'Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.',
},
},
// ── Vertical row windowing (phase 53, req-1/2/3) ──────────────────────────────────
// virtual: opt-in gate. Default false → byte-identical to the pre-phase output (req-1):
// every virtual-core runtime reference sits behind this guard and the <tbody> windowed
// structure is an r-if branch whose r-else lowers character-for-character to today.
virtual: {
type: Boolean,
default: false,
docs: {
description:
'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.',
},
},
// estimateRowHeight (px, D-11): seeds virtual-core's estimateSize before measureElement
// refines actual heights. Default 40. Only consulted when virtual is on.
estimateRowHeight: {
type: Number,
default: 40,
docs: {
description:
'Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.',
},
},
// maxHeight: a CSS STRING (D-06, NOT a Number) bounding the rdt-scroll container — applied
// inline AND mirrored to --rozie-data-table-max-height (prop wins; token is the fallback for
// the token-only sizing case). Empty default → the container falls back to the token rule.
maxHeight: {
type: String,
default: '',
docs: {
description:
'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.',
},
},
}
</props>
<data>
{
// Uncontrolled fallback for the `data` slice (Phase 51 req-4) — used when the
// consumer passes a one-way `:data` rather than `r-model:data`. A committed edit
// whole-array-replaces this (never in-place mutation → silent on
// React/Solid/Angular/Lit). Seeded from $props.data in $onMount.
dataDefault: [],
// Uncontrolled fallback for the sorting slice (used when the consumer did NOT bind
// r-model:sorting). Whole-array-replace on write (never in-place push → silent on
// React/Solid/Angular/Lit).
sortingDefault: [],
// Uncontrolled fallbacks for the filter / pagination / selection slices (used when
// the consumer did NOT bind the matching r-model). Each is whole-value-replace on
// write (never in-place mutation → silent on React/Solid/Angular/Lit).
globalFilterDefault: '',
columnFiltersDefault: [],
paginationDefault: { pageIndex: 0, pageSize: 10 },
rowSelectionDefault: {},
// Uncontrolled fallback for the expanded slice (phase 50 req-1/3) — used when the
// consumer did NOT bind r-model:expanded. ExpandedState ({ [rowId]: true } | true).
// Whole-value-replace on write (never in-place mutation → silent on
// React/Solid/Angular/Lit).
expandedDefault: {},
// Uncontrolled fallback for the grouping slice (phase 50 reqs 4-7) — used when the
// consumer did NOT bind r-model:grouping. GroupingState (ordered string[] of column ids).
// Whole-array-replace on write (never in-place push → silent on React/Solid/Angular/Lit).
// [] = ungrouped (byte-identical-off, req-10).
groupingDefault: [],
// Uncontrolled fallbacks for the column-management slices (used when the consumer
// did NOT bind the matching r-model). Each is whole-value-replace on write (never
// in-place mutation → silent on React/Solid/Angular/Lit).
columnVisibilityDefault: {},
columnSizingDefault: {},
columnOrderDefault: [],
columnPinningDefault: { left: [], right: [] },
// Transient resize-gesture state (NOT a two-way model slice). table-core reads
// getState().columnSizingInfo during a resize drag; an absent key throws in
// getIsResizing()/getResizeHandler(). Seed table-core's default shape; the
// onColumnSizingInfoChange callback writes a FRESH object during the drag.
columnSizingInfo: { startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] },
// The id-keyed <Column> registry. WHOLE-OBJECT-REPLACE on every mutation (never
// in-place $data.colReg[id] = …). T-48-PP: a column id is consumer-controlled, so
// columnDefs() builds the resolved map prototype-safe (Object.create(null) +
// __proto__/constructor guard).
colReg: {},
// The current rendered rows (table.getRowModel().rows), refreshed by the re-feed
// watcher. Drives the <tbody> keyed r-for. A FRESH array each refresh so the
// template re-renders.
rows: [],
// The current header groups (table.getHeaderGroups()), refreshed alongside rows.
headerGroups: [],
// bumps each refreshRowModel() — lets the post-render cell-mount watcher react.
rowModelVer: 0,
// Vertical windowing (phase 53) reactive trigger — a SEPARATE counter from rowModelVer so
// scroll churn (the virtualizer onChange push) re-renders the windowed slice WITHOUT
// re-pulling the table-core row model. Bumped in the virtualizer onChange; read (subscribed)
// by windowedRows()/padTop()/padBottom(). Mirrors the rowModelVer imperative→reactive idiom.
windowVer: 0,
// ── Grid interaction-mode active-cell state (phase 49, REQ-1/2/6) ──────────────────
// The active cell is tracked as an INDEX PAIR over the current visible model — NEVER a
// stored DOM node (req-6). On every focus move it is re-resolved to an element through
// the single focusActiveCell() seam. These are NOT model:true slices (D-01 — the count
// stays 9) and are inert while interactionMode='table'.
// activeRow: body-row index over $data.rows (D-04 entry = first body row, 0).
activeRow: 0,
// activeColIndex: position in the row's VISIBLE cell list (uniform header + body).
// IN-02: col 0 is the FIRST VISIBLE column — which is the auto-injected leading select
// column when selectionMode is 'single'/'multiple' (D-04), NOT necessarily the first
// DATA column. The grid demo uses selectionMode='none' (no select column injected) so
// there col 0 happens to be the first data column; do not generalise that to the
// selection-enabled case.
activeColIndex: 0,
// activeIsHeader: the active cell is in the header row (reachable via ArrowUp). When
// true the rowKey resolves to the literal '__header'.
activeIsHeader: false,
// activeHeaderLevel: WHICH header-row level the active header cell is on (B12 — grouped
// multi-level headers). The group index into $data.headerGroups (0 = the topmost parent
// row, headerGroups.length-1 = the leaf row adjacent to the body). Only meaningful while
// activeIsHeader is true; a flat grid has a single level (0), so the flat/table path is
// unchanged. Pairs with activeColIndex (the index within THAT level's headers).
activeHeaderLevel: 0,
// NB: the B6 empty-grid header-fallback flag is NOT a $data field — it is the plain
// component-scope `let gridEmptyFallback` (declared after the gridKeydownHandlers import).
// clampActiveCell is reached via the mount-time refreshRowModel closure, so a $data read there
// is async-stale on React (it skipped the empty→non-empty recovery); a plain `let` reads fresh.
// activeInControl: navigation (false) vs interaction (true) mode (D-07). Plan 03 drives
// the Enter/F2-to-enter / Escape-to-exit transition; this field reserves the state.
activeInControl: false,
// ── Editable-cell state (phase 51 req-1/3/5) ───────────────────────────────────────
// The cell currently in edit, as an INDEX PAIR over the visible model (NEVER a stored
// DOM node — survives recycling, T-49 invariant). editingRow is a body-row index over
// $data.rows (the activeRow index space); editingCol is the position in the row's
// visible cell list (the activeColIndex space). Both -1 = no cell editing (the
// byte-identical-off baseline: the editor branch r-if is always false → no editor DOM).
editingRow: -1,
editingCol: -1,
// The in-progress (uncommitted) editor value — bound to the editor input, written on
// input/seeded on beginEdit (D-05: printable seeds the char, F2/Enter seeds the value).
draftValue: null,
// The current validation error message (req-5/D-01) — empty when valid. Drives the
// aria-live region + the editing <td>'s :aria-invalid (wired in Task 3).
invalidMsg: '',
// Bumped on EVERY editing-state transition (begin/commit/cancel). isEditing() reads it
// so the per-cell display↔editor branch re-evaluates on Svelte/Solid fine-grained
// reactivity even when the editing state is mutated from a foreign slot-callback scope
// (the slot's commit/cancel) — the same imperative→reactive `tick()`/rowModelVer idiom
// the windowed slice + cell loop use. Without it, a commit fired from the #editor slot's
// `commit` prop left the editor mounted on Svelte (the {#each} branch didn't re-derive).
editVer: 0,
// ── Full-row edit state (phase 51 req-6 / D-06) ────────────────────────────────────
// editingRowIndex: the row in FULL-ROW edit, as a body-row index in the SAME index space
// as editingRow / activeRow (rowIndexOf(row) — the visible-model index for the non-virtual
// body; the wr.vi.index full-model index under windowing, Plan 51-04). null = no row in
// edit (the byte-identical-off baseline: isEditing's row branch is always false → no row
// editors). Mutually exclusive with the single-cell editingRow/editingCol pair (beginRowEdit
// clears it; beginEdit clears editingRowIndex) so isEditing never resolves both modes at once.
editingRowIndex: null,
// rowDraft: the in-progress (uncommitted) per-cell drafts for the row in edit, an object
// KEYED BY columnId — { [columnId]: draftValue }. Seeded from each editable column's current
// value on beginRowEdit; each open editor binds + writes its own key (NEVER the shared
// single-cell draftValue, which only one editor can own). commitRow applies every key to the
// row object in ONE fresh-array replace; cancelRow drops it (revert — never written). {} = none.
rowDraft: {},
// ── Cell-range selection state (phase 51 req-7 / D-07) ──────────────────────────────
// The rectangular cell range is tracked as TWO index pairs over the FULL visible model —
// NEVER a stored DOM node (the activeRow/activeColIndex invariant, T-49: survives
// recycling; the highlight re-resolves to cells every render via the pure inRange() math).
// rangeAnchor = the fixed corner (seeded from the active cell when a range begins);
// rangeFocus = the moving corner (extended by Shift+Arrow / Shift+Click). The selected
// rectangle is the min/max box of the two. BOTH null = no range (the byte-identical-off
// baseline: inRange() always false → no range markup). This is ONE-WAY (D-07): exposed via
// the getSelectedRange verb + the range-change event, NEVER a model:true slice — the
// model:true count stays at 10, leaving the Angular multi-model-CVA condition untouched.
// It is a SEPARATE visual layer from the rowSelection slice (the two never corrupt each
// other — toggling a row checkbox leaves the range intact and vice versa).
rangeAnchor: null,
rangeFocus: null,
// ── Clipboard paste announce (phase 51 req-8 / D-03) ────────────────────────────────
// The polite aria-live announcement after a TSV paste / drag-fill — "N of M cells pasted"
// (D-03). SEPARATE from the validation invalidMsg region (different semantics). '' when no
// paste has happened (the byte-identical-off baseline: the announce region r-if is false).
pasteAnnounce: '',
}
</data>
<script lang="ts">
import {
createTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
getExpandedRowModel,
getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues,
getFacetedMinMaxValues as makeFacetedMinMaxValues,
} from '@tanstack/table-core'
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import {
Virtualizer,
elementScroll,
observeElementRect,
observeElementOffset,
measureElement,
} from '@tanstack/virtual-core'
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table = null
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer = null
let virtualizerCleanup = null
let gridScrollEl = null
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot = null
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false
import { groupingActiveDefault, currentState, currentData } from './stateAssembly.rzts'
import { isSafeKey, wrapAggregationFn, columnDefs, SELECT_COL_ID, EXPANDER_COL_ID, selectionEnabled, tableColumns } from './columnBuilders.rzts'
import { writeSorting, applyUpdater, writeExpanded, writeGrouping, writeGlobalFilter, writeColumnFilters, writePagination, writeRowSelection, writeColumnVisibility, writeColumnSizing, writeColumnOrder, writeColumnPinning, writeData, columnFilterValue, setColumnFilter } from './writeFunnels.rzts'
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel = null
import { onSortingChangeCb, onExpandedChangeCb, onGroupingChangeCb, onGlobalFilterChangeCb, onColumnFiltersChangeCb, onPaginationChangeCb, onRowSelectionChangeCb, onColumnVisibilityChangeCb, onColumnSizingChangeCb, onColumnOrderChangeCb, onColumnPinningChangeCb, onColumnSizingInfoChangeCb } from './stateChangeCallbacks.rzts'
import { windowSource, scheduleRemeasure, pinnedEditIndex, pinnedMeasurement, remeasureWindow } from './virtualization.rzts'
import { virtualItemKey, virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow } from '@rozie-ui/headless-core/windowing.rzts'
$onMount(() => {
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
$data.dataDefault = $props.data || []
// Build the table instance HERE so the closures below capture the live `table`.
table = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: currentData(),
columns: tableColumns(),
state: currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: ($props.getSubRows || undefined) as any,
getRowCanExpand: ($props.expandable === true && $props.getSubRows == null) ? (() => true) : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: $props.manual === true,
manualFiltering: $props.manual === true,
manualSorting: $props.manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: $props.selectionMode !== 'none',
enableMultiRowSelection: $props.selectionMode === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {},
})
refreshRowModel = () => {
if (!table) return
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = windowSource().slice()
const nextGroups = table.getHeaderGroups().slice()
$data.rows = nextRows
$data.headerGroups = nextGroups
$data.rowModelVer = $data.rowModelVer + 1
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if ($props.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions())
virtualizer._willUpdate()
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length
const nextColCount = nextRows.length
? nextRows[0].getVisibleCells().length
: (nextGroups.length ? ((nextGroups[nextGroups.length - 1].headers || []).length) : 0)
clampActiveCell(nextRowCount, nextColCount)
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (pendingEditFollow && isGrid()) {
const follow = pendingEditFollow
pendingEditFollow = null
const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId)
if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col)
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
syncIndeterminate()
if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate)
else Promise.resolve().then(syncIndeterminate)
}
// initial pull
refreshRowModel()
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
gridRoot = $el ? $el.querySelector('.rozie-data-table') : null
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if ($props.virtual) {
gridScrollEl = $el ? $el.querySelector('.rdt-scroll') : null
virtualizer = new Virtualizer(virtualizerOptions())
virtualizerCleanup = virtualizer._didMount()
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
$data.windowVer = $data.windowVer + 1
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
remeasureWindow()
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = gridScrollEl ? gridScrollEl.clientHeight : 0
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height')
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = $props.pagination
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10)
if ($props.manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.')
}
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame))
else setTimeout(afterFirstFrame, 0)
}
})
// Tear down the virtualizer's scroll-element ResizeObserver on unmount (the embla destroy
// precedent). No-op when virtual was off (cleanup stays null).
$onUnmount(() => {
if (virtualizerCleanup) virtualizerCleanup()
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
teardownFillDrag()
})
// Reactive re-feed: when the bound sorting slice OR data length OR the column registry
// changes, push fresh options into table-core and re-pull the row model. Watch the
// bound references / a derived primitive — never a freshly-built array (Pitfall 3).
// Lazy by default ($onMount did the first pull). EXTENSION: add the other bound slices
// to this getter array as they are wired.
$watch(
() => [
$props.sorting,
$props.globalFilter,
$props.columnFilters,
$props.pagination,
$props.rowSelection,
$props.expanded,
$props.expandable,
$props.grouping,
$props.groupable,
$props.columnVisibility,
$props.columnSizing,
$props.columnOrder,
$props.columnPinning,
$props.selectionMode,
($props.data || []).length,
// Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
$props.data,
$data.dataDefault,
$data.colReg,
],
() => { reFeed() },
)
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
const reFeed = () => {
if (!table) return
table.setOptions((prev) => ({
...prev,
data: currentData(),
columns: tableColumns(),
state: currentState(),
enableRowSelection: $props.selectionMode !== 'none',
enableMultiRowSelection: $props.selectionMode === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: ($props.getSubRows || undefined) as any,
getRowCanExpand: ($props.expandable === true && $props.getSubRows == null) ? (() => true) : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
}))
if (refreshRowModel) refreshRowModel()
}
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
let lastData = null
let lastDataLen = -1
$onUpdate(() => {
if (!table) return
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = currentData() || []
if (d === lastData && d.length === lastDataLen) return
lastData = d
lastDataLen = d.length
reFeed()
})
// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
const onHeaderSort = (colId, evt) => {
if (!table) return
const col = table.getColumn(colId)
if (!col || !col.getCanSort()) return
const multi = !!(evt && evt.shiftKey)
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi)
}
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
const tick = () => $data.rowModelVer
import { ariaSortFor, sortIndicator, defFor, visibleCellsFor, editMetaOf, columnEditable, editorTypeOf, editorOptionsOf, hasEditorSlot, columnIsFilterable, headerLabel, headerWidth, onResizeStart, findHeader, columnIsResizing, columnIsVisible, onToggleVisibility, allLeafColumns, columnPinSide, onPinColumn, pinStyle, thStyle } from './columnChrome.rzts'
import { onGlobalFilterInput, onColumnFilterInput, globalFilterValue, pageIndex, pageSize, pageCount, canPrevPage, canNextPage, onPrevPage, onNextPage, onPageSizeChange, isSelectColumn, isExpanderColumn, rowCanExpand, rowIsExpanded, rowShowsDetail, onToggleExpand, bodyCellStyle, rowIsGrouped, groupingActive, cellIsGrouped, cellIsAggregated, groupSubRowCount, groupingKeys, groupableColumns, stopEvent, isAllRowsSelected, isSomeRowsSelected, onToggleAllRows, rowIsSelected, onToggleRow } from './filterPaginationRowChrome.rzts'
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox = null
const syncIndeterminate = () => {
if (!$el || !$el.querySelector) return
selectAllBox = $el.querySelector('.rdt-select-all')
if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected()
}
// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
$provide('data-table:columns', {
registerColumn: (id, spec) => {
if (id == null) return
const key = String(id)
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return
$data.colReg = { ...$data.colReg, [key]: spec }
},
unregisterColumn: (id) => {
if (id == null) return
const r = { ...$data.colReg }
delete r[String(id)]
$data.colReg = r
},
})
import { sortColumn, clearSorting, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, getRowIndexRelativeToPage, cut } from './exposeStateVerbs.rzts'
import { isGrid, tableRole, cellRole, rowIndexOf, colIndexOf, headerColIndexOf, pageRowOffset, toAbsRow, absRowIndexOf, prePaginationRowCount, cellTabindex, resolveCellEl, focusActiveCell, totalRowCount, visibleColCount, bodyRowCount, clamp, moveCol, moveRow, gotoColEdge, gotoStart, gotoEnd, currentCellEl, focusables, enterControl, cycleWithinCell } from './gridFocusNav.rzts'
import { onGridKeyDown, syncActiveFromEvent, onGridMouseDown, onGridFocusOut, clampActiveCell } from './gridKeydownHandlers.rzts'
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false
import { inRange, getSelectedRange, isFillHandleCell, emitRangeChange, extendRange, setRangeFocus, clearRange, clampRange } from './rangeSelection.rzts'
import { announce, clipboardActiveAllowed, fieldOfColId, normalizedRange, escapeTsvField, rangeToTsv, parseTsv, copyRange, applyGridToRange, rowOriginalAt, rowIdAt, tileGridToBox, pasteRange, cutRange, tileIndex, fillRange } from './clipboardFill.rzts'
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove = null
let fillDragUp = null
import { teardownFillDrag, cellIndexFromPoint, onFillHandlePointerDown } from './fillDrag.rzts'
import { activeCellColumnId, isActiveCellEditable, isEditing, cellAriaInvalid, runValidator, setInvalid, replaceRowValue, sourceIndexOfRow, editingColumnId, editingColumnField, editingCellValue, editingRowOriginal, editingRowId, focusEditorWhenReady, columnIdAt, cellValueAt, beginEdit, focusCellWhenReady, indexOfRowIn, endEdit, endRowEdit, coerceCellValue, commitEdit, cancelEdit } from './editCellLifecycle.rzts'
import { editableColumnsForRow, focusRowEditorAt, beginRowEdit, commitRow, cancelRow, replaceRowValues, nextEditableCell, prevEditableCell } from './editRowLifecycle.rzts'
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow = null
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
import { inRowEdit, editorValueFor, editorCheckedFor, editorCommitFor, editorCancelFor, onCellEditorInput, onCellEditorCheckbox, setRowDraft, onEditorKeyDown, onEditorBlur, editCell, commitEditing, editRow } from './editorBindings.rzts'
import { focusCell, getActiveCell, clearActiveCell } from './gridActiveCellVerbs.rzts'
import { toggleRowExpanded, expandAll, collapseAll, getExpandedRows } from './expand.rzts'
import { applyGrouping, clearGrouping } from './group.rzts'
import { getFacetedUniqueValues, getFacetedMinMaxValues } from './facet.rzts'
$expose({
sortColumn,
clearSorting,
toggleRowExpanded,
expandAll,
collapseAll,
getExpandedRows,
applyGrouping,
clearGrouping,
getFacetedUniqueValues,
getFacetedMinMaxValues,
getColumnDefs,
toggleAllRows,
clearSelection,
getSelectedRows,
setPage,
setRowsPerPage,
toggleColumnVisibility,
applyColumnOrder,
resetColumnSizing,
pinColumn,
focusCell,
getActiveCell,
clearActiveCell,
getRowIndexRelativeToPage,
editCell,
commitEditing,
editRow,
getSelectedRange,
cut,
})
</script>
<template>
<!--
Semantic table markup. The header sort BUTTON carries :aria-sort reflecting the live
table-core sort state; @click toggles (shift-click adds a secondary sort). The body
is a keyed r-for over the pulled row model; the per-row cell r-for loop var is
`cellCtx` (NEVER `cell` — a `#cell`-named child slot would shadow it → Svelte mount
throw, the embla/rete lesson). The per-cell render is a single `#cell` SCOPED SLOT
rendered DIRECTLY inside the keyed <td> (D-A: no portal, no projection); the consumer
switches on the `columnId` scope param to vary the render per column, and the slot
FALLBACK (`{{ value }}`) is the plain accessor value (the template-less fast path).
Same for the `#header` scoped slot in the <th>.
-->
<div class="rozie-data-table-wrap">
<!-- Declarative <Column> children mount here (the DEFAULT slot). They are
renderless (each Column's own root is display:none + draws nothing), but they
MUST be in the rendered tree so their $onMount runs and registers the column
spec into the parent's `data-table:columns` registry via $inject. Without a
default <slot/> the <Column> children passed as default-slot content have
nowhere to mount → they never register → the table renders ZERO columns
(headers + cells empty). Hidden so it adds no visible chrome (the rete
FlowCanvas declarative-children precedent: config children stay in the normal
child tree so $inject resolves tree-scoped). -->
<div class="rdt-column-defs" style="display:none" aria-hidden="true"><slot /></div>
<!-- Validation aria-live region (phase 51 req-5/D-01): a visually-hidden polite status
region announcing the current editor validation error. Gated `r-if="!!$data.invalidMsg"`
so it renders ONLY while an editor holds an invalid value — a table with no editable
columns (invalidMsg always '') never emits this node (byte-identical-off, req-10). -->
<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true" r-if="!!$data.invalidMsg">{{ $data.invalidMsg }}</div>
<!-- Clipboard paste aria-live region (phase 51 req-8/D-03): a visually-hidden polite status
region announcing the "N of M cells pasted" summary after a TSV paste / drag-fill. A
SEPARATE node from the validation region (different semantics + lifetime). Gated
`r-if="!!$data.pasteAnnounce"` so a table that never pasted emits nothing (byte-identical-off). -->
<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" r-if="!!$data.pasteAnnounce">{{ $data.pasteAnnounce }}</div>
<!-- Global search (req-5) — bound, value-driven (NOT eval'd). Narrows all columns. -->
<div class="rdt-toolbar">
<input
class="rdt-global-filter"
type="text"
role="searchbox"
aria-label="Search table"
:value="globalFilterValue()"
@input="onGlobalFilterInput($event)"
/>
<!-- Column-visibility toggle menu (req-8) — a native <details>/<summary> disclosure
(keyboard-reachable, no JS open-state) with one checkbox per leaf column. Toggling
a checkbox drives table-core's column.toggleVisibility → visibility-change. -->
<details class="rdt-colvis" r-if="allLeafColumns().length">
<summary class="rdt-colvis-summary">Columns</summary>
<div class="rdt-colvis-menu" role="group" aria-label="Toggle columns">
<label
class="rdt-colvis-item"
r-for="lc in allLeafColumns()"
:key="lc.id"
>
<input
type="checkbox"
class="rdt-colvis-checkbox"
:checked="lc.visible"
@change="onToggleVisibility(lc.id)"
/>
<span class="rdt-colvis-label">{{ lc.label }}</span>
</label>
</div>
</details>
</div>
<!-- HEADLESS group bar (phase 50 req-6, D-02 REVISED) — the #groupBar scoped slot, mirroring
the #cell plain-scoped-slot lowering (no portal/projection; React render-prop edge is the
documented divergence). Gated on $props.groupable so the host <div> is ABSENT for any
non-grouping consumer (byte-identical-off, req-10). Slot props: the live ordered `grouping`
array, the `groupableColumns` (`[{ id, label }]`), and the `applyGrouping`/`clearGrouping`
helpers (the same $expose verbs) so a consumer builds ANY bar/drag UI themselves. The DEFAULT
render is a NON-INTERACTIVE styled-token reflection of the grouping state (or empty when
ungrouped) — the component renders NO draggable / drag handle (D-02 retired). -->
<div class="rdt-group-bar-host" r-if="$props.groupable">
<slot
name="groupBar"
:grouping="groupingKeys()"
:groupableColumns="groupableColumns()"
:applyGrouping="applyGrouping"
:clearGrouping="clearGrouping"
>
<span
class="rdt-group-token"
data-group-token
r-for="gk in groupingKeys()"
:key="gk"
>{{ gk }}</span>
</slot>
</div>
<!-- ══ VIRTUAL (phase 53): scroll-bounded WINDOWED table — emitted ONLY when $props.virtual ══
(D-05). The rdt-scroll wrapper bounds the scroll viewport (max-height from the maxHeight
prop inline AND mirrored to --rozie-data-table-max-height; the CSS rule supplies the
token-only fallback — D-06, prop wins). aria-rowcount = the FULL model count (req-6). The
<tbody> renders a leading spacer <tr>, the windowed { vi, row } slice keyed on the FULL-model
row.id (Pitfall 3 / req-10 — NOT vi.index), and a trailing spacer <tr>. Because this whole
branch is r-if and the r-else table below is the verbatim pre-phase markup, the non-virtual
emitted render is byte-identical (req-1). -->
<div
class="rdt-scroll"
r-if="$props.virtual"
:style="$props.maxHeight ? ('max-height:' + $props.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + $props.maxHeight) : 'overflow:auto'"
>
<table
class="rozie-data-table"
:class="{ 'rdt-sticky': $props.stickyHeader }"
:role="tableRole()"
:aria-rowcount="$data.rows.length"
@keydown="onGridKeyDown($event)"
@focusin="syncActiveFromEvent($event)"
@focusout="onGridFocusOut($event)"
@mousedown="onGridMouseDown($event)"
>
<thead class="rdt-thead" role="rowgroup">
<tr
class="rdt-tr"
role="row"
r-for="(hg, hgLevel) in $data.headerGroups"
:key="hg.id"
>
<th
class="rdt-th"
role="columnheader"
r-for="header in hg.headers"
:key="header.id"
:data-col="header.column.id"
data-grid-cell
data-row="__header"
:data-header-level="hgLevel"
:colspan="header.colSpan > 1 ? header.colSpan : null"
:data-col-index="headerColIndexOf(hg, header)"
:tabindex="cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)"
:class="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }"
:aria-sort="ariaSortFor(header.column.id)"
:style="thStyle(header.column.id)"
>
<span style="display:contents" r-if="isSelectColumn(header.column.id)">
<slot
name="selectAll"
:checked="isAllRowsSelected()"
:indeterminate="isSomeRowsSelected()"
:toggle="onToggleAllRows"
>
<input
r-if="$props.selectionMode === 'multiple'"
class="rdt-select-all"
type="checkbox"
aria-label="Select all rows"
:checked="isAllRowsSelected()"
@change="onToggleAllRows($event)"
/>
</slot>
</span>
<span style="display:contents" r-else>
<button
r-if="header.column.getCanSort && header.column.getCanSort()"
type="button"
class="rdt-sort-btn"
@click="onHeaderSort(header.column.id, $event)"
>
<span class="rdt-header-label">
<slot
name="colHeader"
:columnId="header.column.id"
:column="header.column"
:label="headerLabel(header.column.id)"
>{{ headerLabel(header.column.id) }}</slot>
</span>
<span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
</button>
<span style="display:contents" r-else>
<span class="rdt-header-label">
<slot
name="colHeader"
:columnId="header.column.id"
:column="header.column"
:label="headerLabel(header.column.id)"
>{{ headerLabel(header.column.id) }}</slot>
</span>
</span>
<input
r-if="columnIsFilterable(header.column.id)"
class="rdt-col-filter"
type="text"
:aria-label="'Filter ' + headerLabel(header.column.id)"
:value="columnFilterValue(header.column.id)"
@input="onColumnFilterInput(header.column.id, $event)"
@click="stopEvent($event)"
/>
<!-- HEADLESS facet slot (phase 50 reqs 8-9, D-03) — the #filter scoped slot, mirroring
the #cell/#colHeader plain-scoped-slot lowering (no portal/projection). Gated on
the column's filterable flag (faceted filtering requires the column be filterable in
table-core), wrapped in a display:contents host so an UNFACETED column emits nothing
extra. NO fallback content → the component renders NO built-in facet control (the
headless line, #groupBar precedent); a consumer builds a category-checkbox list +
a numeric range slider purely from the slot props. Slot props: the column id, the
CROSS-FILTERED unique-value list (keys only — counts deferred, D-03) + the [min,max]
range. ⚠️ #filter → a Svelte $props() snippet named `filter` (the #header→`const
header` collision class) — re-grepped: no `filter` local in the script. -->
<span style="display:contents" r-if="columnIsFilterable(header.column.id)">
<slot
name="filter"
:columnId="header.column.id"
:uniqueValues="getFacetedUniqueValues(header.column.id)"
:minMax="getFacetedMinMaxValues(header.column.id)"
:setFilter="setColumnFilter"
/>
</span>
<span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
<button
type="button"
class="rdt-pin-btn rdt-pin-left"
:aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'"
:aria-pressed="columnPinSide(header.column.id) === 'left'"
@click="onPinColumn(header.column.id, 'left', $event)"
>⇤</button>
<button
type="button"
class="rdt-pin-btn rdt-pin-none"
:aria-label="'Unpin ' + headerLabel(header.column.id)"
:aria-pressed="!columnPinSide(header.column.id)"
@click="onPinColumn(header.column.id, false, $event)"
>⇔</button>
<button
type="button"
class="rdt-pin-btn rdt-pin-right"
:aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'"
:aria-pressed="columnPinSide(header.column.id) === 'right'"
@click="onPinColumn(header.column.id, 'right', $event)"
>⇥</button>
</span>
<button
type="button"
class="rdt-resize-handle"
:aria-label="'Resize ' + headerLabel(header.column.id)"
@pointerdown="onResizeStart(header.column.id, $event)"
@touchstart="onResizeStart(header.column.id, $event)"
><span class="rdt-resize-grip" aria-hidden="true"></span></button>
</span>
</th>
</tr>
</thead>
<tbody class="rdt-tbody" role="rowgroup">
<!-- Leading spacer (D-03): occupies the height of the rows scrolled past the top of the
window. No data-grid-cell/data-index/role — not a navigable, measurable cell (Pitfall 5). -->
<tr class="rdt-spacer" aria-hidden="true">
<td :colspan="visibleColCount()" :style="'height:' + padTop() + 'px;padding:0;border:0'"></td>
</tr>
<!-- The windowed slice. Loop var `wr` = { vi, row }; keyed on the FULL-model row.id so Lit's
repeat / Solid's For never recycle a node from one full-model row into another (req-10).
data-row/data-index use vi.index (O(1), authoritative full-model index — NOT the O(n)
rowIndexOf scan over the full model). The <td> cell loop mirrors the non-virtual body.
B13 (phase 63, FULL PARITY): the windowed body now carries the SAME structural branches
as the non-virtual body (~1711-1905) — the data-group-header/leaf/depth markers + the
rdt-group-header class on the <tr>, the isExpanderColumn / cellIsGrouped <td> branches,
and a conditional #detail <tr> sibling — so a virtual + grouping/expand grid renders at
full parity (no silent feature drop). Wrapped in a <template r-for> (wrapper-free multi-
root) so the data <tr> + the #detail <tr> ride one keyed iteration, exactly like the
non-virtual D-04 expand seam. The verified-correct invariants are preserved: group rows
render a FULL cell set (no colspan, data-col-index intact) and the #detail <tr> carries
no data-row/data-index so it never shifts the windowed body's row indices. -->
<template r-for="wr in windowedRows()" :key="wr.row.id">
<tr
class="rdt-tr"
role="row"
:data-row="wr.vi.index"
:aria-rowindex="wr.vi.index + 1"
:data-index="wr.vi.index"
:data-pinned="wr.pinned ? 'true' : null"
:data-depth="wr.row.depth"
:data-group-header="rowIsGrouped(wr.row) ? wr.row.id : null"
:data-group-leaf="(groupingActive() && !rowIsGrouped(wr.row)) ? wr.row.id : null"
:aria-expanded="rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null"
:aria-level="groupingActive() ? (wr.row.depth + 1) : null"
:class="{ 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }"
>
<td
class="rdt-td"
:role="cellRole()"
r-for="cellCtx in visibleCellsFor(wr.row)"
:key="cellCtx.id"
:data-col="cellCtx.column.id"
data-grid-cell
:data-row="wr.vi.index"
:data-col-index="colIndexOf(wr.row, cellCtx)"
:tabindex="cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx))"
:class="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }"
:style="bodyCellStyle(wr.row, cellCtx.column.id)"
:aria-invalid="cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx))"
:data-in-range="inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null"
:data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.column.id : null"
>
<!-- B13 (phase 63) — auto-injected chevron expander cell, windowed body. Identical
shape to the non-virtual body (~1797): a native <button> (Enter/Space → click
natively) gated on rowCanExpand(wr.row); aria-expanded reflects wr.row's expanded
state. FIRST branch (matching the non-virtual order). -->
<span style="display:contents" r-if="isExpanderColumn(cellCtx.column.id)">
<button
r-if="rowCanExpand(wr.row)"
type="button"
class="rdt-expander"
data-expander
:aria-expanded="!!rowIsExpanded(wr.row)"
:aria-label="rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row'"
@click="onToggleExpand(wr.row, $event)"
>{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button>
</span>
<span style="display:contents" r-else-if="isSelectColumn(cellCtx.column.id)">
<slot
name="selectCell"
:row="wr.row.original"
:checked="rowIsSelected(wr.row)"
:toggle="(e) => onToggleRow(wr.row, e)"
>
<input
class="rdt-select-row"
type="checkbox"
aria-label="Select row"
:checked="rowIsSelected(wr.row)"
@change="onToggleRow(wr.row, $event)"
/>
</slot>
</span>
<!-- B13 (phase 63) — group-header cell, windowed body. Identical shape to the non-
virtual body (~1840): the cellIsGrouped branch renders a native <button data-expander
class="rdt-group-toggle"> (REUSING onToggleExpand — group rows are expandable rows) +
the group key through the EXISTING #cell slot + the member count. -->
<span style="display:contents" r-else-if="cellIsGrouped(cellCtx)">
<button
type="button"
class="rdt-expander rdt-group-toggle"
data-expander
:aria-expanded="!!rowIsExpanded(wr.row)"
:aria-label="rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group'"
@click="onToggleExpand(wr.row, $event)"
>{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button>
<span class="rdt-group-value">
<slot
name="cell"
:columnId="cellCtx.column.id"
:column="cellCtx.column"
:row="wr.row.original"
:value="cellCtx.getValue()"
>{{ cellCtx.getValue() }}</slot>
</span>
<span class="rdt-group-count">{{ '(' + groupSubRowCount(wr.row) + ')' }}</span>
</span>
<!-- Editor branch (phase 51) — virtual body. Identical shape to the non-virtual
body; keyed on wr.vi.index (the full-model index, the activeRow space). Single-
cell editing under virtualization (the D-02 pin-row) lands in Plan 51-04; this
keeps both <td> bodies in lockstep so the branch is structurally consistent. -->
<span style="display:contents" r-else-if="isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))">
<span style="display:contents" r-if="hasEditorSlot(cellCtx.column.id)">
<slot
name="editor"
:columnId="cellCtx.column.id"
:column="cellCtx.column"
:row="wr.row.original"
:value="editorValueFor(cellCtx.column.id)"
:commit="editorCommitFor(cellCtx.column.id)"
:cancel="editorCancelFor()"
/>
</span>
<input
r-else-if="editorTypeOf(cellCtx.column.id) === 'number'"
class="rdt-cell-editor"
type="number"
data-editing-cell
:value="editorValueFor(cellCtx.column.id)"
@input="onCellEditorInput(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
/>
<select
r-else-if="editorTypeOf(cellCtx.column.id) === 'select'"
class="rdt-cell-editor"
data-editing-cell
:value="editorValueFor(cellCtx.column.id)"
@change="onCellEditorInput(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
>
<option r-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<input
r-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'"
class="rdt-cell-editor"
type="checkbox"
data-editing-cell
:checked="editorCheckedFor(cellCtx.column.id)"
@change="onCellEditorCheckbox(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
/>
<input
r-else
class="rdt-cell-editor"
type="text"
data-editing-cell
:value="editorValueFor(cellCtx.column.id)"
@input="onCellEditorInput(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
/>
</span>
<span r-else class="rdt-cell-value">
<slot
name="cell"
:columnId="cellCtx.column.id"
:column="cellCtx.column"
:row="wr.row.original"
:value="cellCtx.getValue()"
>{{ cellCtx.getValue() }}</slot>
</span>
<!-- Fill handle (req-8 / D-04): the small draggable affordance on the range's
bottom-right cell. A pointer drag extends the range + value-fills the dragged
rectangle on release (value-copy ONLY — no series detection). Gated so it renders
only on the corner cell (byte-identical-off when no range). -->
<span
r-if="isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))"
class="rdt-fill-handle"
data-fill-handle
data-testid="fill-handle"
aria-hidden="true"
@pointerdown="onFillHandlePointerDown($event)"
></span>
</td>
</tr>
<!-- B13 (phase 63) — #detail row, windowed body. Identical shape to the non-virtual
body (~1948): renders the #detail scoped slot ({ row }) under an expanded row, ONLY in
#detail mode (no getSubRows; rowShowsDetail gates it). Sibling of the data <tr> inside
the same <template r-for> iteration. Carries NO data-row/data-index → it never shifts
the windowed body's row indices (the verified-correct invariant). -->
<tr
r-if="rowShowsDetail(wr.row)"
class="rdt-detail-row"
role="row"
:data-detail-row="wr.row.id"
>
<td class="rdt-detail-cell" :colspan="visibleColCount()">
<slot name="detail" :row="wr.row.original" />
</td>
</tr>
</template>
<!-- Trailing spacer (D-03): occupies the height of the rows below the window. -->
<tr class="rdt-spacer" aria-hidden="true">
<td :colspan="visibleColCount()" :style="'height:' + padBottom() + 'px;padding:0;border:0'"></td>
</tr>
</tbody>
</table>
</div>
<!-- ══ NON-VIRTUAL (byte-identical to the pre-phase output): the bare table, NO scroll wrapper.
This r-else branch lowers character-for-character to today's markup (req-1). ══ -->
<table
r-else
class="rozie-data-table"
:class="{ 'rdt-sticky': $props.stickyHeader }"
:role="tableRole()"
:aria-rowcount="totalRowCount()"
@keydown="onGridKeyDown($event)"
@focusin="syncActiveFromEvent($event)"
@focusout="onGridFocusOut($event)"
@mousedown="onGridMouseDown($event)"
>
<thead class="rdt-thead" role="rowgroup">
<tr
class="rdt-tr"
role="row"
r-for="(hg, hgLevel) in $data.headerGroups"
:key="hg.id"
>
<th
class="rdt-th"
role="columnheader"
r-for="header in hg.headers"
:key="header.id"
:data-col="header.column.id"
data-grid-cell
data-row="__header"
:data-header-level="hgLevel"
:colspan="header.colSpan > 1 ? header.colSpan : null"
:data-col-index="headerColIndexOf(hg, header)"
:tabindex="cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)"
:class="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }"
:aria-sort="ariaSortFor(header.column.id)"
:style="thStyle(header.column.id)"
>
<!-- auto-injected select-all header (D-04) — slot-overridable via #selectAll;
default = a select-all checkbox (indeterminate when partial, D-06). The
default select-all checkbox renders ONLY in 'multiple' mode — a 'single'
select has no "select all" (it caps at ≤1), so its select-column header
stays empty (the slot still fires for a consumer override). -->
<!-- display:contents <span> wrappers instead of `<template r-if>` grouping —
a `<template>` grouping wrapper renders as a LITERAL inert `<template>`
DOM element on svelte/solid/lit/angular in this component's nesting (its
children land in the .content DocumentFragment → never displayed → empty
headers). A real element with r-if/r-else lowers cleanly on all six;
display:contents keeps it layout-transparent. -->
<span style="display:contents" r-if="isSelectColumn(header.column.id)">
<slot
name="selectAll"
:checked="isAllRowsSelected()"
:indeterminate="isSomeRowsSelected()"
:toggle="onToggleAllRows"
>
<!-- `indeterminate` is a DOM PROPERTY (inert as a bound attribute on 5/6
targets) — set imperatively by syncIndeterminate(); do NOT bind it here
(React warns on an unknown `indeterminate` JSX attr; the others no-op). -->
<input
r-if="$props.selectionMode === 'multiple'"
class="rdt-select-all"
type="checkbox"
aria-label="Select all rows"
:checked="isAllRowsSelected()"
@change="onToggleAllRows($event)"
/>
</slot>
</span>
<span style="display:contents" r-else>
<!-- sortable header → a button that toggles sort (shift = multi). -->
<button
r-if="header.column.getCanSort && header.column.getCanSort()"
type="button"
class="rdt-sort-btn"
@click="onHeaderSort(header.column.id, $event)"
>
<!-- #header scoped slot, dispatched by columnId; fallback = plain label. -->
<span class="rdt-header-label">
<slot
name="colHeader"
:columnId="header.column.id"
:column="header.column"
:label="headerLabel(header.column.id)"
>{{ headerLabel(header.column.id) }}</slot>
</span>
<span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
</button>
<!-- non-sortable header (display:contents span, NOT <template r-else>). -->
<span style="display:contents" r-else>
<span class="rdt-header-label">
<slot
name="colHeader"
:columnId="header.column.id"
:column="header.column"
:label="headerLabel(header.column.id)"
>{{ headerLabel(header.column.id) }}</slot>
</span>
</span>
<!-- per-column filter input (req-5) — gated by the column's filterable flag.
Bound value, NOT eval'd (T-48-XSS). -->
<input
r-if="columnIsFilterable(header.column.id)"
class="rdt-col-filter"
type="text"
:aria-label="'Filter ' + headerLabel(header.column.id)"
:value="columnFilterValue(header.column.id)"
@input="onColumnFilterInput(header.column.id, $event)"
@click="stopEvent($event)"
/>
<!-- HEADLESS facet slot (phase 50 reqs 8-9, D-03) — the #filter scoped slot (see the
virtual-branch twin above for the full rationale). Gated on the column's filterable
flag, display:contents host (unfaceted column emits nothing extra), NO fallback →
NO built-in facet control. Slot props: column id + cross-filtered unique-value list
(keys only, D-03) + [min,max] range so a consumer builds the facet UI themselves. -->
<span style="display:contents" r-if="columnIsFilterable(header.column.id)">
<slot
name="filter"
:columnId="header.column.id"
:uniqueValues="getFacetedUniqueValues(header.column.id)"
:minMax="getFacetedMinMaxValues(header.column.id)"
:setFilter="setColumnFilter"
/>
</span>
<!-- Pin control (req-11) — three native buttons (left / unpin / right), each
keyboard-reachable with an accessible name. Drives column.pin(side|false)
→ pin-change. The active side is reflected with aria-pressed. -->
<span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
<button
type="button"
class="rdt-pin-btn rdt-pin-left"
:aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'"
:aria-pressed="columnPinSide(header.column.id) === 'left'"
@click="onPinColumn(header.column.id, 'left', $event)"
>⇤</button>
<button
type="button"
class="rdt-pin-btn rdt-pin-none"
:aria-label="'Unpin ' + headerLabel(header.column.id)"
:aria-pressed="!columnPinSide(header.column.id)"
@click="onPinColumn(header.column.id, false, $event)"
>⇔</button>
<button
type="button"
class="rdt-pin-btn rdt-pin-right"
:aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'"
:aria-pressed="columnPinSide(header.column.id) === 'right'"
@click="onPinColumn(header.column.id, 'right', $event)"
>⇥</button>
</span>
<!-- Resize handle (req-9) — a keyboard-reachable button positioned at the
column's trailing edge; pointerdown/touchstart hand off to table-core's
resize handler (columnResizeMode:'onChange' → live width delta →
resize-change). The drag gesture state is owned by table-core, NOT a
top-level scratch const (the React fragile-binding rule). -->
<button
type="button"
class="rdt-resize-handle"
:aria-label="'Resize ' + headerLabel(header.column.id)"
@pointerdown="onResizeStart(header.column.id, $event)"
@touchstart="onResizeStart(header.column.id, $event)"
><span class="rdt-resize-grip" aria-hidden="true"></span></button>
</span>
</th>
</tr>
</thead>
<tbody class="rdt-tbody" role="rowgroup">
<!-- D-04 expand seam (the canonical shape Wave 3 grouping reuses): each keyed iteration
is a <template r-for> (wrapper-free multi-root, core e6ac185d) holding the data <tr>
followed by a conditional #detail <tr>. getSubRows children arrive as ordinary
depth-indented rows in $data.rows (NO nested r-for — Pitfall 1). -->
<template r-for="row in $data.rows" :key="row.id">
<tr
class="rdt-tr"
:class="{ 'rdt-group-header': rowIsGrouped(row) }"
role="row"
:data-depth="row.depth"
:aria-rowindex="isGrid() ? (absRowIndexOf(row) + 1) : null"
:data-group-header="rowIsGrouped(row) ? row.id : null"
:data-group-leaf="(groupingActive() && !rowIsGrouped(row)) ? row.id : null"
:aria-expanded="rowIsGrouped(row) ? !!rowIsExpanded(row) : null"
:aria-level="groupingActive() ? (row.depth + 1) : null"
>
<td
class="rdt-td"
:role="cellRole()"
r-for="cellCtx in visibleCellsFor(row)"
:key="cellCtx.id"
:data-col="cellCtx.column.id"
data-grid-cell
:data-row="rowIndexOf(row)"
:data-col-index="colIndexOf(row, cellCtx)"
:tabindex="cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx))"
:class="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }"
:style="bodyCellStyle(row, cellCtx.column.id)"
:aria-invalid="cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx))"
:data-in-range="inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null"
:data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.column.id : null"
>
<!-- auto-injected chevron expander cell (phase 50, D-04) — a native <button>
(handles Enter/Space → click natively; NO explicit @keydown.enter/.space which
would double-toggle). aria-expanded reflects row.getIsExpanded(); the chevron
renders only when row.getCanExpand(). -->
<span style="display:contents" r-if="isExpanderColumn(cellCtx.column.id)">
<button
r-if="rowCanExpand(row)"
type="button"
class="rdt-expander"
data-expander
:aria-expanded="!!rowIsExpanded(row)"
:aria-label="rowIsExpanded(row) ? 'Collapse row' : 'Expand row'"
@click="onToggleExpand(row, $event)"
>{{ rowIsExpanded(row) ? '▾' : '▸' }}</button>
</span>
<!-- auto-injected per-row select checkbox (D-04/D-05) — slot-overridable via
#selectCell; checkbox-only toggle (row body does NOT select). The
conditional branches use `display:contents` <span> wrappers (NOT
`<template r-if>` grouping): a `<template>` grouping wrapper renders as a
LITERAL inert `<template>` DOM element on svelte/solid/lit/angular in this
component's nesting (its children land in the template's .content
DocumentFragment → never displayed → empty cells). A real element with
r-if/r-else lowers cleanly on all six; `display:contents` keeps it
layout-transparent so the <td> box is unaffected. -->
<span style="display:contents" r-else-if="isSelectColumn(cellCtx.column.id)">
<slot
name="selectCell"
:row="row.original"
:checked="rowIsSelected(row)"
:toggle="(e) => onToggleRow(row, e)"
>
<input
class="rdt-select-row"
type="checkbox"
aria-label="Select row"
:checked="rowIsSelected(row)"
@change="onToggleRow(row, $event)"
/>
</slot>
</span>
<!-- ── Group-header cell (phase 50 req-4/5, D-04) — the grouped cell on a flattened
group-header row (cell.getIsGrouped()). Renders a native <button data-expander>
(REUSING the D-04 onToggleExpand path — group rows are expandable rows, no new
collapse branch) + the group key through the EXISTING #cell slot (cell.getValue())
+ the member count. Aggregated cells (cell.getIsAggregated()) are NOT branched here
— they flow through the #cell r-else below via cell.getValue() (NO aggregatedCell
template); placeholder cells fall through to the same r-else and render empty. -->
<span style="display:contents" r-else-if="cellIsGrouped(cellCtx)">
<button
type="button"
class="rdt-expander rdt-group-toggle"
data-expander
:aria-expanded="!!rowIsExpanded(row)"
:aria-label="rowIsExpanded(row) ? 'Collapse group' : 'Expand group'"
@click="onToggleExpand(row, $event)"
>{{ rowIsExpanded(row) ? '▾' : '▸' }}</button>
<span class="rdt-group-value">
<slot
name="cell"
:columnId="cellCtx.column.id"
:column="cellCtx.column"
:row="row.original"
:value="cellCtx.getValue()"
>{{ cellCtx.getValue() }}</slot>
</span>
<span class="rdt-group-count">{{ '(' + groupSubRowCount(row) + ')' }}</span>
</span>
<!-- ── Editor branch (phase 51 req-1/2): display↔editor r-if INSIDE the keyed <td>.
A `display:contents` <span> (NEVER a <template> — inert on 4 targets). The
#editor scoped slot (req-2) takes a column declaring editor='custom'; every
other editable column gets a built-in editor keyed on editorTypeOf. Each editor
element carries data-editing-cell so focusEditorWhenReady resolves it off
gridRoot (NEVER $refs.cellEditor — ROZ123 + last-ref-wins + Solid TDZ). -->
<span style="display:contents" r-else-if="isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))">
<span style="display:contents" r-if="hasEditorSlot(cellCtx.column.id)">
<slot
name="editor"
:columnId="cellCtx.column.id"
:column="cellCtx.column"
:row="row.original"
:value="editorValueFor(cellCtx.column.id)"
:commit="editorCommitFor(cellCtx.column.id)"
:cancel="editorCancelFor()"
/>
</span>
<input
r-else-if="editorTypeOf(cellCtx.column.id) === 'number'"
class="rdt-cell-editor"
type="number"
data-editing-cell
:value="editorValueFor(cellCtx.column.id)"
@input="onCellEditorInput(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
/>
<select
r-else-if="editorTypeOf(cellCtx.column.id) === 'select'"
class="rdt-cell-editor"
data-editing-cell
:value="editorValueFor(cellCtx.column.id)"
@change="onCellEditorInput(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
>
<option r-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<input
r-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'"
class="rdt-cell-editor"
type="checkbox"
data-editing-cell
:checked="editorCheckedFor(cellCtx.column.id)"
@change="onCellEditorCheckbox(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
/>
<input
r-else
class="rdt-cell-editor"
type="text"
data-editing-cell
:value="editorValueFor(cellCtx.column.id)"
@input="onCellEditorInput(cellCtx.column.id, $event)"
@keydown="onEditorKeyDown($event)"
@blur="onEditorBlur($event)"
/>
</span>
<!-- #cell scoped slot rendered DIRECTLY in the framework-owned <td> (D-A: no
portal, no projection). Dispatched by columnId; the slot FALLBACK is the
plain accessor value (the template-less fast path). -->
<span r-else class="rdt-cell-value">
<slot
name="cell"
:columnId="cellCtx.column.id"
:column="cellCtx.column"
:row="row.original"
:value="cellCtx.getValue()"
>{{ cellCtx.getValue() }}</slot>
</span>
<!-- Fill handle (req-8 / D-04) — non-virtual body. Identical shape to the virtual
body; keyed on rowIndexOf(row). Renders only on the range's bottom-right corner. -->
<span
r-if="isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))"
class="rdt-fill-handle"
data-fill-handle
data-testid="fill-handle"
aria-hidden="true"
@pointerdown="onFillHandlePointerDown($event)"
></span>
</td>
</tr>
<!-- #detail row (phase 50 req-2, D-04): the ONLY genuinely additive DOM row — renders
the #detail scoped slot ({ row }) under an expanded row, ONLY in #detail mode (no
getSubRows; rowShowsDetail gates it). The React render-prop edge (documented
divergence). Sibling of the data <tr> inside the same <template r-for> iteration. -->
<tr
r-if="rowShowsDetail(row)"
class="rdt-detail-row"
role="row"
:data-detail-row="row.id"
>
<td class="rdt-detail-cell" :colspan="visibleColCount()">
<slot name="detail" :row="row.original" />
</td>
</tr>
</template>
</tbody>
</table>
<!-- Pagination chrome (req-6) — prev/next + page-size select. Page indicator is
1-based for display; pageIndex() is 0-based. SUPPRESSED when virtual (req-9): windowing
renders the full pre-pagination model, so the pagination chrome is meaningless — gated by
`!$props.virtual`. The D-07 dev-mode warn (in $onMount) fires alongside this suppression
when the consumer ALSO configured client pagination. -->
<div class="rdt-pagination" role="group" aria-label="Pagination" r-if="!$props.virtual">
<button
type="button"
class="rdt-page-btn rdt-page-prev"
:disabled="!canPrevPage()"
@click="onPrevPage()"
>Prev</button>
<span class="rdt-page-status" aria-live="polite">
{{ 'Page ' + (pageIndex() + 1) + ' of ' + pageCount() }}
</span>
<button
type="button"
class="rdt-page-btn rdt-page-next"
:disabled="!canNextPage()"
@click="onNextPage()"
>Next</button>
<select
class="rdt-page-size"
aria-label="Rows per page"
:value="pageSize()"
@change="onPageSizeChange($event)"
>
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
</template>
<style>
.rozie-data-table {
border-collapse: collapse;
width: 100%;
font: var(--rdt-font, 14px system-ui, sans-serif);
color: var(--rdt-color, inherit);
}
/* Validation aria-live region (req-5/D-01): visually hidden, announced to AT. */
.rdt-sr-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Built-in cell editor (req-1): inline, full-cell-width input/select. */
.rozie-data-table .rdt-cell-editor {
font: inherit;
width: 100%;
box-sizing: border-box;
}
/* Invalid-state cell highlight (req-5/D-01). */
.rozie-data-table .rdt-td[aria-invalid="true"] {
outline: var(--rdt-invalid-outline, 2px solid #d33);
outline-offset: -2px;
}
/* Cell-range selection highlight (req-7 / D-07): a SEPARATE visual layer from the
row-selection slice — a background tint marking the rectangular range. Index-driven
(inRange()), so it reattaches to the correct cells across virtualization recycling. */
.rozie-data-table .rdt-td.rdt-in-range {
background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
/* Fill handle (req-8 / D-04): a small draggable square anchored at the range's bottom-right
corner. The owning <td> is positioned relative so the handle sits in its corner. */
.rozie-data-table .rdt-td {
position: relative;
}
.rozie-data-table .rdt-fill-handle {
position: absolute;
right: -3px;
bottom: -3px;
width: 8px;
height: 8px;
background: var(--rdt-fill-handle-bg, #2563eb);
border: 1px solid #fff;
cursor: crosshair;
z-index: 1;
touch-action: none;
}
.rozie-data-table .rdt-th,
.rozie-data-table .rdt-td {
padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
text-align: left;
border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table .rdt-thead .rdt-th {
font-weight: var(--rdt-header-weight, 600);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table .rdt-sort-btn {
display: inline-flex;
align-items: center;
gap: var(--rdt-sort-gap, 0.35em);
background: none;
border: none;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.rozie-data-table .rdt-sort-ind {
font-size: 0.8em;
opacity: var(--rdt-sort-ind-opacity, 0.7);
}
/* Sticky header (req-12) — token-driven, gated by the stickyHeader prop. */
.rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
position: sticky;
top: var(--rdt-sticky-top, 0);
z-index: var(--rdt-sticky-z, 2);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
/* Vertical row windowing (phase 53, req-3/D-06) — the bounded scroll viewport, emitted ONLY
when virtual. The maxHeight prop sets max-height inline (and mirrors the custom property);
this rule supplies the TOKEN-ONLY fallback so a consumer can bound the container purely via
--rozie-data-table-max-height without passing the prop. overflow:auto enables row windowing. */
.rozie-data-table-wrap .rdt-scroll {
max-height: var(--rozie-data-table-max-height);
overflow: auto;
}
/* Headless group-bar host (phase 50 req-6, D-02 REVISED) — token-driven, NON-interactive.
The default render is a styled-token reflection of the grouping state; the component ships
NO drag affordance (consumers build any bar/drag UI from the #groupBar slot props). */
.rozie-data-table-wrap .rdt-group-bar-host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap .rdt-group-token {
display: inline-flex;
align-items: center;
padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
border-radius: var(--rdt-group-token-radius, 999px);
background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
font-size: var(--rdt-group-token-size, 0.8125em);
}
/* Group-header rows + grouped-cell chrome (phase 50 req-4/5) — group rows ride the D-04 seam. */
.rozie-data-table .rdt-group-header {
background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table .rdt-group-toggle {
margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table .rdt-group-count {
margin-left: var(--rdt-group-count-gap, 0.375rem);
opacity: var(--rdt-group-count-opacity, 0.65);
font-weight: 400;
}
/* Filter / pagination chrome (req-5, req-6) — token-driven. */
.rozie-data-table-wrap {
display: flex;
flex-direction: column;
gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-toolbar {
display: flex;
gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-global-filter,
.rozie-data-table-wrap .rdt-col-filter {
font: inherit;
padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-filter-radius, 4px);
background: var(--rdt-filter-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap .rdt-col-filter {
display: block;
margin-top: var(--rdt-col-filter-gap, 0.25rem);
width: 100%;
font-weight: normal;
}
.rozie-data-table-wrap .rdt-pagination {
display: flex;
align-items: center;
gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-page-btn {
font: inherit;
cursor: pointer;
padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-btn-radius, 4px);
background: var(--rdt-page-btn-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap .rdt-page-btn:disabled {
opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
cursor: default;
}
.rozie-data-table-wrap .rdt-page-status {
font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap .rdt-page-size {
font: inherit;
padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-size-radius, 4px);
background: var(--rdt-page-size-bg, transparent);
color: inherit;
}
/* Column-management chrome (req-8/9/10/11) — token-driven. */
/* The <th> is the positioning context for the trailing resize handle. */
.rozie-data-table .rdt-th {
position: relative;
}
/* Resize handle (req-9) — a thin grab strip at the column's trailing edge. */
.rozie-data-table .rdt-resize-handle {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--rdt-resize-handle-width, 6px);
padding: 0;
border: none;
background: none;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.rozie-data-table .rdt-resize-grip {
display: block;
width: var(--rdt-resize-grip-width, 2px);
height: 100%;
margin: 0 auto;
background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table .rdt-resize-handle:hover .rdt-resize-grip,
.rozie-data-table .rdt-th-resizing .rdt-resize-grip {
background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
/* Pin controls (req-11) — compact button group. */
.rozie-data-table .rdt-pin-controls {
display: inline-flex;
gap: var(--rdt-pin-gap, 0.1em);
margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table .rdt-pin-btn {
font: inherit;
font-size: var(--rdt-pin-btn-size, 0.8em);
line-height: 1;
cursor: pointer;
padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-pin-btn-radius, 3px);
background: var(--rdt-pin-btn-bg, transparent);
color: inherit;
}
.rozie-data-table .rdt-pin-btn[aria-pressed='true'] {
background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
font-weight: 700;
}
/* Column-visibility toggle menu (req-8) — native <details> disclosure. */
.rozie-data-table-wrap .rdt-colvis {
position: relative;
}
.rozie-data-table-wrap .rdt-colvis-summary {
cursor: pointer;
font: inherit;
padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-colvis-summary-radius, 4px);
list-style: none;
user-select: none;
}
.rozie-data-table-wrap .rdt-colvis-menu {
position: absolute;
z-index: var(--rdt-colvis-menu-z, 5);
margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
display: flex;
flex-direction: column;
gap: var(--rdt-colvis-item-gap, 0.25rem);
border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-colvis-menu-radius, 4px);
background: var(--rdt-colvis-menu-bg, #fff);
box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap .rdt-colvis-item {
display: flex;
align-items: center;
gap: var(--rdt-colvis-label-gap, 0.4em);
cursor: pointer;
white-space: nowrap;
}
/* Auto-injected select column (req-7, D-04) — token-driven, narrow checkbox cell. */
.rozie-data-table .rdt-select-th,
.rozie-data-table .rdt-select-td {
width: var(--rdt-select-col-width, 1%);
text-align: var(--rdt-select-col-align, center);
white-space: nowrap;
}
.rozie-data-table .rdt-select-all,
.rozie-data-table .rdt-select-row {
cursor: pointer;
accent-color: var(--rdt-select-accent, currentColor);
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/data-table-{react,vue,svelte,angular,solid,lit}):
tsx
import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieContext, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './DataTable.css';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
interface GroupBarCtx { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any; }
interface SelectAllCtx { checked: any; indeterminate: any; toggle: any; }
interface ColHeaderCtx { columnId: any; column: any; label: any; }
interface FilterCtx { columnId: any; uniqueValues: any; minMax: any; setFilter: any; }
interface SelectCellCtx { row: any; checked: any; toggle: any; }
interface CellCtx { columnId: any; column: any; row: any; value: any; }
interface EditorCtx { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; }
interface DetailCtx { row: any; }
interface DataTableProps {
/**
* 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).
* @example
* <DataTable r-model:data="rows" :columns="cols" />
*/
data: any[];
defaultData?: any[];
onDataChange?: (data: any[]) => void;
/**
* 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).
*/
columns?: any[];
/**
* Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
*/
selectionMode?: string;
/**
* `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
*/
sorting?: any[];
defaultSorting?: any[];
onSortingChange?: (sorting: any[]) => void;
/**
* The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
*/
globalFilter?: string;
defaultGlobalFilter?: string;
onGlobalFilterChange?: (globalFilter: string) => void;
/**
* `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
*/
columnFilters?: any[];
defaultColumnFilters?: any[];
onColumnFiltersChange?: (columnFilters: any[]) => void;
/**
* `{ 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`.
*/
pagination?: Record<string, any>;
defaultPagination?: Record<string, any>;
onPaginationChange?: (pagination: Record<string, any>) => void;
/**
* 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).
*/
manual?: boolean;
/**
* 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).
*/
expandable?: boolean;
/**
* `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.
*/
expanded?: (Record<string, any> | boolean) | null;
defaultExpanded?: (Record<string, any> | boolean) | null;
onExpandedChange?: (expanded: (Record<string, any> | boolean) | null) => void;
/**
* 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.
*/
getSubRows?: ((...args: any[]) => any) | null;
/**
* 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).
*/
groupable?: boolean;
/**
* `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.
*/
grouping?: (any[]) | null;
defaultGrouping?: (any[]) | null;
onGroupingChange?: (grouping: (any[]) | null) => void;
/**
* `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.
*/
rowSelection?: Record<string, any>;
defaultRowSelection?: Record<string, any>;
onRowSelectionChange?: (rowSelection: Record<string, any>) => void;
/**
* `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
*/
columnVisibility?: Record<string, any>;
defaultColumnVisibility?: Record<string, any>;
onColumnVisibilityChange?: (columnVisibility: Record<string, any>) => void;
/**
* `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
*/
columnSizing?: Record<string, any>;
defaultColumnSizing?: Record<string, any>;
onColumnSizingChange?: (columnSizing: Record<string, any>) => void;
/**
* `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
*/
columnOrder?: any[];
defaultColumnOrder?: any[];
onColumnOrderChange?: (columnOrder: any[]) => void;
/**
* `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
*/
columnPinning?: Record<string, any>;
defaultColumnPinning?: Record<string, any>;
onColumnPinningChange?: (columnPinning: Record<string, any>) => void;
/**
* Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
*/
stickyHeader?: boolean;
/**
* `'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
* @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
*/
interactionMode?: string;
/**
* 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.
*/
virtual?: boolean;
/**
* Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* 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.
*/
maxHeight?: string;
onSortChange?: (...args: any[]) => void;
onExpandChange?: (...args: any[]) => void;
onGroupChange?: (...args: any[]) => void;
onFilterChange?: (...args: any[]) => void;
onPageChange?: (...args: any[]) => void;
onSelectionChange?: (...args: any[]) => void;
onVisibilityChange?: (...args: any[]) => void;
onResizeChange?: (...args: any[]) => void;
onReorderChange?: (...args: any[]) => void;
onPinChange?: (...args: any[]) => void;
onActivecellChange?: (...args: any[]) => void;
onRangeChange?: (...args: any[]) => void;
onCellEditCommit?: (...args: any[]) => void;
onRowEditCommit?: (...args: any[]) => void;
children?: ReactNode;
renderGroupBar?: (ctx: GroupBarCtx) => ReactNode;
renderSelectAll?: (ctx: SelectAllCtx) => ReactNode;
renderColHeader?: (ctx: ColHeaderCtx) => ReactNode;
renderFilter?: (ctx: FilterCtx) => ReactNode;
renderSelectCell?: (ctx: SelectCellCtx) => ReactNode;
renderCell?: (ctx: CellCtx) => ReactNode;
renderEditor?: (ctx: EditorCtx) => ReactNode;
renderDetail?: (ctx: DetailCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface DataTableHandle {
sortColumn: (...args: any[]) => any;
clearSorting: (...args: any[]) => any;
toggleRowExpanded: (...args: any[]) => any;
expandAll: (...args: any[]) => any;
collapseAll: (...args: any[]) => any;
getExpandedRows: (...args: any[]) => any;
applyGrouping: (...args: any[]) => any;
clearGrouping: (...args: any[]) => any;
getFacetedUniqueValues: (...args: any[]) => any;
getFacetedMinMaxValues: (...args: any[]) => any;
getColumnDefs: (...args: any[]) => any;
toggleAllRows: (...args: any[]) => any;
clearSelection: (...args: any[]) => any;
getSelectedRows: (...args: any[]) => any;
setPage: (...args: any[]) => any;
setRowsPerPage: (...args: any[]) => any;
toggleColumnVisibility: (...args: any[]) => any;
applyColumnOrder: (...args: any[]) => any;
resetColumnSizing: (...args: any[]) => any;
pinColumn: (...args: any[]) => any;
focusCell: (...args: any[]) => any;
getActiveCell: (...args: any[]) => any;
clearActiveCell: (...args: any[]) => any;
getRowIndexRelativeToPage: (...args: any[]) => any;
editCell: (...args: any[]) => any;
commitEditing: (...args: any[]) => any;
editRow: (...args: any[]) => any;
getSelectedRange: (...args: any[]) => any;
cut: (...args: any[]) => any;
}
const DataTable = forwardRef<DataTableHandle, DataTableProps>(function DataTable(_props: DataTableProps, ref): JSX.Element {
const __ctx_data_table_columns = rozieContext("data-table:columns");
const __defaultColumns = useState(() => (() => [])())[0];
const props: Omit<DataTableProps, 'columns' | 'selectionMode' | 'manual' | 'expandable' | 'getSubRows' | 'groupable' | 'stickyHeader' | 'interactionMode' | 'virtual' | 'estimateRowHeight' | 'maxHeight'> & { columns: any[]; selectionMode: string; manual: boolean; expandable: boolean; getSubRows: ((...args: any[]) => any) | null; groupable: boolean; stickyHeader: boolean; interactionMode: string; virtual: boolean; estimateRowHeight: number; maxHeight: string } = {
..._props,
columns: _props.columns ?? __defaultColumns,
selectionMode: _props.selectionMode ?? 'none',
manual: _props.manual ?? false,
expandable: _props.expandable ?? false,
getSubRows: _props.getSubRows ?? null,
groupable: _props.groupable ?? false,
stickyHeader: _props.stickyHeader ?? false,
interactionMode: _props.interactionMode ?? 'table',
virtual: _props.virtual ?? false,
estimateRowHeight: _props.estimateRowHeight ?? 40,
maxHeight: _props.maxHeight ?? '',
};
const table = useRef<any>(null);
const refreshRowModel = useRef<any>(null);
const virtualizer = useRef<any>(null);
const pendingEditFollow = useRef<any>(null);
const gridRoot = useRef<any>(null);
const gridScrollEl = useRef<any>(null);
const virtualizerCleanup = useRef<any>(null);
const expandedTouched = useRef(false);
const programmatic = useRef(0);
const remeasurePending = useRef(false);
const gridEmptyFallback = useRef(false);
const rangeActive = useRef(false);
const selectAllBox = useRef<any>(null);
const fillDragMove = useRef<any>(null);
const fillDragUp = useRef<any>(null);
const fillDragging = useRef(false);
const lastData = useRef<any>(null);
const lastDataLen = useRef(-1);
const editTransition = useRef(false);
const [data, setData] = useControllableState({
value: props.data,
defaultValue: props.defaultData ?? [],
onValueChange: props.onDataChange,
});
const [sorting, setSorting] = useControllableState({
value: props.sorting,
defaultValue: props.defaultSorting ?? (() => [])(),
onValueChange: props.onSortingChange,
});
const [globalFilter, setGlobalFilter] = useControllableState({
value: props.globalFilter,
defaultValue: props.defaultGlobalFilter ?? '',
onValueChange: props.onGlobalFilterChange,
});
const [columnFilters, setColumnFilters] = useControllableState({
value: props.columnFilters,
defaultValue: props.defaultColumnFilters ?? (() => [])(),
onValueChange: props.onColumnFiltersChange,
});
const [pagination, setPagination] = useControllableState({
value: props.pagination,
defaultValue: props.defaultPagination ?? (() => ({
pageIndex: 0,
pageSize: 10
}))(),
onValueChange: props.onPaginationChange,
});
const [expanded, setExpanded] = useControllableState({
value: props.expanded,
defaultValue: props.defaultExpanded ?? null,
onValueChange: props.onExpandedChange,
});
const [grouping, setGrouping] = useControllableState({
value: props.grouping,
defaultValue: props.defaultGrouping ?? null,
onValueChange: props.onGroupingChange,
});
const [rowSelection, setRowSelection] = useControllableState({
value: props.rowSelection,
defaultValue: props.defaultRowSelection ?? (() => ({}))(),
onValueChange: props.onRowSelectionChange,
});
const [columnVisibility, setColumnVisibility] = useControllableState({
value: props.columnVisibility,
defaultValue: props.defaultColumnVisibility ?? (() => ({}))(),
onValueChange: props.onColumnVisibilityChange,
});
const [columnSizing, setColumnSizing] = useControllableState({
value: props.columnSizing,
defaultValue: props.defaultColumnSizing ?? (() => ({}))(),
onValueChange: props.onColumnSizingChange,
});
const [columnOrder, setColumnOrder] = useControllableState({
value: props.columnOrder,
defaultValue: props.defaultColumnOrder ?? (() => [])(),
onValueChange: props.onColumnOrderChange,
});
const [columnPinning, setColumnPinning] = useControllableState({
value: props.columnPinning,
defaultValue: props.defaultColumnPinning ?? (() => ({
left: [],
right: []
}))(),
onValueChange: props.onColumnPinningChange,
});
const _expandableRef = useRef(props.expandable);
_expandableRef.current = props.expandable;
const _selectionModeRef = useRef(props.selectionMode);
_selectionModeRef.current = props.selectionMode;
const _dataRef = useRef(data);
_dataRef.current = data;
const _paginationRef = useRef(pagination);
_paginationRef.current = pagination;
const [dataDefault, setDataDefault] = useState<any[]>([]);
const [sortingDefault, setSortingDefault] = useState<any[]>([]);
const [globalFilterDefault, setGlobalFilterDefault] = useState('');
const [columnFiltersDefault, setColumnFiltersDefault] = useState<any[]>([]);
const [paginationDefault, setPaginationDefault] = useState({
pageIndex: 0,
pageSize: 10
});
const [rowSelectionDefault, setRowSelectionDefault] = useState<Record<string, any>>({});
const [expandedDefault, setExpandedDefault] = useState<Record<string, any>>({});
const [groupingDefault, setGroupingDefault] = useState<any[]>([]);
const [columnVisibilityDefault, setColumnVisibilityDefault] = useState<Record<string, any>>({});
const [columnSizingDefault, setColumnSizingDefault] = useState<Record<string, any>>({});
const [columnOrderDefault, setColumnOrderDefault] = useState<any[]>([]);
const [columnPinningDefault, setColumnPinningDefault] = useState({
left: [],
right: []
});
const [columnSizingInfo, setColumnSizingInfo] = useState({
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: []
});
const [colReg, setColReg] = useState<Record<string, any>>({});
const [rows, setRows] = useState<any[]>([]);
const [headerGroups, setHeaderGroups] = useState<any[]>([]);
const [rowModelVer, setRowModelVer] = useState(0);
const [windowVer, setWindowVer] = useState(0);
const [activeRow, setActiveRow] = useState(0);
const [activeColIndex, setActiveColIndex] = useState(0);
const [activeIsHeader, setActiveIsHeader] = useState(false);
const [activeHeaderLevel, setActiveHeaderLevel] = useState(0);
const [activeInControl, setActiveInControl] = useState(false);
const [editingRow, setEditingRow] = useState(-1);
const [editingCol, setEditingCol] = useState(-1);
const [draftValue, setDraftValue] = useState<any>(null);
const [invalidMsg, setInvalidMsg] = useState('');
const [editVer, setEditVer] = useState(0);
const [editingRowIndex, setEditingRowIndex] = useState<any>(null);
const [rowDraft, setRowDraft] = useState<Record<string, any>>({});
const [rangeAnchor, setRangeAnchor] = useState<any>(null);
const [rangeFocus, setRangeFocus] = useState<any>(null);
const [pasteAnnounce, setPasteAnnounce] = useState('');
const __rozieRoot = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10;
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
function groupingActiveDefault() {
return ((grouping != null ? grouping : groupingDefault) || []).length > 0;
}
const currentState = useCallback((): any => ({
sorting: sorting != null ? sorting : sortingDefault,
globalFilter: globalFilter != null ? globalFilter : globalFilterDefault,
columnFilters: columnFilters != null ? columnFilters : columnFiltersDefault,
pagination: pagination != null ? pagination : paginationDefault,
rowSelection: rowSelection != null ? rowSelection : rowSelectionDefault,
// expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
// literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
// guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
// GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
// `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
// literal so the grouped subtree is visible by default; the first toggle latches
// expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
// unchanged → byte-identical-off (the table + the expandable-rows feature both keep
// $data.expandedDefault).
expanded: expanded != null ? expanded : groupingActiveDefault() && !expandedTouched.current ? true : expandedDefault,
// grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
// to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
// is inert when this is empty (byte-identical-off, req-10).
grouping: grouping != null ? grouping : groupingDefault,
columnVisibility: columnVisibility != null ? columnVisibility : columnVisibilityDefault,
columnSizing: columnSizing != null ? columnSizing : columnSizingDefault,
columnOrder: columnOrder != null ? columnOrder : columnOrderDefault,
columnPinning: columnPinning != null ? columnPinning : columnPinningDefault,
// columnSizingInfo: table-core's transient resize-gesture state. We pass an
// EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
// `column.getIsResizing()` / `getResizeHandler()` read
// `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
// absent. Seed the default shape (matches table-core's
// getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
// every render. Not a two-way model slice (transient gesture state, not consumer
// state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
columnSizingInfo: columnSizingInfo
}), [columnFilters, columnFiltersDefault, columnOrder, columnOrderDefault, columnPinning, columnPinningDefault, columnSizing, columnSizingDefault, columnSizingInfo, columnVisibility, columnVisibilityDefault, expanded, expandedDefault, globalFilter, globalFilterDefault, grouping, groupingActiveDefault, groupingDefault, pagination, paginationDefault, rowSelection, rowSelectionDefault, sorting, sortingDefault]);
const currentData = useCallback((): any => data != null ? data : dataDefault, [data, dataDefault]);
function isSafeKey(k: any) {
return k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
}
function wrapAggregationFn(fn: any) {
if (typeof fn === 'string') return fn;
if (typeof fn !== 'function') return undefined;
return (columnId: any, leafRows: any, childRows: any) => {
try {
return fn(columnId, leafRows, childRows);
} catch (err: any) {
return undefined;
}
};
}
function buildConfigDef(c: any) {
if (!c) return null;
// Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
// getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
// header spans its leaf children (B12). The group id falls back to its header text so it
// stays addressable (no accessor; group columns carry no data).
if (Array.isArray(c.columns)) {
const gid = c.id != null ? c.id : c.header;
if (gid == null) return null;
const id = String(gid);
if (!isSafeKey(id)) return null;
const kids = [];
for (const child of c.columns as any) {
const cd = buildConfigDef(child);
if (cd) kids.push(cd);
}
if (!kids.length) return null;
return {
id,
header: c.header != null ? c.header : id,
columns: kids
};
}
const rawId = c.id != null ? c.id : c.field;
if (rawId == null) return null;
const id = String(rawId);
if (!isSafeKey(id)) return null;
return {
id,
accessorKey: c.field != null ? c.field : id,
header: c.header != null ? c.header : id,
enableSorting: c.sortable === true,
// per-column filter opt-in (req-5). table-core gates the filter input + value
// funnel on enableColumnFilter; a column with filterable !== true cannot be
// filtered (and renders no per-column filter input in the chrome below).
enableColumnFilter: c.filterable === true,
filterable: c.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: c.expandable === true,
// Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
// so every data column is offered to the headless #groupBar by default; the per-column
// aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
// a custom fn defensively wrapped (T-50-04).
groupable: c.groupable !== false,
aggregationFn: wrapAggregationFn(c.aggregationFn),
pinned: c.pinned != null ? c.pinned : '',
width: c.width != null ? c.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
// metadata carrier the display↔editor branch + runValidator read. Off by default.
meta: {
editable: c.editable === true,
editor: c.editor != null ? c.editor : 'text',
editorOptions: c.editorOptions != null ? c.editorOptions : [],
validate: typeof c.validate === 'function' ? c.validate : null
}
};
}
function columnDefs() {
const byId = Object.create(null);
const order = [];
const cfg = props.columns || [];
for (const c of cfg as any) {
const def = buildConfigDef(c);
if (!def) continue;
const id = def.id;
if (!(id in byId)) order.push(id);
byId[id] = def;
}
const reg = colReg || {};
for (const id in reg) {
if (!isSafeKey(id)) continue;
const spec = reg[id];
if (!spec) continue;
if (!(id in byId)) order.push(id);
byId[id] = {
id,
accessorKey: spec.field != null ? spec.field : id,
header: spec.header != null ? spec.header : id,
enableSorting: spec.sortable === true,
enableColumnFilter: spec.filterable === true,
filterable: spec.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: spec.expandable === true,
// Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
groupable: spec.groupable !== false,
aggregationFn: wrapAggregationFn(spec.aggregationFn),
pinned: spec.pinned != null ? spec.pinned : '',
width: spec.width != null ? spec.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
meta: {
editable: spec.editable === true,
editor: spec.editor != null ? spec.editor : 'text',
editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
validate: typeof spec.validate === 'function' ? spec.validate : null
}
};
}
const out = [];
for (const id of order as any) if (byId[id]) out.push(byId[id]);
return out;
}
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
const SELECT_COL_ID = '__rdt_select';
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
const EXPANDER_COL_ID = '__rdt_expander';
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
function selectionEnabled() {
return props.selectionMode === 'single' || props.selectionMode === 'multiple';
}
const tableColumns = useCallback(() => {
const cols = columnDefs();
// Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
// isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
// value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
// → byte-identical-off (req-10).
let withExpander = cols;
if (props.expandable === true) {
const expanderCol = {
id: EXPANDER_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isExpanderColumn: true,
pinned: '',
width: ''
};
withExpander = [expanderCol].concat(cols);
}
if (selectionEnabled()) {
const selectCol = {
id: SELECT_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isSelectColumn: true,
pinned: '',
width: ''
};
return [selectCol].concat(withExpander);
}
return withExpander;
}, [columnDefs, props.expandable, selectionEnabled]);
function writeSorting(next: any) {
if (programmatic.current) return;
programmatic.current++;
setSortingDefault(next); // fresh array only (never in-place)
setSorting(next); // two-way emit if bound (no-op-diff if not)
props.onSortChange && props.onSortChange(next);
programmatic.current--;
}
function applyUpdater(updater: any, current: any) {
return typeof updater === 'function' ? updater(current) : updater;
}
function writeExpanded(next: any) {
if (programmatic.current) return;
programmatic.current++;
// Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
// the user now owns the expanded state, so currentState() stops defaulting grouped rows to
// the `true` expand-all literal and honors $data.expandedDefault from here on.
expandedTouched.current = true;
setExpandedDefault(next); // fresh value only (never in-place)
setExpanded(next); // two-way emit if bound (no-op-diff if not)
// Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
// prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
// interface, and an `expanded-change` event would camelCase to the SAME identifier
// → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
// sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
// sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
props.onExpandChange && props.onExpandChange(next);
programmatic.current--;
}
function writeGrouping(next: any) {
if (programmatic.current) return;
programmatic.current++;
setGroupingDefault(next); // fresh ordered array only (never in-place push)
setGrouping(next); // two-way emit if bound (no-op-diff if not)
props.onGroupChange && props.onGroupChange(next);
programmatic.current--;
}
function writeGlobalFilter(next: any) {
if (programmatic.current) return;
programmatic.current++;
setGlobalFilterDefault(next);
setGlobalFilter(next);
props.onFilterChange && props.onFilterChange({
globalFilter: next
});
programmatic.current--;
}
function writeColumnFilters(next: any) {
if (programmatic.current) return;
programmatic.current++;
setColumnFiltersDefault(next);
setColumnFilters(next);
props.onFilterChange && props.onFilterChange({
columnFilters: next
});
programmatic.current--;
}
function writePagination(next: any) {
if (programmatic.current) return;
programmatic.current++;
setPaginationDefault(next);
setPagination(next);
props.onPageChange && props.onPageChange(next);
programmatic.current--;
}
function writeRowSelection(next: any) {
if (programmatic.current) return;
programmatic.current++;
setRowSelectionDefault(next);
setRowSelection(next);
props.onSelectionChange && props.onSelectionChange(next);
programmatic.current--;
}
function writeColumnVisibility(next: any) {
if (programmatic.current) return;
programmatic.current++;
setColumnVisibilityDefault(next);
setColumnVisibility(next);
props.onVisibilityChange && props.onVisibilityChange(next);
programmatic.current--;
}
function writeColumnSizing(next: any) {
if (programmatic.current) return;
programmatic.current++;
setColumnSizingDefault(next);
setColumnSizing(next);
props.onResizeChange && props.onResizeChange(next);
programmatic.current--;
}
function writeColumnOrder(next: any) {
if (programmatic.current) return;
programmatic.current++;
setColumnOrderDefault(next);
setColumnOrder(next);
props.onReorderChange && props.onReorderChange(next);
programmatic.current--;
}
function writeColumnPinning(next: any) {
if (programmatic.current) return;
programmatic.current++;
setColumnPinningDefault(next);
setColumnPinning(next);
props.onPinChange && props.onPinChange(next);
programmatic.current--;
}
function writeData(next: any) {
if (programmatic.current) return;
programmatic.current++;
setDataDefault(next); // fresh array only (never in-place)
setData(next); // two-way emit if bound (no-op-diff if not)
programmatic.current--;
}
function columnFilterValue(colId: any) {
const cf = currentState().columnFilters || [];
for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
return '';
}
function setColumnFilter(colId: any, value: any) {
const prev = currentState().columnFilters || [];
const next = [];
for (const f of prev as any) if (f && f.id !== colId) next.push(f);
if (value != null && value !== '') next.push({
id: colId,
value
});
writeColumnFilters(next);
}
const onSortingChangeCb = useCallback((updater: any) => {
writeSorting(applyUpdater(updater, currentState().sorting));
}, [applyUpdater, currentState, writeSorting]);
const onExpandedChangeCb = useCallback((updater: any) => {
writeExpanded(applyUpdater(updater, currentState().expanded));
}, [applyUpdater, currentState, writeExpanded]);
const onGroupingChangeCb = useCallback((updater: any) => {
writeGrouping(applyUpdater(updater, currentState().grouping));
}, [applyUpdater, currentState, writeGrouping]);
const onGlobalFilterChangeCb = useCallback((updater: any) => {
writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
}, [applyUpdater, currentState, writeGlobalFilter]);
const onColumnFiltersChangeCb = useCallback((updater: any) => {
writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
}, [applyUpdater, currentState, writeColumnFilters]);
const onPaginationChangeCb = useCallback((updater: any) => {
writePagination(applyUpdater(updater, currentState().pagination));
}, [applyUpdater, currentState, writePagination]);
const onRowSelectionChangeCb = useCallback((updater: any) => {
writeRowSelection(applyUpdater(updater, currentState().rowSelection));
}, [applyUpdater, currentState, writeRowSelection]);
const onColumnVisibilityChangeCb = useCallback((updater: any) => {
writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
}, [applyUpdater, currentState, writeColumnVisibility]);
const onColumnSizingChangeCb = useCallback((updater: any) => {
writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
}, [applyUpdater, currentState, writeColumnSizing]);
const onColumnOrderChangeCb = useCallback((updater: any) => {
writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
}, [applyUpdater, currentState, writeColumnOrder]);
const onColumnPinningChangeCb = useCallback((updater: any) => {
writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
}, [applyUpdater, currentState, writeColumnPinning]);
const onColumnSizingInfoChangeCb = useCallback((updater: any) => {
const next = applyUpdater(updater, columnSizingInfo);
setColumnSizingInfo(prev => next != null ? next : prev);
}, [applyUpdater, columnSizingInfo]);
const windowSource = useCallback(() => {
if (!table.current) return [];
if (props.virtual) return table.current.getPrePaginationRowModel().rows;
return table.current.getRowModel().rows;
}, [props.virtual]);
function scheduleRemeasure() {
if (remeasurePending.current) return;
remeasurePending.current = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending.current = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending.current = false;else setTimeout(rafPass, 0);
}
function pinnedEditIndex() {
if (editingRow >= 0) return editingRow;
if (editingRowIndex != null) return editingRowIndex;
return -1;
}
function pinnedMeasurement(pin: any) {
if (!virtualizer.current || pin < 0) return null;
const ms = virtualizer.current.getMeasurements();
return ms && ms[pin] ? ms[pin] : null;
}
const remeasureWindow = useCallback(() => {
if (!virtualizer.current || !gridRoot.current) return;
// Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
// exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
// null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
// scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
// resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
// regression); the next settled onChange re-measures the stable window. Manual-scroll
// recycling (the CR-01 case) has scrollState === null, so it measures normally.
if (virtualizer.current.scrollState) return;
const trs = gridRoot.current.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
for (const tr of trs as any) virtualizer.current.measureElement(tr);
}, []);
function virtualItemKey(i: any) {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
}
const virtualizerOptions = useCallback((): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl.current,
estimateSize: () => props.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
setWindowVer(prev => prev + 1);
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
}), [props.estimateRowHeight, scheduleRemeasure, virtualItemKey, windowSource]);
function pinMeasurement(pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null {
return pinnedMeasurement(pin);
}
function windowedRows() {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer;
void editVer;
if (!virtualizer.current) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!props.virtual) {
const rowList = rows || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.current.getVirtualItems();
const rowList = rows || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
}
function padTop() {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer;
void editVer;
if (!props.virtual || !virtualizer.current) return 0;
const items = virtualizer.current.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
}
function padBottom() {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer;
void editVer;
if (!props.virtual || !virtualizer.current) return 0;
const items = virtualizer.current.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.current.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
}
function pmIndexInWindow(items: any, idx: any) {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
}
function rowIsOutsideWindow(r: any) {
if (!props.virtual || !virtualizer.current) return false;
const items = virtualizer.current.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
}
const reFeed = useCallback(() => {
if (!table.current) return;
table.current.setOptions((prev: any) => ({
...prev,
data: currentData(),
columns: tableColumns(),
state: currentState(),
enableRowSelection: props.selectionMode !== 'none',
enableMultiRowSelection: props.selectionMode === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (props.getSubRows || undefined) as any,
getRowCanExpand: props.expandable === true && props.getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb
}));
if (refreshRowModel.current) refreshRowModel.current();
}, [currentData, currentState, onColumnFiltersChangeCb, onColumnOrderChangeCb, onColumnPinningChangeCb, onColumnSizingChangeCb, onColumnSizingInfoChangeCb, onColumnVisibilityChangeCb, onExpandedChangeCb, onGlobalFilterChangeCb, onGroupingChangeCb, onPaginationChangeCb, onRowSelectionChangeCb, onSortingChangeCb, props.expandable, props.getSubRows, props.selectionMode, tableColumns]);
const onHeaderSort = useCallback((colId: any, evt: any) => {
if (!table.current) return;
const col = table.current.getColumn(colId);
if (!col || !col.getCanSort()) return;
const multi = !!(evt && evt.shiftKey);
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi);
}, []);
function tick() {
return rowModelVer;
}
function ariaSortFor(colId: any) {
if (tick() < 0 || !table.current) return 'none';
const col = table.current.getColumn(colId);
if (!col) return 'none';
const dir = col.getIsSorted();
if (dir === 'asc') return 'ascending';
if (dir === 'desc') return 'descending';
return 'none';
}
function sortIndicator(colId: any) {
if (tick() < 0 || !table.current) return '';
const col = table.current.getColumn(colId);
if (!col) return '';
const dir = col.getIsSorted();
if (dir === 'asc') return '▲';
if (dir === 'desc') return '▼';
return '';
}
function defFor(colId: any) {
const defs = columnDefs();
for (const d of defs as any) if (d.id === colId) return d;
return null;
}
function visibleCellsFor(row: any) {
return rowModelVer >= 0 ? row.getVisibleCells() : [];
}
function editMetaOf(colId: any) {
const d = defFor(colId);
return d && d.meta ? d.meta : null;
}
function columnEditable(colId: any) {
const m = editMetaOf(colId);
return !!(m && m.editable === true);
}
function editorTypeOf(colId: any) {
const m = editMetaOf(colId);
return m && m.editor != null ? m.editor : 'text';
}
function editorOptionsOf(colId: any) {
const m = editMetaOf(colId);
return m && m.editorOptions != null ? m.editorOptions : [];
}
function hasEditorSlot(colId: any) {
return editorTypeOf(colId) === 'custom' && !!(props.renderEditor ?? props.slots?.["editor"]);
}
function columnIsFilterable(colId: any) {
const d = defFor(colId);
return !!(d && d.filterable);
}
function headerLabel(colId: any) {
const d = defFor(colId);
return d ? d.header : colId;
}
function headerWidth(colId: any) {
if (tick() < 0 || !table.current) return null;
const col = table.current.getColumn(colId);
if (!col) return null;
const w = col.getSize();
return w != null && w > 0 ? w + 'px' : null;
}
const onResizeStart = useCallback((colId: any, evt: any) => {
// stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table.current) return;
const header = findHeader(colId);
if (!header || !header.getResizeHandler) return;
const handler = header.getResizeHandler();
if (handler) handler(evt);
}, [findHeader]);
function findHeader(colId: any) {
const groups = headerGroups || [];
for (const hg of groups as any) {
const hs = hg.headers || [];
for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
}
return null;
}
function columnIsResizing(colId: any) {
if (tick() < 0 || !table.current) return false;
const header = findHeader(colId);
return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
}
function columnIsVisible(colId: any) {
if (tick() < 0 || !table.current) return true;
const col = table.current.getColumn(colId);
return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
}
const onToggleVisibility = useCallback((colId: any) => {
if (!table.current) return;
const col = table.current.getColumn(colId);
if (col && col.toggleVisibility) col.toggleVisibility();
}, []);
function allLeafColumns() {
if (tick() < 0 || !table.current) return [];
const cols = table.current.getAllLeafColumns ? table.current.getAllLeafColumns() : [];
const out = [];
for (const c of cols as any) {
if (!c || c.id === SELECT_COL_ID) continue;
out.push({
id: c.id,
label: headerLabel(c.id),
visible: !!(c.getIsVisible && c.getIsVisible())
});
}
return out;
}
function columnPinSide(colId: any) {
if (tick() < 0 || !table.current) return false;
const col = table.current.getColumn(colId);
if (!col || !col.getIsPinned) return false;
return col.getIsPinned();
}
const onPinColumn = useCallback((colId: any, side: any, evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table.current) return;
const col = table.current.getColumn(colId);
if (col && col.pin) col.pin(side);
}, []);
function pinStyle(colId: any) {
if (tick() < 0 || !table.current) return '';
const col = table.current.getColumn(colId);
if (!col || !col.getIsPinned) return '';
const side = col.getIsPinned();
if (side === 'left') {
const left = col.getStart ? col.getStart('left') : 0;
return 'position:sticky;left:' + left + 'px;z-index:1;';
}
if (side === 'right') {
const right = col.getAfter ? col.getAfter('right') : 0;
return 'position:sticky;right:' + right + 'px;z-index:1;';
}
return '';
}
function thStyle(colId: any) {
let s = '';
const w = headerWidth(colId);
if (w) s += 'width:' + w + ';';
s += pinStyle(colId);
return s;
}
const onGlobalFilterInput = useCallback((evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
if (table.current) {
table.current.setGlobalFilter(value);
return;
}
writeGlobalFilter(value);
}, [writeGlobalFilter]);
const onColumnFilterInput = useCallback((colId: any, evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
setColumnFilter(colId, value);
}, [setColumnFilter]);
function globalFilterValue() {
const v = currentState().globalFilter;
return v != null ? v : '';
}
function pageIndex() {
if (tick() >= 0 && table.current) return table.current.getState().pagination.pageIndex;
const p = currentState().pagination;
return p && p.pageIndex != null ? p.pageIndex : 0;
}
function pageSize() {
if (tick() >= 0 && table.current) return table.current.getState().pagination.pageSize;
const p = currentState().pagination;
return p && p.pageSize != null ? p.pageSize : 10;
}
function pageCount() {
if (tick() < 0 || !table.current) return 1;
const c = table.current.getPageCount();
return c != null && c > 0 ? c : 1;
}
function canPrevPage() {
return !!(tick() >= 0 && table.current && table.current.getCanPreviousPage());
}
function canNextPage() {
return !!(tick() >= 0 && table.current && table.current.getCanNextPage());
}
const onPrevPage = useCallback(() => {
if (table.current) table.current.previousPage();
}, []);
const onNextPage = useCallback(() => {
if (table.current) table.current.nextPage();
}, []);
const onPageSizeChange = useCallback((evt: any) => {
if (!table.current) return;
const v = evt && evt.target ? evt.target.value : '';
const n = parseInt(v, 10);
table.current.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
}, []);
function isSelectColumn(colId: any) {
return colId === SELECT_COL_ID;
}
function isExpanderColumn(colId: any) {
return colId === EXPANDER_COL_ID;
}
function rowCanExpand(row: any) {
return !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
}
function rowIsExpanded(row: any) {
return !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
}
function rowShowsDetail(row: any) {
return props.getSubRows == null && rowIsExpanded(row);
}
const onToggleExpand = useCallback((row: any, evt: any) => {
if (!row || !row.toggleExpanded) return;
// Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
// expanded-state re-render. On Solid the expander <td>/<button> is RECREATED on that
// re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
// pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
// breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
// Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
// the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
// reactive flush so the fresh node exists. Harmless on the targets that keep the node
// (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
row.toggleExpanded();
if (ownerRow && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
const btn = ownerRow.querySelector('[data-expander]');
if (btn) btn.focus();
});
}
}, []);
function bodyCellStyle(row: any, colId: any) {
const base = pinStyle(colId);
if (isExpanderColumn(colId) && row && row.depth) {
const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
return base ? base + ';' + pad : pad;
}
return base;
}
function rowIsGrouped(row: any) {
return !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
}
function groupingActive() {
return tick() >= 0 && (currentState().grouping || []).length > 0;
}
function cellIsGrouped(cellCtx: any) {
return !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
}
function cellIsAggregated(cellCtx: any) {
return !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
}
function groupSubRowCount(row: any) {
return row && row.subRows ? row.subRows.length : 0;
}
function groupingKeys() {
return currentState().grouping || [];
}
function groupableColumns() {
const out = [];
const defs = columnDefs();
for (const d of defs as any) {
if (!d || d.groupable === false) continue;
out.push({
id: d.id,
label: d.header != null ? d.header : d.id
});
}
return out;
}
const stopEvent = useCallback((evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
}, []);
function isAllRowsSelected() {
return !!(tick() >= 0 && table.current && table.current.getIsAllRowsSelected());
}
function isSomeRowsSelected() {
return !!(tick() >= 0 && table.current && table.current.getIsSomeRowsSelected());
}
const onToggleAllRows = useCallback((evt: any) => {
if (!table.current) return;
table.current.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
}, []);
function rowIsSelected(row: any) {
if (!row) return false;
const id = row.id;
const sel = currentState().rowSelection || {};
if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
return !!(row.getIsSelected && row.getIsSelected());
}
const onToggleRow = useCallback((row: any, evt: any) => {
if (!row || !row.toggleSelected) return;
row.toggleSelected(!!(evt && evt.target && evt.target.checked));
}, []);
const syncIndeterminate = useCallback(() => {
if (!__rozieRoot.current || !__rozieRoot.current!.querySelector) return;
selectAllBox.current = __rozieRoot.current!.querySelector('.rdt-select-all');
if (selectAllBox.current) selectAllBox.current.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
}, [isAllRowsSelected, isSomeRowsSelected]);
function sortColumn(colId: any, desc: any) {
if (table.current) table.current.getColumn(colId) && table.current.getColumn(colId).toggleSorting(desc, false);
}
function clearSorting() {
if (table.current) table.current.resetSorting(true);
}
function getColumnDefs() {
return columnDefs();
}
function toggleAllRows(value: any) {
if (table.current) table.current.toggleAllRowsSelected(value);
}
function clearSelection() {
if (table.current) table.current.resetRowSelection(true);
}
function getSelectedRows() {
return table.current ? table.current.getSelectedRowModel().rows.map((r: any) => r.original) : [];
}
function setPage(idx: any) {
if (table.current) table.current.setPageIndex(idx);
}
function setRowsPerPage(size: any) {
if (table.current) table.current.setPageSize(size);
}
function toggleColumnVisibility(colId: any) {
if (table.current) {
const c = table.current.getColumn(colId);
if (c && c.toggleVisibility) c.toggleVisibility();
}
}
function applyColumnOrder(order: any) {
if (table.current) table.current.setColumnOrder(order);
}
function resetColumnSizing() {
if (table.current) table.current.resetColumnSizing(true);
}
function pinColumn(colId: any, side: any) {
if (table.current) {
const c = table.current.getColumn(colId);
if (c && c.pin) c.pin(side);
}
}
function getRowIndexRelativeToPage(absRow: any) {
const abs = absRow == null ? toAbsRow(activeRow) : Math.trunc(Number(absRow)) || 0;
if (props.virtual) return abs;
return abs - pageRowOffset();
}
function cut() {
return cutRange();
}
const isGrid = useCallback(() => props.interactionMode === 'grid', [props.interactionMode]);
function tableRole() {
return isGrid() ? 'grid' : 'table';
}
function cellRole() {
return isGrid() ? 'gridcell' : 'cell';
}
function rowIndexOf(row: any) {
return tick() >= 0 ? (rows || []).indexOf(row) : -1;
}
function colIndexOf(row: any, cellCtx: any) {
return tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
}
function headerColIndexOf(hg: any, header: any) {
return (hg && hg.headers ? hg.headers : []).indexOf(header);
}
function pageRowOffset() {
if (!isGrid() || props.virtual) return 0;
return pageIndex() * pageSize();
}
function toAbsRow(localRow: any) {
return localRow + pageRowOffset();
}
function absRowIndexOf(row: any) {
return rowIndexOf(row) + pageRowOffset();
}
function prePaginationRowCount() {
if (!table.current || props.virtual) return bodyRowCount();
const pm = table.current.getPrePaginationRowModel();
return pm && pm.rows ? pm.rows.length : bodyRowCount();
}
function cellTabindex(rowKey: any, colIndex: any, level = null) {
if (!isGrid()) return null;
// B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
// the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
// tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
if (bodyRowCount() === 0) {
return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
}
// B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
// grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
// lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
// tab-stops (the roving invariant broke under grouped headers).
if (activeIsHeader) {
if (rowKey !== '__header') return -1;
return colIndex === activeColIndex && level === activeHeaderLevel ? 0 : -1;
}
const isActive = rowKey === String(activeRow) && colIndex === activeColIndex;
return isActive ? 0 : -1;
}
function resolveCellEl(rowKey: any, colIndex: any, level = null) {
if (!gridRoot.current) return null;
// B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
// same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
// integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
// match (the parent) when the leaf is meant. level is an integer (NO consumer string is
// interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
// byte-unchanged.
let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
return gridRoot.current.querySelector(sel);
}
function focusActiveCell(nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) {
if (!isGrid() || !gridRoot.current) return;
const r = nextRow == null ? activeRow : nextRow;
const c = nextCol == null ? activeColIndex : nextCol;
// B12: thread the FRESH post-write header level (the grouped-header analog of the
// nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
// level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
const lvl = nextLevel == null ? activeHeaderLevel : nextLevel;
// Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
// crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
// Angular's signal write are async within one handler — re-reading $data.activeIsHeader
// here returns the PRE-write value, resolving focus to the BODY cell instead of the
// header. Callers pass the fresh isHeader local; falls back to $data when omitted.
const header = nextIsHeader == null ? activeIsHeader : nextIsHeader;
// ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
// rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
// double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
// in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
// The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
// while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
if (props.virtual && virtualizer.current && !header && rowIsOutsideWindow(r)) {
virtualizer.current.scrollToIndex(r, {
align: 'center'
});
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
let focusAttempts = 0;
const focusWhenReady = () => {
const el = resolveCellEl(String(r), c);
if (el) {
el.focus();
return;
}
focusAttempts = focusAttempts + 1;
if (focusAttempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
return;
}
const rowKey = header ? '__header' : String(r);
const el = resolveCellEl(rowKey, c, header ? lvl : null);
if (el) el.focus();
}
function totalRowCount() {
if (!table.current) return (rows || []).length;
const fm = table.current.getFilteredRowModel();
return fm && fm.rows ? fm.rows.length : (rows || []).length;
}
function visibleColCount() {
// NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
// state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
// self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
const rowList = rows || [];
if (rowList.length) return rowList[0].getVisibleCells().length;
const hg = headerGroups || [];
return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
}
function bodyRowCount() {
return (rows || []).length;
}
function clamp(v: any, lo: any, hi: any) {
return v < lo ? lo : v > hi ? hi : v;
}
function headerLeafLevel() {
const hg = headerGroups || [];
return hg.length ? hg.length - 1 : 0;
}
function headerAt(level: any, colIndex: any) {
const hg = headerGroups || [];
const grp = hg[level];
if (!grp || !grp.headers) return null;
return grp.headers[colIndex] || null;
}
function parentHeaderColIndex(level: any, colIndex: any) {
if (level <= 0) return -1;
const h = headerAt(level, colIndex);
if (!h || !h.column || !h.column.parent) return -1;
const parentId = h.column.parent.id;
const hg = headerGroups || [];
const pg = hg[level - 1];
if (!pg || !pg.headers) return -1;
for (let i = 0; i < pg.headers.length; i++) {
const ph = pg.headers[i];
if (ph && ph.column && ph.column.id === parentId) return i;
}
return -1;
}
function firstChildHeaderColIndex(level: any, colIndex: any) {
const h = headerAt(level, colIndex);
if (!h || !h.column) return -1;
const kids = h.column.columns || [];
if (!kids.length) return -1;
const childId = kids[0].id;
const hg = headerGroups || [];
const cg = hg[level + 1];
if (!cg || !cg.headers) return -1;
for (let i = 0; i < cg.headers.length; i++) {
const ch = cg.headers[i];
if (ch && ch.column && ch.column.id === childId) return i;
}
return -1;
}
function moveCol(delta: any) {
const max = visibleColCount() - 1;
const nextCol = clamp(activeColIndex + delta, 0, max < 0 ? 0 : max);
setActiveColIndex(nextCol);
return nextCol;
}
function moveRow(delta: any) {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const leafLevel = headerLeafLevel();
if (activeIsHeader) {
if (delta > 0) {
// B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
// level down); from the LEAF header level, drop into the body (row 0). A header-level
// move re-targets activeColIndex (parent↔child column indices differ), so the fresh
// col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
if (activeHeaderLevel < leafLevel) {
const childCol = firstChildHeaderColIndex(activeHeaderLevel, activeColIndex);
if (childCol >= 0) {
const nextLevel = activeHeaderLevel + 1;
setActiveHeaderLevel(nextLevel);
setActiveColIndex(childCol);
return {
row: activeRow,
col: childCol,
isHeader: true,
level: nextLevel
};
}
}
// At the leaf header: an empty grid has no body to drop into → stay put.
if (bodyRowCount() === 0) return {
row: activeRow,
col: activeColIndex,
isHeader: true,
level: activeHeaderLevel
};
// B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
// (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
// page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
// (delta-1 = 0); clamped to the page-last body row.
const landRow = clamp(delta - 1, 0, maxRow);
setActiveIsHeader(false);
setActiveRow(landRow);
return {
row: landRow,
col: activeColIndex,
isHeader: false,
level: 0
};
}
// B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
// spans the active column; at the top level (or no real parent) stay put. The parent col
// index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
const parentCol = parentHeaderColIndex(activeHeaderLevel, activeColIndex);
if (parentCol >= 0) {
const nextLevel = activeHeaderLevel - 1;
setActiveHeaderLevel(nextLevel);
setActiveColIndex(parentCol);
return {
row: activeRow,
col: parentCol,
isHeader: true,
level: nextLevel
};
}
return {
row: activeRow,
col: activeColIndex,
isHeader: true,
level: activeHeaderLevel
};
}
// In the body: an upward move from row 0 crosses into the LEAF header level (the header row
// adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
// activeColIndex carries over unchanged.
if (delta < 0 && activeRow === 0) {
setActiveIsHeader(true);
setActiveHeaderLevel(leafLevel);
return {
row: activeRow,
col: activeColIndex,
isHeader: true,
level: leafLevel
};
}
const nextRow = clamp(activeRow + delta, 0, maxRow);
setActiveRow(nextRow);
setActiveIsHeader(false);
return {
row: nextRow,
col: activeColIndex,
isHeader: false,
level: 0
};
}
function gotoColEdge(toEnd: any) {
const max = visibleColCount() - 1;
const nextCol = toEnd ? max < 0 ? 0 : max : 0;
setActiveColIndex(nextCol);
return nextCol;
}
function gotoStart() {
setActiveIsHeader(false);
setActiveRow(0);
setActiveColIndex(0);
return {
row: 0,
col: 0
};
}
function gotoEnd() {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const max = visibleColCount() - 1;
const maxCol = max < 0 ? 0 : max;
setActiveIsHeader(false);
setActiveRow(maxRow);
setActiveColIndex(maxCol);
return {
row: maxRow,
col: maxCol
};
}
function currentCellEl() {
const rowKey = activeIsHeader ? '__header' : String(activeRow);
return resolveCellEl(rowKey, activeColIndex, activeIsHeader ? activeHeaderLevel : null);
}
function focusables(cellEl: any) {
if (!cellEl || !cellEl.querySelectorAll) return [];
const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
return list.filter((n: any) => !n.disabled);
}
function enterControl() {
const cellEl = currentCellEl();
const list = focusables(cellEl);
if (!list.length) return;
setActiveInControl(true);
list[0].focus();
}
function cycleWithinCell(cellEl: any, forward: any) {
const list = focusables(cellEl);
if (!list.length) return;
const active = gridRoot.current ? gridRoot.current.getRootNode().activeElement : null;
const cur = list.indexOf(active);
let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
if (i >= list.length) i = 0;
if (i < 0) i = list.length - 1;
list[i].focus();
}
const { onActivecellChange: _rozieProp_onActivecellChange } = props;
const onGridKeyDown = useCallback((e: any) => {
if (!isGrid() || !e) return;
const key = e.key;
// Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
// via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
// early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
// modes (editing / in-control / navigation) stay mutually exclusive and ordered.
if (editingRow >= 0) return;
// Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
// editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
// keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
if (editingRowIndex != null) return;
// Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
if (activeInControl) {
if (key === 'Escape') {
e.preventDefault();
setActiveInControl(false);
// Return focus to the OWNING cell (no move happened) — pass the current indices
// explicitly (the React-emitted seam types both params as required; a zero-arg call
// is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
focusActiveCell(activeRow, activeColIndex);
} else if (key === 'Tab') {
e.preventDefault();
cycleWithinCell(currentCellEl(), !e.shiftKey);
}
return;
}
// WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
// grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
// user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
// NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
// focused node; if it is not itself a [data-grid-cell], let the event pass through.
const tgt = e.target;
if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
// Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
// nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
// async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
// plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
// in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
// fires ONLY on a real move (a clamped no-op edge move leaves them identical).
const prevRow = activeRow;
const prevCol = activeColIndex;
const prevIsHeader = activeIsHeader;
const prevLevel = activeHeaderLevel;
let nextRow = prevRow;
let nextCol = prevCol;
let nextIsHeader = prevIsHeader;
// B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
// threaded into the focus seam so a leaf↔parent header move lands focus at the correct
// level. moveRow returns it; the non-vertical branches keep the pre-move level.
let nextLevel = prevLevel;
// ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
// the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
// fall through to a plain navigation move). Body cells only (no range from a header). The
// extendRange call owns focus + the range-change emit, so return immediately. ──────────
if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(0, 1);
return;
} else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(0, -1);
return;
} else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(1, 0);
return;
} else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(-1, 0);
return;
} else if (key === 'ArrowRight') {
e.preventDefault();
clearRange();
nextCol = moveCol(1);
} else if (key === 'ArrowLeft') {
e.preventDefault();
clearRange();
nextCol = moveCol(-1);
} else if (key === 'ArrowDown') {
e.preventDefault();
clearRange();
const m = moveRow(1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'ArrowUp') {
e.preventDefault();
clearRange();
const m = moveRow(-1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageDown') {
e.preventDefault();
const m = moveRow(GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageUp') {
e.preventDefault();
const m = moveRow(-GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'Home') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const s = gotoStart();
nextRow = s.row;
nextCol = s.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(false);
}
} else if (key === 'End') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const en = gotoEnd();
nextRow = en.row;
nextCol = en.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(true);
}
}
// ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
// pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
// edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
// type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
// gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
// falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
// (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
copyRange();
return;
} else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
pasteRange();
return;
}
// ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
// source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
// Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
// and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
// shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
cutRange();
return;
}
// ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
// EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
// (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
// collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
// at least the active editable column); a non-editable active cell falls through unchanged.
else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
e.preventDefault();
beginRowEdit((rows || [])[activeRow]);
return;
}
// ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
// Gated by isActiveCellEditable(): a non-editable active cell falls through to
// enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
// (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
e.preventDefault();
beginEdit(activeRow, activeColIndex, null);
return;
} else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
// checkbox/select/date editor must NOT take the typed char as its value (it would
// force-check the checkbox, seed a garbage select option, or corrupt the date) — open
// those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
e.preventDefault();
const editType = editorTypeOf(activeCellColumnId());
const seed = editType === 'text' || editType === 'number' ? key : null;
beginEdit(activeRow, activeColIndex, seed);
return;
}
// ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
// expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
// verified invariant) so it never hits the edit branches above and would otherwise fall to
// enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
// Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
// model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
// ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
// full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
else if (key === 'Enter' && !activeIsHeader && rowIsGrouped((rows || [])[activeRow])) {
e.preventDefault();
// C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
// active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
// the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
// row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
// fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
// DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
// while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
// settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
// onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
// SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
// until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
// already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
const grpRow = activeRow;
const grpCol = activeColIndex;
onToggleExpand((rows || [])[activeRow], e);
recoverGridFocus(String(grpRow), grpCol, null);
return;
} else if (key === 'Enter' || key === 'F2') {
e.preventDefault();
enterControl();
return;
} else return;
// THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
// focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
// WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
// changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
// row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
// B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
// C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
// keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
// toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
// The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
_rozieProp_onActivecellChange && _rozieProp_onActivecellChange({
rowIndex: toAbsRow(nextRow),
colIndex: nextCol
});
}
}, [_rozieProp_onActivecellChange, activeCellColumnId, activeColIndex, activeHeaderLevel, activeInControl, activeIsHeader, activeRow, beginEdit, beginRowEdit, clearRange, clipboardActiveAllowed, copyRange, currentCellEl, cutRange, cycleWithinCell, editingRow, editingRowIndex, editorTypeOf, enterControl, extendRange, focusActiveCell, gotoColEdge, gotoEnd, gotoStart, isActiveCellEditable, isGrid, moveCol, moveRow, onToggleExpand, pasteRange, recoverGridFocus, rowIsGrouped, rows, toAbsRow]);
const syncActiveFromEvent = useCallback((e: any) => {
if (!isGrid() || !e) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null) return;
const col = parseInt(colAttr, 10);
if (!Number.isFinite(col)) return;
const isHeader = rowAttr === '__header';
setActiveIsHeader(isHeader);
if (isHeader) {
// B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
// roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
// one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
const lvlAttr = cellEl.getAttribute('data-header-level');
const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
setActiveHeaderLevel(Number.isFinite(lvl) ? lvl : headerLeafLevel());
} else {
const row = parseInt(rowAttr, 10);
if (Number.isFinite(row)) setActiveRow(row);
}
setActiveColIndex(col);
// A plain focus collapses any range back to the single active cell — EXCEPT (a) the
// programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
// ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
// focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
// BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
// path owns the shift case and flags it here so the collapse is skipped.
if (rangeTransition) {
rangeTransition = false;
} else if (rangeClickPending) {
rangeClickPending = false;
} else {
clearRange();
}
// The cell box (not an inner control) receiving focus = navigation mode.
if (tgt === cellEl) setActiveInControl(false);
}, [clearRange, headerLeafLevel, isGrid]);
const onGridMouseDown = useCallback((e: any) => {
if (!isGrid() || !e || !e.shiftKey) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
const row = parseInt(rowAttr, 10);
const col = parseInt(colAttr, 10);
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
setRangeFocus$local(row, col);
setActiveIsHeader(false);
setActiveRow(row);
setActiveColIndex(col);
rangeClickPending = true;
}, [isGrid, setRangeFocus$local]);
const onGridFocusOut = useCallback((e: any) => {
if (!isGrid() || !activeInControl) return;
const next = e ? e.relatedTarget : null;
const cellEl = currentCellEl();
if (!cellEl || !next || !cellEl.contains(next)) setActiveInControl(false);
}, [activeInControl, currentCellEl, isGrid]);
function recoverGridFocus(rowKey: any, col: any, level: any) {
if (!gridRoot.current) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(rowKey, col, level);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
const clampActiveCell = useCallback((rowCount: any, colCount: any) => {
if (!isGrid()) return;
// B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
// just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
// async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
// and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
// out of bounds on React only). Falls back to the live helpers when called without bounds.
const colN = colCount != null ? colCount : visibleColCount();
const rowN = rowCount != null ? rowCount : bodyRowCount();
// B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
// shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
// framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
// — true on all six, incl React's async setState), so the doomed cell + its focus are still
// observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
// programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
// Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
// doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
// The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
// NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
// shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
// DOM coords are always fresh.
let recoverFocus = false;
let doomedRow = -1;
let doomedCol = 0;
if (gridRoot.current) {
const rootNode = gridRoot.current.getRootNode ? gridRoot.current.getRootNode() : null;
const focusedEl = rootNode ? rootNode.activeElement : null;
const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
if (focusedCell && gridRoot.current.contains(focusedCell)) {
const fRowAttr = focusedCell.getAttribute('data-row');
const fColAttr = focusedCell.getAttribute('data-col-index');
if (fRowAttr != null && fRowAttr !== '__header') {
const fr = parseInt(fRowAttr, 10);
const fc = parseInt(fColAttr, 10);
if (Number.isFinite(fr) && fr > rowN - 1) {
recoverFocus = true;
doomedRow = fr;
doomedCol = Number.isFinite(fc) ? fc : 0;
}
}
}
}
const maxCol = colN - 1;
const col = clamp(activeColIndex, 0, maxCol < 0 ? 0 : maxCol);
if (col !== activeColIndex) setActiveColIndex(col);
// B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
// cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
// an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
// re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
if (rowN <= 0) {
setActiveIsHeader(true);
setActiveHeaderLevel(headerLeafLevel());
setActiveColIndex(0);
// B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
// reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
// async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
// class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
// recovery branch below actually runs on React too.
gridEmptyFallback.current = true;
clampRange(rowN - 1, colN - 1);
// B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
// reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
// Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
// (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
// the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
return;
}
// B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
// re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
// cell. A user-driven header position (not the empty fallback) is left untouched.
if (gridEmptyFallback.current) {
gridEmptyFallback.current = false;
setActiveIsHeader(false);
setActiveRow(0);
}
if (!activeIsHeader) {
const lastRow = rowN - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const row = clamp(activeRow, 0, maxRow);
if (row !== activeRow) setActiveRow(row);
}
// B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
// shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
// out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
clampRange(rowN - 1, colN - 1);
// B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
// when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
// into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
if (recoverFocus) {
const recRow = clamp(doomedRow, 0, rowN - 1);
const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
recoverGridFocus(String(recRow), recCol, null);
}
}, [activeColIndex, activeIsHeader, activeRow, bodyRowCount, clamp, clampRange, headerLeafLevel, isGrid, recoverGridFocus, visibleColCount]);
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false;
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false;
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
function inRange(rIdx: any, cIdx: any) {
const a = rangeAnchor;
const f = rangeFocus;
if (!a || !f) return false;
const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
}
function getSelectedRange() {
// B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
// payload) never reports a corner past a shrunken model — React-stale-safe (the eager
// refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
const a = rangeAnchor;
const f = rangeFocus;
if (!a && !f) return {
anchor: null,
focus: null
};
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
anchor: null,
focus: null
};
const clampCorner = (c: any) => c == null ? null : {
rowIndex: clamp(c.rowIndex, 0, maxRow),
colIndex: clamp(c.colIndex, 0, maxCol)
};
return {
anchor: clampCorner(a),
focus: clampCorner(f)
};
}
function isFillHandleCell(rIdx: any, cIdx: any) {
const a = rangeAnchor;
const f = rangeFocus;
if (!a || !f) return false;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx === r1 && cIdx === c1;
}
function emitRangeChange(anchor: any, focus: any) {
props.onRangeChange && props.onRangeChange({
anchor,
focus
});
}
function extendRange(dRow: any, dCol: any) {
if (activeIsHeader) return;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
// Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
let anchor = rangeAnchor;
let focus = rangeFocus;
const hadRange = !!(anchor && focus);
if (!anchor || !focus) {
anchor = {
rowIndex: activeRow,
colIndex: activeColIndex
};
focus = {
rowIndex: activeRow,
colIndex: activeColIndex
};
}
const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
const nextFocus = {
rowIndex: nextRow,
colIndex: nextCol
};
setRangeAnchor(anchor);
setRangeFocus(nextFocus);
rangeActive.current = true;
// Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
// from the range's leading edge, the spreadsheet convention).
setActiveRow(nextRow);
setActiveColIndex(nextCol);
// Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
// settle on the new focus corner is part of THIS range extension, not a fresh navigation.
rangeTransition = true;
focusActiveCell(nextRow, nextCol, false);
// B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
// and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
// change → no emit. Seeding a brand-new range (no prior range) is always a change (the
// rectangle came into existence) even if its first corner is a degenerate 1×1.
if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
emitRangeChange(anchor, nextFocus);
}
}
function setRangeFocus$local(rIdx: any, cIdx: any) {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
let anchor = rangeAnchor;
if (!anchor) anchor = {
rowIndex: activeRow,
colIndex: activeColIndex
};
const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
const nextFocus = {
rowIndex: r,
colIndex: c
};
setRangeAnchor(anchor);
setRangeFocus(nextFocus);
rangeActive.current = true;
emitRangeChange(anchor, nextFocus);
}
function clearRange() {
// B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
// in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
// move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
// would let the SECOND call through and emit a duplicate range-change. rangeActive flips
// synchronously → the second call returns here.
if (!rangeActive.current) return;
rangeActive.current = false;
setRangeAnchor(null);
setRangeFocus(null);
emitRangeChange(null, null);
}
function clampRange(maxRowArg: any, maxColArg: any) {
const a = rangeAnchor;
const f = rangeFocus;
if (!a && !f) return;
// Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
// shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) {
setRangeAnchor(null);
setRangeFocus(null);
rangeActive.current = false;
return;
}
if (a) {
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
if (ar !== a.rowIndex || ac !== a.colIndex) setRangeAnchor({
rowIndex: ar,
colIndex: ac
});
}
if (f) {
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
if (fr !== f.rowIndex || fc !== f.colIndex) setRangeFocus({
rowIndex: fr,
colIndex: fc
});
}
}
function announce(msg: any) {
setPasteAnnounce(msg != null ? msg : '');
}
function clipboardActiveAllowed() {
return !activeIsHeader;
}
function fieldOfColId(colId: any) {
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
}
function normalizedRange() {
const a = rangeAnchor;
const f = rangeFocus;
if (!a || !f) return null;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return null;
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
return {
r0: ar < fr ? ar : fr,
r1: ar > fr ? ar : fr,
c0: ac < fc ? ac : fc,
c1: ac > fc ? ac : fc
};
}
function escapeTsvField(s: any) {
if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function rangeToTsv() {
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow;
const r1 = box ? box.r1 : activeRow;
const c0 = box ? box.c0 : activeColIndex;
const c1 = box ? box.c1 : activeColIndex;
const lines = [];
for (let r = r0; r <= r1; r++) {
const cells = [];
for (let c = c0; c <= c1; c++) {
const v = cellValueAt(r, c);
cells.push(escapeTsvField(v == null ? '' : String(v)));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
}
function parseTsv(text: any) {
const str = text != null ? String(text) : '';
// CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
// large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
// single-pass scan allocates a cell-per-character grid.
if (str === '' || str.length > 2000000) return [];
// B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
// cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
// newlines, and doubled quotes ("") inside it are literal content until the closing quote;
// an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = str.length;
while (i < n) {
const ch = str[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < n && str[i + 1] === '"') {
field = field + '"';
i = i + 2;
continue;
}
inQuotes = false;
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
continue;
}
if (ch === '"' && field === '') {
inQuotes = true;
i = i + 1;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
i = i + 1;
continue;
}
if (ch === '\r') {
if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
if (ch === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
}
// Flush the trailing field + row.
row.push(field);
rows.push(row);
// Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
if (rows.length > 1) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
}
function copyRange() {
// B11: never copy from a header-active state (the reusable clipboard guard).
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
try {
const p = navigator.clipboard.writeText(rangeToTsv());
if (p && p.catch) p.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
function applyGridToRange(grid: any, originRow: any, originCol: any) {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
wrote: 0,
total: 0
};
let total = 0;
let wrote = 0;
const committed = [];
// Build the fresh data array incrementally so the whole paste is ONE writeData.
let next = currentData();
for (let gr = 0; gr < grid.length; gr++) {
const r = originRow + gr;
if (r > maxRow) break;
const cols = grid[gr] || [];
for (let gc = 0; gc < cols.length; gc++) {
const c = originCol + gc;
if (c > maxCol) break;
total = total + 1;
const colId = columnIdAt(r, c);
if (colId == null || !columnEditable(colId)) continue;
const rowObj = rowOriginalAt(r);
// B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
// single-cell commit coercion) — a numeric column commits a real Number, an empty cell
// commits null; every other editor type passes through verbatim. No mixed/garbage types
// ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
const value = coerceCellValue(colId, cols[gc]);
// T-51-01: validate the pasted value as plain DATA before any write.
if (runValidator(colId, value, rowObj) !== true) continue;
const field = fieldOfColId(colId);
const srcIndex = sourceIndexOfRow(r);
const oldValue = rowObj ? rowObj[field] : null;
next = replaceRowValue(next, srcIndex, field, value);
committed.push({
rowId: rowIdAt(r),
columnId: colId,
oldValue,
newValue: value
});
wrote = wrote + 1;
}
}
if (wrote > 0) {
editTransition.current = true;
writeData(next);
editTransition.current = false;
// One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
for (let i = 0; i < committed.length; i++) props.onCellEditCommit && props.onCellEditCommit(committed[i]);
}
// WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
// targeted real cells but every one was skipped (validation-failed / non-editable), announce a
// distinct validation-failed message instead of a misleading "0 of M cells pasted".
if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
return {
wrote,
total
};
}
function rowOriginalAt(rowIndex: any) {
const rowList = rows || [];
const row = rowList[rowIndex];
return row ? row.original : null;
}
function rowIdAt(rowIndex: any) {
const rowList = rows || [];
const row = rowList[rowIndex];
return row ? row.id : null;
}
function tileGridToBox(grid: any, box: any) {
const srcRows = grid.length;
const srcCols = srcRows > 0 ? grid[0].length : 0;
if (srcRows <= 0 || srcCols <= 0) return grid;
const boxRows = box.r1 - box.r0 + 1;
const boxCols = box.c1 - box.c0 + 1;
const rows = boxRows > srcRows ? boxRows : srcRows;
const cols = boxCols > srcCols ? boxCols : srcCols;
const out = [];
for (let r = 0; r < rows; r++) {
const srcLine = grid[r % srcRows] || [];
const line = [];
for (let c = 0; c < cols; c++) {
const v = srcLine[c % srcCols];
line.push(v != null ? v : '');
}
out.push(line);
}
return out;
}
function pasteRange() {
// B11: never paste into a header-active state (the reusable clipboard guard) — a header
// anchor would silently write body row 0 at the header's column.
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
// CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
// C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
// single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
// on React; re-reading them inside the async .then() returns the mount-render stale value, so a
// selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
// the box + anchor now and pass them into tileGridToBox / applyGridToRange.
const box = normalizedRange();
const anchorRow = box ? box.r0 : activeRow;
const anchorCol = box ? box.c0 : activeColIndex;
const destBox = box || {
r0: anchorRow,
r1: anchorRow,
c0: anchorCol,
c1: anchorCol
};
let p: any = null;
try {
p = navigator.clipboard.readText();
} catch (err: any) {
return;
}
if (!p || !p.then) return;
p.then((text: any) => {
const grid = parseTsv(text);
if (!grid.length) return;
// C3: tile the clipboard block to fill the destination range (single→range fill,
// smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
const tiled = tileGridToBox(grid, destBox);
applyGridToRange(tiled, anchorRow, anchorCol);
}).catch(() => {});
}
function cutRange() {
if (!clipboardActiveAllowed()) return;
// Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow;
const r1 = box ? box.r1 : activeRow;
const c0 = box ? box.c0 : activeColIndex;
const c1 = box ? box.c1 : activeColIndex;
// Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
const cp = navigator.clipboard.writeText(rangeToTsv());
if (cp && cp.catch) cp.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// Clear the source: a grid of empty strings sized to the range, applied at the top-left.
const grid = [];
for (let r = r0; r <= r1; r++) {
const cols = [];
for (let c = c0; c <= c1; c++) cols.push('');
grid.push(cols);
}
applyGridToRange(grid, r0, c0);
}
function tileIndex(i: any, lo: any, hi: any) {
const span = hi - lo + 1;
if (span <= 1) return lo;
let k = (i - lo) % span;
if (k < 0) k = k + span;
return lo + k;
}
function fillRange(sourceBox: any, endCell: any) {
// B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
// the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
// React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
// grows), so deriving the box from the threaded endpoints is what makes the fill cover the
// dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
let box;
if (sourceBox && sourceBox.r0 != null && endCell) {
let r0 = sourceBox.r0;
let r1 = sourceBox.r1;
let c0 = sourceBox.c0;
let c1 = sourceBox.c1;
if (endCell.r < r0) r0 = endCell.r;
if (endCell.r > r1) r1 = endCell.r;
if (endCell.c < c0) c0 = endCell.c;
if (endCell.c > c1) c1 = endCell.c;
box = {
r0,
r1,
c0,
c1
};
} else {
box = normalizedRange();
}
if (!box) return;
const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
r0: box.r0,
r1: box.r0,
c0: box.c0,
c1: box.c0
};
const grid = [];
for (let r = box.r0; r <= box.r1; r++) {
const cols = [];
for (let c = box.c0; c <= box.c1; c++) {
const sr = tileIndex(r, src.r0, src.r1);
const sc = tileIndex(c, src.c0, src.c1);
const v = cellValueAt(sr, sc);
cols.push(v == null ? '' : String(v));
}
grid.push(cols);
}
applyGridToRange(grid, box.r0, box.c0);
}
const teardownFillDrag = useCallback(() => {
if (typeof document !== 'undefined') {
if (fillDragMove.current) document.removeEventListener('pointermove', fillDragMove.current);
if (fillDragUp.current) document.removeEventListener('pointerup', fillDragUp.current);
}
fillDragMove.current = null;
fillDragUp.current = null;
fillDragging.current = false;
}, []);
function cellIndexFromPoint(clientX: any, clientY: any) {
if (typeof document === 'undefined' || !document.elementFromPoint) return null;
let el = document.elementFromPoint(clientX, clientY);
// Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
// a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
// and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
// deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
if (!inner || inner === el) break;
el = inner;
}
if (!el || !el.closest) return null;
const cellEl = el.closest('[data-grid-cell]');
if (!cellEl) return null;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
const r = parseInt(rowAttr, 10);
const c = parseInt(colAttr, 10);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return {
r,
c
};
}
const onFillHandlePointerDown = useCallback((e: any) => {
if (!e) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
fillDragging.current = true;
// B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
// range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
// up/left drag copies from the real origin (not the post-drag corner that would flip to a
// target cell). Captured per-gesture in the closure (no module-let needed).
const sourceBox = normalizedRange();
// B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
// the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
let lastCell = sourceBox ? {
r: sourceBox.r1,
c: sourceBox.c1
} : null;
const move = (ev: any) => {
if (!fillDragging.current) return;
const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
// B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
// pointermove (the pointer fires many per cell) spams the event with identical payloads.
// Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
// seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
// or re-enters the same cell is suppressed (the range is unchanged).
if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
lastCell = cell;
setRangeFocus$local(cell.r, cell.c);
}
};
const up = () => {
// teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
teardownFillDrag();
fillRange(sourceBox, lastCell);
};
// Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
fillDragMove.current = move;
fillDragUp.current = up;
if (typeof document !== 'undefined') {
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
}, [cellIndexFromPoint, fillRange, normalizedRange, setRangeFocus$local, teardownFillDrag]);
function activeCellColumnId() {
if (activeIsHeader) return null;
const rowList = rows || [];
const row = rowList[activeRow];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[activeColIndex];
return cell && cell.column ? cell.column.id : null;
}
function isActiveCellEditable() {
const colId = activeCellColumnId();
return colId != null && columnEditable(colId);
}
function isEditing(rowIndex: any, colIndex: any) {
if (editVer < 0) return false;
if (editingRowIndex != null && editingRowIndex === rowIndex) {
const colId = columnIdAt(rowIndex, colIndex);
return colId != null && columnEditable(colId);
}
return editingRow === rowIndex && editingCol === colIndex;
}
function cellAriaInvalid(rowIndex: any, colIndex: any): 'true' | null {
return isEditing(rowIndex, colIndex) && !!invalidMsg ? 'true' : null;
}
function runValidator(colId: any, value: any, row: any) {
const m = editMetaOf(colId);
const v = m ? m.validate : null;
if (typeof v !== 'function') return true;
let r: any = null;
try {
r = v(value, row);
} catch (err: any) {
return 'Invalid value';
}
if (r === true) return true;
if (typeof r === 'string') return r;
return 'Invalid value';
}
function setInvalid(msg: any) {
setInvalidMsg(msg != null ? msg : '');
}
function replaceRowValue(rows: any, rowIndex: any, field: any, value: any) {
const src = rows || [];
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
// and would copy inherited enumerable props of typed/class-instance row objects.
out.push({
...(src[i] || {}),
[field]: value
});
} else {
out.push(src[i]);
}
}
return out;
}
function sourceIndexOfRow(visibleRowIndex: any) {
const rowList = rows || [];
const row = rowList[visibleRowIndex];
if (!row) return visibleRowIndex;
const orig = row.original;
const data = currentData() || [];
const idx = data.indexOf(orig);
return idx >= 0 ? idx : visibleRowIndex;
}
function editingColumnId() {
const rowList = rows || [];
const row = rowList[editingRow];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol];
return cell && cell.column ? cell.column.id : null;
}
function editingColumnField() {
const colId = editingColumnId();
if (colId == null) return null;
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
}
function editingCellValue() {
const rowList = rows || [];
const row = rowList[editingRow];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol];
return cell ? cell.getValue() : null;
}
function editingRowOriginal() {
const rowList = rows || [];
const row = rowList[editingRow];
return row ? row.original : null;
}
function editingRowId() {
const rowList = rows || [];
const row = rowList[editingRow];
return row ? row.id : null;
}
function focusEditorWhenReady(selectAll = true) {
if (!gridRoot.current) return;
let attempts = 0;
const tryFocus = () => {
const el = gridRoot.current ? gridRoot.current.querySelector('[data-editing-cell]') : null;
if (el) {
el.focus();
if (selectAll && el.select) {
try {
el.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
function columnIdAt(rowIndex: any, colIndex: any) {
const rowList = rows || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell && cell.column ? cell.column.id : null;
}
function cellValueAt(rowIndex: any, colIndex: any) {
const rowList = rows || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell ? cell.getValue() : null;
}
function beginEdit(rowIndex: any, colIndex: any, seed: any) {
const colId = columnIdAt(rowIndex, colIndex);
if (colId == null || !columnEditable(colId)) return;
setInvalid('');
// Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
// editor clears any row-edit state so isEditing never resolves both modes for one cell.
setEditingRowIndex(null);
setRowDraft({});
setEditingRow(rowIndex);
setEditingCol(colIndex);
setDraftValue(seed != null ? seed : cellValueAt(rowIndex, colIndex));
setActiveInControl(true);
setEditVer(prev => prev + 1);
// B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
// seeded char so subsequent typing appends instead of replacing it.
focusEditorWhenReady(seed == null);
}
const focusCellWhenReady = useCallback((row: any, col: any) => {
if (!gridRoot.current) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(String(row), col);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}, [resolveCellEl]);
const indexOfRowIn = useCallback((rows: any, rowOriginal: any, rowId: any) => {
const list = rows || [];
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (!r) continue;
if (rowId != null && r.id === rowId) return i;
if (rowOriginal != null && r.original === rowOriginal) return i;
}
return -1;
}, [rows]);
function endEdit() {
setEditingRow(-1);
setEditingCol(-1);
setDraftValue(null);
setInvalidMsg('');
setActiveInControl(false);
setEditVer(prev => prev + 1);
}
function endRowEdit() {
setEditingRowIndex(null);
setRowDraft({});
setInvalidMsg('');
setActiveInControl(false);
setEditVer(prev => prev + 1);
}
function coerceCellValue(colId: any, raw: any) {
if (editorTypeOf(colId) !== 'number') return raw;
if (raw == null) return null;
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
function commitEdit(overrideValue = undefined, skipFocusReturn = false) {
if (editingRow < 0) return false;
const colId = editingColumnId();
if (colId == null) {
endEdit();
return false;
}
const field = editingColumnField();
const oldValue = editingCellValue();
const rowOriginal = editingRowOriginal();
const rowId = editingRowId();
// B3: coerce by the column's editor type BEFORE validation + write so the validator
// and the model both see the typed value (number/null), not the raw draft string.
const rawValue = overrideValue !== undefined ? overrideValue : draftValue;
const newValue = coerceCellValue(colId, rawValue);
const err = runValidator(colId, newValue, rowOriginal);
if (err !== true) {
// D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
setInvalid(err);
focusEditorWhenReady();
return false;
}
setInvalid('');
const srcIndex = sourceIndexOfRow(editingRow);
const next = replaceRowValue(currentData(), srcIndex, field, newValue);
// Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
const focusRow = editingRow;
const focusCol = editingCol;
// Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
// must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
editTransition.current = true;
writeData(next);
// Exactly one emit per commit, from this single call site (writeData does NOT emit).
props.onCellEditCommit && props.onCellEditCommit({
rowId,
columnId: colId,
oldValue,
newValue
});
endEdit();
editTransition.current = false;
// Defer the focus return so the display↔editor re-render commits first (async on
// React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
// editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
// Tab-advance (the caller immediately opens the next editor and focuses THAT).
// B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
// RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
// index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
// consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
// fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
// active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
// index → byte-behaviorally identical to before.
if (skipFocusReturn !== true) pendingEditFollow.current = {
rowOriginal,
rowId,
col: focusCol
};
return true;
}
function cancelEdit() {
if (editingRow < 0) return;
// CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
// Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
// an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
// instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
const focusRow = editingRow;
const focusCol = editingCol;
editTransition.current = true;
endEdit();
editTransition.current = false;
focusCellWhenReady(focusRow, focusCol);
}
function editableColumnsForRow(rowIndex: any) {
const rowList = rows || [];
const row = rowList[rowIndex];
if (!row) return [];
const cells = visibleCellsFor(row);
const out = [];
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
const colId = cell && cell.column ? cell.column.id : null;
if (colId == null || !columnEditable(colId)) continue;
const d = defFor(colId);
const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
// colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
// Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
// can address a SPECIFIC editor by column, not just the first [data-editing-cell].
out.push({
colId,
field,
colIndex: c
});
}
return out;
}
function focusRowEditorAt(rowIndex: any, colIndex: any) {
if (!gridRoot.current) return;
let attempts = 0;
const tryFocus = () => {
const cellEl = resolveCellEl(String(rowIndex), colIndex);
const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
if (ed) {
ed.focus();
if (ed.select) {
try {
ed.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
function beginRowEdit(row: any) {
const rowIndex = rowIndexOf(row);
if (rowIndex < 0) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
// Clear any single-cell editor first (mutual exclusivity).
setEditingRow(-1);
setEditingCol(-1);
setDraftValue(null);
setInvalid('');
// Seed each editable cell's draft from its current value.
const draft = {};
const rowList = rows || [];
const r = rowList[rowIndex];
const orig = r ? r.original : null;
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
draft[ec.colId] = orig ? orig[ec.field] : null;
}
setRowDraft(draft);
setEditingRowIndex(rowIndex);
setActiveInControl(true);
setEditVer(prev => prev + 1);
focusEditorWhenReady();
}
function commitRow() {
if (editingRowIndex == null) return false;
const rowIndex = editingRowIndex;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) {
endRowEdit();
return false;
}
const rowList = rows || [];
const r = rowList[rowIndex];
const rowOriginal = r ? r.original : null;
const rowId = r ? r.id : null;
const draft = rowDraft || {};
// Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
// B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
// 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
// commitEdit already coerces via coerceCellValue; the row path silently committed strings →
// a number column ended up holding '99'). Coerce once here so the validator and the model both
// see the typed value, identical to the single-cell funnel.
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
if (err !== true) {
setInvalid(err);
// B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
// unconditionally the first editor (focusEditorWhenReady resolves the first
// [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
focusRowEditorAt(rowIndex, ec.colIndex);
return false;
}
}
setInvalid('');
// Build the changes payload (only the columns whose value actually changed) + the field→
// value map for the single row-object replace.
const changes = [];
const fieldValues = {};
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
// B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
// string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
fieldValues[ec.field] = newValue;
if (oldValue !== newValue) changes.push({
columnId: ec.colId,
oldValue,
newValue
});
}
// ONE fresh-array replace of the SINGLE row object with all field values applied at once.
const srcIndex = sourceIndexOfRow(rowIndex);
const next = replaceRowValues(currentData(), srcIndex, fieldValues);
// Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
// active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
const focusCol = activeColIndex;
editTransition.current = true;
writeData(next);
// EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
props.onRowEditCommit && props.onRowEditCommit({
rowId,
changes
});
endRowEdit();
editTransition.current = false;
// WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
// like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
// col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
// $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
// DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
// refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
// display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
// the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
// poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
pendingEditFollow.current = {
rowOriginal,
rowId,
col: focusCol
};
return true;
}
function cancelRow() {
if (editingRowIndex == null) return;
const focusRow = activeRow;
const focusCol = activeColIndex;
editTransition.current = true;
endRowEdit();
editTransition.current = false;
focusCellWhenReady(focusRow, focusCol);
}
function replaceRowValues(rows: any, rowIndex: any, fieldValues: any) {
const src = rows || [];
const fv = fieldValues || {};
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
// prototype-walking copy. Spread copies own enumerable props only.
out.push({
...(src[i] || {}),
...fv
});
} else {
out.push(src[i]);
}
}
return out;
}
function nextEditableCell(fromRow: any, fromCol: any) {
const rowList = rows || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol + 1;
while (r < rowCount) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c < cells.length) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c + 1;
}
r = r + 1;
c = 0;
}
return null;
}
function prevEditableCell(fromRow: any, fromCol: any) {
const rowList = rows || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol - 1;
while (r >= 0) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c >= 0) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c - 1;
}
r = r - 1;
if (r >= 0) {
const prow = rowList[r];
const pcells = prow ? visibleCellsFor(prow) : [];
c = pcells.length - 1;
}
}
return null;
}
function inRowEdit() {
return editingRowIndex != null;
}
function editorValueFor(colId: any) {
return inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue;
}
function editorCheckedFor(colId: any) {
return !!(inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue);
}
function editorCommitFor(colId: any) {
return (value: any) => {
if (inRowEdit()) {
setRowDraft$local(colId, value);
return;
}
commitEdit(value);
};
}
function editorCancelFor() {
return () => {
if (inRowEdit()) {
cancelRow();
return;
}
cancelEdit();
};
}
const onCellEditorInput = useCallback((colId: any, evt: any) => {
const v = evt && evt.target ? evt.target.value : '';
if (inRowEdit()) {
setRowDraft$local(colId, v);
return;
}
setDraftValue(v);
}, [inRowEdit, setRowDraft$local]);
const onCellEditorCheckbox = useCallback((colId: any, evt: any) => {
const v = !!(evt && evt.target && evt.target.checked);
if (inRowEdit()) {
setRowDraft$local(colId, v);
return;
}
setDraftValue(v);
}, [inRowEdit, setRowDraft$local]);
function setRowDraft$local(colId: any, value: any) {
const src = rowDraft || {};
const next = {};
for (const k in src) next[k] = src[k];
next[colId] = value;
setRowDraft(next);
}
function rowEditTab(target: any, backward: any) {
const rowIndex = editingRowIndex;
if (rowIndex == null) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
const cols = editable.map((ec: any) => ec.colIndex);
const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
const curAttr = cell ? cell.getAttribute('data-col-index') : null;
const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
let pos = cols.indexOf(cur);
if (pos < 0) pos = 0;
const len = cols.length;
const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
focusRowEditorAt(rowIndex, cols[nextPos]);
}
const onEditorKeyDown = useCallback((e: any) => {
if (!e) return;
const key = e.key;
// Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
// model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
// row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
// preventDefault it here.
if (inRowEdit()) {
if (key === 'Enter') {
e.preventDefault();
commitRow();
} else if (key === 'Escape') {
e.preventDefault();
cancelRow();
}
// B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
// editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
// Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
// first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
// tab order across a Lit shadow boundary).
else if (key === 'Tab') {
e.preventDefault();
rowEditTab(e.target, e.shiftKey);
}
return;
}
if (key === 'Enter') {
e.preventDefault();
commitEdit(undefined);
} else if (key === 'Tab') {
e.preventDefault();
// Resolve the advance target from the EDITING pair (the cell that is open), not the
// active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
// moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
// the editing pair BEFORE commit (commitEdit resets it to -1).
const fromRow = editingRow;
const fromCol = editingCol;
const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
// skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
// straight into the next editable cell's editor below. Use the RETURN value (not a
// re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
// failure returns false and keeps the editor open (the user must fix the value first).
const committed = commitEdit(undefined, true);
if (committed && target) {
setActiveRow(target.row);
setActiveColIndex(target.col);
beginEdit(target.row, target.col, null);
} else if (committed) {
// B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
// grid by returning it to the just-committed cell instead of letting it drop to <body>.
focusCellWhenReady(fromRow, fromCol);
}
} else if (key === 'Escape') {
e.preventDefault();
cancelEdit();
}
}, [beginEdit, cancelEdit, cancelRow, commitEdit, commitRow, editingCol, editingRow, focusCellWhenReady, inRowEdit, nextEditableCell, prevEditableCell, rowEditTab]);
const onEditorBlur = useCallback((e: any) => {
// Full-row mode (req-6): blur NEVER commits — the row commits as a UNIT only on an
// explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
// into N writes + N events, violating the one-write/one-event contract). Tabbing between
// the row's own editors is a normal focus move, not a commit.
if (inRowEdit()) return;
if (editingRow < 0 || editTransition.current) return;
const next = e ? e.relatedTarget : null;
// A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
// keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
// a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
// conservative null=skip behavior.
if (next == null) return;
// Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
// the editor open on an invalid value).
if (!(gridRoot.current && gridRoot.current.contains && gridRoot.current.contains(next))) {
commitEdit(undefined);
return;
}
// Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
// keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
// (which must commit + close so the grid is not wedged with an open editor).
const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
// Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
// controlled move; skip. Also skip when either cell can't be resolved (an unmounting
// editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
if (!nextCell || !fromCell || nextCell === fromCell) return;
// A Tab-advance already committed the old editor and opened the next one, so the live
// editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
// editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
// double commit, WR-04).
const fromRow = fromCell.getAttribute('data-row');
const fromCol = fromCell.getAttribute('data-col-index');
if (fromRow !== String(editingRow) || fromCol !== String(editingCol)) return;
// Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
// commit does NOT bounce focus back to the just-committed editing cell (which would fight
// the click destination). The commit's writeData re-renders the table and can DROP DOM
// focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
// DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
// (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
// late, which would steal focus back from a subsequent navigation.
const destRow = nextCell.getAttribute('data-row');
const destCol = nextCell.getAttribute('data-col-index');
commitEdit(undefined, true);
const reseatDestFocus = () => {
if (!gridRoot.current || destRow == null || destCol == null || destRow === '__header') return;
const root = gridRoot.current.getRootNode ? gridRoot.current.getRootNode() : null;
const act = root && root.activeElement ? root.activeElement : null;
// Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
if (act && gridRoot.current.contains && gridRoot.current.contains(act)) return;
const el = resolveCellEl(destRow, parseInt(destCol, 10));
if (el) el.focus();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
}, [commitEdit, editingCol, editingRow, inRowEdit, resolveCellEl]);
function editCell(rowIndex: any, colIndex: any) {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const maxCol = visibleColCount() - 1;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
setActiveIsHeader(false);
setActiveRow(r);
setActiveColIndex(c);
beginEdit(r, c, null);
}
function commitEditing() {
if (editingRow >= 0) commitEdit(undefined);
}
function editRow(rowIndex: any) {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const rowList = rows || [];
const row = rowList[r];
if (!row) return;
setActiveIsHeader(false);
setActiveRow(r);
beginRowEdit(row);
}
function focusAbsCellWhenReady(absRow: any, localRow: any, col: any) {
if (!gridRoot.current) return;
let attempts = 0;
const want = String(absRow + 1);
const tryFocus = () => {
const el = resolveCellEl(String(localRow), col);
if (el) {
const rowEl = el.closest ? el.closest('[role="row"]') : null;
const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
if (ari === want) {
el.focus();
return;
}
}
attempts = attempts + 1;
if (attempts >= 60) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
function focusCell(rowIndex: any, colIndex: any) {
// B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
// is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
// isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
// instance does not leak a spurious activecell-change.
if (!isGrid()) return;
const maxCol = visibleColCount() - 1;
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
// C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
const absLast = prePaginationRowCount() - 1;
const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
// B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
// real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
// active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
const prevAbs = toAbsRow(activeRow);
const prevIsHeader = activeIsHeader;
if (props.virtual) {
// Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
// the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
// focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
setActiveIsHeader(false);
setActiveInControl(false);
setActiveRow(absRow);
setActiveColIndex(c);
focusActiveCell(absRow, c, false);
} else {
// Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
// in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
// body's data-row markers (and the roving tabindex) address.
const size = pageSize();
const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
const localRow = absRow - targetPage * size;
const switched = targetPage !== pageIndex();
if (switched) setPage(targetPage);
setActiveIsHeader(false);
setActiveInControl(false);
setActiveRow(localRow);
setActiveColIndex(c);
if (switched) {
// The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
// TARGET page's absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
// same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
focusAbsCellWhenReady(absRow, localRow, c);
} else {
// Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
// Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
// async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
focusActiveCell(localRow, c, false);
}
}
if (absRow !== prevAbs || prevIsHeader) {
props.onActivecellChange && props.onActivecellChange({
rowIndex: absRow,
colIndex: c
});
}
}
function getActiveCell() {
return activeIsHeader ? {
rowIndex: null,
colIndex: activeColIndex,
isHeader: true
} : {
rowIndex: toAbsRow(activeRow),
colIndex: activeColIndex,
isHeader: false
};
}
function clearActiveCell() {
if (!isGrid()) return;
setActiveIsHeader(false);
setActiveInControl(false);
setActiveRow(0);
setActiveColIndex(0);
}
function toggleRowExpanded(rowId: any) {
if (!table.current) return;
const target = String(rowId);
const flat = table.current.getCoreRowModel().flatRows;
for (const r of flat as any) {
if (r.id === target || r.original && String(r.original.id) === target) {
r.toggleExpanded();
return;
}
}
}
function expandAll() {
if (!table.current) return;
table.current.toggleAllRowsExpanded(true);
}
function collapseAll() {
if (!table.current) return;
table.current.resetExpanded(true);
}
function getExpandedRows() {
if (!table.current) return [];
const out = [];
const flat = table.current.getCoreRowModel().flatRows;
for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
return out;
}
function applyGrouping(cols: any) {
if (table.current) table.current.setGrouping(cols);
}
function clearGrouping() {
if (table.current) table.current.setGrouping([]);
}
function getFacetedUniqueValues(colId: any) {
if (tick() < 0 || !table.current) return [];
const col = table.current.getColumn(colId);
if (!col || !col.getFacetedUniqueValues) return [];
const map = col.getFacetedUniqueValues(); // Map<any, number>
return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
}
function getFacetedMinMaxValues(colId: any) {
if (tick() < 0 || !table.current) return null;
const col = table.current.getColumn(colId);
if (!col || !col.getFacetedMinMaxValues) return null;
return col.getFacetedMinMaxValues() || null; // [number, number] | null
}
useEffect(() => {
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
setDataDefault(_dataRef.current || []);
// Build the table instance HERE so the closures below capture the live `table`.
table.current = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: currentData(),
columns: tableColumns(),
state: currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (props.getSubRows || undefined) as any,
getRowCanExpand: _expandableRef.current === true && props.getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: props.manual === true,
manualFiltering: props.manual === true,
manualSorting: props.manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: _selectionModeRef.current !== 'none',
enableMultiRowSelection: _selectionModeRef.current === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {}
});
refreshRowModel.current = () => {
if (!table.current) return;
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = windowSource().slice();
const nextGroups = table.current.getHeaderGroups().slice();
setRows(nextRows);
setHeaderGroups(nextGroups);
setRowModelVer(prev => prev + 1);
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if (props.virtual && virtualizer.current) {
virtualizer.current.setOptions(virtualizerOptions());
virtualizer.current._willUpdate();
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length;
const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
clampActiveCell(nextRowCount, nextColCount);
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (pendingEditFollow.current && isGrid()) {
const follow = pendingEditFollow.current;
pendingEditFollow.current = null;
const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
syncIndeterminate();
if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
};
// initial pull
refreshRowModel.current();
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
gridRoot.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-data-table') : null;
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if (props.virtual) {
gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rdt-scroll') : null;
virtualizer.current = new Virtualizer(virtualizerOptions());
virtualizerCleanup.current = virtualizer.current._didMount();
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
setWindowVer(prev => prev + 1);
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
remeasureWindow();
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = gridScrollEl.current ? gridScrollEl.current.clientHeight : 0;
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = _paginationRef.current;
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
if (props.manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
if (virtualizerCleanup.current) virtualizerCleanup.current();
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
teardownFillDrag();
};
}, []);
useEffect(() => {
if (!table.current) return;
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = currentData() || [];
if (d === lastData.current && d.length === lastDataLen.current) return;
lastData.current = d;
lastDataLen.current = d.length;
reFeed();
}, [currentData, lastData, lastDataLen, reFeed, table]);
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
reFeed();
}, [colReg, columnFilters, columnOrder, columnPinning, columnSizing, columnVisibility, data, dataDefault, expanded, globalFilter, grouping, pagination, props.expandable, props.groupable, props.selectionMode, rowSelection, sorting]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut });
_rozieExposeRef.current = { sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut };
useImperativeHandle(ref, () => ({ sortColumn: (...args: Parameters<typeof sortColumn>): ReturnType<typeof sortColumn> => _rozieExposeRef.current.sortColumn(...args), clearSorting: (...args: Parameters<typeof clearSorting>): ReturnType<typeof clearSorting> => _rozieExposeRef.current.clearSorting(...args), toggleRowExpanded: (...args: Parameters<typeof toggleRowExpanded>): ReturnType<typeof toggleRowExpanded> => _rozieExposeRef.current.toggleRowExpanded(...args), expandAll: (...args: Parameters<typeof expandAll>): ReturnType<typeof expandAll> => _rozieExposeRef.current.expandAll(...args), collapseAll: (...args: Parameters<typeof collapseAll>): ReturnType<typeof collapseAll> => _rozieExposeRef.current.collapseAll(...args), getExpandedRows: (...args: Parameters<typeof getExpandedRows>): ReturnType<typeof getExpandedRows> => _rozieExposeRef.current.getExpandedRows(...args), applyGrouping: (...args: Parameters<typeof applyGrouping>): ReturnType<typeof applyGrouping> => _rozieExposeRef.current.applyGrouping(...args), clearGrouping: (...args: Parameters<typeof clearGrouping>): ReturnType<typeof clearGrouping> => _rozieExposeRef.current.clearGrouping(...args), getFacetedUniqueValues: (...args: Parameters<typeof getFacetedUniqueValues>): ReturnType<typeof getFacetedUniqueValues> => _rozieExposeRef.current.getFacetedUniqueValues(...args), getFacetedMinMaxValues: (...args: Parameters<typeof getFacetedMinMaxValues>): ReturnType<typeof getFacetedMinMaxValues> => _rozieExposeRef.current.getFacetedMinMaxValues(...args), getColumnDefs: (...args: Parameters<typeof getColumnDefs>): ReturnType<typeof getColumnDefs> => _rozieExposeRef.current.getColumnDefs(...args), toggleAllRows: (...args: Parameters<typeof toggleAllRows>): ReturnType<typeof toggleAllRows> => _rozieExposeRef.current.toggleAllRows(...args), clearSelection: (...args: Parameters<typeof clearSelection>): ReturnType<typeof clearSelection> => _rozieExposeRef.current.clearSelection(...args), getSelectedRows: (...args: Parameters<typeof getSelectedRows>): ReturnType<typeof getSelectedRows> => _rozieExposeRef.current.getSelectedRows(...args), setPage: (...args: Parameters<typeof setPage>): ReturnType<typeof setPage> => _rozieExposeRef.current.setPage(...args), setRowsPerPage: (...args: Parameters<typeof setRowsPerPage>): ReturnType<typeof setRowsPerPage> => _rozieExposeRef.current.setRowsPerPage(...args), toggleColumnVisibility: (...args: Parameters<typeof toggleColumnVisibility>): ReturnType<typeof toggleColumnVisibility> => _rozieExposeRef.current.toggleColumnVisibility(...args), applyColumnOrder: (...args: Parameters<typeof applyColumnOrder>): ReturnType<typeof applyColumnOrder> => _rozieExposeRef.current.applyColumnOrder(...args), resetColumnSizing: (...args: Parameters<typeof resetColumnSizing>): ReturnType<typeof resetColumnSizing> => _rozieExposeRef.current.resetColumnSizing(...args), pinColumn: (...args: Parameters<typeof pinColumn>): ReturnType<typeof pinColumn> => _rozieExposeRef.current.pinColumn(...args), focusCell: (...args: Parameters<typeof focusCell>): ReturnType<typeof focusCell> => _rozieExposeRef.current.focusCell(...args), getActiveCell: (...args: Parameters<typeof getActiveCell>): ReturnType<typeof getActiveCell> => _rozieExposeRef.current.getActiveCell(...args), clearActiveCell: (...args: Parameters<typeof clearActiveCell>): ReturnType<typeof clearActiveCell> => _rozieExposeRef.current.clearActiveCell(...args), getRowIndexRelativeToPage: (...args: Parameters<typeof getRowIndexRelativeToPage>): ReturnType<typeof getRowIndexRelativeToPage> => _rozieExposeRef.current.getRowIndexRelativeToPage(...args), editCell: (...args: Parameters<typeof editCell>): ReturnType<typeof editCell> => _rozieExposeRef.current.editCell(...args), commitEditing: (...args: Parameters<typeof commitEditing>): ReturnType<typeof commitEditing> => _rozieExposeRef.current.commitEditing(...args), editRow: (...args: Parameters<typeof editRow>): ReturnType<typeof editRow> => _rozieExposeRef.current.editRow(...args), getSelectedRange: (...args: Parameters<typeof getSelectedRange>): ReturnType<typeof getSelectedRange> => _rozieExposeRef.current.getSelectedRange(...args), cut: (...args: Parameters<typeof cut>): ReturnType<typeof cut> => _rozieExposeRef.current.cut(...args) }), []);
return (
<__ctx_data_table_columns.Provider value={{
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
setColReg(prev => ({
...prev,
[key]: spec
}));
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...colReg
};
delete r[String(id)];
setColReg(r);
}
}}>
<>
<div className={"rozie-data-table-wrap"} ref={__rozieRoot} data-rozie-s-d5dcab4c="">
<div className={"rdt-column-defs"} style={{ display: "none" }} aria-hidden="true" data-rozie-s-d5dcab4c="">{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}</div>
{(!!invalidMsg) && <div className={"rdt-sr-live"} role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{invalidMsg}</div>}{(!!pasteAnnounce) && <div className={"rdt-sr-live rdt-sr-paste"} data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{pasteAnnounce}</div>}<div className={"rdt-toolbar"} data-rozie-s-d5dcab4c="">
<input className={"rdt-global-filter"} type="text" role="searchbox" aria-label="Search table" value={globalFilterValue()} onInput={($event) => { onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c="" />
{(allLeafColumns().length) && <details className={"rdt-colvis"} data-rozie-s-d5dcab4c="">
<summary className={"rdt-colvis-summary"} data-rozie-s-d5dcab4c="">Columns</summary>
<div className={"rdt-colvis-menu"} role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c="">
{allLeafColumns().map((lc) => <label key={lc.id} className={"rdt-colvis-item"} data-rozie-s-d5dcab4c="">
<input type="checkbox" className={"rdt-colvis-checkbox"} checked={lc.visible} onChange={($event) => { onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c="" />
<span className={"rdt-colvis-label"} data-rozie-s-d5dcab4c="">{rozieDisplay(lc.label)}</span>
</label>)}
</div>
</details>}</div>
{(props.groupable) && <div className={"rdt-group-bar-host"} data-rozie-s-d5dcab4c="">
{(props.renderGroupBar ?? props.slots?.['groupBar']) ? ((props.renderGroupBar ?? props.slots?.['groupBar']) as Function)({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping }) : groupingKeys().map((gk) => <span key={gk} className={"rdt-group-token"} data-group-token="" data-rozie-s-d5dcab4c="">{rozieDisplay(gk)}</span>)}
</div>}{(props.virtual) ? <div className={"rdt-scroll"} style={parseInlineStyle(props.maxHeight ? 'max-height:' + props.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + props.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c="">
<table className={clsx("rozie-data-table", { "rdt-sticky": props.stickyHeader })} role={rozieAttr(tableRole())} aria-rowcount={rows.length} onKeyDown={($event) => { onGridKeyDown($event); }} onFocus={($event) => { syncActiveFromEvent($event); }} onBlur={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c="">
<thead className={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
{headerGroups.map((hg, hgLevel) => <tr key={hg.id} className={"rdt-tr"} role="row" data-rozie-s-d5dcab4c="">
{hg.headers.map((header) => <th key={header.id} className={clsx("rdt-th", { "rdt-select-th": isSelectColumn(header.column.id), "rdt-th-resizing": columnIsResizing(header.column.id) })} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colSpan={(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabIndex={cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={parseInlineStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c="">
{(isSelectColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderSelectAll ?? props.slots?.['selectAll']) ? ((props.renderSelectAll ?? props.slots?.['selectAll']) as Function)({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) : (props.selectionMode === 'multiple') && <input className={"rdt-select-all"} type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onChange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" />}
</span> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(header.column.getCanSort && header.column.getCanSort()) ? <button type="button" className={"rdt-sort-btn"} onClick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c="">
<span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
</span>
<span className={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header.column.id))}</span>
</button> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
</span>
</span>}{(columnIsFilterable(header.column.id)) && <input className={"rdt-col-filter"} type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" />}{(columnIsFilterable(header.column.id)) && <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderFilter ?? props.slots?.['filter'])?.({ columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
</span>}<span className={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
<button type="button" className={"rdt-pin-btn rdt-pin-left"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
<button type="button" className={"rdt-pin-btn rdt-pin-none"} aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
<button type="button" className={"rdt-pin-btn rdt-pin-right"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
</span>
<button type="button" className={"rdt-resize-handle"} aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onPointerDown={($event) => { onResizeStart(header.column.id, $event); }} onTouchStart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span className={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
</span>}</th>)}
</tr>)}
</thead>
<tbody className={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
<tr className={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
<td colSpan={visibleColCount()} style={parseInlineStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
</tr>
{windowedRows().map((wr) => <Fragment key={wr.row.id}>
<tr key={wr.row.id} className={clsx("rdt-tr", { "rdt-group-header": rowIsGrouped(wr.row), "rdt-row-pinned": wr.pinned })} role="row" data-row={rozieAttr(wr.vi.index)} aria-rowindex={wr.vi.index + 1} data-index={rozieAttr(wr.vi.index)} data-pinned={rozieAttr(wr.pinned ? 'true' : undefined)} data-depth={rozieAttr(wr.row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : undefined)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : undefined)} aria-expanded={(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : undefined) ?? undefined} aria-level={(groupingActive() ? wr.row.depth + 1 : undefined) ?? undefined} data-rozie-s-d5dcab4c="">
{visibleCellsFor(wr.row).map((cellCtx) => <td key={cellCtx.id} className={clsx("rdt-td", { "rdt-select-td": isSelectColumn(cellCtx.column.id), "rdt-in-range": inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cellCtx))} tabIndex={cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx))} style={parseInlineStyle(bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : undefined)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined)} data-rozie-s-d5dcab4c="">
{(isExpanderColumn(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(rowCanExpand(wr.row)) && <button type="button" className={"rdt-expander"} data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} onClick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>}</span> : (isSelectColumn(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderSelectCell ?? props.slots?.['selectCell']) ? ((props.renderSelectCell ?? props.slots?.['selectCell']) as Function)({ row: wr.row.original, checked: rowIsSelected(wr.row), toggle: e => onToggleRow(wr.row, e) }) : <input className={"rdt-select-row"} type="checkbox" aria-label="Select row" checked={rowIsSelected(wr.row)} onChange={($event) => { onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c="" />}
</span> : (cellIsGrouped(cellCtx)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<button type="button" className={"rdt-expander rdt-group-toggle"} data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} onClick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>
<span className={"rdt-group-value"} data-rozie-s-d5dcab4c="">
{(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
</span>
<span className={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(wr.row) + ')')}</span>
</span> : (isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(hasEditorSlot(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderEditor ?? props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
</span> : (editorTypeOf(cellCtx.column.id) === 'number') ? <input className={"rdt-cell-editor"} type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : (editorTypeOf(cellCtx.column.id) === 'select') ? <select className={"rdt-cell-editor"} data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
{editorOptionsOf(cellCtx.column.id).map((opt) => <option key={opt.value} value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>)}
</select> : (editorTypeOf(cellCtx.column.id) === 'checkbox') ? <input className={"rdt-cell-editor"} type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : <input className={"rdt-cell-editor"} type="text" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}</span> : <span className={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
{(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
</span>}{(isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))) && <span className={"rdt-fill-handle"} data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" />}</td>)}
</tr>
{(rowShowsDetail(wr.row)) && <tr key={wr.row.id} className={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c="">
<td className={"rdt-detail-cell"} colSpan={visibleColCount()} data-rozie-s-d5dcab4c="">
{(props.renderDetail ?? props.slots?.['detail'])?.({ row: wr.row.original })}
</td>
</tr>}</Fragment>)}
<tr className={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
<td colSpan={visibleColCount()} style={parseInlineStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
</tr>
</tbody>
</table>
</div> : <table className={clsx("rozie-data-table", { "rdt-sticky": props.stickyHeader })} role={rozieAttr(tableRole())} aria-rowcount={totalRowCount()} onKeyDown={($event) => { onGridKeyDown($event); }} onFocus={($event) => { syncActiveFromEvent($event); }} onBlur={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c="">
<thead className={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
{headerGroups.map((hg, hgLevel) => <tr key={hg.id} className={"rdt-tr"} role="row" data-rozie-s-d5dcab4c="">
{hg.headers.map((header) => <th key={header.id} className={clsx("rdt-th", { "rdt-select-th": isSelectColumn(header.column.id), "rdt-th-resizing": columnIsResizing(header.column.id) })} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colSpan={(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabIndex={cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={parseInlineStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c="">
{(isSelectColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderSelectAll ?? props.slots?.['selectAll']) ? ((props.renderSelectAll ?? props.slots?.['selectAll']) as Function)({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) : (props.selectionMode === 'multiple') && <input className={"rdt-select-all"} type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onChange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" />}
</span> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(header.column.getCanSort && header.column.getCanSort()) ? <button type="button" className={"rdt-sort-btn"} onClick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c="">
<span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
</span>
<span className={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header.column.id))}</span>
</button> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
</span>
</span>}{(columnIsFilterable(header.column.id)) && <input className={"rdt-col-filter"} type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" />}{(columnIsFilterable(header.column.id)) && <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderFilter ?? props.slots?.['filter'])?.({ columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
</span>}<span className={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
<button type="button" className={"rdt-pin-btn rdt-pin-left"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
<button type="button" className={"rdt-pin-btn rdt-pin-none"} aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
<button type="button" className={"rdt-pin-btn rdt-pin-right"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
</span>
<button type="button" className={"rdt-resize-handle"} aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onPointerDown={($event) => { onResizeStart(header.column.id, $event); }} onTouchStart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span className={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
</span>}</th>)}
</tr>)}
</thead>
<tbody className={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
{rows.map((row) => <Fragment key={row.id}>
<tr key={row.id} className={clsx("rdt-tr", { "rdt-group-header": rowIsGrouped(row) })} role="row" data-depth={rozieAttr(row.depth)} aria-rowindex={(isGrid() ? absRowIndexOf(row) + 1 : undefined) ?? undefined} data-group-header={rozieAttr(rowIsGrouped(row) ? row.id : undefined)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : undefined)} aria-expanded={(rowIsGrouped(row) ? !!rowIsExpanded(row) : undefined) ?? undefined} aria-level={(groupingActive() ? row.depth + 1 : undefined) ?? undefined} data-rozie-s-d5dcab4c="">
{visibleCellsFor(row).map((cellCtx) => <td key={cellCtx.id} className={clsx("rdt-td", { "rdt-select-td": isSelectColumn(cellCtx.column.id), "rdt-in-range": inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cellCtx))} tabIndex={cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx))} style={parseInlineStyle(bodyCellStyle(row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : undefined)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined)} data-rozie-s-d5dcab4c="">
{(isExpanderColumn(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(rowCanExpand(row)) && <button type="button" className={"rdt-expander"} data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} onClick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>}</span> : (isSelectColumn(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderSelectCell ?? props.slots?.['selectCell']) ? ((props.renderSelectCell ?? props.slots?.['selectCell']) as Function)({ row: row.original, checked: rowIsSelected(row), toggle: e => onToggleRow(row, e) }) : <input className={"rdt-select-row"} type="checkbox" aria-label="Select row" checked={rowIsSelected(row)} onChange={($event) => { onToggleRow(row, $event); }} data-rozie-s-d5dcab4c="" />}
</span> : (cellIsGrouped(cellCtx)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<button type="button" className={"rdt-expander rdt-group-toggle"} data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} onClick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>
<span className={"rdt-group-value"} data-rozie-s-d5dcab4c="">
{(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
</span>
<span className={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span>
</span> : (isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(hasEditorSlot(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(props.renderEditor ?? props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
</span> : (editorTypeOf(cellCtx.column.id) === 'number') ? <input className={"rdt-cell-editor"} type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : (editorTypeOf(cellCtx.column.id) === 'select') ? <select className={"rdt-cell-editor"} data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
{editorOptionsOf(cellCtx.column.id).map((opt) => <option key={opt.value} value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>)}
</select> : (editorTypeOf(cellCtx.column.id) === 'checkbox') ? <input className={"rdt-cell-editor"} type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : <input className={"rdt-cell-editor"} type="text" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}</span> : <span className={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
{(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
</span>}{(isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))) && <span className={"rdt-fill-handle"} data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" />}</td>)}
</tr>
{(rowShowsDetail(row)) && <tr key={row.id} className={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(row.id)} data-rozie-s-d5dcab4c="">
<td className={"rdt-detail-cell"} colSpan={visibleColCount()} data-rozie-s-d5dcab4c="">
{(props.renderDetail ?? props.slots?.['detail'])?.({ row: row.original })}
</td>
</tr>}</Fragment>)}
</tbody>
</table>}{(!props.virtual) && <div className={"rdt-pagination"} role="group" aria-label="Pagination" data-rozie-s-d5dcab4c="">
<button type="button" className={"rdt-page-btn rdt-page-prev"} disabled={!canPrevPage()} onClick={($event) => { onPrevPage(); }} data-rozie-s-d5dcab4c="">Prev</button>
<span className={"rdt-page-status"} aria-live="polite" data-rozie-s-d5dcab4c="">
{rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + pageCount())}
</span>
<button type="button" className={"rdt-page-btn rdt-page-next"} disabled={!canNextPage()} onClick={($event) => { onNextPage(); }} data-rozie-s-d5dcab4c="">Next</button>
<select className={"rdt-page-size"} aria-label="Rows per page" value={pageSize()} onChange={($event) => { onPageSizeChange($event); }} data-rozie-s-d5dcab4c="">
<option value={10} data-rozie-s-d5dcab4c="">10</option>
<option value={25} data-rozie-s-d5dcab4c="">25</option>
<option value={50} data-rozie-s-d5dcab4c="">50</option>
<option value={100} data-rozie-s-d5dcab4c="">100</option>
</select>
</div>}</div>
</>
</__ctx_data_table_columns.Provider>
);
});
export default DataTable;vue
<template>
<div class="rozie-data-table-wrap" ref="__rozieRootRef">
<div class="rdt-column-defs" style="display:none" aria-hidden="true"><slot></slot></div>
<div v-if="!!invalidMsg" class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true">{{ invalidMsg }}</div><div v-if="!!pasteAnnounce" class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true">{{ pasteAnnounce }}</div><div class="rdt-toolbar">
<input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" :value="globalFilterValue()" @input="onGlobalFilterInput($event)" />
<details v-if="allLeafColumns().length" class="rdt-colvis">
<summary class="rdt-colvis-summary">Columns</summary>
<div class="rdt-colvis-menu" role="group" aria-label="Toggle columns">
<label v-for="lc in allLeafColumns()" :key="lc.id" class="rdt-colvis-item">
<input type="checkbox" class="rdt-colvis-checkbox" :checked="lc.visible" @change="onToggleVisibility(lc.id)" />
<span class="rdt-colvis-label">{{ lc.label }}</span>
</label>
</div>
</details></div>
<div v-if="props.groupable" class="rdt-group-bar-host">
<slot name="groupBar" :grouping="groupingKeys()" :groupableColumns="groupableColumns()" :applyGrouping="applyGrouping" :clearGrouping="clearGrouping">
<span v-for="gk in groupingKeys()" :key="gk" class="rdt-group-token" data-group-token="">{{ gk }}</span>
</slot>
</div><div v-if="props.virtual" class="rdt-scroll" :style="props.maxHeight ? 'max-height:' + props.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + props.maxHeight : 'overflow:auto'">
<table :class="['rozie-data-table', { 'rdt-sticky': props.stickyHeader }]" :role="tableRole()" :aria-rowcount="rows.length" @keydown="onGridKeyDown($event)" @focusin="syncActiveFromEvent($event)" @focusout="onGridFocusOut($event)" @mousedown="onGridMouseDown($event)">
<thead class="rdt-thead" role="rowgroup">
<tr v-for="(hg, hgLevel) in headerGroups" :key="hg.id" class="rdt-tr" role="row">
<th v-for="header in hg.headers" :key="header.id" :class="['rdt-th', { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]" role="columnheader" :data-col="header.column.id" data-grid-cell="" data-row="__header" :data-header-level="hgLevel" :colspan="(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined" :data-col-index="headerColIndexOf(hg, header)" :tabindex="(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)) ?? undefined" :aria-sort="ariaSortFor(header.column.id)" :style="thStyle(header.column.id)">
<span v-if="isSelectColumn(header.column.id)" style="display:contents">
<slot name="selectAll" :checked="isAllRowsSelected()" :indeterminate="isSomeRowsSelected()" :toggle="onToggleAllRows">
<input v-if="props.selectionMode === 'multiple'" class="rdt-select-all" type="checkbox" aria-label="Select all rows" :checked="isAllRowsSelected()" @change="onToggleAllRows($event)" /></slot>
</span><span v-else style="display:contents">
<button v-if="header.column.getCanSort && header.column.getCanSort()" type="button" class="rdt-sort-btn" @click="onHeaderSort(header.column.id, $event)">
<span class="rdt-header-label">
<slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
</span>
<span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
</button><span v-else style="display:contents">
<span class="rdt-header-label">
<slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
</span>
</span><input v-if="columnIsFilterable(header.column.id)" class="rdt-col-filter" type="text" :aria-label="'Filter ' + headerLabel(header.column.id)" :value="columnFilterValue(header.column.id)" @input="onColumnFilterInput(header.column.id, $event)" @click="stopEvent($event)" /><span v-if="columnIsFilterable(header.column.id)" style="display:contents">
<slot name="filter" :columnId="header.column.id" :uniqueValues="getFacetedUniqueValues(header.column.id)" :minMax="getFacetedMinMaxValues(header.column.id)" :setFilter="setColumnFilter"></slot>
</span><span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
<button type="button" class="rdt-pin-btn rdt-pin-left" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'" :aria-pressed="columnPinSide(header.column.id) === 'left'" @click="onPinColumn(header.column.id, 'left', $event)">⇤</button>
<button type="button" class="rdt-pin-btn rdt-pin-none" :aria-label="'Unpin ' + headerLabel(header.column.id)" :aria-pressed="!columnPinSide(header.column.id)" @click="onPinColumn(header.column.id, false, $event)">⇔</button>
<button type="button" class="rdt-pin-btn rdt-pin-right" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'" :aria-pressed="columnPinSide(header.column.id) === 'right'" @click="onPinColumn(header.column.id, 'right', $event)">⇥</button>
</span>
<button type="button" class="rdt-resize-handle" :aria-label="'Resize ' + headerLabel(header.column.id)" @pointerdown="onResizeStart(header.column.id, $event)" @touchstart="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
</span></th>
</tr>
</thead>
<tbody class="rdt-tbody" role="rowgroup">
<tr class="rdt-spacer" aria-hidden="true">
<td :colspan="(visibleColCount()) ?? undefined" :style="'height:' + padTop() + 'px;padding:0;border:0'"></td>
</tr>
<template v-for="wr in windowedRows()" :key="wr.row.id">
<tr :class="['rdt-tr', { 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }]" role="row" :data-row="wr.vi.index" :aria-rowindex="wr.vi.index + 1" :data-index="wr.vi.index" :data-pinned="wr.pinned ? 'true' : undefined" :data-depth="wr.row.depth" :data-group-header="rowIsGrouped(wr.row) ? wr.row.id : undefined" :data-group-leaf="groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : undefined" :aria-expanded="(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : undefined) ?? undefined" :aria-level="(groupingActive() ? wr.row.depth + 1 : undefined) ?? undefined">
<td v-for="cellCtx in visibleCellsFor(wr.row)" :key="cellCtx.id" :class="['rdt-td', { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }]" :role="cellRole()" :data-col="cellCtx.column.id" data-grid-cell="" :data-row="wr.vi.index" :data-col-index="colIndexOf(wr.row, cellCtx)" :tabindex="(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx))) ?? undefined" :style="bodyCellStyle(wr.row, cellCtx.column.id)" :aria-invalid="(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx))) ?? undefined" :data-in-range="inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : undefined" :data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined">
<span v-if="isExpanderColumn(cellCtx.column.id)" style="display:contents">
<button v-if="rowCanExpand(wr.row)" type="button" class="rdt-expander" data-expander="" :aria-expanded="!!rowIsExpanded(wr.row)" :aria-label="rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row'" @click="onToggleExpand(wr.row, $event)">{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button></span><span v-else-if="isSelectColumn(cellCtx.column.id)" style="display:contents">
<slot name="selectCell" :row="wr.row.original" :checked="rowIsSelected(wr.row)" :toggle="e => onToggleRow(wr.row, e)">
<input class="rdt-select-row" type="checkbox" aria-label="Select row" :checked="rowIsSelected(wr.row)" @change="onToggleRow(wr.row, $event)" />
</slot>
</span><span v-else-if="cellIsGrouped(cellCtx)" style="display:contents">
<button type="button" class="rdt-expander rdt-group-toggle" data-expander="" :aria-expanded="!!rowIsExpanded(wr.row)" :aria-label="rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group'" @click="onToggleExpand(wr.row, $event)">{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button>
<span class="rdt-group-value">
<slot name="cell" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="wr.row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
</span>
<span class="rdt-group-count">{{ '(' + groupSubRowCount(wr.row) + ')' }}</span>
</span><span v-else-if="isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))" style="display:contents">
<span v-if="hasEditorSlot(cellCtx.column.id)" style="display:contents">
<slot name="editor" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="wr.row.original" :value="editorValueFor(cellCtx.column.id)" :commit="editorCommitFor(cellCtx.column.id)" :cancel="editorCancelFor()"></slot>
</span><input v-else-if="editorTypeOf(cellCtx.column.id) === 'number'" class="rdt-cell-editor" type="number" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><select v-else-if="editorTypeOf(cellCtx.column.id) === 'select'" class="rdt-cell-editor" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @change="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)">
<option v-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select><input v-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'" class="rdt-cell-editor" type="checkbox" data-editing-cell="" :checked="editorCheckedFor(cellCtx.column.id)" @change="onCellEditorCheckbox(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><input v-else class="rdt-cell-editor" type="text" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /></span><span v-else class="rdt-cell-value">
<slot name="cell" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="wr.row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
</span><span v-if="isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))" class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown="onFillHandlePointerDown($event)"></span></td>
</tr>
<tr v-if="rowShowsDetail(wr.row)" class="rdt-detail-row" role="row" :data-detail-row="wr.row.id">
<td class="rdt-detail-cell" :colspan="(visibleColCount()) ?? undefined">
<slot name="detail" :row="wr.row.original"></slot>
</td>
</tr></template>
<tr class="rdt-spacer" aria-hidden="true">
<td :colspan="(visibleColCount()) ?? undefined" :style="'height:' + padBottom() + 'px;padding:0;border:0'"></td>
</tr>
</tbody>
</table>
</div><table v-else :class="['rozie-data-table', { 'rdt-sticky': props.stickyHeader }]" :role="tableRole()" :aria-rowcount="(totalRowCount()) ?? undefined" @keydown="onGridKeyDown($event)" @focusin="syncActiveFromEvent($event)" @focusout="onGridFocusOut($event)" @mousedown="onGridMouseDown($event)">
<thead class="rdt-thead" role="rowgroup">
<tr v-for="(hg, hgLevel) in headerGroups" :key="hg.id" class="rdt-tr" role="row">
<th v-for="header in hg.headers" :key="header.id" :class="['rdt-th', { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]" role="columnheader" :data-col="header.column.id" data-grid-cell="" data-row="__header" :data-header-level="hgLevel" :colspan="(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined" :data-col-index="headerColIndexOf(hg, header)" :tabindex="(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)) ?? undefined" :aria-sort="ariaSortFor(header.column.id)" :style="thStyle(header.column.id)">
<span v-if="isSelectColumn(header.column.id)" style="display:contents">
<slot name="selectAll" :checked="isAllRowsSelected()" :indeterminate="isSomeRowsSelected()" :toggle="onToggleAllRows">
<input v-if="props.selectionMode === 'multiple'" class="rdt-select-all" type="checkbox" aria-label="Select all rows" :checked="isAllRowsSelected()" @change="onToggleAllRows($event)" /></slot>
</span><span v-else style="display:contents">
<button v-if="header.column.getCanSort && header.column.getCanSort()" type="button" class="rdt-sort-btn" @click="onHeaderSort(header.column.id, $event)">
<span class="rdt-header-label">
<slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
</span>
<span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
</button><span v-else style="display:contents">
<span class="rdt-header-label">
<slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
</span>
</span><input v-if="columnIsFilterable(header.column.id)" class="rdt-col-filter" type="text" :aria-label="'Filter ' + headerLabel(header.column.id)" :value="columnFilterValue(header.column.id)" @input="onColumnFilterInput(header.column.id, $event)" @click="stopEvent($event)" /><span v-if="columnIsFilterable(header.column.id)" style="display:contents">
<slot name="filter" :columnId="header.column.id" :uniqueValues="getFacetedUniqueValues(header.column.id)" :minMax="getFacetedMinMaxValues(header.column.id)" :setFilter="setColumnFilter"></slot>
</span><span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
<button type="button" class="rdt-pin-btn rdt-pin-left" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'" :aria-pressed="columnPinSide(header.column.id) === 'left'" @click="onPinColumn(header.column.id, 'left', $event)">⇤</button>
<button type="button" class="rdt-pin-btn rdt-pin-none" :aria-label="'Unpin ' + headerLabel(header.column.id)" :aria-pressed="!columnPinSide(header.column.id)" @click="onPinColumn(header.column.id, false, $event)">⇔</button>
<button type="button" class="rdt-pin-btn rdt-pin-right" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'" :aria-pressed="columnPinSide(header.column.id) === 'right'" @click="onPinColumn(header.column.id, 'right', $event)">⇥</button>
</span>
<button type="button" class="rdt-resize-handle" :aria-label="'Resize ' + headerLabel(header.column.id)" @pointerdown="onResizeStart(header.column.id, $event)" @touchstart="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
</span></th>
</tr>
</thead>
<tbody class="rdt-tbody" role="rowgroup">
<template v-for="row in rows" :key="row.id">
<tr :class="['rdt-tr', { 'rdt-group-header': rowIsGrouped(row) }]" role="row" :data-depth="row.depth" :aria-rowindex="(isGrid() ? absRowIndexOf(row) + 1 : undefined) ?? undefined" :data-group-header="rowIsGrouped(row) ? row.id : undefined" :data-group-leaf="groupingActive() && !rowIsGrouped(row) ? row.id : undefined" :aria-expanded="(rowIsGrouped(row) ? !!rowIsExpanded(row) : undefined) ?? undefined" :aria-level="(groupingActive() ? row.depth + 1 : undefined) ?? undefined">
<td v-for="cellCtx in visibleCellsFor(row)" :key="cellCtx.id" :class="['rdt-td', { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }]" :role="cellRole()" :data-col="cellCtx.column.id" data-grid-cell="" :data-row="rowIndexOf(row)" :data-col-index="colIndexOf(row, cellCtx)" :tabindex="(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx))) ?? undefined" :style="bodyCellStyle(row, cellCtx.column.id)" :aria-invalid="(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx))) ?? undefined" :data-in-range="inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : undefined" :data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined">
<span v-if="isExpanderColumn(cellCtx.column.id)" style="display:contents">
<button v-if="rowCanExpand(row)" type="button" class="rdt-expander" data-expander="" :aria-expanded="!!rowIsExpanded(row)" :aria-label="rowIsExpanded(row) ? 'Collapse row' : 'Expand row'" @click="onToggleExpand(row, $event)">{{ rowIsExpanded(row) ? '▾' : '▸' }}</button></span><span v-else-if="isSelectColumn(cellCtx.column.id)" style="display:contents">
<slot name="selectCell" :row="row.original" :checked="rowIsSelected(row)" :toggle="e => onToggleRow(row, e)">
<input class="rdt-select-row" type="checkbox" aria-label="Select row" :checked="rowIsSelected(row)" @change="onToggleRow(row, $event)" />
</slot>
</span><span v-else-if="cellIsGrouped(cellCtx)" style="display:contents">
<button type="button" class="rdt-expander rdt-group-toggle" data-expander="" :aria-expanded="!!rowIsExpanded(row)" :aria-label="rowIsExpanded(row) ? 'Collapse group' : 'Expand group'" @click="onToggleExpand(row, $event)">{{ rowIsExpanded(row) ? '▾' : '▸' }}</button>
<span class="rdt-group-value">
<slot name="cell" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
</span>
<span class="rdt-group-count">{{ '(' + groupSubRowCount(row) + ')' }}</span>
</span><span v-else-if="isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))" style="display:contents">
<span v-if="hasEditorSlot(cellCtx.column.id)" style="display:contents">
<slot name="editor" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="row.original" :value="editorValueFor(cellCtx.column.id)" :commit="editorCommitFor(cellCtx.column.id)" :cancel="editorCancelFor()"></slot>
</span><input v-else-if="editorTypeOf(cellCtx.column.id) === 'number'" class="rdt-cell-editor" type="number" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><select v-else-if="editorTypeOf(cellCtx.column.id) === 'select'" class="rdt-cell-editor" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @change="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)">
<option v-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select><input v-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'" class="rdt-cell-editor" type="checkbox" data-editing-cell="" :checked="editorCheckedFor(cellCtx.column.id)" @change="onCellEditorCheckbox(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><input v-else class="rdt-cell-editor" type="text" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /></span><span v-else class="rdt-cell-value">
<slot name="cell" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
</span><span v-if="isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))" class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown="onFillHandlePointerDown($event)"></span></td>
</tr>
<tr v-if="rowShowsDetail(row)" class="rdt-detail-row" role="row" :data-detail-row="row.id">
<td class="rdt-detail-cell" :colspan="(visibleColCount()) ?? undefined">
<slot name="detail" :row="row.original"></slot>
</td>
</tr></template>
</tbody>
</table><div v-if="!props.virtual" class="rdt-pagination" role="group" aria-label="Pagination">
<button type="button" class="rdt-page-btn rdt-page-prev" :disabled="!canPrevPage()" @click="onPrevPage()">Prev</button>
<span class="rdt-page-status" aria-live="polite">
{{ 'Page ' + (pageIndex() + 1) + ' of ' + pageCount() }}
</span>
<button type="button" class="rdt-page-btn rdt-page-next" :disabled="!canNextPage()" @click="onNextPage()">Next</button>
<select class="rdt-page-size" aria-label="Rows per page" :value="pageSize()" @change="onPageSizeChange($event)">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, onUpdated, provide, ref, useSlots, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* 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).
*/
columns?: any[];
/**
* Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
*/
selectionMode?: string;
/**
* 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).
*/
manual?: boolean;
/**
* 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).
*/
expandable?: boolean;
/**
* 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.
*/
getSubRows?: ((...args: any[]) => any) | null;
/**
* 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).
*/
groupable?: boolean;
/**
* Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
*/
stickyHeader?: boolean;
/**
* `'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
* @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
*/
interactionMode?: string;
/**
* 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.
*/
virtual?: boolean;
/**
* Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* 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.
*/
maxHeight?: string;
}>(),
{ columns: () => [], selectionMode: 'none', manual: false, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', virtual: false, estimateRowHeight: 40, maxHeight: '' }
);
/**
* 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).
* @example
* <DataTable r-model:data="rows" :columns="cols" />
*/
const data = defineModel<any[]>('data', { required: true });
/**
* `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
*/
const sorting = defineModel<any[]>('sorting', { default: () => [] });
/**
* The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
*/
const globalFilter = defineModel<string>('globalFilter', { default: '' });
/**
* `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
*/
const columnFilters = defineModel<any[]>('columnFilters', { default: () => [] });
/**
* `{ 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`.
*/
const pagination = defineModel<Record<string, any>>('pagination', { default: () => ({
pageIndex: 0,
pageSize: 10
}) });
/**
* `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.
*/
const expanded = defineModel<Record<string, any> | boolean>('expanded', { default: 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.
*/
const grouping = defineModel<any[]>('grouping', { default: null });
/**
* `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.
*/
const rowSelection = defineModel<Record<string, any>>('rowSelection', { default: () => ({}) });
/**
* `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
*/
const columnVisibility = defineModel<Record<string, any>>('columnVisibility', { default: () => ({}) });
/**
* `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
*/
const columnSizing = defineModel<Record<string, any>>('columnSizing', { default: () => ({}) });
/**
* `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
*/
const columnOrder = defineModel<any[]>('columnOrder', { default: () => [] });
/**
* `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
*/
const columnPinning = defineModel<Record<string, any>>('columnPinning', { default: () => ({
left: [],
right: []
}) });
const emit = defineEmits<{
'sort-change': [...args: any[]];
'expand-change': [...args: any[]];
'group-change': [...args: any[]];
'filter-change': [...args: any[]];
'page-change': [...args: any[]];
'selection-change': [...args: any[]];
'visibility-change': [...args: any[]];
'resize-change': [...args: any[]];
'reorder-change': [...args: any[]];
'pin-change': [...args: any[]];
'activecell-change': [...args: any[]];
'range-change': [...args: any[]];
'cell-edit-commit': [...args: any[]];
'row-edit-commit': [...args: any[]];
}>();
defineSlots<{
default(props: { }): any;
groupBar(props: { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any }): any;
selectAll(props: { checked: any; indeterminate: any; toggle: any }): any;
colHeader(props: { columnId: any; column: any; label: any }): any;
colHeader(props: { columnId: any; column: any; label: any }): any;
filter(props: { columnId: any; uniqueValues: any; minMax: any; setFilter: any }): any;
selectCell(props: { row: any; checked: any; toggle: any }): any;
cell(props: { columnId: any; column: any; row: any; value: any }): any;
editor(props: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any }): any;
cell(props: { columnId: any; column: any; row: any; value: any }): any;
detail(props: { row: any }): any;
selectAll(props: { checked: any; indeterminate: any; toggle: any }): any;
colHeader(props: { columnId: any; column: any; label: any }): any;
colHeader(props: { columnId: any; column: any; label: any }): any;
filter(props: { columnId: any; uniqueValues: any; minMax: any; setFilter: any }): any;
selectCell(props: { row: any; checked: any; toggle: any }): any;
cell(props: { columnId: any; column: any; row: any; value: any }): any;
editor(props: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any }): any;
cell(props: { columnId: any; column: any; row: any; value: any }): any;
detail(props: { row: any }): any;
}>();
const slots = useSlots();
const dataDefault = ref<any[]>([]);
const sortingDefault = ref<any[]>([]);
const globalFilterDefault = ref('');
const columnFiltersDefault = ref<any[]>([]);
const paginationDefault = ref({
pageIndex: 0,
pageSize: 10
});
const rowSelectionDefault = ref({});
const expandedDefault = ref({});
const groupingDefault = ref<any[]>([]);
const columnVisibilityDefault = ref({});
const columnSizingDefault = ref({});
const columnOrderDefault = ref<any[]>([]);
const columnPinningDefault = ref({
left: [],
right: []
});
const columnSizingInfo = ref({
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: []
});
const colReg = ref({});
const rows = ref<any[]>([]);
const headerGroups = ref<any[]>([]);
const rowModelVer = ref(0);
const windowVer = ref(0);
const activeRow = ref(0);
const activeColIndex = ref(0);
const activeIsHeader = ref(false);
const activeHeaderLevel = ref(0);
const activeInControl = ref(false);
const editingRow = ref(-1);
const editingCol = ref(-1);
const draftValue = ref<any>(null);
const invalidMsg = ref('');
const editVer = ref(0);
const editingRowIndex = ref<any>(null);
const rowDraft = ref({});
const rangeAnchor = ref<any>(null);
const rangeFocus = ref<any>(null);
const pasteAnnounce = ref('');
const __rozieRootRef = ref<HTMLElement>();
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table: any = null;
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false;
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10;
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot: any = null;
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0;
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false;
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
const groupingActiveDefault = () => ((grouping.value != null ? grouping.value : groupingDefault.value) || []).length > 0;
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
const currentState = (): any => ({
sorting: sorting.value != null ? sorting.value : sortingDefault.value,
globalFilter: globalFilter.value != null ? globalFilter.value : globalFilterDefault.value,
columnFilters: columnFilters.value != null ? columnFilters.value : columnFiltersDefault.value,
pagination: pagination.value != null ? pagination.value : paginationDefault.value,
rowSelection: rowSelection.value != null ? rowSelection.value : rowSelectionDefault.value,
// expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
// literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
// guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
// GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
// `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
// literal so the grouped subtree is visible by default; the first toggle latches
// expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
// unchanged → byte-identical-off (the table + the expandable-rows feature both keep
// $data.expandedDefault).
expanded: expanded.value != null ? expanded.value : groupingActiveDefault() && !expandedTouched ? true : expandedDefault.value,
// grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
// to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
// is inert when this is empty (byte-identical-off, req-10).
grouping: grouping.value != null ? grouping.value : groupingDefault.value,
columnVisibility: columnVisibility.value != null ? columnVisibility.value : columnVisibilityDefault.value,
columnSizing: columnSizing.value != null ? columnSizing.value : columnSizingDefault.value,
columnOrder: columnOrder.value != null ? columnOrder.value : columnOrderDefault.value,
columnPinning: columnPinning.value != null ? columnPinning.value : columnPinningDefault.value,
// columnSizingInfo: table-core's transient resize-gesture state. We pass an
// EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
// `column.getIsResizing()` / `getResizeHandler()` read
// `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
// absent. Seed the default shape (matches table-core's
// getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
// every render. Not a two-way model slice (transient gesture state, not consumer
// state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
columnSizingInfo: columnSizingInfo.value
});
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
const currentData = (): any => data.value != null ? data.value : dataDefault.value;
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
const isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
const wrapAggregationFn = (fn: any) => {
if (typeof fn === 'string') return fn;
if (typeof fn !== 'function') return undefined;
return (columnId: any, leafRows: any, childRows: any) => {
try {
return fn(columnId, leafRows, childRows);
} catch (err: any) {
return undefined;
}
};
};
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
const buildConfigDef = (c: any) => {
if (!c) return null;
// Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
// getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
// header spans its leaf children (B12). The group id falls back to its header text so it
// stays addressable (no accessor; group columns carry no data).
if (Array.isArray(c.columns)) {
const gid = c.id != null ? c.id : c.header;
if (gid == null) return null;
const id = String(gid);
if (!isSafeKey(id)) return null;
const kids = [];
for (const child of c.columns as any) {
const cd = buildConfigDef(child);
if (cd) kids.push(cd);
}
if (!kids.length) return null;
return {
id,
header: c.header != null ? c.header : id,
columns: kids
};
}
const rawId = c.id != null ? c.id : c.field;
if (rawId == null) return null;
const id = String(rawId);
if (!isSafeKey(id)) return null;
return {
id,
accessorKey: c.field != null ? c.field : id,
header: c.header != null ? c.header : id,
enableSorting: c.sortable === true,
// per-column filter opt-in (req-5). table-core gates the filter input + value
// funnel on enableColumnFilter; a column with filterable !== true cannot be
// filtered (and renders no per-column filter input in the chrome below).
enableColumnFilter: c.filterable === true,
filterable: c.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: c.expandable === true,
// Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
// so every data column is offered to the headless #groupBar by default; the per-column
// aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
// a custom fn defensively wrapped (T-50-04).
groupable: c.groupable !== false,
aggregationFn: wrapAggregationFn(c.aggregationFn),
pinned: c.pinned != null ? c.pinned : '',
width: c.width != null ? c.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
// metadata carrier the display↔editor branch + runValidator read. Off by default.
meta: {
editable: c.editable === true,
editor: c.editor != null ? c.editor : 'text',
editorOptions: c.editorOptions != null ? c.editorOptions : [],
validate: typeof c.validate === 'function' ? c.validate : null
}
};
};
const columnDefs = () => {
const byId = Object.create(null);
const order = [];
const cfg = props.columns || [];
for (const c of cfg as any) {
const def = buildConfigDef(c);
if (!def) continue;
const id = def.id;
if (!(id in byId)) order.push(id);
byId[id] = def;
}
const reg = colReg.value || {};
for (const id in reg) {
if (!isSafeKey(id)) continue;
const spec = reg[id];
if (!spec) continue;
if (!(id in byId)) order.push(id);
byId[id] = {
id,
accessorKey: spec.field != null ? spec.field : id,
header: spec.header != null ? spec.header : id,
enableSorting: spec.sortable === true,
enableColumnFilter: spec.filterable === true,
filterable: spec.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: spec.expandable === true,
// Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
groupable: spec.groupable !== false,
aggregationFn: wrapAggregationFn(spec.aggregationFn),
pinned: spec.pinned != null ? spec.pinned : '',
width: spec.width != null ? spec.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
meta: {
editable: spec.editable === true,
editor: spec.editor != null ? spec.editor : 'text',
editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
validate: typeof spec.validate === 'function' ? spec.validate : null
}
};
}
const out = [];
for (const id of order as any) if (byId[id]) out.push(byId[id]);
return out;
};
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
const SELECT_COL_ID = '__rdt_select';
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
const EXPANDER_COL_ID = '__rdt_expander';
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
const selectionEnabled = () => props.selectionMode === 'single' || props.selectionMode === 'multiple';
const tableColumns = () => {
const cols = columnDefs();
// Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
// isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
// value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
// → byte-identical-off (req-10).
let withExpander = cols;
if (props.expandable === true) {
const expanderCol = {
id: EXPANDER_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isExpanderColumn: true,
pinned: '',
width: ''
};
withExpander = [expanderCol].concat(cols);
}
if (selectionEnabled()) {
const selectCol = {
id: SELECT_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isSelectColumn: true,
pinned: '',
width: ''
};
return [selectCol].concat(withExpander);
}
return withExpander;
};
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
const writeSorting = (next: any) => {
if (programmatic) return;
programmatic++;
sortingDefault.value = next; // fresh array only (never in-place)
sorting.value = next; // two-way emit if bound (no-op-diff if not)
emit('sort-change', next);
programmatic--;
};
const applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
const writeExpanded = (next: any) => {
if (programmatic) return;
programmatic++;
// Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
// the user now owns the expanded state, so currentState() stops defaulting grouped rows to
// the `true` expand-all literal and honors $data.expandedDefault from here on.
expandedTouched = true;
expandedDefault.value = next; // fresh value only (never in-place)
expanded.value = next; // two-way emit if bound (no-op-diff if not)
// Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
// prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
// interface, and an `expanded-change` event would camelCase to the SAME identifier
// → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
// sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
// sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
emit('expand-change', next);
programmatic--;
};
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
const writeGrouping = (next: any) => {
if (programmatic) return;
programmatic++;
groupingDefault.value = next; // fresh ordered array only (never in-place push)
grouping.value = next; // two-way emit if bound (no-op-diff if not)
emit('group-change', next);
programmatic--;
};
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
const writeGlobalFilter = (next: any) => {
if (programmatic) return;
programmatic++;
globalFilterDefault.value = next;
globalFilter.value = next;
emit('filter-change', {
globalFilter: next
});
programmatic--;
};
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
const writeColumnFilters = (next: any) => {
if (programmatic) return;
programmatic++;
columnFiltersDefault.value = next;
columnFilters.value = next;
emit('filter-change', {
columnFilters: next
});
programmatic--;
};
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
const writePagination = (next: any) => {
if (programmatic) return;
programmatic++;
paginationDefault.value = next;
pagination.value = next;
emit('page-change', next);
programmatic--;
};
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
const writeRowSelection = (next: any) => {
if (programmatic) return;
programmatic++;
rowSelectionDefault.value = next;
rowSelection.value = next;
emit('selection-change', next);
programmatic--;
};
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
const writeColumnVisibility = (next: any) => {
if (programmatic) return;
programmatic++;
columnVisibilityDefault.value = next;
columnVisibility.value = next;
emit('visibility-change', next);
programmatic--;
};
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
const writeColumnSizing = (next: any) => {
if (programmatic) return;
programmatic++;
columnSizingDefault.value = next;
columnSizing.value = next;
emit('resize-change', next);
programmatic--;
};
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
const writeColumnOrder = (next: any) => {
if (programmatic) return;
programmatic++;
columnOrderDefault.value = next;
columnOrder.value = next;
emit('reorder-change', next);
programmatic--;
};
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
const writeColumnPinning = (next: any) => {
if (programmatic) return;
programmatic++;
columnPinningDefault.value = next;
columnPinning.value = next;
emit('pin-change', next);
programmatic--;
};
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
const writeData = (next: any) => {
if (programmatic) return;
programmatic++;
dataDefault.value = next; // fresh array only (never in-place)
data.value = next; // two-way emit if bound (no-op-diff if not)
programmatic--;
};
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
const columnFilterValue = (colId: any) => {
const cf = currentState().columnFilters || [];
for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
return '';
};
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
const setColumnFilter = (colId: any, value: any) => {
const prev = currentState().columnFilters || [];
const next = [];
for (const f of prev as any) if (f && f.id !== colId) next.push(f);
if (value != null && value !== '') next.push({
id: colId,
value
});
writeColumnFilters(next);
};
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel: any = null;
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
const onSortingChangeCb = (updater: any) => {
writeSorting(applyUpdater(updater, currentState().sorting));
};
const onExpandedChangeCb = (updater: any) => {
writeExpanded(applyUpdater(updater, currentState().expanded));
};
const onGroupingChangeCb = (updater: any) => {
writeGrouping(applyUpdater(updater, currentState().grouping));
};
const onGlobalFilterChangeCb = (updater: any) => {
writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
};
const onColumnFiltersChangeCb = (updater: any) => {
writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
};
const onPaginationChangeCb = (updater: any) => {
writePagination(applyUpdater(updater, currentState().pagination));
};
const onRowSelectionChangeCb = (updater: any) => {
writeRowSelection(applyUpdater(updater, currentState().rowSelection));
};
const onColumnVisibilityChangeCb = (updater: any) => {
writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
};
const onColumnSizingChangeCb = (updater: any) => {
writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
};
const onColumnOrderChangeCb = (updater: any) => {
writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
};
const onColumnPinningChangeCb = (updater: any) => {
writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
};
const onColumnSizingInfoChangeCb = (updater: any) => {
const next = applyUpdater(updater, columnSizingInfo.value);
columnSizingInfo.value = next != null ? next : columnSizingInfo.value;
};
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
const windowSource = () => {
if (!table) return [];
if (props.virtual) return table.getPrePaginationRowModel().rows;
return table.getRowModel().rows;
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
// - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
// tick that the windowVer bump triggers, so the recycled nodes already exist by the next
// microtask — measuring there observes them while they are still connected, BEFORE the next
// fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
// fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
// frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
// total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
// - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
// committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
// - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
// tick that the windowVer bump triggers, so the recycled nodes already exist by the next
// microtask — measuring there observes them while they are still connected, BEFORE the next
// fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
// fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
// frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
// total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
// - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
// committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
};
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
const pinnedEditIndex = () => {
if (editingRow.value >= 0) return editingRow.value;
if (editingRowIndex.value != null) return editingRowIndex.value;
return -1;
};
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
const pinnedMeasurement = (pin: any) => {
if (!virtualizer || pin < 0) return null;
const ms = virtualizer.getMeasurements();
return ms && ms[pin] ? ms[pin] : null;
};
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
const remeasureWindow = () => {
if (!virtualizer || !gridRoot) return;
// Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
// exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
// null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
// scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
// resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
// regression); the next settled onChange re-measures the stable window. Manual-scroll
// recycling (the CR-01 case) has scrollState === null, so it measures normally.
if (virtualizer.scrollState) return;
const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
for (const tr of trs as any) virtualizer.measureElement(tr);
};
// D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
// math (windowedRows / padTop / padBottom / pmIndexInWindow / rowIsOutsideWindow / virtualizerOptions
// / virtualItemKey) is imported DIRECTLY by the host (DataTable.rozie) from
// `@rozie-ui/headless-core/windowing.rzts` via bare specifier — the P0-proven cross-package inline
// path that DISSOLVES the partial into the leaf (a re-export-from THROUGH this shell would survive as
// a runtime import, not inline — verified). The math closes over these host symbols by convention.
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
const virtualItemKey = (i: any) => {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
};
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
const virtualizerOptions = (): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: () => props.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
windowVer.value = windowVer.value + 1;
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
});
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
const pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => pinnedMeasurement(pin);
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
const windowedRows = () => {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer.value;
void editVer.value;
if (!virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!props.virtual) {
const rowList = rows.value || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.getVirtualItems();
const rowList = rows.value || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
const padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer.value;
void editVer.value;
if (!props.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
const padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer.value;
void editVer.value;
if (!props.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
const pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
const rowIsOutsideWindow = (r: any) => {
if (!props.virtual || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
const reFeed = () => {
if (!table) return;
table.setOptions((prev: any) => ({
...prev,
data: currentData(),
columns: tableColumns(),
state: currentState(),
enableRowSelection: props.selectionMode !== 'none',
enableMultiRowSelection: props.selectionMode === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (props.getSubRows || undefined) as any,
getRowCanExpand: props.expandable === true && props.getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb
}));
if (refreshRowModel) refreshRowModel();
};
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
let lastData: any = null;
let lastDataLen = -1;
// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
const onHeaderSort = (colId: any, evt: any) => {
if (!table) return;
const col = table.getColumn(colId);
if (!col || !col.getCanSort()) return;
const multi = !!(evt && evt.shiftKey);
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi);
};
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
const tick = () => rowModelVer.value;
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
const ariaSortFor = (colId: any) => {
if (tick() < 0 || !table) return 'none';
const col = table.getColumn(colId);
if (!col) return 'none';
const dir = col.getIsSorted();
if (dir === 'asc') return 'ascending';
if (dir === 'desc') return 'descending';
return 'none';
};
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
const sortIndicator = (colId: any) => {
if (tick() < 0 || !table) return '';
const col = table.getColumn(colId);
if (!col) return '';
const dir = col.getIsSorted();
if (dir === 'asc') return '▲';
if (dir === 'desc') return '▼';
return '';
};
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
const defFor = (colId: any) => {
const defs = columnDefs();
for (const d of defs as any) if (d.id === colId) return d;
return null;
};
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. Solid's reference-keyed <For> keeps the existing <tr> and will NOT re-run a
// child loop whose `each` reads no signal — so a bare `row.getVisibleCells()` goes
// stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. Solid's reference-keyed <For> keeps the existing <tr> and will NOT re-run a
// child loop whose `each` reads no signal — so a bare `row.getVisibleCells()` goes
// stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
const visibleCellsFor = (row: any) => rowModelVer.value >= 0 ? row.getVisibleCells() : [];
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
const editMetaOf = (colId: any) => {
const d = defFor(colId);
return d && d.meta ? d.meta : null;
};
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
const columnEditable = (colId: any) => {
const m = editMetaOf(colId);
return !!(m && m.editable === true);
};
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
const editorTypeOf = (colId: any) => {
const m = editMetaOf(colId);
return m && m.editor != null ? m.editor : 'text';
};
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
const editorOptionsOf = (colId: any) => {
const m = editMetaOf(colId);
return m && m.editorOptions != null ? m.editorOptions : [];
};
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
const hasEditorSlot = (colId: any) => editorTypeOf(colId) === 'custom' && !!slots.editor;
const columnIsFilterable = (colId: any) => {
const d = defFor(colId);
return !!(d && d.filterable);
};
const headerLabel = (colId: any) => {
const d = defFor(colId);
return d ? d.header : colId;
};
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
const headerWidth = (colId: any) => {
if (tick() < 0 || !table) return null;
const col = table.getColumn(colId);
if (!col) return null;
const w = col.getSize();
return w != null && w > 0 ? w + 'px' : null;
};
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
const onResizeStart = (colId: any, evt: any) => {
// stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table) return;
const header = findHeader(colId);
if (!header || !header.getResizeHandler) return;
const handler = header.getResizeHandler();
if (handler) handler(evt);
};
// Find the live header object for a column id across the rendered header groups.
// Find the live header object for a column id across the rendered header groups.
const findHeader = (colId: any) => {
const groups = headerGroups.value || [];
for (const hg of groups as any) {
const hs = hg.headers || [];
for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
}
return null;
};
const columnIsResizing = (colId: any) => {
if (tick() < 0 || !table) return false;
const header = findHeader(colId);
return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
const columnIsVisible = (colId: any) => {
if (tick() < 0 || !table) return true;
const col = table.getColumn(colId);
return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};
const onToggleVisibility = (colId: any) => {
if (!table) return;
const col = table.getColumn(colId);
if (col && col.toggleVisibility) col.toggleVisibility();
};
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
const allLeafColumns = () => {
if (tick() < 0 || !table) return [];
const cols = table.getAllLeafColumns ? table.getAllLeafColumns() : [];
const out = [];
for (const c of cols as any) {
if (!c || c.id === SELECT_COL_ID) continue;
out.push({
id: c.id,
label: headerLabel(c.id),
visible: !!(c.getIsVisible && c.getIsVisible())
});
}
return out;
};
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
const columnPinSide = (colId: any) => {
if (tick() < 0 || !table) return false;
const col = table.getColumn(colId);
if (!col || !col.getIsPinned) return false;
return col.getIsPinned();
};
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
const onPinColumn = (colId: any, side: any, evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table) return;
const col = table.getColumn(colId);
if (col && col.pin) col.pin(side);
};
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
const pinStyle = (colId: any) => {
if (tick() < 0 || !table) return '';
const col = table.getColumn(colId);
if (!col || !col.getIsPinned) return '';
const side = col.getIsPinned();
if (side === 'left') {
const left = col.getStart ? col.getStart('left') : 0;
return 'position:sticky;left:' + left + 'px;z-index:1;';
}
if (side === 'right') {
const right = col.getAfter ? col.getAfter('right') : 0;
return 'position:sticky;right:' + right + 'px;z-index:1;';
}
return '';
};
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
const thStyle = (colId: any) => {
let s = '';
const w = headerWidth(colId);
if (w) s += 'width:' + w + ';';
s += pinStyle(colId);
return s;
};
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
const onGlobalFilterInput = (evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
if (table) {
table.setGlobalFilter(value);
return;
}
writeGlobalFilter(value);
};
// Per-column filter input → setColumnFilter (fresh-array funnel).
// Per-column filter input → setColumnFilter (fresh-array funnel).
const onColumnFilterInput = (colId: any, evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
setColumnFilter(colId, value);
};
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
const globalFilterValue = () => {
const v = currentState().globalFilter;
return v != null ? v : '';
};
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
const pageIndex = () => {
if (tick() >= 0 && table) return table.getState().pagination.pageIndex;
const p = currentState().pagination;
return p && p.pageIndex != null ? p.pageIndex : 0;
};
const pageSize = () => {
if (tick() >= 0 && table) return table.getState().pagination.pageSize;
const p = currentState().pagination;
return p && p.pageSize != null ? p.pageSize : 10;
};
const pageCount = () => {
if (tick() < 0 || !table) return 1;
const c = table.getPageCount();
return c != null && c > 0 ? c : 1;
};
const canPrevPage = () => !!(tick() >= 0 && table && table.getCanPreviousPage());
const canNextPage = () => !!(tick() >= 0 && table && table.getCanNextPage());
const onPrevPage = () => {
if (table) table.previousPage();
};
const onNextPage = () => {
if (table) table.nextPage();
};
const onPageSizeChange = (evt: any) => {
if (!table) return;
const v = evt && evt.target ? evt.target.value : '';
const n = parseInt(v, 10);
table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
const isSelectColumn = (colId: any) => colId === SELECT_COL_ID;
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
const isExpanderColumn = (colId: any) => colId === EXPANDER_COL_ID;
const rowCanExpand = (row: any) => !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
const rowIsExpanded = (row: any) => !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. With getSubRows the children arrive as ordinary depth-indented rows in
// $data.rows (table-core flattens) — NO additive detail row, NO nested r-for (Pitfall 1).
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. With getSubRows the children arrive as ordinary depth-indented rows in
// $data.rows (table-core flattens) — NO additive detail row, NO nested r-for (Pitfall 1).
const rowShowsDetail = (row: any) => props.getSubRows == null && rowIsExpanded(row);
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
const onToggleExpand = (row: any, evt: any) => {
if (!row || !row.toggleExpanded) return;
// Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
// expanded-state re-render. On Solid the expander <td>/<button> is RECREATED on that
// re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
// pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
// breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
// Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
// the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
// reactive flush so the fresh node exists. Harmless on the targets that keep the node
// (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
row.toggleExpanded();
if (ownerRow && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
const btn = ownerRow.querySelector('[data-expander]');
if (btn) btn.focus();
});
}
};
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
const bodyCellStyle = (row: any, colId: any) => {
const base = pinStyle(colId);
if (isExpanderColumn(colId) && row && row.depth) {
const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
return base ? base + ';' + pad : pad;
}
return base;
};
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
const rowIsGrouped = (row: any) => !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
const groupingActive = () => tick() >= 0 && (currentState().grouping || []).length > 0;
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
const cellIsGrouped = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
const cellIsAggregated = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
const groupSubRowCount = (row: any) => row && row.subRows ? row.subRows.length : 0;
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
const groupingKeys = () => currentState().grouping || [];
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
const groupableColumns = () => {
const out = [];
const defs = columnDefs();
for (const d of defs as any) {
if (!d || d.groupable === false) continue;
out.push({
id: d.id,
label: d.header != null ? d.header : d.id
});
}
return out;
};
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
const stopEvent = (evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
};
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
const isAllRowsSelected = () => !!(tick() >= 0 && table && table.getIsAllRowsSelected());
const isSomeRowsSelected = () => !!(tick() >= 0 && table && table.getIsSomeRowsSelected());
const onToggleAllRows = (evt: any) => {
if (!table) return;
table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
const rowIsSelected = (row: any) => {
if (!row) return false;
const id = row.id;
const sel = currentState().rowSelection || {};
if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
return !!(row.getIsSelected && row.getIsSelected());
};
const onToggleRow = (row: any, evt: any) => {
if (!row || !row.toggleSelected) return;
row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox: any = null;
const syncIndeterminate = () => {
if (!__rozieRootRef.value || !__rozieRootRef.value!.querySelector) return;
selectAllBox = __rozieRootRef.value!.querySelector('.rdt-select-all');
if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
};
// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
// Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
// `const` (the canonical $expose contract — `$expose({ name })` references a
// binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
// is dropped on ALL SIX targets, only the by-reference key survives → a
// runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
// a fresh column-def readout, selection, pagination, and column-management verbs.
const sortColumn = (colId: any, desc: any) => {
if (table) table.getColumn(colId) && table.getColumn(colId).toggleSorting(desc, false);
};
const clearSorting = () => {
if (table) table.resetSorting(true);
};
const getColumnDefs = () => columnDefs();
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
const toggleAllRows = (value: any) => {
if (table) table.toggleAllRowsSelected(value);
};
const clearSelection = () => {
if (table) table.resetRowSelection(true);
};
const getSelectedRows = () => table ? table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
// pagination verbs.
// pagination verbs.
const setPage = (idx: any) => {
if (table) table.setPageIndex(idx);
};
const setRowsPerPage = (size: any) => {
if (table) table.setPageSize(size);
};
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
const toggleColumnVisibility = (colId: any) => {
if (table) {
const c = table.getColumn(colId);
if (c && c.toggleVisibility) c.toggleVisibility();
}
};
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
const applyColumnOrder = (order: any) => {
if (table) table.setColumnOrder(order);
};
const resetColumnSizing = () => {
if (table) table.resetColumnSizing(true);
};
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
const pinColumn = (colId: any, side: any) => {
if (table) {
const c = table.getColumn(colId);
if (c && c.pin) c.pin(side);
}
};
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
const getRowIndexRelativeToPage = (absRow: any) => {
const abs = absRow == null ? toAbsRow(activeRow.value) : Math.trunc(Number(absRow)) || 0;
if (props.virtual) return abs;
return abs - pageRowOffset();
};
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
const cut = () => cutRange();
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
const isGrid = () => props.interactionMode === 'grid';
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
const tableRole = () => isGrid() ? 'grid' : 'table';
const cellRole = () => isGrid() ? 'gridcell' : 'cell';
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
const rowIndexOf = (row: any) => tick() >= 0 ? (rows.value || []).indexOf(row) : -1;
// colIndexOf: a body cell's position in its row's visible cell list.
// colIndexOf: a body cell's position in its row's visible cell list.
const colIndexOf = (row: any, cellCtx: any) => tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
// headerColIndexOf: a header cell's position in its header group's leaf headers.
// headerColIndexOf: a header cell's position in its header group's leaf headers.
const headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
// - virtual mode: activeRow is already the full pre-pagination index → offset 0.
// - non-virtual: activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
// - virtual mode: activeRow is already the full pre-pagination index → offset 0.
// - non-virtual: activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
const pageRowOffset = () => {
if (!isGrid() || props.virtual) return 0;
return pageIndex() * pageSize();
};
// page-relative active row → absolute (display-order) index.
// page-relative active row → absolute (display-order) index.
const toAbsRow = (localRow: any) => localRow + pageRowOffset();
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
const absRowIndexOf = (row: any) => rowIndexOf(row) + pageRowOffset();
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
const prePaginationRowCount = () => {
if (!table || props.virtual) return bodyRowCount();
const pm = table.getPrePaginationRowModel();
return pm && pm.rows ? pm.rows.length : bodyRowCount();
};
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
const cellTabindex = (rowKey: any, colIndex: any, level = null) => {
if (!isGrid()) return null;
// B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
// the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
// tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
if (bodyRowCount() === 0) {
return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
}
// B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
// grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
// lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
// tab-stops (the roving invariant broke under grouped headers).
if (activeIsHeader.value) {
if (rowKey !== '__header') return -1;
return colIndex === activeColIndex.value && level === activeHeaderLevel.value ? 0 : -1;
}
const isActive = rowKey === String(activeRow.value) && colIndex === activeColIndex.value;
return isActive ? 0 : -1;
};
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
const resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
if (!gridRoot) return null;
// B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
// same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
// integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
// match (the parent) when the leaf is meant. level is an integer (NO consumer string is
// interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
// byte-unchanged.
let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
return gridRoot.querySelector(sel);
};
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
const focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
if (!isGrid() || !gridRoot) return;
const r = nextRow == null ? activeRow.value : nextRow;
const c = nextCol == null ? activeColIndex.value : nextCol;
// B12: thread the FRESH post-write header level (the grouped-header analog of the
// nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
// level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
const lvl = nextLevel == null ? activeHeaderLevel.value : nextLevel;
// Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
// crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
// Angular's signal write are async within one handler — re-reading $data.activeIsHeader
// here returns the PRE-write value, resolving focus to the BODY cell instead of the
// header. Callers pass the fresh isHeader local; falls back to $data when omitted.
const header = nextIsHeader == null ? activeIsHeader.value : nextIsHeader;
// ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
// rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
// double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
// in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
// The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
// while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
if (props.virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
virtualizer.scrollToIndex(r, {
align: 'center'
});
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
let focusAttempts = 0;
const focusWhenReady = () => {
const el = resolveCellEl(String(r), c);
if (el) {
el.focus();
return;
}
focusAttempts = focusAttempts + 1;
if (focusAttempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
return;
}
const rowKey = header ? '__header' : String(r);
const el = resolveCellEl(rowKey, c, header ? lvl : null);
if (el) el.focus();
};
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).
// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).
// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
const totalRowCount = () => {
if (!table) return (rows.value || []).length;
const fm = table.getFilteredRowModel();
return fm && fm.rows ? fm.rows.length : (rows.value || []).length;
};
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
const visibleColCount = () => {
// NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
// state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
// self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
const rowList = rows.value || [];
if (rowList.length) return rowList[0].getVisibleCells().length;
const hg = headerGroups.value || [];
return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};
const bodyRowCount = () => (rows.value || []).length;
const clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
const headerLeafLevel = () => {
const hg = headerGroups.value || [];
return hg.length ? hg.length - 1 : 0;
};
const headerAt = (level: any, colIndex: any) => {
const hg = headerGroups.value || [];
const grp = hg[level];
if (!grp || !grp.headers) return null;
return grp.headers[colIndex] || null;
};
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
const parentHeaderColIndex = (level: any, colIndex: any) => {
if (level <= 0) return -1;
const h = headerAt(level, colIndex);
if (!h || !h.column || !h.column.parent) return -1;
const parentId = h.column.parent.id;
const hg = headerGroups.value || [];
const pg = hg[level - 1];
if (!pg || !pg.headers) return -1;
for (let i = 0; i < pg.headers.length; i++) {
const ph = pg.headers[i];
if (ph && ph.column && ph.column.id === parentId) return i;
}
return -1;
};
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
const firstChildHeaderColIndex = (level: any, colIndex: any) => {
const h = headerAt(level, colIndex);
if (!h || !h.column) return -1;
const kids = h.column.columns || [];
if (!kids.length) return -1;
const childId = kids[0].id;
const hg = headerGroups.value || [];
const cg = hg[level + 1];
if (!cg || !cg.headers) return -1;
for (let i = 0; i < cg.headers.length; i++) {
const ch = cg.headers[i];
if (ch && ch.column && ch.column.id === childId) return i;
}
return -1;
};
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────
// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────
// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
const moveCol = (delta: any) => {
const max = visibleColCount() - 1;
const nextCol = clamp(activeColIndex.value + delta, 0, max < 0 ? 0 : max);
activeColIndex.value = nextCol;
return nextCol;
};
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
// - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
// - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
// - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
// - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
// - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
// - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
const moveRow = (delta: any) => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const leafLevel = headerLeafLevel();
if (activeIsHeader.value) {
if (delta > 0) {
// B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
// level down); from the LEAF header level, drop into the body (row 0). A header-level
// move re-targets activeColIndex (parent↔child column indices differ), so the fresh
// col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
if (activeHeaderLevel.value < leafLevel) {
const childCol = firstChildHeaderColIndex(activeHeaderLevel.value, activeColIndex.value);
if (childCol >= 0) {
const nextLevel = activeHeaderLevel.value + 1;
activeHeaderLevel.value = nextLevel;
activeColIndex.value = childCol;
return {
row: activeRow.value,
col: childCol,
isHeader: true,
level: nextLevel
};
}
}
// At the leaf header: an empty grid has no body to drop into → stay put.
if (bodyRowCount() === 0) return {
row: activeRow.value,
col: activeColIndex.value,
isHeader: true,
level: activeHeaderLevel.value
};
// B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
// (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
// page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
// (delta-1 = 0); clamped to the page-last body row.
const landRow = clamp(delta - 1, 0, maxRow);
activeIsHeader.value = false;
activeRow.value = landRow;
return {
row: landRow,
col: activeColIndex.value,
isHeader: false,
level: 0
};
}
// B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
// spans the active column; at the top level (or no real parent) stay put. The parent col
// index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
const parentCol = parentHeaderColIndex(activeHeaderLevel.value, activeColIndex.value);
if (parentCol >= 0) {
const nextLevel = activeHeaderLevel.value - 1;
activeHeaderLevel.value = nextLevel;
activeColIndex.value = parentCol;
return {
row: activeRow.value,
col: parentCol,
isHeader: true,
level: nextLevel
};
}
return {
row: activeRow.value,
col: activeColIndex.value,
isHeader: true,
level: activeHeaderLevel.value
};
}
// In the body: an upward move from row 0 crosses into the LEAF header level (the header row
// adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
// activeColIndex carries over unchanged.
if (delta < 0 && activeRow.value === 0) {
activeIsHeader.value = true;
activeHeaderLevel.value = leafLevel;
return {
row: activeRow.value,
col: activeColIndex.value,
isHeader: true,
level: leafLevel
};
}
const nextRow = clamp(activeRow.value + delta, 0, maxRow);
activeRow.value = nextRow;
activeIsHeader.value = false;
return {
row: nextRow,
col: activeColIndex.value,
isHeader: false,
level: 0
};
};
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
const gotoColEdge = (toEnd: any) => {
const max = visibleColCount() - 1;
const nextCol = toEnd ? max < 0 ? 0 : max : 0;
activeColIndex.value = nextCol;
return nextCol;
};
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
const gotoStart = () => {
activeIsHeader.value = false;
activeRow.value = 0;
activeColIndex.value = 0;
return {
row: 0,
col: 0
};
};
const gotoEnd = () => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const max = visibleColCount() - 1;
const maxCol = max < 0 ? 0 : max;
activeIsHeader.value = false;
activeRow.value = maxRow;
activeColIndex.value = maxCol;
return {
row: maxRow,
col: maxCol
};
};
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
const currentCellEl = () => {
const rowKey = activeIsHeader.value ? '__header' : String(activeRow.value);
return resolveCellEl(rowKey, activeColIndex.value, activeIsHeader.value ? activeHeaderLevel.value : null);
};
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
const focusables = (cellEl: any) => {
if (!cellEl || !cellEl.querySelectorAll) return [];
const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
return list.filter((n: any) => !n.disabled);
};
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
const enterControl = () => {
const cellEl = currentCellEl();
const list = focusables(cellEl);
if (!list.length) return;
activeInControl.value = true;
list[0].focus();
};
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
const cycleWithinCell = (cellEl: any, forward: any) => {
const list = focusables(cellEl);
if (!list.length) return;
const active = gridRoot ? gridRoot.getRootNode().activeElement : null;
const cur = list.indexOf(active);
let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
if (i >= list.length) i = 0;
if (i < 0) i = list.length - 1;
list[i].focus();
};
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
const onGridKeyDown = (e: any) => {
if (!isGrid() || !e) return;
const key = e.key;
// Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
// via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
// early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
// modes (editing / in-control / navigation) stay mutually exclusive and ordered.
if (editingRow.value >= 0) return;
// Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
// editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
// keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
if (editingRowIndex.value != null) return;
// Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
if (activeInControl.value) {
if (key === 'Escape') {
e.preventDefault();
activeInControl.value = false;
// Return focus to the OWNING cell (no move happened) — pass the current indices
// explicitly (the React-emitted seam types both params as required; a zero-arg call
// is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
focusActiveCell(activeRow.value, activeColIndex.value);
} else if (key === 'Tab') {
e.preventDefault();
cycleWithinCell(currentCellEl(), !e.shiftKey);
}
return;
}
// WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
// grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
// user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
// NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
// focused node; if it is not itself a [data-grid-cell], let the event pass through.
const tgt = e.target;
if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
// Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
// nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
// async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
// plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
// in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
// fires ONLY on a real move (a clamped no-op edge move leaves them identical).
const prevRow = activeRow.value;
const prevCol = activeColIndex.value;
const prevIsHeader = activeIsHeader.value;
const prevLevel = activeHeaderLevel.value;
let nextRow = prevRow;
let nextCol = prevCol;
let nextIsHeader = prevIsHeader;
// B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
// threaded into the focus seam so a leaf↔parent header move lands focus at the correct
// level. moveRow returns it; the non-vertical branches keep the pre-move level.
let nextLevel = prevLevel;
// ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
// the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
// fall through to a plain navigation move). Body cells only (no range from a header). The
// extendRange call owns focus + the range-change emit, so return immediately. ──────────
if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader.value) {
e.preventDefault();
extendRange(0, 1);
return;
} else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader.value) {
e.preventDefault();
extendRange(0, -1);
return;
} else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader.value) {
e.preventDefault();
extendRange(1, 0);
return;
} else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader.value) {
e.preventDefault();
extendRange(-1, 0);
return;
} else if (key === 'ArrowRight') {
e.preventDefault();
clearRange();
nextCol = moveCol(1);
} else if (key === 'ArrowLeft') {
e.preventDefault();
clearRange();
nextCol = moveCol(-1);
} else if (key === 'ArrowDown') {
e.preventDefault();
clearRange();
const m = moveRow(1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'ArrowUp') {
e.preventDefault();
clearRange();
const m = moveRow(-1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageDown') {
e.preventDefault();
const m = moveRow(GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageUp') {
e.preventDefault();
const m = moveRow(-GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'Home') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const s = gotoStart();
nextRow = s.row;
nextCol = s.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(false);
}
} else if (key === 'End') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const en = gotoEnd();
nextRow = en.row;
nextCol = en.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(true);
}
}
// ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
// pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
// edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
// type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
// gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
// falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
// (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
copyRange();
return;
} else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
pasteRange();
return;
}
// ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
// source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
// Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
// and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
// shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
cutRange();
return;
}
// ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
// EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
// (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
// collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
// at least the active editable column); a non-editable active cell falls through unchanged.
else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
e.preventDefault();
beginRowEdit((rows.value || [])[activeRow.value]);
return;
}
// ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
// Gated by isActiveCellEditable(): a non-editable active cell falls through to
// enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
// (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
e.preventDefault();
beginEdit(activeRow.value, activeColIndex.value, null);
return;
} else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
// checkbox/select/date editor must NOT take the typed char as its value (it would
// force-check the checkbox, seed a garbage select option, or corrupt the date) — open
// those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
e.preventDefault();
const editType = editorTypeOf(activeCellColumnId());
const seed = editType === 'text' || editType === 'number' ? key : null;
beginEdit(activeRow.value, activeColIndex.value, seed);
return;
}
// ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
// expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
// verified invariant) so it never hits the edit branches above and would otherwise fall to
// enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
// Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
// model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
// ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
// full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
else if (key === 'Enter' && !activeIsHeader.value && rowIsGrouped((rows.value || [])[activeRow.value])) {
e.preventDefault();
// C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
// active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
// the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
// row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
// fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
// DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
// while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
// settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
// onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
// SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
// until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
// already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
const grpRow = activeRow.value;
const grpCol = activeColIndex.value;
onToggleExpand((rows.value || [])[activeRow.value], e);
recoverGridFocus(String(grpRow), grpCol, null);
return;
} else if (key === 'Enter' || key === 'F2') {
e.preventDefault();
enterControl();
return;
} else return;
// THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
// focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
// WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
// changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
// row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
// B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
// C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
// keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
// toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
// The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
emit('activecell-change', {
rowIndex: toAbsRow(nextRow),
colIndex: nextCol
});
}
};
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
const syncActiveFromEvent = (e: any) => {
if (!isGrid() || !e) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null) return;
const col = parseInt(colAttr, 10);
if (!Number.isFinite(col)) return;
const isHeader = rowAttr === '__header';
activeIsHeader.value = isHeader;
if (isHeader) {
// B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
// roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
// one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
const lvlAttr = cellEl.getAttribute('data-header-level');
const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
activeHeaderLevel.value = Number.isFinite(lvl) ? lvl : headerLeafLevel();
} else {
const row = parseInt(rowAttr, 10);
if (Number.isFinite(row)) activeRow.value = row;
}
activeColIndex.value = col;
// A plain focus collapses any range back to the single active cell — EXCEPT (a) the
// programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
// ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
// focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
// BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
// path owns the shift case and flags it here so the collapse is skipped.
if (rangeTransition) {
rangeTransition = false;
} else if (rangeClickPending) {
rangeClickPending = false;
} else {
clearRange();
}
// The cell box (not an inner control) receiving focus = navigation mode.
if (tgt === cellEl) activeInControl.value = false;
};
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). A focusin
// event carries no reliable `shiftKey`, so the modifier MUST be read off the pointer event
// — @mousedown fires BEFORE the cell's focusin and DOES carry shiftKey. A shift-held
// mousedown on a BODY cell sets the range's moving corner to that cell (keeping the anchor),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). A focusin
// event carries no reliable `shiftKey`, so the modifier MUST be read off the pointer event
// — @mousedown fires BEFORE the cell's focusin and DOES carry shiftKey. A shift-held
// mousedown on a BODY cell sets the range's moving corner to that cell (keeping the anchor),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
const onGridMouseDown = (e: any) => {
if (!isGrid() || !e || !e.shiftKey) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
const row = parseInt(rowAttr, 10);
const col = parseInt(colAttr, 10);
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
setRangeFocus(row, col);
activeIsHeader.value = false;
activeRow.value = row;
activeColIndex.value = col;
rangeClickPending = true;
};
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
const onGridFocusOut = (e: any) => {
if (!isGrid() || !activeInControl.value) return;
const next = e ? e.relatedTarget : null;
const cellEl = currentCellEl();
if (!cellEl || !next || !cellEl.contains(next)) activeInControl.value = false;
};
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
const recoverGridFocus = (rowKey: any, col: any, level: any) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(rowKey, col, level);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
const clampActiveCell = (rowCount: any, colCount: any) => {
if (!isGrid()) return;
// B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
// just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
// async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
// and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
// out of bounds on React only). Falls back to the live helpers when called without bounds.
const colN = colCount != null ? colCount : visibleColCount();
const rowN = rowCount != null ? rowCount : bodyRowCount();
// B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
// shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
// framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
// — true on all six, incl React's async setState), so the doomed cell + its focus are still
// observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
// programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
// Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
// doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
// The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
// NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
// shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
// DOM coords are always fresh.
let recoverFocus = false;
let doomedRow = -1;
let doomedCol = 0;
if (gridRoot) {
const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
const focusedEl = rootNode ? rootNode.activeElement : null;
const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
if (focusedCell && gridRoot.contains(focusedCell)) {
const fRowAttr = focusedCell.getAttribute('data-row');
const fColAttr = focusedCell.getAttribute('data-col-index');
if (fRowAttr != null && fRowAttr !== '__header') {
const fr = parseInt(fRowAttr, 10);
const fc = parseInt(fColAttr, 10);
if (Number.isFinite(fr) && fr > rowN - 1) {
recoverFocus = true;
doomedRow = fr;
doomedCol = Number.isFinite(fc) ? fc : 0;
}
}
}
}
const maxCol = colN - 1;
const col = clamp(activeColIndex.value, 0, maxCol < 0 ? 0 : maxCol);
if (col !== activeColIndex.value) activeColIndex.value = col;
// B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
// cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
// an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
// re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
if (rowN <= 0) {
activeIsHeader.value = true;
activeHeaderLevel.value = headerLeafLevel();
activeColIndex.value = 0;
// B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
// reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
// async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
// class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
// recovery branch below actually runs on React too.
gridEmptyFallback = true;
clampRange(rowN - 1, colN - 1);
// B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
// reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
// Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
// (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
// the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
return;
}
// B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
// re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
// cell. A user-driven header position (not the empty fallback) is left untouched.
if (gridEmptyFallback) {
gridEmptyFallback = false;
activeIsHeader.value = false;
activeRow.value = 0;
}
if (!activeIsHeader.value) {
const lastRow = rowN - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const row = clamp(activeRow.value, 0, maxRow);
if (row !== activeRow.value) activeRow.value = row;
}
// B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
// shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
// out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
clampRange(rowN - 1, colN - 1);
// B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
// when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
// into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
if (recoverFocus) {
const recRow = clamp(doomedRow, 0, rowN - 1);
const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
recoverGridFocus(String(recRow), recCol, null);
}
};
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false;
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false;
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false;
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false;
const inRange = (rIdx: any, cIdx: any) => {
const a = rangeAnchor.value;
const f = rangeFocus.value;
if (!a || !f) return false;
const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
const getSelectedRange = () => {
// B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
// payload) never reports a corner past a shrunken model — React-stale-safe (the eager
// refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
const a = rangeAnchor.value;
const f = rangeFocus.value;
if (!a && !f) return {
anchor: null,
focus: null
};
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
anchor: null,
focus: null
};
const clampCorner = (c: any) => c == null ? null : {
rowIndex: clamp(c.rowIndex, 0, maxRow),
colIndex: clamp(c.colIndex, 0, maxCol)
};
return {
anchor: clampCorner(a),
focus: clampCorner(f)
};
};
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
const isFillHandleCell = (rIdx: any, cIdx: any) => {
const a = rangeAnchor.value;
const f = rangeFocus.value;
if (!a || !f) return false;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx === r1 && cIdx === c1;
};
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
const emitRangeChange = (anchor: any, focus: any) => {
emit('range-change', {
anchor,
focus
});
};
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
const extendRange = (dRow: any, dCol: any) => {
if (activeIsHeader.value) return;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
// Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
let anchor = rangeAnchor.value;
let focus = rangeFocus.value;
const hadRange = !!(anchor && focus);
if (!anchor || !focus) {
anchor = {
rowIndex: activeRow.value,
colIndex: activeColIndex.value
};
focus = {
rowIndex: activeRow.value,
colIndex: activeColIndex.value
};
}
const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
const nextFocus = {
rowIndex: nextRow,
colIndex: nextCol
};
rangeAnchor.value = anchor;
rangeFocus.value = nextFocus;
rangeActive = true;
// Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
// from the range's leading edge, the spreadsheet convention).
activeRow.value = nextRow;
activeColIndex.value = nextCol;
// Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
// settle on the new focus corner is part of THIS range extension, not a fresh navigation.
rangeTransition = true;
focusActiveCell(nextRow, nextCol, false);
// B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
// and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
// change → no emit. Seeding a brand-new range (no prior range) is always a change (the
// rectangle came into existence) even if its first corner is a degenerate 1×1.
if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
emitRangeChange(anchor, nextFocus);
}
};
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
const setRangeFocus = (rIdx: any, cIdx: any) => {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
let anchor = rangeAnchor.value;
if (!anchor) anchor = {
rowIndex: activeRow.value,
colIndex: activeColIndex.value
};
const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
const nextFocus = {
rowIndex: r,
colIndex: c
};
rangeAnchor.value = anchor;
rangeFocus.value = nextFocus;
rangeActive = true;
emitRangeChange(anchor, nextFocus);
};
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
const clearRange = () => {
// B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
// in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
// move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
// would let the SECOND call through and emit a duplicate range-change. rangeActive flips
// synchronously → the second call returns here.
if (!rangeActive) return;
rangeActive = false;
rangeAnchor.value = null;
rangeFocus.value = null;
emitRangeChange(null, null);
};
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
const clampRange = (maxRowArg: any, maxColArg: any) => {
const a = rangeAnchor.value;
const f = rangeFocus.value;
if (!a && !f) return;
// Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
// shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) {
rangeAnchor.value = null;
rangeFocus.value = null;
rangeActive = false;
return;
}
if (a) {
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
if (ar !== a.rowIndex || ac !== a.colIndex) rangeAnchor.value = {
rowIndex: ar,
colIndex: ac
};
}
if (f) {
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
if (fr !== f.rowIndex || fc !== f.colIndex) rangeFocus.value = {
rowIndex: fr,
colIndex: fc
};
}
};
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
const announce = (msg: any) => {
pasteAnnounce.value = msg != null ? msg : '';
};
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
const clipboardActiveAllowed = () => !activeIsHeader.value;
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
const fieldOfColId = (colId: any) => {
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
const normalizedRange = () => {
const a = rangeAnchor.value;
const f = rangeFocus.value;
if (!a || !f) return null;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return null;
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
return {
r0: ar < fr ? ar : fr,
r1: ar > fr ? ar : fr,
c0: ac < fc ? ac : fc,
c1: ac > fc ? ac : fc
};
};
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
const escapeTsvField = (s: any) => {
if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
const rangeToTsv = () => {
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow.value;
const r1 = box ? box.r1 : activeRow.value;
const c0 = box ? box.c0 : activeColIndex.value;
const c1 = box ? box.c1 : activeColIndex.value;
const lines = [];
for (let r = r0; r <= r1; r++) {
const cells = [];
for (let c = c0; c <= c1; c++) {
const v = cellValueAt(r, c);
cells.push(escapeTsvField(v == null ? '' : String(v)));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
};
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
const parseTsv = (text: any) => {
const str = text != null ? String(text) : '';
// CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
// large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
// single-pass scan allocates a cell-per-character grid.
if (str === '' || str.length > 2000000) return [];
// B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
// cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
// newlines, and doubled quotes ("") inside it are literal content until the closing quote;
// an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = str.length;
while (i < n) {
const ch = str[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < n && str[i + 1] === '"') {
field = field + '"';
i = i + 2;
continue;
}
inQuotes = false;
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
continue;
}
if (ch === '"' && field === '') {
inQuotes = true;
i = i + 1;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
i = i + 1;
continue;
}
if (ch === '\r') {
if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
if (ch === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
}
// Flush the trailing field + row.
row.push(field);
rows.push(row);
// Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
if (rows.length > 1) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
};
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
const copyRange = () => {
// B11: never copy from a header-active state (the reusable clipboard guard).
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
try {
const p = navigator.clipboard.writeText(rangeToTsv());
if (p && p.catch) p.catch(() => {});
} catch (err: any) {/* best-effort copy */}
};
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
const applyGridToRange = (grid: any, originRow: any, originCol: any) => {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
wrote: 0,
total: 0
};
let total = 0;
let wrote = 0;
const committed = [];
// Build the fresh data array incrementally so the whole paste is ONE writeData.
let next = currentData();
for (let gr = 0; gr < grid.length; gr++) {
const r = originRow + gr;
if (r > maxRow) break;
const cols = grid[gr] || [];
for (let gc = 0; gc < cols.length; gc++) {
const c = originCol + gc;
if (c > maxCol) break;
total = total + 1;
const colId = columnIdAt(r, c);
if (colId == null || !columnEditable(colId)) continue;
const rowObj = rowOriginalAt(r);
// B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
// single-cell commit coercion) — a numeric column commits a real Number, an empty cell
// commits null; every other editor type passes through verbatim. No mixed/garbage types
// ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
const value = coerceCellValue(colId, cols[gc]);
// T-51-01: validate the pasted value as plain DATA before any write.
if (runValidator(colId, value, rowObj) !== true) continue;
const field = fieldOfColId(colId);
const srcIndex = sourceIndexOfRow(r);
const oldValue = rowObj ? rowObj[field] : null;
next = replaceRowValue(next, srcIndex, field, value);
committed.push({
rowId: rowIdAt(r),
columnId: colId,
oldValue,
newValue: value
});
wrote = wrote + 1;
}
}
if (wrote > 0) {
editTransition = true;
writeData(next);
editTransition = false;
// One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
for (let i = 0; i < committed.length; i++) emit('cell-edit-commit', committed[i]);
}
// WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
// targeted real cells but every one was skipped (validation-failed / non-editable), announce a
// distinct validation-failed message instead of a misleading "0 of M cells pasted".
if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
return {
wrote,
total
};
};
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
const rowOriginalAt = (rowIndex: any) => {
const rowList = rows.value || [];
const row = rowList[rowIndex];
return row ? row.original : null;
};
const rowIdAt = (rowIndex: any) => {
const rowList = rows.value || [];
const row = rowList[rowIndex];
return row ? row.id : null;
};
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
const tileGridToBox = (grid: any, box: any) => {
const srcRows = grid.length;
const srcCols = srcRows > 0 ? grid[0].length : 0;
if (srcRows <= 0 || srcCols <= 0) return grid;
const boxRows = box.r1 - box.r0 + 1;
const boxCols = box.c1 - box.c0 + 1;
const rows = boxRows > srcRows ? boxRows : srcRows;
const cols = boxCols > srcCols ? boxCols : srcCols;
const out = [];
for (let r = 0; r < rows; r++) {
const srcLine = grid[r % srcRows] || [];
const line = [];
for (let c = 0; c < cols; c++) {
const v = srcLine[c % srcCols];
line.push(v != null ? v : '');
}
out.push(line);
}
return out;
};
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
const pasteRange = () => {
// B11: never paste into a header-active state (the reusable clipboard guard) — a header
// anchor would silently write body row 0 at the header's column.
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
// CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
// C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
// single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
// on React; re-reading them inside the async .then() returns the mount-render stale value, so a
// selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
// the box + anchor now and pass them into tileGridToBox / applyGridToRange.
const box = normalizedRange();
const anchorRow = box ? box.r0 : activeRow.value;
const anchorCol = box ? box.c0 : activeColIndex.value;
const destBox = box || {
r0: anchorRow,
r1: anchorRow,
c0: anchorCol,
c1: anchorCol
};
let p: any = null;
try {
p = navigator.clipboard.readText();
} catch (err: any) {
return;
}
if (!p || !p.then) return;
p.then((text: any) => {
const grid = parseTsv(text);
if (!grid.length) return;
// C3: tile the clipboard block to fill the destination range (single→range fill,
// smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
const tiled = tileGridToBox(grid, destBox);
applyGridToRange(tiled, anchorRow, anchorCol);
}).catch(() => {});
};
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
const cutRange = () => {
if (!clipboardActiveAllowed()) return;
// Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow.value;
const r1 = box ? box.r1 : activeRow.value;
const c0 = box ? box.c0 : activeColIndex.value;
const c1 = box ? box.c1 : activeColIndex.value;
// Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
const cp = navigator.clipboard.writeText(rangeToTsv());
if (cp && cp.catch) cp.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// Clear the source: a grid of empty strings sized to the range, applied at the top-left.
const grid = [];
for (let r = r0; r <= r1; r++) {
const cols = [];
for (let c = c0; c <= c1; c++) cols.push('');
grid.push(cols);
}
applyGridToRange(grid, r0, c0);
};
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
const tileIndex = (i: any, lo: any, hi: any) => {
const span = hi - lo + 1;
if (span <= 1) return lo;
let k = (i - lo) % span;
if (k < 0) k = k + span;
return lo + k;
};
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
const fillRange = (sourceBox: any, endCell: any) => {
// B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
// the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
// React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
// grows), so deriving the box from the threaded endpoints is what makes the fill cover the
// dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
let box;
if (sourceBox && sourceBox.r0 != null && endCell) {
let r0 = sourceBox.r0;
let r1 = sourceBox.r1;
let c0 = sourceBox.c0;
let c1 = sourceBox.c1;
if (endCell.r < r0) r0 = endCell.r;
if (endCell.r > r1) r1 = endCell.r;
if (endCell.c < c0) c0 = endCell.c;
if (endCell.c > c1) c1 = endCell.c;
box = {
r0,
r1,
c0,
c1
};
} else {
box = normalizedRange();
}
if (!box) return;
const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
r0: box.r0,
r1: box.r0,
c0: box.c0,
c1: box.c0
};
const grid = [];
for (let r = box.r0; r <= box.r1; r++) {
const cols = [];
for (let c = box.c0; c <= box.c1; c++) {
const sr = tileIndex(r, src.r0, src.r1);
const sc = tileIndex(c, src.c0, src.c1);
const v = cellValueAt(sr, sc);
cols.push(v == null ? '' : String(v));
}
grid.push(cols);
}
applyGridToRange(grid, box.r0, box.c0);
};
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false;
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove: any = null;
let fillDragUp: any = null;
const teardownFillDrag = () => {
if (typeof document !== 'undefined') {
if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
if (fillDragUp) document.removeEventListener('pointerup', fillDragUp);
}
fillDragMove = null;
fillDragUp = null;
fillDragging = false;
};
const cellIndexFromPoint = (clientX: any, clientY: any) => {
if (typeof document === 'undefined' || !document.elementFromPoint) return null;
let el = document.elementFromPoint(clientX, clientY);
// Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
// a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
// and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
// deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
if (!inner || inner === el) break;
el = inner;
}
if (!el || !el.closest) return null;
const cellEl = el.closest('[data-grid-cell]');
if (!cellEl) return null;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
const r = parseInt(rowAttr, 10);
const c = parseInt(colAttr, 10);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return {
r,
c
};
};
const onFillHandlePointerDown = (e: any) => {
if (!e) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
fillDragging = true;
// B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
// range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
// up/left drag copies from the real origin (not the post-drag corner that would flip to a
// target cell). Captured per-gesture in the closure (no module-let needed).
const sourceBox = normalizedRange();
// B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
// the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
let lastCell = sourceBox ? {
r: sourceBox.r1,
c: sourceBox.c1
} : null;
const move = (ev: any) => {
if (!fillDragging) return;
const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
// B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
// pointermove (the pointer fires many per cell) spams the event with identical payloads.
// Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
// seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
// or re-enters the same cell is suppressed (the range is unchanged).
if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
lastCell = cell;
setRangeFocus(cell.r, cell.c);
}
};
const up = () => {
// teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
teardownFillDrag();
fillRange(sourceBox, lastCell);
};
// Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
fillDragMove = move;
fillDragUp = up;
if (typeof document !== 'undefined') {
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
};
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).
// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).
// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
const activeCellColumnId = () => {
if (activeIsHeader.value) return null;
const rowList = rows.value || [];
const row = rowList[activeRow.value];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[activeColIndex.value];
return cell && cell.column ? cell.column.id : null;
};
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
const isActiveCellEditable = () => {
const colId = activeCellColumnId();
return colId != null && columnEditable(colId);
};
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
// - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
// so EVERY editable cell in the row enters edit simultaneously (the editor template branch
// re-uses this gate verbatim, no template fork);
// - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
// - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
// so EVERY editable cell in the row enters edit simultaneously (the editor template branch
// re-uses this gate verbatim, no template fork);
// - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
const isEditing = (rowIndex: any, colIndex: any) => {
if (editVer.value < 0) return false;
if (editingRowIndex.value != null && editingRowIndex.value === rowIndex) {
const colId = columnIdAt(rowIndex, colIndex);
return colId != null && columnEditable(colId);
}
return editingRow.value === rowIndex && editingCol.value === colIndex;
};
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
const cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => isEditing(rowIndex, colIndex) && !!invalidMsg.value ? 'true' : null;
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
const runValidator = (colId: any, value: any, row: any) => {
const m = editMetaOf(colId);
const v = m ? m.validate : null;
if (typeof v !== 'function') return true;
let r: any = null;
try {
r = v(value, row);
} catch (err: any) {
return 'Invalid value';
}
if (r === true) return true;
if (typeof r === 'string') return r;
return 'Invalid value';
};
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
const setInvalid = (msg: any) => {
invalidMsg.value = msg != null ? msg : '';
};
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
const replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
const src = rows || [];
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
// and would copy inherited enumerable props of typed/class-instance row objects.
out.push({
...(src[i] || {}),
[field]: value
});
} else {
out.push(src[i]);
}
}
return out;
};
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
const sourceIndexOfRow = (visibleRowIndex: any) => {
const rowList = rows.value || [];
const row = rowList[visibleRowIndex];
if (!row) return visibleRowIndex;
const orig = row.original;
const data = currentData() || [];
const idx = data.indexOf(orig);
return idx >= 0 ? idx : visibleRowIndex;
};
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
const editingColumnId = () => {
const rowList = rows.value || [];
const row = rowList[editingRow.value];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol.value];
return cell && cell.column ? cell.column.id : null;
};
const editingColumnField = () => {
const colId = editingColumnId();
if (colId == null) return null;
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
const editingCellValue = () => {
const rowList = rows.value || [];
const row = rowList[editingRow.value];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol.value];
return cell ? cell.getValue() : null;
};
const editingRowOriginal = () => {
const rowList = rows.value || [];
const row = rowList[editingRow.value];
return row ? row.original : null;
};
const editingRowId = () => {
const rowList = rows.value || [];
const row = rowList[editingRow.value];
return row ? row.id : null;
};
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
const focusEditorWhenReady = (selectAll = true) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
if (el) {
el.focus();
if (selectAll && el.select) {
try {
el.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
const columnIdAt = (rowIndex: any, colIndex: any) => {
const rowList = rows.value || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell && cell.column ? cell.column.id : null;
};
const cellValueAt = (rowIndex: any, colIndex: any) => {
const rowList = rows.value || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell ? cell.getValue() : null;
};
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
const beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
const colId = columnIdAt(rowIndex, colIndex);
if (colId == null || !columnEditable(colId)) return;
setInvalid('');
// Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
// editor clears any row-edit state so isEditing never resolves both modes for one cell.
editingRowIndex.value = null;
rowDraft.value = {};
editingRow.value = rowIndex;
editingCol.value = colIndex;
draftValue.value = seed != null ? seed : cellValueAt(rowIndex, colIndex);
activeInControl.value = true;
editVer.value = editVer.value + 1;
// B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
// seeded char so subsequent typing appends instead of replacing it.
focusEditorWhenReady(seed == null);
};
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
const focusCellWhenReady = (row: any, col: any) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(String(row), col);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
const indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
const list = rows || [];
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (!r) continue;
if (rowId != null && r.id === rowId) return i;
if (rowOriginal != null && r.original === rowOriginal) return i;
}
return -1;
};
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
const endEdit = () => {
editingRow.value = -1;
editingCol.value = -1;
draftValue.value = null;
invalidMsg.value = '';
activeInControl.value = false;
editVer.value = editVer.value + 1;
};
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
const endRowEdit = () => {
editingRowIndex.value = null;
rowDraft.value = {};
invalidMsg.value = '';
activeInControl.value = false;
editVer.value = editVer.value + 1;
};
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
const coerceCellValue = (colId: any, raw: any) => {
if (editorTypeOf(colId) !== 'number') return raw;
if (raw == null) return null;
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
};
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
const commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
if (editingRow.value < 0) return false;
const colId = editingColumnId();
if (colId == null) {
endEdit();
return false;
}
const field = editingColumnField();
const oldValue = editingCellValue();
const rowOriginal = editingRowOriginal();
const rowId = editingRowId();
// B3: coerce by the column's editor type BEFORE validation + write so the validator
// and the model both see the typed value (number/null), not the raw draft string.
const rawValue = overrideValue !== undefined ? overrideValue : draftValue.value;
const newValue = coerceCellValue(colId, rawValue);
const err = runValidator(colId, newValue, rowOriginal);
if (err !== true) {
// D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
setInvalid(err);
focusEditorWhenReady();
return false;
}
setInvalid('');
const srcIndex = sourceIndexOfRow(editingRow.value);
const next = replaceRowValue(currentData(), srcIndex, field, newValue);
// Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
const focusRow = editingRow.value;
const focusCol = editingCol.value;
// Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
// must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
editTransition = true;
writeData(next);
// Exactly one emit per commit, from this single call site (writeData does NOT emit).
emit('cell-edit-commit', {
rowId,
columnId: colId,
oldValue,
newValue
});
endEdit();
editTransition = false;
// Defer the focus return so the display↔editor re-render commits first (async on
// React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
// editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
// Tab-advance (the caller immediately opens the next editor and focuses THAT).
// B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
// RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
// index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
// consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
// fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
// active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
// index → byte-behaviorally identical to before.
if (skipFocusReturn !== true) pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
const cancelEdit = () => {
if (editingRow.value < 0) return;
// CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
// Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
// an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
// instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
const focusRow = editingRow.value;
const focusCol = editingCol.value;
editTransition = true;
endEdit();
editTransition = false;
focusCellWhenReady(focusRow, focusCol);
};
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
const editableColumnsForRow = (rowIndex: any) => {
const rowList = rows.value || [];
const row = rowList[rowIndex];
if (!row) return [];
const cells = visibleCellsFor(row);
const out = [];
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
const colId = cell && cell.column ? cell.column.id : null;
if (colId == null || !columnEditable(colId)) continue;
const d = defFor(colId);
const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
// colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
// Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
// can address a SPECIFIC editor by column, not just the first [data-editing-cell].
out.push({
colId,
field,
colIndex: c
});
}
return out;
};
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
const focusRowEditorAt = (rowIndex: any, colIndex: any) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const cellEl = resolveCellEl(String(rowIndex), colIndex);
const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
if (ed) {
ed.focus();
if (ed.select) {
try {
ed.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
const beginRowEdit = (row: any) => {
const rowIndex = rowIndexOf(row);
if (rowIndex < 0) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
// Clear any single-cell editor first (mutual exclusivity).
editingRow.value = -1;
editingCol.value = -1;
draftValue.value = null;
setInvalid('');
// Seed each editable cell's draft from its current value.
const draft = {};
const rowList = rows.value || [];
const r = rowList[rowIndex];
const orig = r ? r.original : null;
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
draft[ec.colId] = orig ? orig[ec.field] : null;
}
rowDraft.value = draft;
editingRowIndex.value = rowIndex;
activeInControl.value = true;
editVer.value = editVer.value + 1;
focusEditorWhenReady();
};
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
const commitRow = () => {
if (editingRowIndex.value == null) return false;
const rowIndex = editingRowIndex.value;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) {
endRowEdit();
return false;
}
const rowList = rows.value || [];
const r = rowList[rowIndex];
const rowOriginal = r ? r.original : null;
const rowId = r ? r.id : null;
const draft = rowDraft.value || {};
// Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
// B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
// 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
// commitEdit already coerces via coerceCellValue; the row path silently committed strings →
// a number column ended up holding '99'). Coerce once here so the validator and the model both
// see the typed value, identical to the single-cell funnel.
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
if (err !== true) {
setInvalid(err);
// B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
// unconditionally the first editor (focusEditorWhenReady resolves the first
// [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
focusRowEditorAt(rowIndex, ec.colIndex);
return false;
}
}
setInvalid('');
// Build the changes payload (only the columns whose value actually changed) + the field→
// value map for the single row-object replace.
const changes = [];
const fieldValues = {};
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
// B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
// string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
fieldValues[ec.field] = newValue;
if (oldValue !== newValue) changes.push({
columnId: ec.colId,
oldValue,
newValue
});
}
// ONE fresh-array replace of the SINGLE row object with all field values applied at once.
const srcIndex = sourceIndexOfRow(rowIndex);
const next = replaceRowValues(currentData(), srcIndex, fieldValues);
// Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
// active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
const focusCol = activeColIndex.value;
editTransition = true;
writeData(next);
// EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
emit('row-edit-commit', {
rowId,
changes
});
endRowEdit();
editTransition = false;
// WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
// like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
// col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
// $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
// DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
// refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
// display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
// the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
// poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
const cancelRow = () => {
if (editingRowIndex.value == null) return;
const focusRow = activeRow.value;
const focusCol = activeColIndex.value;
editTransition = true;
endRowEdit();
editTransition = false;
focusCellWhenReady(focusRow, focusCol);
};
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
const replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
const src = rows || [];
const fv = fieldValues || {};
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
// prototype-walking copy. Spread copies own enumerable props only.
out.push({
...(src[i] || {}),
...fv
});
} else {
out.push(src[i]);
}
}
return out;
};
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
const nextEditableCell = (fromRow: any, fromCol: any) => {
const rowList = rows.value || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol + 1;
while (r < rowCount) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c < cells.length) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c + 1;
}
r = r + 1;
c = 0;
}
return null;
};
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
const prevEditableCell = (fromRow: any, fromCol: any) => {
const rowList = rows.value || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol - 1;
while (r >= 0) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c >= 0) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c - 1;
}
r = r - 1;
if (r >= 0) {
const prow = rowList[r];
const pcells = prow ? visibleCellsFor(prow) : [];
c = pcells.length - 1;
}
}
return null;
};
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false;
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow: any = null;
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
const inRowEdit = () => editingRowIndex.value != null;
const editorValueFor = (colId: any) => inRowEdit() ? rowDraft.value ? rowDraft.value[colId] : null : draftValue.value;
const editorCheckedFor = (colId: any) => !!(inRowEdit() ? rowDraft.value ? rowDraft.value[colId] : null : draftValue.value);
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
const editorCommitFor = (colId: any) => (value: any) => {
if (inRowEdit()) {
setRowDraft(colId, value);
return;
}
commitEdit(value);
};
const editorCancelFor = () => () => {
if (inRowEdit()) {
cancelRow();
return;
}
cancelEdit();
};
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
const onCellEditorInput = (colId: any, evt: any) => {
const v = evt && evt.target ? evt.target.value : '';
if (inRowEdit()) {
setRowDraft(colId, v);
return;
}
draftValue.value = v;
};
const onCellEditorCheckbox = (colId: any, evt: any) => {
const v = !!(evt && evt.target && evt.target.checked);
if (inRowEdit()) {
setRowDraft(colId, v);
return;
}
draftValue.value = v;
};
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
const setRowDraft = (colId: any, value: any) => {
const src = rowDraft.value || {};
const next = {};
for (const k in src) next[k] = src[k];
next[colId] = value;
rowDraft.value = next;
};
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
const rowEditTab = (target: any, backward: any) => {
const rowIndex = editingRowIndex.value;
if (rowIndex == null) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
const cols = editable.map((ec: any) => ec.colIndex);
const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
const curAttr = cell ? cell.getAttribute('data-col-index') : null;
const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
let pos = cols.indexOf(cur);
if (pos < 0) pos = 0;
const len = cols.length;
const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
focusRowEditorAt(rowIndex, cols[nextPos]);
};
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
const onEditorKeyDown = (e: any) => {
if (!e) return;
const key = e.key;
// Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
// model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
// row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
// preventDefault it here.
if (inRowEdit()) {
if (key === 'Enter') {
e.preventDefault();
commitRow();
} else if (key === 'Escape') {
e.preventDefault();
cancelRow();
}
// B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
// editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
// Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
// first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
// tab order across a Lit shadow boundary).
else if (key === 'Tab') {
e.preventDefault();
rowEditTab(e.target, e.shiftKey);
}
return;
}
if (key === 'Enter') {
e.preventDefault();
commitEdit(undefined);
} else if (key === 'Tab') {
e.preventDefault();
// Resolve the advance target from the EDITING pair (the cell that is open), not the
// active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
// moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
// the editing pair BEFORE commit (commitEdit resets it to -1).
const fromRow = editingRow.value;
const fromCol = editingCol.value;
const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
// skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
// straight into the next editable cell's editor below. Use the RETURN value (not a
// re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
// failure returns false and keeps the editor open (the user must fix the value first).
const committed = commitEdit(undefined, true);
if (committed && target) {
activeRow.value = target.row;
activeColIndex.value = target.col;
beginEdit(target.row, target.col, null);
} else if (committed) {
// B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
// grid by returning it to the just-committed cell instead of letting it drop to <body>.
focusCellWhenReady(fromRow, fromCol);
}
} else if (key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
// - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
// - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
// or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
// async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
// the relatedTarget/containment check is the load-bearing guard, not the flag alone.
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
// - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
// - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
// or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
// async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
// the relatedTarget/containment check is the load-bearing guard, not the flag alone.
const onEditorBlur = (e: any) => {
// Full-row mode (req-6): blur NEVER commits — the row commits as a UNIT only on an
// explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
// into N writes + N events, violating the one-write/one-event contract). Tabbing between
// the row's own editors is a normal focus move, not a commit.
if (inRowEdit()) return;
if (editingRow.value < 0 || editTransition) return;
const next = e ? e.relatedTarget : null;
// A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
// keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
// a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
// conservative null=skip behavior.
if (next == null) return;
// Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
// the editor open on an invalid value).
if (!(gridRoot && gridRoot.contains && gridRoot.contains(next))) {
commitEdit(undefined);
return;
}
// Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
// keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
// (which must commit + close so the grid is not wedged with an open editor).
const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
// Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
// controlled move; skip. Also skip when either cell can't be resolved (an unmounting
// editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
if (!nextCell || !fromCell || nextCell === fromCell) return;
// A Tab-advance already committed the old editor and opened the next one, so the live
// editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
// editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
// double commit, WR-04).
const fromRow = fromCell.getAttribute('data-row');
const fromCol = fromCell.getAttribute('data-col-index');
if (fromRow !== String(editingRow.value) || fromCol !== String(editingCol.value)) return;
// Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
// commit does NOT bounce focus back to the just-committed editing cell (which would fight
// the click destination). The commit's writeData re-renders the table and can DROP DOM
// focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
// DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
// (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
// late, which would steal focus back from a subsequent navigation.
const destRow = nextCell.getAttribute('data-row');
const destCol = nextCell.getAttribute('data-col-index');
commitEdit(undefined, true);
const reseatDestFocus = () => {
if (!gridRoot || destRow == null || destCol == null || destRow === '__header') return;
const root = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
const act = root && root.activeElement ? root.activeElement : null;
// Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
if (act && gridRoot.contains && gridRoot.contains(act)) return;
const el = resolveCellEl(destRow, parseInt(destCol, 10));
if (el) el.focus();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
const editCell = (rowIndex: any, colIndex: any) => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const maxCol = visibleColCount() - 1;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
activeIsHeader.value = false;
activeRow.value = r;
activeColIndex.value = c;
beginEdit(r, c, null);
};
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
const commitEditing = () => {
if (editingRow.value >= 0) commitEdit(undefined);
};
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
const editRow = (rowIndex: any) => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const rowList = rows.value || [];
const row = rowList[r];
if (!row) return;
activeIsHeader.value = false;
activeRow.value = r;
beginRowEdit(row);
};
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────
// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries aria-rowindex === absRow+1 (i.e. the TARGET page has
// actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works for both
// controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames (~1s) to
// cover the controlled-state parent round-trip on React/Solid/Lit.
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────
// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries aria-rowindex === absRow+1 (i.e. the TARGET page has
// actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works for both
// controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames (~1s) to
// cover the controlled-state parent round-trip on React/Solid/Lit.
const focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
if (!gridRoot) return;
let attempts = 0;
const want = String(absRow + 1);
const tryFocus = () => {
const el = resolveCellEl(String(localRow), col);
if (el) {
const rowEl = el.closest ? el.closest('[role="row"]') : null;
const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
if (ari === want) {
el.focus();
return;
}
}
attempts = attempts + 1;
if (attempts >= 60) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
const focusCell = (rowIndex: any, colIndex: any) => {
// B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
// is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
// isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
// instance does not leak a spurious activecell-change.
if (!isGrid()) return;
const maxCol = visibleColCount() - 1;
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
// C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
const absLast = prePaginationRowCount() - 1;
const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
// B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
// real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
// active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
const prevAbs = toAbsRow(activeRow.value);
const prevIsHeader = activeIsHeader.value;
if (props.virtual) {
// Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
// the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
// focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
activeIsHeader.value = false;
activeInControl.value = false;
activeRow.value = absRow;
activeColIndex.value = c;
focusActiveCell(absRow, c, false);
} else {
// Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
// in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
// body's data-row markers (and the roving tabindex) address.
const size = pageSize();
const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
const localRow = absRow - targetPage * size;
const switched = targetPage !== pageIndex();
if (switched) setPage(targetPage);
activeIsHeader.value = false;
activeInControl.value = false;
activeRow.value = localRow;
activeColIndex.value = c;
if (switched) {
// The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
// TARGET page's absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
// same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
focusAbsCellWhenReady(absRow, localRow, c);
} else {
// Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
// Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
// async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
focusActiveCell(localRow, c, false);
}
}
if (absRow !== prevAbs || prevIsHeader) {
emit('activecell-change', {
rowIndex: absRow,
colIndex: c
});
}
};
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
const getActiveCell = () => activeIsHeader.value ? {
rowIndex: null,
colIndex: activeColIndex.value,
isHeader: true
} : {
rowIndex: toAbsRow(activeRow.value),
colIndex: activeColIndex.value,
isHeader: false
};
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
const clearActiveCell = () => {
if (!isGrid()) return;
activeIsHeader.value = false;
activeInControl.value = false;
activeRow.value = 0;
activeColIndex.value = 0;
};
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────
// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────
// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
const toggleRowExpanded = (rowId: any) => {
if (!table) return;
const target = String(rowId);
const flat = table.getCoreRowModel().flatRows;
for (const r of flat as any) {
if (r.id === target || r.original && String(r.original.id) === target) {
r.toggleExpanded();
return;
}
}
};
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
const expandAll = () => {
if (!table) return;
table.toggleAllRowsExpanded(true);
};
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
const collapseAll = () => {
if (!table) return;
table.resetExpanded(true);
};
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
const getExpandedRows = () => {
if (!table) return [];
const out = [];
const flat = table.getCoreRowModel().flatRows;
for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
return out;
};
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
const applyGrouping = (cols: any) => {
if (table) table.setGrouping(cols);
};
const clearGrouping = () => {
if (table) table.setGrouping([]);
};
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
const getFacetedUniqueValues = (colId: any) => {
if (tick() < 0 || !table) return [];
const col = table.getColumn(colId);
if (!col || !col.getFacetedUniqueValues) return [];
const map = col.getFacetedUniqueValues(); // Map<any, number>
return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
const getFacetedMinMaxValues = (colId: any) => {
if (tick() < 0 || !table) return null;
const col = table.getColumn(colId);
if (!col || !col.getFacetedMinMaxValues) return null;
return col.getFacetedMinMaxValues() || null; // [number, number] | null
};
provide('data-table:columns', {
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
colReg.value = {
...colReg.value,
[key]: spec
};
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...colReg.value
};
delete r[String(id)];
colReg.value = r;
}
});
onMounted(() => {
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
dataDefault.value = data.value || [];
// Build the table instance HERE so the closures below capture the live `table`.
table = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: currentData(),
columns: tableColumns(),
state: currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (props.getSubRows || undefined) as any,
getRowCanExpand: props.expandable === true && props.getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: props.manual === true,
manualFiltering: props.manual === true,
manualSorting: props.manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: props.selectionMode !== 'none',
enableMultiRowSelection: props.selectionMode === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {}
});
refreshRowModel = () => {
if (!table) return;
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = windowSource().slice();
const nextGroups = table.getHeaderGroups().slice();
rows.value = nextRows;
headerGroups.value = nextGroups;
rowModelVer.value = rowModelVer.value + 1;
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if (props.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length;
const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
clampActiveCell(nextRowCount, nextColCount);
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (pendingEditFollow && isGrid()) {
const follow = pendingEditFollow;
pendingEditFollow = null;
const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
syncIndeterminate();
if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
};
// initial pull
refreshRowModel();
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
gridRoot = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-data-table') : null;
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if (props.virtual) {
gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rdt-scroll') : null;
virtualizer = new Virtualizer(virtualizerOptions());
virtualizerCleanup = virtualizer._didMount();
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
windowVer.value = windowVer.value + 1;
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
remeasureWindow();
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = pagination.value;
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
if (props.manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
}
});
onBeforeUnmount(() => {
if (virtualizerCleanup) virtualizerCleanup();
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
teardownFillDrag();
});
onUpdated(() => {
if (!table) return;
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = currentData() || [];
if (d === lastData && d.length === lastDataLen) return;
lastData = d;
lastDataLen = d.length;
reFeed();
});
watch(() => [sorting.value, globalFilter.value, columnFilters.value, pagination.value, rowSelection.value, expanded.value, props.expandable, grouping.value, props.groupable, columnVisibility.value, columnSizing.value, columnOrder.value, columnPinning.value, props.selectionMode, (data.value || []).length,
// Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
data.value, dataDefault.value, colReg.value], () => {
reFeed();
});
defineExpose({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut });
</script>
<style scoped>
.rozie-data-table {
border-collapse: collapse;
width: 100%;
font: var(--rdt-font, 14px system-ui, sans-serif);
color: var(--rdt-color, inherit);
}
.rdt-sr-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.rozie-data-table .rdt-cell-editor {
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-data-table .rdt-td[aria-invalid="true"] {
outline: var(--rdt-invalid-outline, 2px solid #d33);
outline-offset: -2px;
}
.rozie-data-table .rdt-td.rdt-in-range {
background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table .rdt-td {
position: relative;
}
.rozie-data-table .rdt-fill-handle {
position: absolute;
right: -3px;
bottom: -3px;
width: 8px;
height: 8px;
background: var(--rdt-fill-handle-bg, #2563eb);
border: 1px solid #fff;
cursor: crosshair;
z-index: 1;
touch-action: none;
}
.rozie-data-table .rdt-th,
.rozie-data-table .rdt-td {
padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
text-align: left;
border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table .rdt-thead .rdt-th {
font-weight: var(--rdt-header-weight, 600);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table .rdt-sort-btn {
display: inline-flex;
align-items: center;
gap: var(--rdt-sort-gap, 0.35em);
background: none;
border: none;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.rozie-data-table .rdt-sort-ind {
font-size: 0.8em;
opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
position: sticky;
top: var(--rdt-sticky-top, 0);
z-index: var(--rdt-sticky-z, 2);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table-wrap .rdt-scroll {
max-height: var(--rozie-data-table-max-height);
overflow: auto;
}
.rozie-data-table-wrap .rdt-group-bar-host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap .rdt-group-token {
display: inline-flex;
align-items: center;
padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
border-radius: var(--rdt-group-token-radius, 999px);
background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table .rdt-group-header {
background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table .rdt-group-toggle {
margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table .rdt-group-count {
margin-left: var(--rdt-group-count-gap, 0.375rem);
opacity: var(--rdt-group-count-opacity, 0.65);
font-weight: 400;
}
.rozie-data-table-wrap {
display: flex;
flex-direction: column;
gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-toolbar {
display: flex;
gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-global-filter,
.rozie-data-table-wrap .rdt-col-filter {
font: inherit;
padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-filter-radius, 4px);
background: var(--rdt-filter-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap .rdt-col-filter {
display: block;
margin-top: var(--rdt-col-filter-gap, 0.25rem);
width: 100%;
font-weight: normal;
}
.rozie-data-table-wrap .rdt-pagination {
display: flex;
align-items: center;
gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-page-btn {
font: inherit;
cursor: pointer;
padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-btn-radius, 4px);
background: var(--rdt-page-btn-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap .rdt-page-btn:disabled {
opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
cursor: default;
}
.rozie-data-table-wrap .rdt-page-status {
font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap .rdt-page-size {
font: inherit;
padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-size-radius, 4px);
background: var(--rdt-page-size-bg, transparent);
color: inherit;
}
.rozie-data-table .rdt-th {
position: relative;
}
.rozie-data-table .rdt-resize-handle {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--rdt-resize-handle-width, 6px);
padding: 0;
border: none;
background: none;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.rozie-data-table .rdt-resize-grip {
display: block;
width: var(--rdt-resize-grip-width, 2px);
height: 100%;
margin: 0 auto;
background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table .rdt-resize-handle:hover .rdt-resize-grip,
.rozie-data-table .rdt-th-resizing .rdt-resize-grip {
background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table .rdt-pin-controls {
display: inline-flex;
gap: var(--rdt-pin-gap, 0.1em);
margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table .rdt-pin-btn {
font: inherit;
font-size: var(--rdt-pin-btn-size, 0.8em);
line-height: 1;
cursor: pointer;
padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-pin-btn-radius, 3px);
background: var(--rdt-pin-btn-bg, transparent);
color: inherit;
}
.rozie-data-table .rdt-pin-btn[aria-pressed='true'] {
background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
font-weight: 700;
}
.rozie-data-table-wrap .rdt-colvis {
position: relative;
}
.rozie-data-table-wrap .rdt-colvis-summary {
cursor: pointer;
font: inherit;
padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-colvis-summary-radius, 4px);
list-style: none;
user-select: none;
}
.rozie-data-table-wrap .rdt-colvis-menu {
position: absolute;
z-index: var(--rdt-colvis-menu-z, 5);
margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
display: flex;
flex-direction: column;
gap: var(--rdt-colvis-item-gap, 0.25rem);
border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-colvis-menu-radius, 4px);
background: var(--rdt-colvis-menu-bg, #fff);
box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap .rdt-colvis-item {
display: flex;
align-items: center;
gap: var(--rdt-colvis-label-gap, 0.4em);
cursor: pointer;
white-space: nowrap;
}
.rozie-data-table .rdt-select-th,
.rozie-data-table .rdt-select-td {
width: var(--rdt-select-col-width, 1%);
text-align: var(--rdt-select-col-align, center);
white-space: nowrap;
}
.rozie-data-table .rdt-select-all,
.rozie-data-table .rdt-select-row {
cursor: pointer;
accent-color: var(--rdt-select-accent, currentColor);
}
</style>svelte
<script lang="ts">
import { rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount, setContext, untrack } from 'svelte';
interface Props {
/**
* 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).
* @example
* <DataTable r-model:data="rows" :columns="cols" />
*/
data: any[];
/**
* 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).
*/
columns?: any[];
/**
* Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
*/
selectionMode?: string;
/**
* `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
*/
sorting?: any[];
/**
* The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
*/
globalFilter?: string;
/**
* `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
*/
columnFilters?: any[];
/**
* `{ 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`.
*/
pagination?: any;
/**
* 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).
*/
manual?: boolean;
/**
* 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).
*/
expandable?: boolean;
/**
* `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.
*/
expanded?: (any | boolean) | 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.
*/
getSubRows?: ((...args: any[]) => any) | null;
/**
* 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).
*/
groupable?: boolean;
/**
* `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.
*/
grouping?: (any[]) | null;
/**
* `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.
*/
rowSelection?: any;
/**
* `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
*/
columnVisibility?: any;
/**
* `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
*/
columnSizing?: any;
/**
* `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
*/
columnOrder?: any[];
/**
* `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
*/
columnPinning?: any;
/**
* Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
*/
stickyHeader?: boolean;
/**
* `'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
* @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
*/
interactionMode?: string;
/**
* 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.
*/
virtual?: boolean;
/**
* Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* 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.
*/
maxHeight?: string;
children?: Snippet;
groupBar?: Snippet<[{ grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any }]>;
selectAll?: Snippet<[{ checked: any; indeterminate: any; toggle: any }]>;
colHeader?: Snippet<[{ columnId: any; column: any; label: any }]>;
filter?: Snippet<[{ columnId: any; uniqueValues: any; minMax: any; setFilter: any }]>;
selectCell?: Snippet<[{ row: any; checked: any; toggle: any }]>;
cell?: Snippet<[{ columnId: any; column: any; row: any; value: any }]>;
editor?: Snippet<[{ columnId: any; column: any; row: any; value: any; commit: any; cancel: any }]>;
detail?: Snippet<[{ row: any }]>;
snippets?: Record<string, any>;
onsortchange?: (...args: unknown[]) => void;
onexpandchange?: (...args: unknown[]) => void;
ongroupchange?: (...args: unknown[]) => void;
onfilterchange?: (...args: unknown[]) => void;
onpagechange?: (...args: unknown[]) => void;
onselectionchange?: (...args: unknown[]) => void;
onvisibilitychange?: (...args: unknown[]) => void;
onresizechange?: (...args: unknown[]) => void;
onreorderchange?: (...args: unknown[]) => void;
onpinchange?: (...args: unknown[]) => void;
onactivecellchange?: (...args: unknown[]) => void;
onrangechange?: (...args: unknown[]) => void;
oncelleditcommit?: (...args: unknown[]) => void;
onroweditcommit?: (...args: unknown[]) => void;
}
let __defaultColumns = (() => [])();
let {
data = $bindable(),
columns = __defaultColumns,
selectionMode = 'none',
sorting = $bindable((() => [])()),
globalFilter = $bindable(''),
columnFilters = $bindable((() => [])()),
pagination = $bindable((() => ({
pageIndex: 0,
pageSize: 10
}))()),
manual = false,
expandable = false,
expanded = $bindable(null),
getSubRows = null,
groupable = false,
grouping = $bindable(null),
rowSelection = $bindable((() => ({}))()),
columnVisibility = $bindable((() => ({}))()),
columnSizing = $bindable((() => ({}))()),
columnOrder = $bindable((() => [])()),
columnPinning = $bindable((() => ({
left: [],
right: []
}))()),
stickyHeader = false,
interactionMode = 'table',
virtual = false,
estimateRowHeight = 40,
maxHeight = '',
children: __childrenProp,
groupBar: __groupBarProp,
selectAll: __selectAllProp,
colHeader: __colHeaderProp,
filter: __filterProp,
selectCell: __selectCellProp,
cell: __cellProp,
editor: __editorProp,
detail: __detailProp,
snippets,
onsortchange,
onexpandchange,
ongroupchange,
onfilterchange,
onpagechange,
onselectionchange,
onvisibilitychange,
onresizechange,
onreorderchange,
onpinchange,
onactivecellchange,
onrangechange,
oncelleditcommit,
onroweditcommit
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
const groupBar = $derived(__groupBarProp ?? snippets?.groupBar);
const selectAll = $derived(__selectAllProp ?? snippets?.selectAll);
const colHeader = $derived(__colHeaderProp ?? snippets?.colHeader);
const filter = $derived(__filterProp ?? snippets?.filter);
const selectCell = $derived(__selectCellProp ?? snippets?.selectCell);
const cell = $derived(__cellProp ?? snippets?.cell);
const editor = $derived(__editorProp ?? snippets?.editor);
const detail = $derived(__detailProp ?? snippets?.detail);
let dataDefault: any[] = $state([]);
let sortingDefault: any[] = $state([]);
let globalFilterDefault = $state('');
let columnFiltersDefault: any[] = $state([]);
let paginationDefault = $state({
pageIndex: 0,
pageSize: 10
});
let rowSelectionDefault = $state({});
let expandedDefault = $state({});
let groupingDefault: any[] = $state([]);
let columnVisibilityDefault = $state({});
let columnSizingDefault = $state({});
let columnOrderDefault: any[] = $state([]);
let columnPinningDefault = $state({
left: [],
right: []
});
let columnSizingInfo = $state({
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: []
});
let colReg = $state({});
let rows: any[] = $state([]);
let headerGroups: any[] = $state([]);
let rowModelVer = $state(0);
let windowVer = $state(0);
let activeRow = $state(0);
let activeColIndex = $state(0);
let activeIsHeader = $state(false);
let activeHeaderLevel = $state(0);
let activeInControl = $state(false);
let editingRow = $state(-1);
let editingCol = $state(-1);
let draftValue: any = $state(null);
let invalidMsg = $state('');
let editVer = $state(0);
let editingRowIndex: any = $state(null);
let rowDraft = $state({});
let rangeAnchor: any = $state(null);
let rangeFocus: any = $state(null);
let pasteAnnounce = $state('');
let __rozieRoot = $state<HTMLElement | undefined>(undefined);
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table: any = null;
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false;
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10;
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot: any = null;
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0;
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false;
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
const groupingActiveDefault = () => ((grouping != null ? grouping : groupingDefault) || []).length > 0;
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
const currentState = (): any => ({
sorting: sorting != null ? sorting : sortingDefault,
globalFilter: globalFilter != null ? globalFilter : globalFilterDefault,
columnFilters: columnFilters != null ? columnFilters : columnFiltersDefault,
pagination: pagination != null ? pagination : paginationDefault,
rowSelection: rowSelection != null ? rowSelection : rowSelectionDefault,
// expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
// literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
// guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
// GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
// `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
// literal so the grouped subtree is visible by default; the first toggle latches
// expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
// unchanged → byte-identical-off (the table + the expandable-rows feature both keep
// $data.expandedDefault).
expanded: expanded != null ? expanded : groupingActiveDefault() && !expandedTouched ? true : expandedDefault,
// grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
// to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
// is inert when this is empty (byte-identical-off, req-10).
grouping: grouping != null ? grouping : groupingDefault,
columnVisibility: columnVisibility != null ? columnVisibility : columnVisibilityDefault,
columnSizing: columnSizing != null ? columnSizing : columnSizingDefault,
columnOrder: columnOrder != null ? columnOrder : columnOrderDefault,
columnPinning: columnPinning != null ? columnPinning : columnPinningDefault,
// columnSizingInfo: table-core's transient resize-gesture state. We pass an
// EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
// `column.getIsResizing()` / `getResizeHandler()` read
// `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
// absent. Seed the default shape (matches table-core's
// getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
// every render. Not a two-way model slice (transient gesture state, not consumer
// state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
columnSizingInfo: columnSizingInfo
});
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
const currentData = (): any => data != null ? data : dataDefault;
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
const isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
const wrapAggregationFn = (fn: any) => {
if (typeof fn === 'string') return fn;
if (typeof fn !== 'function') return undefined;
return (columnId: any, leafRows: any, childRows: any) => {
try {
return fn(columnId, leafRows, childRows);
} catch (err: any) {
return undefined;
}
};
};
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
const buildConfigDef = (c: any) => {
if (!c) return null;
// Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
// getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
// header spans its leaf children (B12). The group id falls back to its header text so it
// stays addressable (no accessor; group columns carry no data).
if (Array.isArray(c.columns)) {
const gid = c.id != null ? c.id : c.header;
if (gid == null) return null;
const id = String(gid);
if (!isSafeKey(id)) return null;
const kids = [];
for (const child of c.columns as any) {
const cd = buildConfigDef(child);
if (cd) kids.push(cd);
}
if (!kids.length) return null;
return {
id,
header: c.header != null ? c.header : id,
columns: kids
};
}
const rawId = c.id != null ? c.id : c.field;
if (rawId == null) return null;
const id = String(rawId);
if (!isSafeKey(id)) return null;
return {
id,
accessorKey: c.field != null ? c.field : id,
header: c.header != null ? c.header : id,
enableSorting: c.sortable === true,
// per-column filter opt-in (req-5). table-core gates the filter input + value
// funnel on enableColumnFilter; a column with filterable !== true cannot be
// filtered (and renders no per-column filter input in the chrome below).
enableColumnFilter: c.filterable === true,
filterable: c.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: c.expandable === true,
// Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
// so every data column is offered to the headless #groupBar by default; the per-column
// aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
// a custom fn defensively wrapped (T-50-04).
groupable: c.groupable !== false,
aggregationFn: wrapAggregationFn(c.aggregationFn),
pinned: c.pinned != null ? c.pinned : '',
width: c.width != null ? c.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
// metadata carrier the display↔editor branch + runValidator read. Off by default.
meta: {
editable: c.editable === true,
editor: c.editor != null ? c.editor : 'text',
editorOptions: c.editorOptions != null ? c.editorOptions : [],
validate: typeof c.validate === 'function' ? c.validate : null
}
};
};
const columnDefs = () => {
const byId = Object.create(null);
const order = [];
const cfg = columns || [];
for (const c of cfg as any) {
const def = buildConfigDef(c);
if (!def) continue;
const id = def.id;
if (!(id in byId)) order.push(id);
byId[id] = def;
}
const reg = colReg || {};
for (const id in reg) {
if (!isSafeKey(id)) continue;
const spec = reg[id];
if (!spec) continue;
if (!(id in byId)) order.push(id);
byId[id] = {
id,
accessorKey: spec.field != null ? spec.field : id,
header: spec.header != null ? spec.header : id,
enableSorting: spec.sortable === true,
enableColumnFilter: spec.filterable === true,
filterable: spec.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: spec.expandable === true,
// Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
groupable: spec.groupable !== false,
aggregationFn: wrapAggregationFn(spec.aggregationFn),
pinned: spec.pinned != null ? spec.pinned : '',
width: spec.width != null ? spec.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
meta: {
editable: spec.editable === true,
editor: spec.editor != null ? spec.editor : 'text',
editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
validate: typeof spec.validate === 'function' ? spec.validate : null
}
};
}
const out = [];
for (const id of order as any) if (byId[id]) out.push(byId[id]);
return out;
};
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
const SELECT_COL_ID = '__rdt_select';
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
const EXPANDER_COL_ID = '__rdt_expander';
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
const selectionEnabled = () => selectionMode === 'single' || selectionMode === 'multiple';
const tableColumns = () => {
const cols = columnDefs();
// Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
// isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
// value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
// → byte-identical-off (req-10).
let withExpander = cols;
if (expandable === true) {
const expanderCol = {
id: EXPANDER_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isExpanderColumn: true,
pinned: '',
width: ''
};
withExpander = [expanderCol].concat(cols);
}
if (selectionEnabled()) {
const selectCol = {
id: SELECT_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isSelectColumn: true,
pinned: '',
width: ''
};
return [selectCol].concat(withExpander);
}
return withExpander;
};
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
const writeSorting = (next: any) => {
if (programmatic) return;
programmatic++;
sortingDefault = next; // fresh array only (never in-place)
sorting = next; // two-way emit if bound (no-op-diff if not)
onsortchange?.(next);
programmatic--;
};
const applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
const writeExpanded = (next: any) => {
if (programmatic) return;
programmatic++;
// Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
// the user now owns the expanded state, so currentState() stops defaulting grouped rows to
// the `true` expand-all literal and honors $data.expandedDefault from here on.
expandedTouched = true;
expandedDefault = next; // fresh value only (never in-place)
expanded = next; // two-way emit if bound (no-op-diff if not)
// Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
// prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
// interface, and an `expanded-change` event would camelCase to the SAME identifier
// → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
// sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
// sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
onexpandchange?.(next);
programmatic--;
};
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
const writeGrouping = (next: any) => {
if (programmatic) return;
programmatic++;
groupingDefault = next; // fresh ordered array only (never in-place push)
grouping = next; // two-way emit if bound (no-op-diff if not)
ongroupchange?.(next);
programmatic--;
};
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
const writeGlobalFilter = (next: any) => {
if (programmatic) return;
programmatic++;
globalFilterDefault = next;
globalFilter = next;
onfilterchange?.({
globalFilter: next
});
programmatic--;
};
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
const writeColumnFilters = (next: any) => {
if (programmatic) return;
programmatic++;
columnFiltersDefault = next;
columnFilters = next;
onfilterchange?.({
columnFilters: next
});
programmatic--;
};
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
const writePagination = (next: any) => {
if (programmatic) return;
programmatic++;
paginationDefault = next;
pagination = next;
onpagechange?.(next);
programmatic--;
};
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
const writeRowSelection = (next: any) => {
if (programmatic) return;
programmatic++;
rowSelectionDefault = next;
rowSelection = next;
onselectionchange?.(next);
programmatic--;
};
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
const writeColumnVisibility = (next: any) => {
if (programmatic) return;
programmatic++;
columnVisibilityDefault = next;
columnVisibility = next;
onvisibilitychange?.(next);
programmatic--;
};
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
const writeColumnSizing = (next: any) => {
if (programmatic) return;
programmatic++;
columnSizingDefault = next;
columnSizing = next;
onresizechange?.(next);
programmatic--;
};
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
const writeColumnOrder = (next: any) => {
if (programmatic) return;
programmatic++;
columnOrderDefault = next;
columnOrder = next;
onreorderchange?.(next);
programmatic--;
};
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
const writeColumnPinning = (next: any) => {
if (programmatic) return;
programmatic++;
columnPinningDefault = next;
columnPinning = next;
onpinchange?.(next);
programmatic--;
};
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
const writeData = (next: any) => {
if (programmatic) return;
programmatic++;
dataDefault = next; // fresh array only (never in-place)
data = next; // two-way emit if bound (no-op-diff if not)
programmatic--;
};
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
const columnFilterValue = (colId: any) => {
const cf = currentState().columnFilters || [];
for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
return '';
};
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
const setColumnFilter = (colId: any, value: any) => {
const prev = currentState().columnFilters || [];
const next = [];
for (const f of prev as any) if (f && f.id !== colId) next.push(f);
if (value != null && value !== '') next.push({
id: colId,
value
});
writeColumnFilters(next);
};
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel: any = null;
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
const onSortingChangeCb = (updater: any) => {
writeSorting(applyUpdater(updater, currentState().sorting));
};
const onExpandedChangeCb = (updater: any) => {
writeExpanded(applyUpdater(updater, currentState().expanded));
};
const onGroupingChangeCb = (updater: any) => {
writeGrouping(applyUpdater(updater, currentState().grouping));
};
const onGlobalFilterChangeCb = (updater: any) => {
writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
};
const onColumnFiltersChangeCb = (updater: any) => {
writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
};
const onPaginationChangeCb = (updater: any) => {
writePagination(applyUpdater(updater, currentState().pagination));
};
const onRowSelectionChangeCb = (updater: any) => {
writeRowSelection(applyUpdater(updater, currentState().rowSelection));
};
const onColumnVisibilityChangeCb = (updater: any) => {
writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
};
const onColumnSizingChangeCb = (updater: any) => {
writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
};
const onColumnOrderChangeCb = (updater: any) => {
writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
};
const onColumnPinningChangeCb = (updater: any) => {
writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
};
const onColumnSizingInfoChangeCb = (updater: any) => {
const next = applyUpdater(updater, columnSizingInfo);
columnSizingInfo = next != null ? next : columnSizingInfo;
};
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
const windowSource = () => {
if (!table) return [];
if (virtual) return table.getPrePaginationRowModel().rows;
return table.getRowModel().rows;
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
// - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
// tick that the windowVer bump triggers, so the recycled nodes already exist by the next
// microtask — measuring there observes them while they are still connected, BEFORE the next
// fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
// fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
// frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
// total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
// - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
// committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
// - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
// tick that the windowVer bump triggers, so the recycled nodes already exist by the next
// microtask — measuring there observes them while they are still connected, BEFORE the next
// fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
// fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
// frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
// total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
// - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
// committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
};
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
const pinnedEditIndex = () => {
if (editingRow >= 0) return editingRow;
if (editingRowIndex != null) return editingRowIndex;
return -1;
};
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
const pinnedMeasurement = (pin: any) => {
if (!virtualizer || pin < 0) return null;
const ms = virtualizer.getMeasurements();
return ms && ms[pin] ? ms[pin] : null;
};
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
const remeasureWindow = () => {
if (!virtualizer || !gridRoot) return;
// Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
// exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
// null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
// scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
// resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
// regression); the next settled onChange re-measures the stable window. Manual-scroll
// recycling (the CR-01 case) has scrollState === null, so it measures normally.
if (virtualizer.scrollState) return;
const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
for (const tr of trs as any) virtualizer.measureElement(tr);
};
// D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
// math (windowedRows / padTop / padBottom / pmIndexInWindow / rowIsOutsideWindow / virtualizerOptions
// / virtualItemKey) is imported DIRECTLY by the host (DataTable.rozie) from
// `@rozie-ui/headless-core/windowing.rzts` via bare specifier — the P0-proven cross-package inline
// path that DISSOLVES the partial into the leaf (a re-export-from THROUGH this shell would survive as
// a runtime import, not inline — verified). The math closes over these host symbols by convention.
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
const virtualItemKey = (i: any) => {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
};
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
const virtualizerOptions = (): any => ({
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: () => estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
windowVer = windowVer + 1;
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
});
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
const pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => pinnedMeasurement(pin);
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
const windowedRows = () => {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer;
void editVer;
if (!virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!virtual) {
const rowList = rows || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.getVirtualItems();
const rowList = rows || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
const padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer;
void editVer;
if (!virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
const padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer;
void editVer;
if (!virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
const pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
const rowIsOutsideWindow = (r: any) => {
if (!virtual || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
const reFeed = () => {
if (!table) return;
table.setOptions((prev: any) => ({
...prev,
data: currentData(),
columns: tableColumns(),
state: currentState(),
enableRowSelection: selectionMode !== 'none',
enableMultiRowSelection: selectionMode === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (getSubRows || undefined) as any,
getRowCanExpand: expandable === true && getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb
}));
if (refreshRowModel) refreshRowModel();
};
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
let lastData: any = null;
let lastDataLen = -1;
// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
const onHeaderSort = (colId: any, evt: any) => {
if (!table) return;
const col = table.getColumn(colId);
if (!col || !col.getCanSort()) return;
const multi = !!(evt && evt.shiftKey);
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi);
};
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
const tick = () => rowModelVer;
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
const ariaSortFor = (colId: any) => {
if (tick() < 0 || !table) return 'none';
const col = table.getColumn(colId);
if (!col) return 'none';
const dir = col.getIsSorted();
if (dir === 'asc') return 'ascending';
if (dir === 'desc') return 'descending';
return 'none';
};
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
const sortIndicator = (colId: any) => {
if (tick() < 0 || !table) return '';
const col = table.getColumn(colId);
if (!col) return '';
const dir = col.getIsSorted();
if (dir === 'asc') return '▲';
if (dir === 'desc') return '▼';
return '';
};
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
const defFor = (colId: any) => {
const defs = columnDefs();
for (const d of defs as any) if (d.id === colId) return d;
return null;
};
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. Solid's reference-keyed <For> keeps the existing <tr> and will NOT re-run a
// child loop whose `each` reads no signal — so a bare `row.getVisibleCells()` goes
// stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. Solid's reference-keyed <For> keeps the existing <tr> and will NOT re-run a
// child loop whose `each` reads no signal — so a bare `row.getVisibleCells()` goes
// stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
const visibleCellsFor = (row: any) => rowModelVer >= 0 ? row.getVisibleCells() : [];
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
const editMetaOf = (colId: any) => {
const d = defFor(colId);
return d && d.meta ? d.meta : null;
};
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
const columnEditable = (colId: any) => {
const m = editMetaOf(colId);
return !!(m && m.editable === true);
};
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
const editorTypeOf = (colId: any) => {
const m = editMetaOf(colId);
return m && m.editor != null ? m.editor : 'text';
};
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
const editorOptionsOf = (colId: any) => {
const m = editMetaOf(colId);
return m && m.editorOptions != null ? m.editorOptions : [];
};
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
const hasEditorSlot = (colId: any) => editorTypeOf(colId) === 'custom' && !!editor;
const columnIsFilterable = (colId: any) => {
const d = defFor(colId);
return !!(d && d.filterable);
};
const headerLabel = (colId: any) => {
const d = defFor(colId);
return d ? d.header : colId;
};
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
const headerWidth = (colId: any) => {
if (tick() < 0 || !table) return null;
const col = table.getColumn(colId);
if (!col) return null;
const w = col.getSize();
return w != null && w > 0 ? w + 'px' : null;
};
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
const onResizeStart = (colId: any, evt: any) => {
// stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table) return;
const header = findHeader(colId);
if (!header || !header.getResizeHandler) return;
const handler = header.getResizeHandler();
if (handler) handler(evt);
};
// Find the live header object for a column id across the rendered header groups.
// Find the live header object for a column id across the rendered header groups.
const findHeader = (colId: any) => {
const groups = headerGroups || [];
for (const hg of groups as any) {
const hs = hg.headers || [];
for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
}
return null;
};
const columnIsResizing = (colId: any) => {
if (tick() < 0 || !table) return false;
const header = findHeader(colId);
return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
const columnIsVisible = (colId: any) => {
if (tick() < 0 || !table) return true;
const col = table.getColumn(colId);
return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};
const onToggleVisibility = (colId: any) => {
if (!table) return;
const col = table.getColumn(colId);
if (col && col.toggleVisibility) col.toggleVisibility();
};
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
const allLeafColumns = () => {
if (tick() < 0 || !table) return [];
const cols = table.getAllLeafColumns ? table.getAllLeafColumns() : [];
const out = [];
for (const c of cols as any) {
if (!c || c.id === SELECT_COL_ID) continue;
out.push({
id: c.id,
label: headerLabel(c.id),
visible: !!(c.getIsVisible && c.getIsVisible())
});
}
return out;
};
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
const columnPinSide = (colId: any) => {
if (tick() < 0 || !table) return false;
const col = table.getColumn(colId);
if (!col || !col.getIsPinned) return false;
return col.getIsPinned();
};
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
const onPinColumn = (colId: any, side: any, evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table) return;
const col = table.getColumn(colId);
if (col && col.pin) col.pin(side);
};
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
const pinStyle = (colId: any) => {
if (tick() < 0 || !table) return '';
const col = table.getColumn(colId);
if (!col || !col.getIsPinned) return '';
const side = col.getIsPinned();
if (side === 'left') {
const left = col.getStart ? col.getStart('left') : 0;
return 'position:sticky;left:' + left + 'px;z-index:1;';
}
if (side === 'right') {
const right = col.getAfter ? col.getAfter('right') : 0;
return 'position:sticky;right:' + right + 'px;z-index:1;';
}
return '';
};
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
const thStyle = (colId: any) => {
let s = '';
const w = headerWidth(colId);
if (w) s += 'width:' + w + ';';
s += pinStyle(colId);
return s;
};
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
const onGlobalFilterInput = (evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
if (table) {
table.setGlobalFilter(value);
return;
}
writeGlobalFilter(value);
};
// Per-column filter input → setColumnFilter (fresh-array funnel).
// Per-column filter input → setColumnFilter (fresh-array funnel).
const onColumnFilterInput = (colId: any, evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
setColumnFilter(colId, value);
};
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
const globalFilterValue = () => {
const v = currentState().globalFilter;
return v != null ? v : '';
};
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
const pageIndex = () => {
if (tick() >= 0 && table) return table.getState().pagination.pageIndex;
const p = currentState().pagination;
return p && p.pageIndex != null ? p.pageIndex : 0;
};
const pageSize = () => {
if (tick() >= 0 && table) return table.getState().pagination.pageSize;
const p = currentState().pagination;
return p && p.pageSize != null ? p.pageSize : 10;
};
const pageCount = () => {
if (tick() < 0 || !table) return 1;
const c = table.getPageCount();
return c != null && c > 0 ? c : 1;
};
const canPrevPage = () => !!(tick() >= 0 && table && table.getCanPreviousPage());
const canNextPage = () => !!(tick() >= 0 && table && table.getCanNextPage());
const onPrevPage = () => {
if (table) table.previousPage();
};
const onNextPage = () => {
if (table) table.nextPage();
};
const onPageSizeChange = (evt: any) => {
if (!table) return;
const v = evt && evt.target ? evt.target.value : '';
const n = parseInt(v, 10);
table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
const isSelectColumn = (colId: any) => colId === SELECT_COL_ID;
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
const isExpanderColumn = (colId: any) => colId === EXPANDER_COL_ID;
const rowCanExpand = (row: any) => !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
const rowIsExpanded = (row: any) => !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. With getSubRows the children arrive as ordinary depth-indented rows in
// $data.rows (table-core flattens) — NO additive detail row, NO nested r-for (Pitfall 1).
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. With getSubRows the children arrive as ordinary depth-indented rows in
// $data.rows (table-core flattens) — NO additive detail row, NO nested r-for (Pitfall 1).
const rowShowsDetail = (row: any) => getSubRows == null && rowIsExpanded(row);
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
const onToggleExpand = (row: any, evt: any) => {
if (!row || !row.toggleExpanded) return;
// Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
// expanded-state re-render. On Solid the expander <td>/<button> is RECREATED on that
// re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
// pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
// breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
// Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
// the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
// reactive flush so the fresh node exists. Harmless on the targets that keep the node
// (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
row.toggleExpanded();
if (ownerRow && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
const btn = ownerRow.querySelector('[data-expander]');
if (btn) btn.focus();
});
}
};
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
const bodyCellStyle = (row: any, colId: any) => {
const base = pinStyle(colId);
if (isExpanderColumn(colId) && row && row.depth) {
const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
return base ? base + ';' + pad : pad;
}
return base;
};
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
const rowIsGrouped = (row: any) => !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
const groupingActive = () => tick() >= 0 && (currentState().grouping || []).length > 0;
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
const cellIsGrouped = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
const cellIsAggregated = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
const groupSubRowCount = (row: any) => row && row.subRows ? row.subRows.length : 0;
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
const groupingKeys = () => currentState().grouping || [];
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
const groupableColumns = () => {
const out = [];
const defs = columnDefs();
for (const d of defs as any) {
if (!d || d.groupable === false) continue;
out.push({
id: d.id,
label: d.header != null ? d.header : d.id
});
}
return out;
};
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
const stopEvent = (evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
};
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
const isAllRowsSelected = () => !!(tick() >= 0 && table && table.getIsAllRowsSelected());
const isSomeRowsSelected = () => !!(tick() >= 0 && table && table.getIsSomeRowsSelected());
const onToggleAllRows = (evt: any) => {
if (!table) return;
table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
const rowIsSelected = (row: any) => {
if (!row) return false;
const id = row.id;
const sel = currentState().rowSelection || {};
if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
return !!(row.getIsSelected && row.getIsSelected());
};
const onToggleRow = (row: any, evt: any) => {
if (!row || !row.toggleSelected) return;
row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox: any = null;
const syncIndeterminate = () => {
if (!__rozieRoot || !__rozieRoot!.querySelector) return;
selectAllBox = __rozieRoot!.querySelector('.rdt-select-all');
if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
};
// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
// Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
// `const` (the canonical $expose contract — `$expose({ name })` references a
// binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
// is dropped on ALL SIX targets, only the by-reference key survives → a
// runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
// a fresh column-def readout, selection, pagination, and column-management verbs.
export const sortColumn = (colId: any, desc: any) => {
if (table) table.getColumn(colId) && table.getColumn(colId).toggleSorting(desc, false);
};
export const clearSorting = () => {
if (table) table.resetSorting(true);
};
export const getColumnDefs = () => columnDefs();
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
export const toggleAllRows = (value: any) => {
if (table) table.toggleAllRowsSelected(value);
};
export const clearSelection = () => {
if (table) table.resetRowSelection(true);
};
export const getSelectedRows = () => table ? table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
// pagination verbs.
// pagination verbs.
export const setPage = (idx: any) => {
if (table) table.setPageIndex(idx);
};
export const setRowsPerPage = (size: any) => {
if (table) table.setPageSize(size);
};
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
export const toggleColumnVisibility = (colId: any) => {
if (table) {
const c = table.getColumn(colId);
if (c && c.toggleVisibility) c.toggleVisibility();
}
};
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
export const applyColumnOrder = (order: any) => {
if (table) table.setColumnOrder(order);
};
export const resetColumnSizing = () => {
if (table) table.resetColumnSizing(true);
};
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
export const pinColumn = (colId: any, side: any) => {
if (table) {
const c = table.getColumn(colId);
if (c && c.pin) c.pin(side);
}
};
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
export const getRowIndexRelativeToPage = (absRow: any) => {
const abs = absRow == null ? toAbsRow(activeRow) : Math.trunc(Number(absRow)) || 0;
if (virtual) return abs;
return abs - pageRowOffset();
};
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
export const cut = () => cutRange();
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
const isGrid = () => interactionMode === 'grid';
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
const tableRole = () => isGrid() ? 'grid' : 'table';
const cellRole = () => isGrid() ? 'gridcell' : 'cell';
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
const rowIndexOf = (row: any) => tick() >= 0 ? (rows || []).indexOf(row) : -1;
// colIndexOf: a body cell's position in its row's visible cell list.
// colIndexOf: a body cell's position in its row's visible cell list.
const colIndexOf = (row: any, cellCtx: any) => tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
// headerColIndexOf: a header cell's position in its header group's leaf headers.
// headerColIndexOf: a header cell's position in its header group's leaf headers.
const headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
// - virtual mode: activeRow is already the full pre-pagination index → offset 0.
// - non-virtual: activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
// - virtual mode: activeRow is already the full pre-pagination index → offset 0.
// - non-virtual: activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
const pageRowOffset = () => {
if (!isGrid() || virtual) return 0;
return pageIndex() * pageSize();
};
// page-relative active row → absolute (display-order) index.
// page-relative active row → absolute (display-order) index.
const toAbsRow = (localRow: any) => localRow + pageRowOffset();
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
const absRowIndexOf = (row: any) => rowIndexOf(row) + pageRowOffset();
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
const prePaginationRowCount = () => {
if (!table || virtual) return bodyRowCount();
const pm = table.getPrePaginationRowModel();
return pm && pm.rows ? pm.rows.length : bodyRowCount();
};
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
const cellTabindex = (rowKey: any, colIndex: any, level = null) => {
if (!isGrid()) return null;
// B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
// the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
// tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
if (bodyRowCount() === 0) {
return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
}
// B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
// grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
// lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
// tab-stops (the roving invariant broke under grouped headers).
if (activeIsHeader) {
if (rowKey !== '__header') return -1;
return colIndex === activeColIndex && level === activeHeaderLevel ? 0 : -1;
}
const isActive = rowKey === String(activeRow) && colIndex === activeColIndex;
return isActive ? 0 : -1;
};
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
const resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
if (!gridRoot) return null;
// B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
// same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
// integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
// match (the parent) when the leaf is meant. level is an integer (NO consumer string is
// interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
// byte-unchanged.
let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
return gridRoot.querySelector(sel);
};
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
const focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
if (!isGrid() || !gridRoot) return;
const r = nextRow == null ? activeRow : nextRow;
const c = nextCol == null ? activeColIndex : nextCol;
// B12: thread the FRESH post-write header level (the grouped-header analog of the
// nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
// level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
const lvl = nextLevel == null ? activeHeaderLevel : nextLevel;
// Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
// crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
// Angular's signal write are async within one handler — re-reading $data.activeIsHeader
// here returns the PRE-write value, resolving focus to the BODY cell instead of the
// header. Callers pass the fresh isHeader local; falls back to $data when omitted.
const header = nextIsHeader == null ? activeIsHeader : nextIsHeader;
// ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
// rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
// double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
// in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
// The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
// while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
if (virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
virtualizer.scrollToIndex(r, {
align: 'center'
});
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
let focusAttempts = 0;
const focusWhenReady = () => {
const el = resolveCellEl(String(r), c);
if (el) {
el.focus();
return;
}
focusAttempts = focusAttempts + 1;
if (focusAttempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
return;
}
const rowKey = header ? '__header' : String(r);
const el = resolveCellEl(rowKey, c, header ? lvl : null);
if (el) el.focus();
};
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).
// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).
// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
const totalRowCount = () => {
if (!table) return (rows || []).length;
const fm = table.getFilteredRowModel();
return fm && fm.rows ? fm.rows.length : (rows || []).length;
};
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
const visibleColCount = () => {
// NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
// state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
// self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
const rowList = rows || [];
if (rowList.length) return rowList[0].getVisibleCells().length;
const hg = headerGroups || [];
return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};
const bodyRowCount = () => (rows || []).length;
const clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
const headerLeafLevel = () => {
const hg = headerGroups || [];
return hg.length ? hg.length - 1 : 0;
};
const headerAt = (level: any, colIndex: any) => {
const hg = headerGroups || [];
const grp = hg[level];
if (!grp || !grp.headers) return null;
return grp.headers[colIndex] || null;
};
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
const parentHeaderColIndex = (level: any, colIndex: any) => {
if (level <= 0) return -1;
const h = headerAt(level, colIndex);
if (!h || !h.column || !h.column.parent) return -1;
const parentId = h.column.parent.id;
const hg = headerGroups || [];
const pg = hg[level - 1];
if (!pg || !pg.headers) return -1;
for (let i = 0; i < pg.headers.length; i++) {
const ph = pg.headers[i];
if (ph && ph.column && ph.column.id === parentId) return i;
}
return -1;
};
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
const firstChildHeaderColIndex = (level: any, colIndex: any) => {
const h = headerAt(level, colIndex);
if (!h || !h.column) return -1;
const kids = h.column.columns || [];
if (!kids.length) return -1;
const childId = kids[0].id;
const hg = headerGroups || [];
const cg = hg[level + 1];
if (!cg || !cg.headers) return -1;
for (let i = 0; i < cg.headers.length; i++) {
const ch = cg.headers[i];
if (ch && ch.column && ch.column.id === childId) return i;
}
return -1;
};
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────
// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────
// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
const moveCol = (delta: any) => {
const max = visibleColCount() - 1;
const nextCol = clamp(activeColIndex + delta, 0, max < 0 ? 0 : max);
activeColIndex = nextCol;
return nextCol;
};
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
// - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
// - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
// - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
// - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
// - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
// - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
const moveRow = (delta: any) => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const leafLevel = headerLeafLevel();
if (activeIsHeader) {
if (delta > 0) {
// B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
// level down); from the LEAF header level, drop into the body (row 0). A header-level
// move re-targets activeColIndex (parent↔child column indices differ), so the fresh
// col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
if (activeHeaderLevel < leafLevel) {
const childCol = firstChildHeaderColIndex(activeHeaderLevel, activeColIndex);
if (childCol >= 0) {
const nextLevel = activeHeaderLevel + 1;
activeHeaderLevel = nextLevel;
activeColIndex = childCol;
return {
row: activeRow,
col: childCol,
isHeader: true,
level: nextLevel
};
}
}
// At the leaf header: an empty grid has no body to drop into → stay put.
if (bodyRowCount() === 0) return {
row: activeRow,
col: activeColIndex,
isHeader: true,
level: activeHeaderLevel
};
// B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
// (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
// page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
// (delta-1 = 0); clamped to the page-last body row.
const landRow = clamp(delta - 1, 0, maxRow);
activeIsHeader = false;
activeRow = landRow;
return {
row: landRow,
col: activeColIndex,
isHeader: false,
level: 0
};
}
// B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
// spans the active column; at the top level (or no real parent) stay put. The parent col
// index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
const parentCol = parentHeaderColIndex(activeHeaderLevel, activeColIndex);
if (parentCol >= 0) {
const nextLevel = activeHeaderLevel - 1;
activeHeaderLevel = nextLevel;
activeColIndex = parentCol;
return {
row: activeRow,
col: parentCol,
isHeader: true,
level: nextLevel
};
}
return {
row: activeRow,
col: activeColIndex,
isHeader: true,
level: activeHeaderLevel
};
}
// In the body: an upward move from row 0 crosses into the LEAF header level (the header row
// adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
// activeColIndex carries over unchanged.
if (delta < 0 && activeRow === 0) {
activeIsHeader = true;
activeHeaderLevel = leafLevel;
return {
row: activeRow,
col: activeColIndex,
isHeader: true,
level: leafLevel
};
}
const nextRow = clamp(activeRow + delta, 0, maxRow);
activeRow = nextRow;
activeIsHeader = false;
return {
row: nextRow,
col: activeColIndex,
isHeader: false,
level: 0
};
};
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
const gotoColEdge = (toEnd: any) => {
const max = visibleColCount() - 1;
const nextCol = toEnd ? max < 0 ? 0 : max : 0;
activeColIndex = nextCol;
return nextCol;
};
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
const gotoStart = () => {
activeIsHeader = false;
activeRow = 0;
activeColIndex = 0;
return {
row: 0,
col: 0
};
};
const gotoEnd = () => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const max = visibleColCount() - 1;
const maxCol = max < 0 ? 0 : max;
activeIsHeader = false;
activeRow = maxRow;
activeColIndex = maxCol;
return {
row: maxRow,
col: maxCol
};
};
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
const currentCellEl = () => {
const rowKey = activeIsHeader ? '__header' : String(activeRow);
return resolveCellEl(rowKey, activeColIndex, activeIsHeader ? activeHeaderLevel : null);
};
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
const focusables = (cellEl: any) => {
if (!cellEl || !cellEl.querySelectorAll) return [];
const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
return list.filter((n: any) => !n.disabled);
};
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
const enterControl = () => {
const cellEl = currentCellEl();
const list = focusables(cellEl);
if (!list.length) return;
activeInControl = true;
list[0].focus();
};
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
const cycleWithinCell = (cellEl: any, forward: any) => {
const list = focusables(cellEl);
if (!list.length) return;
const active = gridRoot ? gridRoot.getRootNode().activeElement : null;
const cur = list.indexOf(active);
let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
if (i >= list.length) i = 0;
if (i < 0) i = list.length - 1;
list[i].focus();
};
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
const onGridKeyDown = (e: any) => {
if (!isGrid() || !e) return;
const key = e.key;
// Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
// via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
// early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
// modes (editing / in-control / navigation) stay mutually exclusive and ordered.
if (editingRow >= 0) return;
// Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
// editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
// keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
if (editingRowIndex != null) return;
// Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
if (activeInControl) {
if (key === 'Escape') {
e.preventDefault();
activeInControl = false;
// Return focus to the OWNING cell (no move happened) — pass the current indices
// explicitly (the React-emitted seam types both params as required; a zero-arg call
// is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
focusActiveCell(activeRow, activeColIndex);
} else if (key === 'Tab') {
e.preventDefault();
cycleWithinCell(currentCellEl(), !e.shiftKey);
}
return;
}
// WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
// grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
// user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
// NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
// focused node; if it is not itself a [data-grid-cell], let the event pass through.
const tgt = e.target;
if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
// Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
// nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
// async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
// plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
// in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
// fires ONLY on a real move (a clamped no-op edge move leaves them identical).
const prevRow = activeRow;
const prevCol = activeColIndex;
const prevIsHeader = activeIsHeader;
const prevLevel = activeHeaderLevel;
let nextRow = prevRow;
let nextCol = prevCol;
let nextIsHeader = prevIsHeader;
// B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
// threaded into the focus seam so a leaf↔parent header move lands focus at the correct
// level. moveRow returns it; the non-vertical branches keep the pre-move level.
let nextLevel = prevLevel;
// ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
// the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
// fall through to a plain navigation move). Body cells only (no range from a header). The
// extendRange call owns focus + the range-change emit, so return immediately. ──────────
if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(0, 1);
return;
} else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(0, -1);
return;
} else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(1, 0);
return;
} else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader) {
e.preventDefault();
extendRange(-1, 0);
return;
} else if (key === 'ArrowRight') {
e.preventDefault();
clearRange();
nextCol = moveCol(1);
} else if (key === 'ArrowLeft') {
e.preventDefault();
clearRange();
nextCol = moveCol(-1);
} else if (key === 'ArrowDown') {
e.preventDefault();
clearRange();
const m = moveRow(1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'ArrowUp') {
e.preventDefault();
clearRange();
const m = moveRow(-1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageDown') {
e.preventDefault();
const m = moveRow(GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageUp') {
e.preventDefault();
const m = moveRow(-GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'Home') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const s = gotoStart();
nextRow = s.row;
nextCol = s.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(false);
}
} else if (key === 'End') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const en = gotoEnd();
nextRow = en.row;
nextCol = en.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(true);
}
}
// ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
// pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
// edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
// type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
// gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
// falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
// (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
copyRange();
return;
} else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
pasteRange();
return;
}
// ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
// source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
// Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
// and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
// shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
cutRange();
return;
}
// ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
// EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
// (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
// collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
// at least the active editable column); a non-editable active cell falls through unchanged.
else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
e.preventDefault();
beginRowEdit((rows || [])[activeRow]);
return;
}
// ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
// Gated by isActiveCellEditable(): a non-editable active cell falls through to
// enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
// (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
e.preventDefault();
beginEdit(activeRow, activeColIndex, null);
return;
} else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
// checkbox/select/date editor must NOT take the typed char as its value (it would
// force-check the checkbox, seed a garbage select option, or corrupt the date) — open
// those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
e.preventDefault();
const editType = editorTypeOf(activeCellColumnId());
const seed = editType === 'text' || editType === 'number' ? key : null;
beginEdit(activeRow, activeColIndex, seed);
return;
}
// ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
// expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
// verified invariant) so it never hits the edit branches above and would otherwise fall to
// enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
// Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
// model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
// ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
// full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
else if (key === 'Enter' && !activeIsHeader && rowIsGrouped((rows || [])[activeRow])) {
e.preventDefault();
// C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
// active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
// the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
// row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
// fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
// DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
// while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
// settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
// onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
// SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
// until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
// already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
const grpRow = activeRow;
const grpCol = activeColIndex;
onToggleExpand((rows || [])[activeRow], e);
recoverGridFocus(String(grpRow), grpCol, null);
return;
} else if (key === 'Enter' || key === 'F2') {
e.preventDefault();
enterControl();
return;
} else return;
// THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
// focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
// WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
// changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
// row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
// B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
// C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
// keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
// toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
// The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
onactivecellchange?.({
rowIndex: toAbsRow(nextRow),
colIndex: nextCol
});
}
};
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
const syncActiveFromEvent = (e: any) => {
if (!isGrid() || !e) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null) return;
const col = parseInt(colAttr, 10);
if (!Number.isFinite(col)) return;
const isHeader = rowAttr === '__header';
activeIsHeader = isHeader;
if (isHeader) {
// B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
// roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
// one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
const lvlAttr = cellEl.getAttribute('data-header-level');
const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
activeHeaderLevel = Number.isFinite(lvl) ? lvl : headerLeafLevel();
} else {
const row = parseInt(rowAttr, 10);
if (Number.isFinite(row)) activeRow = row;
}
activeColIndex = col;
// A plain focus collapses any range back to the single active cell — EXCEPT (a) the
// programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
// ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
// focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
// BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
// path owns the shift case and flags it here so the collapse is skipped.
if (rangeTransition) {
rangeTransition = false;
} else if (rangeClickPending) {
rangeClickPending = false;
} else {
clearRange();
}
// The cell box (not an inner control) receiving focus = navigation mode.
if (tgt === cellEl) activeInControl = false;
};
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). A focusin
// event carries no reliable `shiftKey`, so the modifier MUST be read off the pointer event
// — @mousedown fires BEFORE the cell's focusin and DOES carry shiftKey. A shift-held
// mousedown on a BODY cell sets the range's moving corner to that cell (keeping the anchor),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). A focusin
// event carries no reliable `shiftKey`, so the modifier MUST be read off the pointer event
// — @mousedown fires BEFORE the cell's focusin and DOES carry shiftKey. A shift-held
// mousedown on a BODY cell sets the range's moving corner to that cell (keeping the anchor),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
const onGridMouseDown = (e: any) => {
if (!isGrid() || !e || !e.shiftKey) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
const row = parseInt(rowAttr, 10);
const col = parseInt(colAttr, 10);
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
setRangeFocus(row, col);
activeIsHeader = false;
activeRow = row;
activeColIndex = col;
rangeClickPending = true;
};
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
const onGridFocusOut = (e: any) => {
if (!isGrid() || !activeInControl) return;
const next = e ? e.relatedTarget : null;
const cellEl = currentCellEl();
if (!cellEl || !next || !cellEl.contains(next)) activeInControl = false;
};
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
const recoverGridFocus = (rowKey: any, col: any, level: any) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(rowKey, col, level);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
const clampActiveCell = (rowCount: any, colCount: any) => {
if (!isGrid()) return;
// B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
// just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
// async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
// and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
// out of bounds on React only). Falls back to the live helpers when called without bounds.
const colN = colCount != null ? colCount : visibleColCount();
const rowN = rowCount != null ? rowCount : bodyRowCount();
// B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
// shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
// framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
// — true on all six, incl React's async setState), so the doomed cell + its focus are still
// observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
// programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
// Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
// doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
// The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
// NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
// shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
// DOM coords are always fresh.
let recoverFocus = false;
let doomedRow = -1;
let doomedCol = 0;
if (gridRoot) {
const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
const focusedEl = rootNode ? rootNode.activeElement : null;
const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
if (focusedCell && gridRoot.contains(focusedCell)) {
const fRowAttr = focusedCell.getAttribute('data-row');
const fColAttr = focusedCell.getAttribute('data-col-index');
if (fRowAttr != null && fRowAttr !== '__header') {
const fr = parseInt(fRowAttr, 10);
const fc = parseInt(fColAttr, 10);
if (Number.isFinite(fr) && fr > rowN - 1) {
recoverFocus = true;
doomedRow = fr;
doomedCol = Number.isFinite(fc) ? fc : 0;
}
}
}
}
const maxCol = colN - 1;
const col = clamp(activeColIndex, 0, maxCol < 0 ? 0 : maxCol);
if (col !== activeColIndex) activeColIndex = col;
// B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
// cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
// an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
// re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
if (rowN <= 0) {
activeIsHeader = true;
activeHeaderLevel = headerLeafLevel();
activeColIndex = 0;
// B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
// reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
// async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
// class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
// recovery branch below actually runs on React too.
gridEmptyFallback = true;
clampRange(rowN - 1, colN - 1);
// B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
// reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
// Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
// (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
// the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
return;
}
// B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
// re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
// cell. A user-driven header position (not the empty fallback) is left untouched.
if (gridEmptyFallback) {
gridEmptyFallback = false;
activeIsHeader = false;
activeRow = 0;
}
if (!activeIsHeader) {
const lastRow = rowN - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const row = clamp(activeRow, 0, maxRow);
if (row !== activeRow) activeRow = row;
}
// B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
// shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
// out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
clampRange(rowN - 1, colN - 1);
// B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
// when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
// into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
if (recoverFocus) {
const recRow = clamp(doomedRow, 0, rowN - 1);
const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
recoverGridFocus(String(recRow), recCol, null);
}
};
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false;
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false;
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false;
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false;
const inRange = (rIdx: any, cIdx: any) => {
const a = rangeAnchor;
const f = rangeFocus;
if (!a || !f) return false;
const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
export const getSelectedRange = () => {
// B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
// payload) never reports a corner past a shrunken model — React-stale-safe (the eager
// refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
const a = rangeAnchor;
const f = rangeFocus;
if (!a && !f) return {
anchor: null,
focus: null
};
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
anchor: null,
focus: null
};
const clampCorner = (c: any) => c == null ? null : {
rowIndex: clamp(c.rowIndex, 0, maxRow),
colIndex: clamp(c.colIndex, 0, maxCol)
};
return {
anchor: clampCorner(a),
focus: clampCorner(f)
};
};
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
const isFillHandleCell = (rIdx: any, cIdx: any) => {
const a = rangeAnchor;
const f = rangeFocus;
if (!a || !f) return false;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx === r1 && cIdx === c1;
};
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
const emitRangeChange = (anchor: any, focus: any) => {
onrangechange?.({
anchor,
focus
});
};
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
const extendRange = (dRow: any, dCol: any) => {
if (activeIsHeader) return;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
// Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
let anchor = rangeAnchor;
let focus = rangeFocus;
const hadRange = !!(anchor && focus);
if (!anchor || !focus) {
anchor = {
rowIndex: activeRow,
colIndex: activeColIndex
};
focus = {
rowIndex: activeRow,
colIndex: activeColIndex
};
}
const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
const nextFocus = {
rowIndex: nextRow,
colIndex: nextCol
};
rangeAnchor = anchor;
rangeFocus = nextFocus;
rangeActive = true;
// Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
// from the range's leading edge, the spreadsheet convention).
activeRow = nextRow;
activeColIndex = nextCol;
// Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
// settle on the new focus corner is part of THIS range extension, not a fresh navigation.
rangeTransition = true;
focusActiveCell(nextRow, nextCol, false);
// B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
// and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
// change → no emit. Seeding a brand-new range (no prior range) is always a change (the
// rectangle came into existence) even if its first corner is a degenerate 1×1.
if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
emitRangeChange(anchor, nextFocus);
}
};
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
const setRangeFocus = (rIdx: any, cIdx: any) => {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
let anchor = rangeAnchor;
if (!anchor) anchor = {
rowIndex: activeRow,
colIndex: activeColIndex
};
const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
const nextFocus = {
rowIndex: r,
colIndex: c
};
rangeAnchor = anchor;
rangeFocus = nextFocus;
rangeActive = true;
emitRangeChange(anchor, nextFocus);
};
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
const clearRange = () => {
// B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
// in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
// move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
// would let the SECOND call through and emit a duplicate range-change. rangeActive flips
// synchronously → the second call returns here.
if (!rangeActive) return;
rangeActive = false;
rangeAnchor = null;
rangeFocus = null;
emitRangeChange(null, null);
};
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
const clampRange = (maxRowArg: any, maxColArg: any) => {
const a = rangeAnchor;
const f = rangeFocus;
if (!a && !f) return;
// Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
// shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) {
rangeAnchor = null;
rangeFocus = null;
rangeActive = false;
return;
}
if (a) {
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
if (ar !== a.rowIndex || ac !== a.colIndex) rangeAnchor = {
rowIndex: ar,
colIndex: ac
};
}
if (f) {
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
if (fr !== f.rowIndex || fc !== f.colIndex) rangeFocus = {
rowIndex: fr,
colIndex: fc
};
}
};
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
const announce = (msg: any) => {
pasteAnnounce = msg != null ? msg : '';
};
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
const clipboardActiveAllowed = () => !activeIsHeader;
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
const fieldOfColId = (colId: any) => {
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
const normalizedRange = () => {
const a = rangeAnchor;
const f = rangeFocus;
if (!a || !f) return null;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return null;
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
return {
r0: ar < fr ? ar : fr,
r1: ar > fr ? ar : fr,
c0: ac < fc ? ac : fc,
c1: ac > fc ? ac : fc
};
};
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
const escapeTsvField = (s: any) => {
if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
const rangeToTsv = () => {
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow;
const r1 = box ? box.r1 : activeRow;
const c0 = box ? box.c0 : activeColIndex;
const c1 = box ? box.c1 : activeColIndex;
const lines = [];
for (let r = r0; r <= r1; r++) {
const cells = [];
for (let c = c0; c <= c1; c++) {
const v = cellValueAt(r, c);
cells.push(escapeTsvField(v == null ? '' : String(v)));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
};
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
const parseTsv = (text: any) => {
const str = text != null ? String(text) : '';
// CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
// large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
// single-pass scan allocates a cell-per-character grid.
if (str === '' || str.length > 2000000) return [];
// B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
// cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
// newlines, and doubled quotes ("") inside it are literal content until the closing quote;
// an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = str.length;
while (i < n) {
const ch = str[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < n && str[i + 1] === '"') {
field = field + '"';
i = i + 2;
continue;
}
inQuotes = false;
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
continue;
}
if (ch === '"' && field === '') {
inQuotes = true;
i = i + 1;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
i = i + 1;
continue;
}
if (ch === '\r') {
if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
if (ch === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
}
// Flush the trailing field + row.
row.push(field);
rows.push(row);
// Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
if (rows.length > 1) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
};
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
const copyRange = () => {
// B11: never copy from a header-active state (the reusable clipboard guard).
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
try {
const p = navigator.clipboard.writeText(rangeToTsv());
if (p && p.catch) p.catch(() => {});
} catch (err: any) {/* best-effort copy */}
};
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
const applyGridToRange = (grid: any, originRow: any, originCol: any) => {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
wrote: 0,
total: 0
};
let total = 0;
let wrote = 0;
const committed = [];
// Build the fresh data array incrementally so the whole paste is ONE writeData.
let next = currentData();
for (let gr = 0; gr < grid.length; gr++) {
const r = originRow + gr;
if (r > maxRow) break;
const cols = grid[gr] || [];
for (let gc = 0; gc < cols.length; gc++) {
const c = originCol + gc;
if (c > maxCol) break;
total = total + 1;
const colId = columnIdAt(r, c);
if (colId == null || !columnEditable(colId)) continue;
const rowObj = rowOriginalAt(r);
// B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
// single-cell commit coercion) — a numeric column commits a real Number, an empty cell
// commits null; every other editor type passes through verbatim. No mixed/garbage types
// ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
const value = coerceCellValue(colId, cols[gc]);
// T-51-01: validate the pasted value as plain DATA before any write.
if (runValidator(colId, value, rowObj) !== true) continue;
const field = fieldOfColId(colId);
const srcIndex = sourceIndexOfRow(r);
const oldValue = rowObj ? rowObj[field] : null;
next = replaceRowValue(next, srcIndex, field, value);
committed.push({
rowId: rowIdAt(r),
columnId: colId,
oldValue,
newValue: value
});
wrote = wrote + 1;
}
}
if (wrote > 0) {
editTransition = true;
writeData(next);
editTransition = false;
// One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
for (let i = 0; i < committed.length; i++) oncelleditcommit?.(committed[i]);
}
// WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
// targeted real cells but every one was skipped (validation-failed / non-editable), announce a
// distinct validation-failed message instead of a misleading "0 of M cells pasted".
if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
return {
wrote,
total
};
};
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
const rowOriginalAt = (rowIndex: any) => {
const rowList = rows || [];
const row = rowList[rowIndex];
return row ? row.original : null;
};
const rowIdAt = (rowIndex: any) => {
const rowList = rows || [];
const row = rowList[rowIndex];
return row ? row.id : null;
};
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
const tileGridToBox = (grid: any, box: any) => {
const srcRows = grid.length;
const srcCols = srcRows > 0 ? grid[0].length : 0;
if (srcRows <= 0 || srcCols <= 0) return grid;
const boxRows = box.r1 - box.r0 + 1;
const boxCols = box.c1 - box.c0 + 1;
const rows = boxRows > srcRows ? boxRows : srcRows;
const cols = boxCols > srcCols ? boxCols : srcCols;
const out = [];
for (let r = 0; r < rows; r++) {
const srcLine = grid[r % srcRows] || [];
const line = [];
for (let c = 0; c < cols; c++) {
const v = srcLine[c % srcCols];
line.push(v != null ? v : '');
}
out.push(line);
}
return out;
};
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
const pasteRange = () => {
// B11: never paste into a header-active state (the reusable clipboard guard) — a header
// anchor would silently write body row 0 at the header's column.
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
// CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
// C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
// single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
// on React; re-reading them inside the async .then() returns the mount-render stale value, so a
// selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
// the box + anchor now and pass them into tileGridToBox / applyGridToRange.
const box = normalizedRange();
const anchorRow = box ? box.r0 : activeRow;
const anchorCol = box ? box.c0 : activeColIndex;
const destBox = box || {
r0: anchorRow,
r1: anchorRow,
c0: anchorCol,
c1: anchorCol
};
let p: any = null;
try {
p = navigator.clipboard.readText();
} catch (err: any) {
return;
}
if (!p || !p.then) return;
p.then((text: any) => {
const grid = parseTsv(text);
if (!grid.length) return;
// C3: tile the clipboard block to fill the destination range (single→range fill,
// smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
const tiled = tileGridToBox(grid, destBox);
applyGridToRange(tiled, anchorRow, anchorCol);
}).catch(() => {});
};
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
const cutRange = () => {
if (!clipboardActiveAllowed()) return;
// Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow;
const r1 = box ? box.r1 : activeRow;
const c0 = box ? box.c0 : activeColIndex;
const c1 = box ? box.c1 : activeColIndex;
// Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
const cp = navigator.clipboard.writeText(rangeToTsv());
if (cp && cp.catch) cp.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// Clear the source: a grid of empty strings sized to the range, applied at the top-left.
const grid = [];
for (let r = r0; r <= r1; r++) {
const cols = [];
for (let c = c0; c <= c1; c++) cols.push('');
grid.push(cols);
}
applyGridToRange(grid, r0, c0);
};
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
const tileIndex = (i: any, lo: any, hi: any) => {
const span = hi - lo + 1;
if (span <= 1) return lo;
let k = (i - lo) % span;
if (k < 0) k = k + span;
return lo + k;
};
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
const fillRange = (sourceBox: any, endCell: any) => {
// B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
// the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
// React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
// grows), so deriving the box from the threaded endpoints is what makes the fill cover the
// dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
let box;
if (sourceBox && sourceBox.r0 != null && endCell) {
let r0 = sourceBox.r0;
let r1 = sourceBox.r1;
let c0 = sourceBox.c0;
let c1 = sourceBox.c1;
if (endCell.r < r0) r0 = endCell.r;
if (endCell.r > r1) r1 = endCell.r;
if (endCell.c < c0) c0 = endCell.c;
if (endCell.c > c1) c1 = endCell.c;
box = {
r0,
r1,
c0,
c1
};
} else {
box = normalizedRange();
}
if (!box) return;
const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
r0: box.r0,
r1: box.r0,
c0: box.c0,
c1: box.c0
};
const grid = [];
for (let r = box.r0; r <= box.r1; r++) {
const cols = [];
for (let c = box.c0; c <= box.c1; c++) {
const sr = tileIndex(r, src.r0, src.r1);
const sc = tileIndex(c, src.c0, src.c1);
const v = cellValueAt(sr, sc);
cols.push(v == null ? '' : String(v));
}
grid.push(cols);
}
applyGridToRange(grid, box.r0, box.c0);
};
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false;
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove: any = null;
let fillDragUp: any = null;
const teardownFillDrag = () => {
if (typeof document !== 'undefined') {
if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
if (fillDragUp) document.removeEventListener('pointerup', fillDragUp);
}
fillDragMove = null;
fillDragUp = null;
fillDragging = false;
};
const cellIndexFromPoint = (clientX: any, clientY: any) => {
if (typeof document === 'undefined' || !document.elementFromPoint) return null;
let el = document.elementFromPoint(clientX, clientY);
// Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
// a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
// and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
// deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
if (!inner || inner === el) break;
el = inner;
}
if (!el || !el.closest) return null;
const cellEl = el.closest('[data-grid-cell]');
if (!cellEl) return null;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
const r = parseInt(rowAttr, 10);
const c = parseInt(colAttr, 10);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return {
r,
c
};
};
const onFillHandlePointerDown = (e: any) => {
if (!e) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
fillDragging = true;
// B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
// range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
// up/left drag copies from the real origin (not the post-drag corner that would flip to a
// target cell). Captured per-gesture in the closure (no module-let needed).
const sourceBox = normalizedRange();
// B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
// the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
let lastCell = sourceBox ? {
r: sourceBox.r1,
c: sourceBox.c1
} : null;
const move = (ev: any) => {
if (!fillDragging) return;
const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
// B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
// pointermove (the pointer fires many per cell) spams the event with identical payloads.
// Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
// seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
// or re-enters the same cell is suppressed (the range is unchanged).
if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
lastCell = cell;
setRangeFocus(cell.r, cell.c);
}
};
const up = () => {
// teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
teardownFillDrag();
fillRange(sourceBox, lastCell);
};
// Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
fillDragMove = move;
fillDragUp = up;
if (typeof document !== 'undefined') {
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
};
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).
// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).
// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
const activeCellColumnId = () => {
if (activeIsHeader) return null;
const rowList = rows || [];
const row = rowList[activeRow];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[activeColIndex];
return cell && cell.column ? cell.column.id : null;
};
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
const isActiveCellEditable = () => {
const colId = activeCellColumnId();
return colId != null && columnEditable(colId);
};
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
// - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
// so EVERY editable cell in the row enters edit simultaneously (the editor template branch
// re-uses this gate verbatim, no template fork);
// - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
// - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
// so EVERY editable cell in the row enters edit simultaneously (the editor template branch
// re-uses this gate verbatim, no template fork);
// - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
const isEditing = (rowIndex: any, colIndex: any) => {
if (editVer < 0) return false;
if (editingRowIndex != null && editingRowIndex === rowIndex) {
const colId = columnIdAt(rowIndex, colIndex);
return colId != null && columnEditable(colId);
}
return editingRow === rowIndex && editingCol === colIndex;
};
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
const cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => isEditing(rowIndex, colIndex) && !!invalidMsg ? 'true' : null;
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
const runValidator = (colId: any, value: any, row: any) => {
const m = editMetaOf(colId);
const v = m ? m.validate : null;
if (typeof v !== 'function') return true;
let r: any = null;
try {
r = v(value, row);
} catch (err: any) {
return 'Invalid value';
}
if (r === true) return true;
if (typeof r === 'string') return r;
return 'Invalid value';
};
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
const setInvalid = (msg: any) => {
invalidMsg = msg != null ? msg : '';
};
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
const replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
const src = rows || [];
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
// and would copy inherited enumerable props of typed/class-instance row objects.
out.push({
...(src[i] || {}),
[field]: value
});
} else {
out.push(src[i]);
}
}
return out;
};
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
const sourceIndexOfRow = (visibleRowIndex: any) => {
const rowList = rows || [];
const row = rowList[visibleRowIndex];
if (!row) return visibleRowIndex;
const orig = row.original;
const data = currentData() || [];
const idx = data.indexOf(orig);
return idx >= 0 ? idx : visibleRowIndex;
};
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
const editingColumnId = () => {
const rowList = rows || [];
const row = rowList[editingRow];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol];
return cell && cell.column ? cell.column.id : null;
};
const editingColumnField = () => {
const colId = editingColumnId();
if (colId == null) return null;
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
const editingCellValue = () => {
const rowList = rows || [];
const row = rowList[editingRow];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol];
return cell ? cell.getValue() : null;
};
const editingRowOriginal = () => {
const rowList = rows || [];
const row = rowList[editingRow];
return row ? row.original : null;
};
const editingRowId = () => {
const rowList = rows || [];
const row = rowList[editingRow];
return row ? row.id : null;
};
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
const focusEditorWhenReady = (selectAll = true) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
if (el) {
el.focus();
if (selectAll && el.select) {
try {
el.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
const columnIdAt = (rowIndex: any, colIndex: any) => {
const rowList = rows || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell && cell.column ? cell.column.id : null;
};
const cellValueAt = (rowIndex: any, colIndex: any) => {
const rowList = rows || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell ? cell.getValue() : null;
};
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
const beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
const colId = columnIdAt(rowIndex, colIndex);
if (colId == null || !columnEditable(colId)) return;
setInvalid('');
// Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
// editor clears any row-edit state so isEditing never resolves both modes for one cell.
editingRowIndex = null;
rowDraft = {};
editingRow = rowIndex;
editingCol = colIndex;
draftValue = seed != null ? seed : cellValueAt(rowIndex, colIndex);
activeInControl = true;
editVer = editVer + 1;
// B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
// seeded char so subsequent typing appends instead of replacing it.
focusEditorWhenReady(seed == null);
};
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
const focusCellWhenReady = (row: any, col: any) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(String(row), col);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
const indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
const list = rows || [];
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (!r) continue;
if (rowId != null && r.id === rowId) return i;
if (rowOriginal != null && r.original === rowOriginal) return i;
}
return -1;
};
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
const endEdit = () => {
editingRow = -1;
editingCol = -1;
draftValue = null;
invalidMsg = '';
activeInControl = false;
editVer = editVer + 1;
};
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
const endRowEdit = () => {
editingRowIndex = null;
rowDraft = {};
invalidMsg = '';
activeInControl = false;
editVer = editVer + 1;
};
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
const coerceCellValue = (colId: any, raw: any) => {
if (editorTypeOf(colId) !== 'number') return raw;
if (raw == null) return null;
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
};
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
const commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
if (editingRow < 0) return false;
const colId = editingColumnId();
if (colId == null) {
endEdit();
return false;
}
const field = editingColumnField();
const oldValue = editingCellValue();
const rowOriginal = editingRowOriginal();
const rowId = editingRowId();
// B3: coerce by the column's editor type BEFORE validation + write so the validator
// and the model both see the typed value (number/null), not the raw draft string.
const rawValue = overrideValue !== undefined ? overrideValue : draftValue;
const newValue = coerceCellValue(colId, rawValue);
const err = runValidator(colId, newValue, rowOriginal);
if (err !== true) {
// D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
setInvalid(err);
focusEditorWhenReady();
return false;
}
setInvalid('');
const srcIndex = sourceIndexOfRow(editingRow);
const next = replaceRowValue(currentData(), srcIndex, field, newValue);
// Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
const focusRow = editingRow;
const focusCol = editingCol;
// Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
// must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
editTransition = true;
writeData(next);
// Exactly one emit per commit, from this single call site (writeData does NOT emit).
oncelleditcommit?.({
rowId,
columnId: colId,
oldValue,
newValue
});
endEdit();
editTransition = false;
// Defer the focus return so the display↔editor re-render commits first (async on
// React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
// editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
// Tab-advance (the caller immediately opens the next editor and focuses THAT).
// B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
// RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
// index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
// consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
// fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
// active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
// index → byte-behaviorally identical to before.
if (skipFocusReturn !== true) pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
const cancelEdit = () => {
if (editingRow < 0) return;
// CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
// Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
// an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
// instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
const focusRow = editingRow;
const focusCol = editingCol;
editTransition = true;
endEdit();
editTransition = false;
focusCellWhenReady(focusRow, focusCol);
};
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
const editableColumnsForRow = (rowIndex: any) => {
const rowList = rows || [];
const row = rowList[rowIndex];
if (!row) return [];
const cells = visibleCellsFor(row);
const out = [];
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
const colId = cell && cell.column ? cell.column.id : null;
if (colId == null || !columnEditable(colId)) continue;
const d = defFor(colId);
const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
// colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
// Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
// can address a SPECIFIC editor by column, not just the first [data-editing-cell].
out.push({
colId,
field,
colIndex: c
});
}
return out;
};
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
const focusRowEditorAt = (rowIndex: any, colIndex: any) => {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const cellEl = resolveCellEl(String(rowIndex), colIndex);
const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
if (ed) {
ed.focus();
if (ed.select) {
try {
ed.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
const beginRowEdit = (row: any) => {
const rowIndex = rowIndexOf(row);
if (rowIndex < 0) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
// Clear any single-cell editor first (mutual exclusivity).
editingRow = -1;
editingCol = -1;
draftValue = null;
setInvalid('');
// Seed each editable cell's draft from its current value.
const draft = {};
const rowList = rows || [];
const r = rowList[rowIndex];
const orig = r ? r.original : null;
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
draft[ec.colId] = orig ? orig[ec.field] : null;
}
rowDraft = draft;
editingRowIndex = rowIndex;
activeInControl = true;
editVer = editVer + 1;
focusEditorWhenReady();
};
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
const commitRow = () => {
if (editingRowIndex == null) return false;
const rowIndex = editingRowIndex;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) {
endRowEdit();
return false;
}
const rowList = rows || [];
const r = rowList[rowIndex];
const rowOriginal = r ? r.original : null;
const rowId = r ? r.id : null;
const draft = rowDraft || {};
// Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
// B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
// 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
// commitEdit already coerces via coerceCellValue; the row path silently committed strings →
// a number column ended up holding '99'). Coerce once here so the validator and the model both
// see the typed value, identical to the single-cell funnel.
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
if (err !== true) {
setInvalid(err);
// B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
// unconditionally the first editor (focusEditorWhenReady resolves the first
// [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
focusRowEditorAt(rowIndex, ec.colIndex);
return false;
}
}
setInvalid('');
// Build the changes payload (only the columns whose value actually changed) + the field→
// value map for the single row-object replace.
const changes = [];
const fieldValues = {};
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
// B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
// string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
fieldValues[ec.field] = newValue;
if (oldValue !== newValue) changes.push({
columnId: ec.colId,
oldValue,
newValue
});
}
// ONE fresh-array replace of the SINGLE row object with all field values applied at once.
const srcIndex = sourceIndexOfRow(rowIndex);
const next = replaceRowValues(currentData(), srcIndex, fieldValues);
// Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
// active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
const focusCol = activeColIndex;
editTransition = true;
writeData(next);
// EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
onroweditcommit?.({
rowId,
changes
});
endRowEdit();
editTransition = false;
// WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
// like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
// col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
// $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
// DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
// refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
// display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
// the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
// poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
const cancelRow = () => {
if (editingRowIndex == null) return;
const focusRow = activeRow;
const focusCol = activeColIndex;
editTransition = true;
endRowEdit();
editTransition = false;
focusCellWhenReady(focusRow, focusCol);
};
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
const replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
const src = rows || [];
const fv = fieldValues || {};
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
// prototype-walking copy. Spread copies own enumerable props only.
out.push({
...(src[i] || {}),
...fv
});
} else {
out.push(src[i]);
}
}
return out;
};
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
const nextEditableCell = (fromRow: any, fromCol: any) => {
const rowList = rows || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol + 1;
while (r < rowCount) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c < cells.length) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c + 1;
}
r = r + 1;
c = 0;
}
return null;
};
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
const prevEditableCell = (fromRow: any, fromCol: any) => {
const rowList = rows || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol - 1;
while (r >= 0) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c >= 0) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c - 1;
}
r = r - 1;
if (r >= 0) {
const prow = rowList[r];
const pcells = prow ? visibleCellsFor(prow) : [];
c = pcells.length - 1;
}
}
return null;
};
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false;
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow: any = null;
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
const inRowEdit = () => editingRowIndex != null;
const editorValueFor = (colId: any) => inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue;
const editorCheckedFor = (colId: any) => !!(inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue);
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
const editorCommitFor = (colId: any) => (value: any) => {
if (inRowEdit()) {
setRowDraft(colId, value);
return;
}
commitEdit(value);
};
const editorCancelFor = () => () => {
if (inRowEdit()) {
cancelRow();
return;
}
cancelEdit();
};
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
const onCellEditorInput = (colId: any, evt: any) => {
const v = evt && evt.target ? evt.target.value : '';
if (inRowEdit()) {
setRowDraft(colId, v);
return;
}
draftValue = v;
};
const onCellEditorCheckbox = (colId: any, evt: any) => {
const v = !!(evt && evt.target && evt.target.checked);
if (inRowEdit()) {
setRowDraft(colId, v);
return;
}
draftValue = v;
};
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
const setRowDraft = (colId: any, value: any) => {
const src = rowDraft || {};
const next = {};
for (const k in src) next[k] = src[k];
next[colId] = value;
rowDraft = next;
};
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
const rowEditTab = (target: any, backward: any) => {
const rowIndex = editingRowIndex;
if (rowIndex == null) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
const cols = editable.map((ec: any) => ec.colIndex);
const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
const curAttr = cell ? cell.getAttribute('data-col-index') : null;
const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
let pos = cols.indexOf(cur);
if (pos < 0) pos = 0;
const len = cols.length;
const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
focusRowEditorAt(rowIndex, cols[nextPos]);
};
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
const onEditorKeyDown = (e: any) => {
if (!e) return;
const key = e.key;
// Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
// model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
// row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
// preventDefault it here.
if (inRowEdit()) {
if (key === 'Enter') {
e.preventDefault();
commitRow();
} else if (key === 'Escape') {
e.preventDefault();
cancelRow();
}
// B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
// editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
// Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
// first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
// tab order across a Lit shadow boundary).
else if (key === 'Tab') {
e.preventDefault();
rowEditTab(e.target, e.shiftKey);
}
return;
}
if (key === 'Enter') {
e.preventDefault();
commitEdit(undefined);
} else if (key === 'Tab') {
e.preventDefault();
// Resolve the advance target from the EDITING pair (the cell that is open), not the
// active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
// moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
// the editing pair BEFORE commit (commitEdit resets it to -1).
const fromRow = editingRow;
const fromCol = editingCol;
const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
// skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
// straight into the next editable cell's editor below. Use the RETURN value (not a
// re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
// failure returns false and keeps the editor open (the user must fix the value first).
const committed = commitEdit(undefined, true);
if (committed && target) {
activeRow = target.row;
activeColIndex = target.col;
beginEdit(target.row, target.col, null);
} else if (committed) {
// B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
// grid by returning it to the just-committed cell instead of letting it drop to <body>.
focusCellWhenReady(fromRow, fromCol);
}
} else if (key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
// - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
// - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
// or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
// async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
// the relatedTarget/containment check is the load-bearing guard, not the flag alone.
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
// - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
// - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
// or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
// async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
// the relatedTarget/containment check is the load-bearing guard, not the flag alone.
const onEditorBlur = (e: any) => {
// Full-row mode (req-6): blur NEVER commits — the row commits as a UNIT only on an
// explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
// into N writes + N events, violating the one-write/one-event contract). Tabbing between
// the row's own editors is a normal focus move, not a commit.
if (inRowEdit()) return;
if (editingRow < 0 || editTransition) return;
const next = e ? e.relatedTarget : null;
// A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
// keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
// a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
// conservative null=skip behavior.
if (next == null) return;
// Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
// the editor open on an invalid value).
if (!(gridRoot && gridRoot.contains && gridRoot.contains(next))) {
commitEdit(undefined);
return;
}
// Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
// keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
// (which must commit + close so the grid is not wedged with an open editor).
const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
// Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
// controlled move; skip. Also skip when either cell can't be resolved (an unmounting
// editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
if (!nextCell || !fromCell || nextCell === fromCell) return;
// A Tab-advance already committed the old editor and opened the next one, so the live
// editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
// editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
// double commit, WR-04).
const fromRow = fromCell.getAttribute('data-row');
const fromCol = fromCell.getAttribute('data-col-index');
if (fromRow !== String(editingRow) || fromCol !== String(editingCol)) return;
// Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
// commit does NOT bounce focus back to the just-committed editing cell (which would fight
// the click destination). The commit's writeData re-renders the table and can DROP DOM
// focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
// DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
// (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
// late, which would steal focus back from a subsequent navigation.
const destRow = nextCell.getAttribute('data-row');
const destCol = nextCell.getAttribute('data-col-index');
commitEdit(undefined, true);
const reseatDestFocus = () => {
if (!gridRoot || destRow == null || destCol == null || destRow === '__header') return;
const root = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
const act = root && root.activeElement ? root.activeElement : null;
// Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
if (act && gridRoot.contains && gridRoot.contains(act)) return;
const el = resolveCellEl(destRow, parseInt(destCol, 10));
if (el) el.focus();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
export const editCell = (rowIndex: any, colIndex: any) => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const maxCol = visibleColCount() - 1;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
activeIsHeader = false;
activeRow = r;
activeColIndex = c;
beginEdit(r, c, null);
};
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
export const commitEditing = () => {
if (editingRow >= 0) commitEdit(undefined);
};
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
export const editRow = (rowIndex: any) => {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const rowList = rows || [];
const row = rowList[r];
if (!row) return;
activeIsHeader = false;
activeRow = r;
beginRowEdit(row);
};
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────
// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries aria-rowindex === absRow+1 (i.e. the TARGET page has
// actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works for both
// controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames (~1s) to
// cover the controlled-state parent round-trip on React/Solid/Lit.
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────
// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries aria-rowindex === absRow+1 (i.e. the TARGET page has
// actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works for both
// controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames (~1s) to
// cover the controlled-state parent round-trip on React/Solid/Lit.
const focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
if (!gridRoot) return;
let attempts = 0;
const want = String(absRow + 1);
const tryFocus = () => {
const el = resolveCellEl(String(localRow), col);
if (el) {
const rowEl = el.closest ? el.closest('[role="row"]') : null;
const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
if (ari === want) {
el.focus();
return;
}
}
attempts = attempts + 1;
if (attempts >= 60) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
export const focusCell = (rowIndex: any, colIndex: any) => {
// B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
// is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
// isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
// instance does not leak a spurious activecell-change.
if (!isGrid()) return;
const maxCol = visibleColCount() - 1;
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
// C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
const absLast = prePaginationRowCount() - 1;
const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
// B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
// real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
// active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
const prevAbs = toAbsRow(activeRow);
const prevIsHeader = activeIsHeader;
if (virtual) {
// Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
// the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
// focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
activeIsHeader = false;
activeInControl = false;
activeRow = absRow;
activeColIndex = c;
focusActiveCell(absRow, c, false);
} else {
// Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
// in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
// body's data-row markers (and the roving tabindex) address.
const size = pageSize();
const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
const localRow = absRow - targetPage * size;
const switched = targetPage !== pageIndex();
if (switched) setPage(targetPage);
activeIsHeader = false;
activeInControl = false;
activeRow = localRow;
activeColIndex = c;
if (switched) {
// The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
// TARGET page's absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
// same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
focusAbsCellWhenReady(absRow, localRow, c);
} else {
// Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
// Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
// async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
focusActiveCell(localRow, c, false);
}
}
if (absRow !== prevAbs || prevIsHeader) {
onactivecellchange?.({
rowIndex: absRow,
colIndex: c
});
}
};
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
export const getActiveCell = () => activeIsHeader ? {
rowIndex: null,
colIndex: activeColIndex,
isHeader: true
} : {
rowIndex: toAbsRow(activeRow),
colIndex: activeColIndex,
isHeader: false
};
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
export const clearActiveCell = () => {
if (!isGrid()) return;
activeIsHeader = false;
activeInControl = false;
activeRow = 0;
activeColIndex = 0;
};
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────
// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────
// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
export const toggleRowExpanded = (rowId: any) => {
if (!table) return;
const target = String(rowId);
const flat = table.getCoreRowModel().flatRows;
for (const r of flat as any) {
if (r.id === target || r.original && String(r.original.id) === target) {
r.toggleExpanded();
return;
}
}
};
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
export const expandAll = () => {
if (!table) return;
table.toggleAllRowsExpanded(true);
};
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
export const collapseAll = () => {
if (!table) return;
table.resetExpanded(true);
};
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
export const getExpandedRows = () => {
if (!table) return [];
const out = [];
const flat = table.getCoreRowModel().flatRows;
for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
return out;
};
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
export const applyGrouping = (cols: any) => {
if (table) table.setGrouping(cols);
};
export const clearGrouping = () => {
if (table) table.setGrouping([]);
};
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
export const getFacetedUniqueValues = (colId: any) => {
if (tick() < 0 || !table) return [];
const col = table.getColumn(colId);
if (!col || !col.getFacetedUniqueValues) return [];
const map = col.getFacetedUniqueValues(); // Map<any, number>
return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
export const getFacetedMinMaxValues = (colId: any) => {
if (tick() < 0 || !table) return null;
const col = table.getColumn(colId);
if (!col || !col.getFacetedMinMaxValues) return null;
return col.getFacetedMinMaxValues() || null; // [number, number] | null
};
setContext('data-table:columns', {
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
colReg = {
...colReg,
[key]: spec
};
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...colReg
};
delete r[String(id)];
colReg = r;
}
});
onMount(() => {
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
dataDefault = data || [];
// Build the table instance HERE so the closures below capture the live `table`.
table = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: currentData(),
columns: tableColumns(),
state: currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (getSubRows || undefined) as any,
getRowCanExpand: expandable === true && getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: manual === true,
manualFiltering: manual === true,
manualSorting: manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: selectionMode !== 'none',
enableMultiRowSelection: selectionMode === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {}
});
refreshRowModel = () => {
if (!table) return;
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = windowSource().slice();
const nextGroups = table.getHeaderGroups().slice();
rows = nextRows;
headerGroups = nextGroups;
rowModelVer = rowModelVer + 1;
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if (virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length;
const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
clampActiveCell(nextRowCount, nextColCount);
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (pendingEditFollow && isGrid()) {
const follow = pendingEditFollow;
pendingEditFollow = null;
const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
syncIndeterminate();
if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
};
// initial pull
refreshRowModel();
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
gridRoot = __rozieRoot ? __rozieRoot!.querySelector('.rozie-data-table') : null;
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if (virtual) {
gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rdt-scroll') : null;
virtualizer = new Virtualizer(virtualizerOptions());
virtualizerCleanup = virtualizer._didMount();
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
windowVer = windowVer + 1;
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
remeasureWindow();
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = pagination;
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
if (manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
}
});
onDestroy(() => (() => {
if (virtualizerCleanup) virtualizerCleanup();
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
teardownFillDrag();
})());
$effect(() => (() => {
if (!table) return;
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = currentData() || [];
if (d === lastData && d.length === lastDataLen) return;
lastData = d;
lastDataLen = d.length;
reFeed();
})());
let __rozieWatchInitial_0 = true;
$effect(() => { (() => [sorting, globalFilter, columnFilters, pagination, rowSelection, expanded, expandable, grouping, groupable, columnVisibility, columnSizing, columnOrder, columnPinning, selectionMode, (data || []).length,
// Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
data, dataDefault, colReg])(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
reFeed();
})(); }); });
</script>
<div class="rozie-data-table-wrap" bind:this={__rozieRoot} data-rozie-s-d5dcab4c><div class="rdt-column-defs" style="display:none" aria-hidden="true" data-rozie-s-d5dcab4c>{@render children?.()}</div>{#if !!invalidMsg}<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>{invalidMsg}</div>{/if}{#if !!pasteAnnounce}<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>{pasteAnnounce}</div>{/if}<div class="rdt-toolbar" data-rozie-s-d5dcab4c><input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" value={globalFilterValue()} oninput={($event) => { onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c />{#if allLeafColumns().length}<details class="rdt-colvis" data-rozie-s-d5dcab4c><summary class="rdt-colvis-summary" data-rozie-s-d5dcab4c>Columns</summary><div class="rdt-colvis-menu" role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c>{#each allLeafColumns() as lc (lc.id)}<label class="rdt-colvis-item" data-rozie-s-d5dcab4c><input type="checkbox" class="rdt-colvis-checkbox" checked={lc.visible} onchange={($event) => { onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c /><span class="rdt-colvis-label" data-rozie-s-d5dcab4c>{rozieDisplay(lc.label)}</span></label>{/each}</div></details>{/if}</div>{#if groupable}<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>{#if groupBar}{@render groupBar({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping })}{:else}{#each groupingKeys() as gk (gk)}<span class="rdt-group-token" data-group-token="" data-rozie-s-d5dcab4c>{rozieDisplay(gk)}</span>{/each}{/if}</div>{/if}{#if virtual}<div class="rdt-scroll" style={rozieStyle(maxHeight ? 'max-height:' + maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c><table class={["rozie-data-table", { 'rdt-sticky': stickyHeader }]} role={rozieAttr(tableRole())} aria-rowcount={rows.length} onkeydown={($event) => { onGridKeyDown($event); }} onfocusin={($event) => { syncActiveFromEvent($event); }} onfocusout={($event) => { onGridFocusOut($event); }} onmousedown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c><thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>{#each headerGroups as hg, hgLevel (hg.id)}<tr class="rdt-tr" role="row" data-rozie-s-d5dcab4c>{#each hg.headers as header (header.id)}<th class={["rdt-th", { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colspan={rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabindex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={rozieStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c>{#if isSelectColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectAll}{@render selectAll({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows })}{:else}{#if selectionMode === 'multiple'}<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onchange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c />{/if}{/if}</span>{:else}<span style="display:contents" data-rozie-s-d5dcab4c>{#if header.column.getCanSort && header.column.getCanSort()}<button type="button" class="rdt-sort-btn" onclick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span><span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>{rozieDisplay(sortIndicator(header.column.id))}</span></button>{:else}<span style="display:contents" data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span></span>{/if}{#if columnIsFilterable(header.column.id)}<input class="rdt-col-filter" type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} oninput={($event) => { onColumnFilterInput(header.column.id, $event); }} onclick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c />{/if}{#if columnIsFilterable(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render filter?.({ columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}</span>{/if}<span class="rdt-pin-controls" role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c><button type="button" class="rdt-pin-btn rdt-pin-left" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onclick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button><button type="button" class="rdt-pin-btn rdt-pin-none" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onclick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button><button type="button" class="rdt-pin-btn rdt-pin-right" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onclick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button></span><button type="button" class="rdt-resize-handle" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onpointerdown={($event) => { onResizeStart(header.column.id, $event); }} ontouchstart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button></span>{/if}</th>{/each}</tr>{/each}</thead><tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c><tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c><td colspan={rozieAttr(visibleColCount())} style={rozieStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td></tr>{#each windowedRows() as wr (wr.row.id)}<tr class={["rdt-tr", { 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }]} role="row" data-row={rozieAttr(wr.vi.index)} aria-rowindex={rozieAttr(wr.vi.index + 1)} data-index={rozieAttr(wr.vi.index)} data-pinned={rozieAttr(wr.pinned ? 'true' : null)} data-depth={rozieAttr(wr.row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : null)} aria-expanded={rozieAttr(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null)} aria-level={rozieAttr(groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>{#each visibleCellsFor(wr.row) as cellCtx (cellCtx.id)}<td class={["rdt-td", { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }]} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cellCtx))} tabindex={rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx)))} style={rozieStyle(bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>{#if isExpanderColumn(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if rowCanExpand(wr.row)}<button type="button" class="rdt-expander" data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} onclick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>{/if}</span>{:else if isSelectColumn(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectCell}{@render selectCell({ row: wr.row.original, checked: rowIsSelected(wr.row), toggle: e => onToggleRow(wr.row, e) })}{:else}<input class="rdt-select-row" type="checkbox" aria-label="Select row" checked={rowIsSelected(wr.row)} onchange={($event) => { onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else if cellIsGrouped(cellCtx)}<span style="display:contents" data-rozie-s-d5dcab4c><button type="button" class="rdt-expander rdt-group-toggle" data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} onclick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button><span class="rdt-group-value" data-rozie-s-d5dcab4c>{#if cell}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span><span class="rdt-group-count" data-rozie-s-d5dcab4c>{rozieDisplay('(' + groupSubRowCount(wr.row) + ')')}</span></span>{:else if isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))}<span style="display:contents" data-rozie-s-d5dcab4c>{#if hasEditorSlot(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render editor?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}</span>{:else if editorTypeOf(cellCtx.column.id) === 'number'}<input class="rdt-cell-editor" type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else if editorTypeOf(cellCtx.column.id) === 'select'}<select class="rdt-cell-editor" data-editing-cell="" value={rozieAttr(editorValueFor(cellCtx.column.id))} onchange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c>{#each editorOptionsOf(cellCtx.column.id) as opt (opt.value)}<option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c>{rozieDisplay(opt.label)}</option>{/each}</select>{:else if editorTypeOf(cellCtx.column.id) === 'checkbox'}<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onchange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else}<input class="rdt-cell-editor" type="text" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else}<span class="rdt-cell-value" data-rozie-s-d5dcab4c>{#if cell}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span>{/if}{#if isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))}<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onpointerdown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>{/if}</td>{/each}</tr>{#if rowShowsDetail(wr.row)}<tr class="rdt-detail-row" role="row" data-detail-row={rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c><td class="rdt-detail-cell" colspan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c>{@render detail?.({ row: wr.row.original })}</td></tr>{/if}{/each}<tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c><td colspan={rozieAttr(visibleColCount())} style={rozieStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td></tr></tbody></table></div>{:else}<table class={["rozie-data-table", { 'rdt-sticky': stickyHeader }]} role={rozieAttr(tableRole())} aria-rowcount={rozieAttr(totalRowCount())} onkeydown={($event) => { onGridKeyDown($event); }} onfocusin={($event) => { syncActiveFromEvent($event); }} onfocusout={($event) => { onGridFocusOut($event); }} onmousedown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c><thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>{#each headerGroups as hg, hgLevel (hg.id)}<tr class="rdt-tr" role="row" data-rozie-s-d5dcab4c>{#each hg.headers as header (header.id)}<th class={["rdt-th", { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colspan={rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabindex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={rozieStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c>{#if isSelectColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectAll}{@render selectAll({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows })}{:else}{#if selectionMode === 'multiple'}<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onchange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c />{/if}{/if}</span>{:else}<span style="display:contents" data-rozie-s-d5dcab4c>{#if header.column.getCanSort && header.column.getCanSort()}<button type="button" class="rdt-sort-btn" onclick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span><span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>{rozieDisplay(sortIndicator(header.column.id))}</span></button>{:else}<span style="display:contents" data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span></span>{/if}{#if columnIsFilterable(header.column.id)}<input class="rdt-col-filter" type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} oninput={($event) => { onColumnFilterInput(header.column.id, $event); }} onclick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c />{/if}{#if columnIsFilterable(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render filter?.({ columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}</span>{/if}<span class="rdt-pin-controls" role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c><button type="button" class="rdt-pin-btn rdt-pin-left" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onclick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button><button type="button" class="rdt-pin-btn rdt-pin-none" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onclick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button><button type="button" class="rdt-pin-btn rdt-pin-right" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onclick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button></span><button type="button" class="rdt-resize-handle" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onpointerdown={($event) => { onResizeStart(header.column.id, $event); }} ontouchstart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button></span>{/if}</th>{/each}</tr>{/each}</thead><tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>{#each rows as row (row.id)}<tr class={["rdt-tr", { 'rdt-group-header': rowIsGrouped(row) }]} role="row" data-depth={rozieAttr(row.depth)} aria-rowindex={rozieAttr(isGrid() ? absRowIndexOf(row) + 1 : null)} data-group-header={rozieAttr(rowIsGrouped(row) ? row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : null)} aria-expanded={rozieAttr(rowIsGrouped(row) ? !!rowIsExpanded(row) : null)} aria-level={rozieAttr(groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c>{#each visibleCellsFor(row) as cellCtx (cellCtx.id)}<td class={["rdt-td", { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }]} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cellCtx))} tabindex={rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx)))} style={rozieStyle(bodyCellStyle(row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>{#if isExpanderColumn(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if rowCanExpand(row)}<button type="button" class="rdt-expander" data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} onclick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>{/if}</span>{:else if isSelectColumn(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectCell}{@render selectCell({ row: row.original, checked: rowIsSelected(row), toggle: e => onToggleRow(row, e) })}{:else}<input class="rdt-select-row" type="checkbox" aria-label="Select row" checked={rowIsSelected(row)} onchange={($event) => { onToggleRow(row, $event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else if cellIsGrouped(cellCtx)}<span style="display:contents" data-rozie-s-d5dcab4c><button type="button" class="rdt-expander rdt-group-toggle" data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} onclick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button><span class="rdt-group-value" data-rozie-s-d5dcab4c>{#if cell}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span><span class="rdt-group-count" data-rozie-s-d5dcab4c>{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span></span>{:else if isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))}<span style="display:contents" data-rozie-s-d5dcab4c>{#if hasEditorSlot(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render editor?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}</span>{:else if editorTypeOf(cellCtx.column.id) === 'number'}<input class="rdt-cell-editor" type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else if editorTypeOf(cellCtx.column.id) === 'select'}<select class="rdt-cell-editor" data-editing-cell="" value={rozieAttr(editorValueFor(cellCtx.column.id))} onchange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c>{#each editorOptionsOf(cellCtx.column.id) as opt (opt.value)}<option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c>{rozieDisplay(opt.label)}</option>{/each}</select>{:else if editorTypeOf(cellCtx.column.id) === 'checkbox'}<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onchange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else}<input class="rdt-cell-editor" type="text" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else}<span class="rdt-cell-value" data-rozie-s-d5dcab4c>{#if cell}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span>{/if}{#if isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))}<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onpointerdown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>{/if}</td>{/each}</tr>{#if rowShowsDetail(row)}<tr class="rdt-detail-row" role="row" data-detail-row={rozieAttr(row.id)} data-rozie-s-d5dcab4c><td class="rdt-detail-cell" colspan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c>{@render detail?.({ row: row.original })}</td></tr>{/if}{/each}</tbody></table>{/if}{#if !virtual}<div class="rdt-pagination" role="group" aria-label="Pagination" data-rozie-s-d5dcab4c><button type="button" class="rdt-page-btn rdt-page-prev" disabled={!canPrevPage()} onclick={($event) => { onPrevPage(); }} data-rozie-s-d5dcab4c>Prev</button><span class="rdt-page-status" aria-live="polite" data-rozie-s-d5dcab4c>{rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + pageCount())}</span><button type="button" class="rdt-page-btn rdt-page-next" disabled={!canNextPage()} onclick={($event) => { onNextPage(); }} data-rozie-s-d5dcab4c>Next</button><select class="rdt-page-size" aria-label="Rows per page" value={rozieAttr(pageSize())} onchange={($event) => { onPageSizeChange($event); }} data-rozie-s-d5dcab4c><option value={10} data-rozie-s-d5dcab4c>10</option><option value={25} data-rozie-s-d5dcab4c>25</option><option value={50} data-rozie-s-d5dcab4c>50</option><option value={100} data-rozie-s-d5dcab4c>100</option></select></div>{/if}</div>
<style>
:global {
.rozie-data-table[data-rozie-s-d5dcab4c] {
border-collapse: collapse;
width: 100%;
font: var(--rdt-font, 14px system-ui, sans-serif);
color: var(--rdt-color, inherit);
}
.rdt-sr-live[data-rozie-s-d5dcab4c] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-cell-editor[data-rozie-s-d5dcab4c] {
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[aria-invalid="true"][data-rozie-s-d5dcab4c] {
outline: var(--rdt-invalid-outline, 2px solid #d33);
outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-in-range[data-rozie-s-d5dcab4c] {
background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-fill-handle[data-rozie-s-d5dcab4c] {
position: absolute;
right: -3px;
bottom: -3px;
width: 8px;
height: 8px;
background: var(--rdt-fill-handle-bg, #2563eb);
border: 1px solid #fff;
cursor: crosshair;
z-index: 1;
touch-action: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
text-align: left;
border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
font-weight: var(--rdt-header-weight, 600);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-btn[data-rozie-s-d5dcab4c] {
display: inline-flex;
align-items: center;
gap: var(--rdt-sort-gap, 0.35em);
background: none;
border: none;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-ind[data-rozie-s-d5dcab4c] {
font-size: 0.8em;
opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
position: sticky;
top: var(--rdt-sticky-top, 0);
z-index: var(--rdt-sticky-z, 2);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-scroll[data-rozie-s-d5dcab4c] {
max-height: var(--rozie-data-table-max-height);
overflow: auto;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-bar-host[data-rozie-s-d5dcab4c] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-token[data-rozie-s-d5dcab4c] {
display: inline-flex;
align-items: center;
padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
border-radius: var(--rdt-group-token-radius, 999px);
background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] {
background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-toggle[data-rozie-s-d5dcab4c] {
margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-count[data-rozie-s-d5dcab4c] {
margin-left: var(--rdt-group-count-gap, 0.375rem);
opacity: var(--rdt-group-count-opacity, 0.65);
font-weight: 400;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] {
display: flex;
flex-direction: column;
gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-toolbar[data-rozie-s-d5dcab4c] {
display: flex;
gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-global-filter[data-rozie-s-d5dcab4c],
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter[data-rozie-s-d5dcab4c] {
font: inherit;
padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-filter-radius, 4px);
background: var(--rdt-filter-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter[data-rozie-s-d5dcab4c] {
display: block;
margin-top: var(--rdt-col-filter-gap, 0.25rem);
width: 100%;
font-weight: normal;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-pagination[data-rozie-s-d5dcab4c] {
display: flex;
align-items: center;
gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c] {
font: inherit;
cursor: pointer;
padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-btn-radius, 4px);
background: var(--rdt-page-btn-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c]:disabled {
opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
cursor: default;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-status[data-rozie-s-d5dcab4c] {
font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-size[data-rozie-s-d5dcab4c] {
font: inherit;
padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-size-radius, 4px);
background: var(--rdt-page-size-bg, transparent);
color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c] {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--rdt-resize-handle-width, 6px);
padding: 0;
border: none;
background: none;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
display: block;
width: var(--rdt-resize-grip-width, 2px);
height: 100%;
margin: 0 auto;
background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c]:hover .rdt-resize-grip[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th-resizing[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-controls[data-rozie-s-d5dcab4c] {
display: inline-flex;
gap: var(--rdt-pin-gap, 0.1em);
margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[data-rozie-s-d5dcab4c] {
font: inherit;
font-size: var(--rdt-pin-btn-size, 0.8em);
line-height: 1;
cursor: pointer;
padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-pin-btn-radius, 3px);
background: var(--rdt-pin-btn-bg, transparent);
color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[aria-pressed='true'][data-rozie-s-d5dcab4c] {
background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
font-weight: 700;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-summary[data-rozie-s-d5dcab4c] {
cursor: pointer;
font: inherit;
padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-colvis-summary-radius, 4px);
list-style: none;
user-select: none;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-menu[data-rozie-s-d5dcab4c] {
position: absolute;
z-index: var(--rdt-colvis-menu-z, 5);
margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
display: flex;
flex-direction: column;
gap: var(--rdt-colvis-item-gap, 0.25rem);
border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-colvis-menu-radius, 4px);
background: var(--rdt-colvis-menu-bg, #fff);
box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-item[data-rozie-s-d5dcab4c] {
display: flex;
align-items: center;
gap: var(--rdt-colvis-label-gap, 0.4em);
cursor: pointer;
white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-td[data-rozie-s-d5dcab4c] {
width: var(--rdt-select-col-width, 1%);
text-align: var(--rdt-select-col-align, center);
white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-all[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-row[data-rozie-s-d5dcab4c] {
cursor: pointer;
accent-color: var(--rdt-select-accent, currentColor);
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, InjectionToken, TemplateRef, ViewEncapsulation, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
interface DefaultCtx {}
interface GroupBarCtx {
$implicit: { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any };
grouping: any;
groupableColumns: any;
applyGrouping: any;
clearGrouping: any;
}
interface SelectAllCtx {
$implicit: { checked: any; indeterminate: any; toggle: any };
checked: any;
indeterminate: any;
toggle: any;
}
interface ColHeaderCtx {
$implicit: { columnId: any; column: any; label: any };
columnId: any;
column: any;
label: any;
}
interface FilterCtx {
$implicit: { columnId: any; uniqueValues: any; minMax: any; setFilter: any };
columnId: any;
uniqueValues: any;
minMax: any;
setFilter: any;
}
interface SelectCellCtx {
$implicit: { row: any; checked: any; toggle: any };
row: any;
checked: any;
toggle: any;
}
interface CellCtx {
$implicit: { columnId: any; column: any; row: any; value: any };
columnId: any;
column: any;
row: any;
value: any;
}
interface EditorCtx {
$implicit: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any };
columnId: any;
column: any;
row: any;
value: any;
commit: any;
cancel: any;
}
interface DetailCtx {
$implicit: { row: any };
row: any;
}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
const __rozieTokenRegistry: Map<string, InjectionToken<unknown>> =
((globalThis as Record<string, unknown>).__rozieCtx ??= new Map()) as Map<
string,
InjectionToken<unknown>
>;
function rozieToken(key: string): InjectionToken<unknown> {
let token = __rozieTokenRegistry.get(key);
if (!token) {
token = new InjectionToken<unknown>('rozie:' + key);
__rozieTokenRegistry.set(key, token);
}
return token;
}
@Component({
selector: 'rozie-data-table',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-data-table-wrap" #__rozieRoot>
<div class="rdt-column-defs" style="display:none" aria-hidden="true"><ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" /></div>
@if (!!invalidMsg()) {
<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true">{{ invalidMsg() }}</div>
}@if (!!pasteAnnounce()) {
<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true">{{ pasteAnnounce() }}</div>
}<div class="rdt-toolbar">
<input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" [value]="globalFilterValue()" (input)="onGlobalFilterInput($event)" />
@if (allLeafColumns().length) {
<details class="rdt-colvis">
<summary class="rdt-colvis-summary">Columns</summary>
<div class="rdt-colvis-menu" role="group" aria-label="Toggle columns">
@for (lc of allLeafColumns(); track lc.id) {
<label class="rdt-colvis-item">
<input type="checkbox" class="rdt-colvis-checkbox" [checked]="lc.visible" (change)="onToggleVisibility(lc.id)" />
<span class="rdt-colvis-label">{{ rozieDisplay(lc.label) }}</span>
</label>
}
</div>
</details>
}</div>
@if (groupable()) {
<div class="rdt-group-bar-host">
@if ((groupBarTpl ?? templates()?.['groupBar'])) {
<ng-container *ngTemplateOutlet="(groupBarTpl ?? templates()?.['groupBar']); context: { $implicit: { grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping: applyGrouping, clearGrouping: clearGrouping }, grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping: applyGrouping, clearGrouping: clearGrouping }" />
} @else {
@for (gk of groupingKeys(); track gk) {
<span class="rdt-group-token" data-group-token="">{{ rozieDisplay(gk) }}</span>
}
}
</div>
}@if (virtual()) {
<div class="rdt-scroll" [style]="__style">
<table class="rozie-data-table" [ngClass]="{ 'rdt-sticky': stickyHeader() }" [attr.role]="rozieAttr(tableRole())" [attr.aria-rowcount]="rows().length" (keydown)="onGridKeyDown($event)" (focusin)="syncActiveFromEvent($event)" (focusout)="onGridFocusOut($event)" (mousedown)="onGridMouseDown($event)">
<thead class="rdt-thead" role="rowgroup">
@for (hg of headerGroups(); track hg.id; let hgLevel = $index) {
<tr class="rdt-tr" role="row">
@for (header of hg.headers; track header.id) {
<th class="rdt-th" [ngClass]="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }" role="columnheader" [attr.data-col]="rozieAttr(header.column.id)" data-grid-cell="" data-row="__header" [attr.data-header-level]="rozieAttr(hgLevel)" [attr.colspan]="rozieAttr(header.colSpan > 1 ? header.colSpan : null)" [attr.data-col-index]="rozieAttr(headerColIndexOf(hg, header))" [attr.tabindex]="rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))" [attr.aria-sort]="rozieAttr(ariaSortFor(header.column.id))" [style]="thStyle(header.column.id)">
@if (isSelectColumn(header.column.id)) {
<span style="display:contents">
@if ((selectAllTpl ?? templates()?.['selectAll'])) {
<ng-container *ngTemplateOutlet="(selectAllTpl ?? templates()?.['selectAll']); context: { $implicit: { checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }, checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }" />
} @else {
@if (selectionMode() === 'multiple') {
<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" [checked]="isAllRowsSelected()" (change)="onToggleAllRows($event)" />
}
}
</span>
} @else {
<span style="display:contents">
@if (header.column.getCanSort && header.column.getCanSort()) {
<button type="button" class="rdt-sort-btn" (click)="onHeaderSort(header.column.id, $event)">
<span class="rdt-header-label">
@if ((colHeaderTpl ?? templates()?.['colHeader'])) {
<ng-container *ngTemplateOutlet="(colHeaderTpl ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
} @else {
{{ rozieDisplay(headerLabel(header.column.id)) }}
}
</span>
<span class="rdt-sort-ind" aria-hidden="true">{{ rozieDisplay(sortIndicator(header.column.id)) }}</span>
</button>
} @else {
<span style="display:contents">
<span class="rdt-header-label">
@if ((colHeaderTpl ?? templates()?.['colHeader'])) {
<ng-container *ngTemplateOutlet="(colHeaderTpl ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
} @else {
{{ rozieDisplay(headerLabel(header.column.id)) }}
}
</span>
</span>
}@if (columnIsFilterable(header.column.id)) {
<input class="rdt-col-filter" type="text" [attr.aria-label]="rozieAttr('Filter ' + headerLabel(header.column.id))" [value]="columnFilterValue(header.column.id)" (input)="onColumnFilterInput(header.column.id, $event)" (click)="stopEvent($event)" />
}@if (columnIsFilterable(header.column.id)) {
<span style="display:contents">
<ng-container *ngTemplateOutlet="(filterTpl ?? templates()?.['filter']); context: { $implicit: { columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }, columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }" />
</span>
}<span class="rdt-pin-controls" role="group" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id))">
<button type="button" class="rdt-pin-btn rdt-pin-left" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'left'" (click)="onPinColumn(header.column.id, 'left', $event)">⇤</button>
<button type="button" class="rdt-pin-btn rdt-pin-none" [attr.aria-label]="rozieAttr('Unpin ' + headerLabel(header.column.id))" [attr.aria-pressed]="!columnPinSide(header.column.id)" (click)="onPinColumn(header.column.id, false, $event)">⇔</button>
<button type="button" class="rdt-pin-btn rdt-pin-right" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'right'" (click)="onPinColumn(header.column.id, 'right', $event)">⇥</button>
</span>
<button type="button" class="rdt-resize-handle" [attr.aria-label]="rozieAttr('Resize ' + headerLabel(header.column.id))" (pointerdown)="onResizeStart(header.column.id, $event)" (touchstart)="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
</span>
}</th>
}
</tr>
}
</thead>
<tbody class="rdt-tbody" role="rowgroup">
<tr class="rdt-spacer" aria-hidden="true">
<td [attr.colspan]="rozieAttr(visibleColCount())" [style]="'height:' + padTop() + 'px;padding:0;border:0'"></td>
</tr>
@for (wr of windowedRows(); track wr.row.id) {
<tr class="rdt-tr" [ngClass]="{ 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }" role="row" [attr.data-row]="rozieAttr(wr.vi.index)" [attr.aria-rowindex]="rozieAttr(wr.vi.index + 1)" [attr.data-index]="rozieAttr(wr.vi.index)" [attr.data-pinned]="rozieAttr(wr.pinned ? 'true' : null)" [attr.data-depth]="rozieAttr(wr.row.depth)" [attr.data-group-header]="rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : null)" [attr.data-group-leaf]="rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : null)" [attr.aria-expanded]="rozieAttr(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null)" [attr.aria-level]="rozieAttr(groupingActive() ? wr.row.depth + 1 : null)">
@for (cellCtx of visibleCellsFor(wr.row); track cellCtx.id) {
<td class="rdt-td" [ngClass]="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }" [attr.role]="rozieAttr(cellRole())" [attr.data-col]="rozieAttr(cellCtx.column.id)" data-grid-cell="" [attr.data-row]="rozieAttr(wr.vi.index)" [attr.data-col-index]="rozieAttr(colIndexOf(wr.row, cellCtx))" [attr.tabindex]="rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx)))" [style]="bodyCellStyle(wr.row, cellCtx.column.id)" [attr.aria-invalid]="rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))" [attr.data-in-range]="rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null)" [attr.data-agg-cell]="rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)">
@if (isExpanderColumn(cellCtx.column.id)) {
<span style="display:contents">
@if (rowCanExpand(wr.row)) {
<button type="button" class="rdt-expander" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(wr.row)" [attr.aria-label]="rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')" (click)="onToggleExpand(wr.row, $event)">{{ rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸') }}</button>
}</span>
} @else if (isSelectColumn(cellCtx.column.id)) {
<span style="display:contents">
@if ((selectCellTpl ?? templates()?.['selectCell'])) {
<ng-container *ngTemplateOutlet="(selectCellTpl ?? templates()?.['selectCell']); context: _selectCell_ctx(wr, cellCtx)" />
} @else {
<input class="rdt-select-row" type="checkbox" aria-label="Select row" [checked]="rowIsSelected(wr.row)" (change)="onToggleRow(wr.row, $event)" />
}
</span>
} @else if (cellIsGrouped(cellCtx)) {
<span style="display:contents">
<button type="button" class="rdt-expander rdt-group-toggle" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(wr.row)" [attr.aria-label]="rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')" (click)="onToggleExpand(wr.row, $event)">{{ rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸') }}</button>
<span class="rdt-group-value">
@if ((cellTpl ?? templates()?.['cell'])) {
<ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }" />
} @else {
{{ rozieDisplay(cellCtx.getValue()) }}
}
</span>
<span class="rdt-group-count">{{ rozieDisplay('(' + groupSubRowCount(wr.row) + ')') }}</span>
</span>
} @else if (isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))) {
<span style="display:contents">
@if (hasEditorSlot(cellCtx.column.id)) {
<span style="display:contents">
<ng-container *ngTemplateOutlet="(editorTpl ?? templates()?.['editor']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }, columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }" />
</span>
} @else if (editorTypeOf(cellCtx.column.id) === 'number') {
<input class="rdt-cell-editor" type="number" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
} @else if (editorTypeOf(cellCtx.column.id) === 'select') {
<select class="rdt-cell-editor" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (change)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)">
@for (opt of editorOptionsOf(cellCtx.column.id); track opt.value) {
<option [attr.value]="rozieAttr(opt.value)">{{ rozieDisplay(opt.label) }}</option>
}
</select>
} @else if (editorTypeOf(cellCtx.column.id) === 'checkbox') {
<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" [checked]="editorCheckedFor(cellCtx.column.id)" (change)="onCellEditorCheckbox(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
} @else {
<input class="rdt-cell-editor" type="text" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
}</span>
} @else {
<span class="rdt-cell-value">
@if ((cellTpl ?? templates()?.['cell'])) {
<ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }" />
} @else {
{{ rozieDisplay(cellCtx.getValue()) }}
}
</span>
}@if (isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))) {
<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" (pointerdown)="onFillHandlePointerDown($event)"></span>
}</td>
}
</tr>
@if (rowShowsDetail(wr.row)) {
<tr class="rdt-detail-row" role="row" [attr.data-detail-row]="rozieAttr(wr.row.id)">
<td class="rdt-detail-cell" [attr.colspan]="rozieAttr(visibleColCount())">
<ng-container *ngTemplateOutlet="(detailTpl ?? templates()?.['detail']); context: { $implicit: { row: wr.row.original }, row: wr.row.original }" />
</td>
</tr>
}
}
<tr class="rdt-spacer" aria-hidden="true">
<td [attr.colspan]="rozieAttr(visibleColCount())" [style]="'height:' + padBottom() + 'px;padding:0;border:0'"></td>
</tr>
</tbody>
</table>
</div>
} @else {
<table class="rozie-data-table" [ngClass]="{ 'rdt-sticky': stickyHeader() }" [attr.role]="rozieAttr(tableRole())" [attr.aria-rowcount]="rozieAttr(totalRowCount())" (keydown)="onGridKeyDown($event)" (focusin)="syncActiveFromEvent($event)" (focusout)="onGridFocusOut($event)" (mousedown)="onGridMouseDown($event)">
<thead class="rdt-thead" role="rowgroup">
@for (hg of headerGroups(); track hg.id; let hgLevel = $index) {
<tr class="rdt-tr" role="row">
@for (header of hg.headers; track header.id) {
<th class="rdt-th" [ngClass]="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }" role="columnheader" [attr.data-col]="rozieAttr(header.column.id)" data-grid-cell="" data-row="__header" [attr.data-header-level]="rozieAttr(hgLevel)" [attr.colspan]="rozieAttr(header.colSpan > 1 ? header.colSpan : null)" [attr.data-col-index]="rozieAttr(headerColIndexOf(hg, header))" [attr.tabindex]="rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))" [attr.aria-sort]="rozieAttr(ariaSortFor(header.column.id))" [style]="thStyle(header.column.id)">
@if (isSelectColumn(header.column.id)) {
<span style="display:contents">
@if ((selectAllTpl ?? templates()?.['selectAll'])) {
<ng-container *ngTemplateOutlet="(selectAllTpl ?? templates()?.['selectAll']); context: { $implicit: { checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }, checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }" />
} @else {
@if (selectionMode() === 'multiple') {
<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" [checked]="isAllRowsSelected()" (change)="onToggleAllRows($event)" />
}
}
</span>
} @else {
<span style="display:contents">
@if (header.column.getCanSort && header.column.getCanSort()) {
<button type="button" class="rdt-sort-btn" (click)="onHeaderSort(header.column.id, $event)">
<span class="rdt-header-label">
@if ((colHeaderTpl ?? templates()?.['colHeader'])) {
<ng-container *ngTemplateOutlet="(colHeaderTpl ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
} @else {
{{ rozieDisplay(headerLabel(header.column.id)) }}
}
</span>
<span class="rdt-sort-ind" aria-hidden="true">{{ rozieDisplay(sortIndicator(header.column.id)) }}</span>
</button>
} @else {
<span style="display:contents">
<span class="rdt-header-label">
@if ((colHeaderTpl ?? templates()?.['colHeader'])) {
<ng-container *ngTemplateOutlet="(colHeaderTpl ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
} @else {
{{ rozieDisplay(headerLabel(header.column.id)) }}
}
</span>
</span>
}@if (columnIsFilterable(header.column.id)) {
<input class="rdt-col-filter" type="text" [attr.aria-label]="rozieAttr('Filter ' + headerLabel(header.column.id))" [value]="columnFilterValue(header.column.id)" (input)="onColumnFilterInput(header.column.id, $event)" (click)="stopEvent($event)" />
}@if (columnIsFilterable(header.column.id)) {
<span style="display:contents">
<ng-container *ngTemplateOutlet="(filterTpl ?? templates()?.['filter']); context: { $implicit: { columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }, columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }" />
</span>
}<span class="rdt-pin-controls" role="group" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id))">
<button type="button" class="rdt-pin-btn rdt-pin-left" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'left'" (click)="onPinColumn(header.column.id, 'left', $event)">⇤</button>
<button type="button" class="rdt-pin-btn rdt-pin-none" [attr.aria-label]="rozieAttr('Unpin ' + headerLabel(header.column.id))" [attr.aria-pressed]="!columnPinSide(header.column.id)" (click)="onPinColumn(header.column.id, false, $event)">⇔</button>
<button type="button" class="rdt-pin-btn rdt-pin-right" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'right'" (click)="onPinColumn(header.column.id, 'right', $event)">⇥</button>
</span>
<button type="button" class="rdt-resize-handle" [attr.aria-label]="rozieAttr('Resize ' + headerLabel(header.column.id))" (pointerdown)="onResizeStart(header.column.id, $event)" (touchstart)="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
</span>
}</th>
}
</tr>
}
</thead>
<tbody class="rdt-tbody" role="rowgroup">
@for (row of rows(); track row.id) {
<tr class="rdt-tr" [ngClass]="{ 'rdt-group-header': rowIsGrouped(row) }" role="row" [attr.data-depth]="rozieAttr(row.depth)" [attr.aria-rowindex]="rozieAttr(isGrid() ? absRowIndexOf(row) + 1 : null)" [attr.data-group-header]="rozieAttr(rowIsGrouped(row) ? row.id : null)" [attr.data-group-leaf]="rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : null)" [attr.aria-expanded]="rozieAttr(rowIsGrouped(row) ? !!rowIsExpanded(row) : null)" [attr.aria-level]="rozieAttr(groupingActive() ? row.depth + 1 : null)">
@for (cellCtx of visibleCellsFor(row); track cellCtx.id) {
<td class="rdt-td" [ngClass]="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }" [attr.role]="rozieAttr(cellRole())" [attr.data-col]="rozieAttr(cellCtx.column.id)" data-grid-cell="" [attr.data-row]="rozieAttr(rowIndexOf(row))" [attr.data-col-index]="rozieAttr(colIndexOf(row, cellCtx))" [attr.tabindex]="rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx)))" [style]="bodyCellStyle(row, cellCtx.column.id)" [attr.aria-invalid]="rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))" [attr.data-in-range]="rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null)" [attr.data-agg-cell]="rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)">
@if (isExpanderColumn(cellCtx.column.id)) {
<span style="display:contents">
@if (rowCanExpand(row)) {
<button type="button" class="rdt-expander" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(row)" [attr.aria-label]="rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')" (click)="onToggleExpand(row, $event)">{{ rozieDisplay(rowIsExpanded(row) ? '▾' : '▸') }}</button>
}</span>
} @else if (isSelectColumn(cellCtx.column.id)) {
<span style="display:contents">
@if ((selectCellTpl ?? templates()?.['selectCell'])) {
<ng-container *ngTemplateOutlet="(selectCellTpl ?? templates()?.['selectCell']); context: _selectCell_ctx_1(row, cellCtx)" />
} @else {
<input class="rdt-select-row" type="checkbox" aria-label="Select row" [checked]="rowIsSelected(row)" (change)="onToggleRow(row, $event)" />
}
</span>
} @else if (cellIsGrouped(cellCtx)) {
<span style="display:contents">
<button type="button" class="rdt-expander rdt-group-toggle" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(row)" [attr.aria-label]="rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')" (click)="onToggleExpand(row, $event)">{{ rozieDisplay(rowIsExpanded(row) ? '▾' : '▸') }}</button>
<span class="rdt-group-value">
@if ((cellTpl ?? templates()?.['cell'])) {
<ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }" />
} @else {
{{ rozieDisplay(cellCtx.getValue()) }}
}
</span>
<span class="rdt-group-count">{{ rozieDisplay('(' + groupSubRowCount(row) + ')') }}</span>
</span>
} @else if (isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))) {
<span style="display:contents">
@if (hasEditorSlot(cellCtx.column.id)) {
<span style="display:contents">
<ng-container *ngTemplateOutlet="(editorTpl ?? templates()?.['editor']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }, columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }" />
</span>
} @else if (editorTypeOf(cellCtx.column.id) === 'number') {
<input class="rdt-cell-editor" type="number" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
} @else if (editorTypeOf(cellCtx.column.id) === 'select') {
<select class="rdt-cell-editor" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (change)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)">
@for (opt of editorOptionsOf(cellCtx.column.id); track opt.value) {
<option [attr.value]="rozieAttr(opt.value)">{{ rozieDisplay(opt.label) }}</option>
}
</select>
} @else if (editorTypeOf(cellCtx.column.id) === 'checkbox') {
<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" [checked]="editorCheckedFor(cellCtx.column.id)" (change)="onCellEditorCheckbox(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
} @else {
<input class="rdt-cell-editor" type="text" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
}</span>
} @else {
<span class="rdt-cell-value">
@if ((cellTpl ?? templates()?.['cell'])) {
<ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }" />
} @else {
{{ rozieDisplay(cellCtx.getValue()) }}
}
</span>
}@if (isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))) {
<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" (pointerdown)="onFillHandlePointerDown($event)"></span>
}</td>
}
</tr>
@if (rowShowsDetail(row)) {
<tr class="rdt-detail-row" role="row" [attr.data-detail-row]="rozieAttr(row.id)">
<td class="rdt-detail-cell" [attr.colspan]="rozieAttr(visibleColCount())">
<ng-container *ngTemplateOutlet="(detailTpl ?? templates()?.['detail']); context: { $implicit: { row: row.original }, row: row.original }" />
</td>
</tr>
}
}
</tbody>
</table>
}@if (!virtual()) {
<div class="rdt-pagination" role="group" aria-label="Pagination">
<button type="button" class="rdt-page-btn rdt-page-prev" [disabled]="!canPrevPage()" (click)="onPrevPage()">Prev</button>
<span class="rdt-page-status" aria-live="polite">
{{ rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + pageCount()) }}
</span>
<button type="button" class="rdt-page-btn rdt-page-next" [disabled]="!canNextPage()" (click)="onNextPage()">Next</button>
<select class="rdt-page-size" aria-label="Rows per page" [value]="pageSize()" (change)="onPageSizeChange($event)">
<option [value]="10">10</option>
<option [value]="25">25</option>
<option [value]="50">50</option>
<option [value]="100">100</option>
</select>
</div>
}</div>
`,
styles: [`
.rozie-data-table {
border-collapse: collapse;
width: 100%;
font: var(--rdt-font, 14px system-ui, sans-serif);
color: var(--rdt-color, inherit);
}
.rdt-sr-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.rozie-data-table .rdt-cell-editor {
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-data-table .rdt-td[aria-invalid="true"] {
outline: var(--rdt-invalid-outline, 2px solid #d33);
outline-offset: -2px;
}
.rozie-data-table .rdt-td.rdt-in-range {
background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table .rdt-td {
position: relative;
}
.rozie-data-table .rdt-fill-handle {
position: absolute;
right: -3px;
bottom: -3px;
width: 8px;
height: 8px;
background: var(--rdt-fill-handle-bg, #2563eb);
border: 1px solid #fff;
cursor: crosshair;
z-index: 1;
touch-action: none;
}
.rozie-data-table .rdt-th,
.rozie-data-table .rdt-td {
padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
text-align: left;
border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table .rdt-thead .rdt-th {
font-weight: var(--rdt-header-weight, 600);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table .rdt-sort-btn {
display: inline-flex;
align-items: center;
gap: var(--rdt-sort-gap, 0.35em);
background: none;
border: none;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.rozie-data-table .rdt-sort-ind {
font-size: 0.8em;
opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
position: sticky;
top: var(--rdt-sticky-top, 0);
z-index: var(--rdt-sticky-z, 2);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table-wrap .rdt-scroll {
max-height: var(--rozie-data-table-max-height);
overflow: auto;
}
.rozie-data-table-wrap .rdt-group-bar-host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap .rdt-group-token {
display: inline-flex;
align-items: center;
padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
border-radius: var(--rdt-group-token-radius, 999px);
background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table .rdt-group-header {
background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table .rdt-group-toggle {
margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table .rdt-group-count {
margin-left: var(--rdt-group-count-gap, 0.375rem);
opacity: var(--rdt-group-count-opacity, 0.65);
font-weight: 400;
}
.rozie-data-table-wrap {
display: flex;
flex-direction: column;
gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-toolbar {
display: flex;
gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-global-filter,
.rozie-data-table-wrap .rdt-col-filter {
font: inherit;
padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-filter-radius, 4px);
background: var(--rdt-filter-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap .rdt-col-filter {
display: block;
margin-top: var(--rdt-col-filter-gap, 0.25rem);
width: 100%;
font-weight: normal;
}
.rozie-data-table-wrap .rdt-pagination {
display: flex;
align-items: center;
gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-page-btn {
font: inherit;
cursor: pointer;
padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-btn-radius, 4px);
background: var(--rdt-page-btn-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap .rdt-page-btn:disabled {
opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
cursor: default;
}
.rozie-data-table-wrap .rdt-page-status {
font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap .rdt-page-size {
font: inherit;
padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-size-radius, 4px);
background: var(--rdt-page-size-bg, transparent);
color: inherit;
}
.rozie-data-table .rdt-th {
position: relative;
}
.rozie-data-table .rdt-resize-handle {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--rdt-resize-handle-width, 6px);
padding: 0;
border: none;
background: none;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.rozie-data-table .rdt-resize-grip {
display: block;
width: var(--rdt-resize-grip-width, 2px);
height: 100%;
margin: 0 auto;
background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table .rdt-resize-handle:hover .rdt-resize-grip,
.rozie-data-table .rdt-th-resizing .rdt-resize-grip {
background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table .rdt-pin-controls {
display: inline-flex;
gap: var(--rdt-pin-gap, 0.1em);
margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table .rdt-pin-btn {
font: inherit;
font-size: var(--rdt-pin-btn-size, 0.8em);
line-height: 1;
cursor: pointer;
padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-pin-btn-radius, 3px);
background: var(--rdt-pin-btn-bg, transparent);
color: inherit;
}
.rozie-data-table .rdt-pin-btn[aria-pressed='true'] {
background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
font-weight: 700;
}
.rozie-data-table-wrap .rdt-colvis {
position: relative;
}
.rozie-data-table-wrap .rdt-colvis-summary {
cursor: pointer;
font: inherit;
padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-colvis-summary-radius, 4px);
list-style: none;
user-select: none;
}
.rozie-data-table-wrap .rdt-colvis-menu {
position: absolute;
z-index: var(--rdt-colvis-menu-z, 5);
margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
display: flex;
flex-direction: column;
gap: var(--rdt-colvis-item-gap, 0.25rem);
border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-colvis-menu-radius, 4px);
background: var(--rdt-colvis-menu-bg, #fff);
box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap .rdt-colvis-item {
display: flex;
align-items: center;
gap: var(--rdt-colvis-label-gap, 0.4em);
cursor: pointer;
white-space: nowrap;
}
.rozie-data-table .rdt-select-th,
.rozie-data-table .rdt-select-td {
width: var(--rdt-select-col-width, 1%);
text-align: var(--rdt-select-col-align, center);
white-space: nowrap;
}
.rozie-data-table .rdt-select-all,
.rozie-data-table .rdt-select-row {
cursor: pointer;
accent-color: var(--rdt-select-accent, currentColor);
}
`],
providers: [
{
provide: rozieToken('data-table:columns'),
useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => DataTable)); return ({
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
__rozieCtxHost.colReg.set({
...__rozieCtxHost.colReg(),
[key]: spec
});
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...__rozieCtxHost.colReg()
};
delete r[String(id)];
__rozieCtxHost.colReg.set(r);
}
}); },
},
],
})
export class DataTable {
/**
* 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).
* @example
* <DataTable r-model:data="rows" :columns="cols" />
*/
data = model.required<any[]>();
/**
* 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).
*/
columns = input<any[]>((() => [])());
/**
* Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
*/
selectionMode = input<string>('none');
/**
* `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
*/
sorting = model<any[]>((() => [])());
/**
* The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
*/
globalFilter = model<string>('');
/**
* `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
*/
columnFilters = model<any[]>((() => [])());
/**
* `{ 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`.
*/
pagination = model<Record<string, any>>((() => ({
pageIndex: 0,
pageSize: 10
}))());
/**
* 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).
*/
manual = input<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).
*/
expandable = input<boolean>(false);
/**
* `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.
*/
expanded = model<(Record<string, any> | boolean) | null>(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.
*/
getSubRows = input<((...args: unknown[]) => unknown) | null>(null);
/**
* 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).
*/
groupable = input<boolean>(false);
/**
* `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.
*/
grouping = model<(any[]) | null>(null);
/**
* `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.
*/
rowSelection = model<Record<string, any>>((() => ({}))());
/**
* `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
*/
columnVisibility = model<Record<string, any>>((() => ({}))());
/**
* `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
*/
columnSizing = model<Record<string, any>>((() => ({}))());
/**
* `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
*/
columnOrder = model<any[]>((() => [])());
/**
* `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
*/
columnPinning = model<Record<string, any>>((() => ({
left: [],
right: []
}))());
/**
* Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
*/
stickyHeader = input<boolean>(false);
/**
* `'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
* @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
*/
interactionMode = input<string>('table');
/**
* 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.
*/
virtual = input<boolean>(false);
/**
* Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight = input<number>(40);
/**
* 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.
*/
maxHeight = input<string>('');
dataDefault = signal<any[]>([]);
sortingDefault = signal<any[]>([]);
globalFilterDefault = signal('');
columnFiltersDefault = signal<any[]>([]);
paginationDefault = signal({
pageIndex: 0,
pageSize: 10
});
rowSelectionDefault = signal({});
expandedDefault = signal({});
groupingDefault = signal<any[]>([]);
columnVisibilityDefault = signal({});
columnSizingDefault = signal({});
columnOrderDefault = signal<any[]>([]);
columnPinningDefault = signal({
left: [],
right: []
});
columnSizingInfo = signal({
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: []
});
colReg = signal({});
rows = signal<any[]>([]);
headerGroups = signal<any[]>([]);
rowModelVer = signal(0);
windowVer = signal(0);
activeRow = signal(0);
activeColIndex = signal(0);
activeIsHeader = signal(false);
activeHeaderLevel = signal(0);
activeInControl = signal(false);
editingRow = signal(-1);
editingCol = signal(-1);
draftValue = signal<any>(null);
invalidMsg = signal('');
editVer = signal(0);
editingRowIndex = signal<any>(null);
rowDraft = signal({});
rangeAnchor = signal<any>(null);
rangeFocus = signal<any>(null);
pasteAnnounce = signal('');
__rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
sortChange = output<unknown>({ alias: 'sort-change' });
expandChange = output<unknown>({ alias: 'expand-change' });
groupChange = output<unknown>({ alias: 'group-change' });
filterChange = output<unknown>({ alias: 'filter-change' });
pageChange = output<unknown>({ alias: 'page-change' });
selectionChange = output<unknown>({ alias: 'selection-change' });
visibilityChange = output<unknown>({ alias: 'visibility-change' });
resizeChange = output<unknown>({ alias: 'resize-change' });
reorderChange = output<unknown>({ alias: 'reorder-change' });
pinChange = output<unknown>({ alias: 'pin-change' });
activecellChange = output<unknown>({ alias: 'activecell-change' });
rangeChange = output<unknown>({ alias: 'range-change' });
cellEditCommit = output<unknown>({ alias: 'cell-edit-commit' });
rowEditCommit = output<unknown>({ alias: 'row-edit-commit' });
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
@ContentChild('groupBar', { read: TemplateRef }) groupBarTpl?: TemplateRef<GroupBarCtx>;
@ContentChild('selectAll', { read: TemplateRef }) selectAllTpl?: TemplateRef<SelectAllCtx>;
@ContentChild('colHeader', { read: TemplateRef }) colHeaderTpl?: TemplateRef<ColHeaderCtx>;
@ContentChild('filter', { read: TemplateRef }) filterTpl?: TemplateRef<FilterCtx>;
@ContentChild('selectCell', { read: TemplateRef }) selectCellTpl?: TemplateRef<SelectCellCtx>;
@ContentChild('cell', { read: TemplateRef }) cellTpl?: TemplateRef<CellCtx>;
@ContentChild('editor', { read: TemplateRef }) editorTpl?: TemplateRef<EditorCtx>;
@ContentChild('detail', { read: TemplateRef }) detailTpl?: TemplateRef<DetailCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieWatchInitial_0 = true;
constructor() {
inject(DestroyRef).onDestroy(() => {
if (this.virtualizerCleanup) this.virtualizerCleanup();
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
this.teardownFillDrag();
});
effect(() => () => {
if (!this.table) return;
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = this.currentData() || [];
if (d === this.lastData && d.length === this.lastDataLen) return;
this.lastData = d;
this.lastDataLen = d.length;
this.reFeed();
});
effect(() => { const __watchVal = (() => [this.sorting(), this.globalFilter(), this.columnFilters(), this.pagination(), this.rowSelection(), this.expanded(), this.expandable(), this.grouping(), this.groupable(), this.columnVisibility(), this.columnSizing(), this.columnOrder(), this.columnPinning(), this.selectionMode(), (this.data() || []).length,
// Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
this.data(), this.dataDefault(), this.colReg()])(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
this.reFeed();
})(); }); });
}
ngAfterViewInit() {
const __getSubRows = this.getSubRows();
const __manual = this.manual();
const __selectionMode = this.selectionMode();
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
this.dataDefault.set(this.data() || []);
// Build the table instance HERE so the closures below capture the live `table`.
// Build the table instance HERE so the closures below capture the live `table`.
this.table = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: this.currentData(),
columns: this.tableColumns(),
state: this.currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (__getSubRows || undefined) as any,
getRowCanExpand: this.expandable() === true && __getSubRows == null ? () => true : undefined,
onExpandedChange: this.onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: this.onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: __manual === true,
manualFiltering: __manual === true,
manualSorting: __manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: __selectionMode !== 'none',
enableMultiRowSelection: __selectionMode === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: this.onSortingChangeCb,
onGlobalFilterChange: this.onGlobalFilterChangeCb,
onColumnFiltersChange: this.onColumnFiltersChangeCb,
onPaginationChange: this.onPaginationChangeCb,
onRowSelectionChange: this.onRowSelectionChangeCb,
onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
onColumnSizingChange: this.onColumnSizingChangeCb,
onColumnOrderChange: this.onColumnOrderChangeCb,
onColumnPinningChange: this.onColumnPinningChangeCb,
onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {}
});
this.refreshRowModel = () => {
if (!this.table) return;
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = this.windowSource().slice();
const nextGroups = this.table.getHeaderGroups().slice();
this.rows.set(nextRows);
this.headerGroups.set(nextGroups);
this.rowModelVer.set(this.rowModelVer() + 1);
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if (this.virtual() && this.virtualizer) {
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length;
const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
this.clampActiveCell(nextRowCount, nextColCount);
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (this.pendingEditFollow && this.isGrid()) {
const follow = this.pendingEditFollow;
this.pendingEditFollow = null;
const followIdx = this.indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
if (followIdx >= 0) this.focusCellWhenReady(followIdx, follow.col);
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
this.syncIndeterminate();
if (typeof queueMicrotask !== 'undefined') queueMicrotask(this.syncIndeterminate);else Promise.resolve().then(this.syncIndeterminate);
};
// initial pull
// initial pull
this.refreshRowModel();
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
this.gridRoot = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-data-table') : null;
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if (this.virtual()) {
this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rdt-scroll') : null;
this.virtualizer = new Virtualizer(this.virtualizerOptions());
this.virtualizerCleanup = this.virtualizer._didMount();
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
this.windowVer.set(this.windowVer() + 1);
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
this.remeasureWindow();
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = this.gridScrollEl ? this.gridScrollEl.clientHeight : 0;
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = this.pagination();
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
if (__manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
}
}
table: any = null;
virtualizer: any = null;
virtualizerCleanup: any = null;
gridScrollEl: any = null;
remeasurePending = false;
GRID_PAGE_STEP = 10;
gridRoot: any = null;
programmatic = 0;
expandedTouched = false;
groupingActiveDefault = () => ((this.grouping() != null ? this.grouping() : this.groupingDefault()) || []).length > 0;
currentState = (): any => ({
sorting: this.sorting() != null ? this.sorting() : this.sortingDefault(),
globalFilter: this.globalFilter() != null ? this.globalFilter() : this.globalFilterDefault(),
columnFilters: this.columnFilters() != null ? this.columnFilters() : this.columnFiltersDefault(),
pagination: this.pagination() != null ? this.pagination() : this.paginationDefault(),
rowSelection: this.rowSelection() != null ? this.rowSelection() : this.rowSelectionDefault(),
// expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
// literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
// guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
// GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
// `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
// literal so the grouped subtree is visible by default; the first toggle latches
// expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
// unchanged → byte-identical-off (the table + the expandable-rows feature both keep
// $data.expandedDefault).
expanded: this.expanded() != null ? this.expanded() : this.groupingActiveDefault() && !this.expandedTouched ? true : this.expandedDefault(),
// grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
// to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
// is inert when this is empty (byte-identical-off, req-10).
grouping: this.grouping() != null ? this.grouping() : this.groupingDefault(),
columnVisibility: this.columnVisibility() != null ? this.columnVisibility() : this.columnVisibilityDefault(),
columnSizing: this.columnSizing() != null ? this.columnSizing() : this.columnSizingDefault(),
columnOrder: this.columnOrder() != null ? this.columnOrder() : this.columnOrderDefault(),
columnPinning: this.columnPinning() != null ? this.columnPinning() : this.columnPinningDefault(),
// columnSizingInfo: table-core's transient resize-gesture state. We pass an
// EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
// `column.getIsResizing()` / `getResizeHandler()` read
// `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
// absent. Seed the default shape (matches table-core's
// getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
// every render. Not a two-way model slice (transient gesture state, not consumer
// state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
columnSizingInfo: this.columnSizingInfo()
});
currentData = (): any => this.data() != null ? this.data() : this.dataDefault();
isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
wrapAggregationFn = (fn: any) => {
if (typeof fn === 'string') return fn;
if (typeof fn !== 'function') return undefined;
return (columnId: any, leafRows: any, childRows: any) => {
try {
return fn(columnId, leafRows, childRows);
} catch (err: any) {
return undefined;
}
};
};
buildConfigDef = (c: any) => {
if (!c) return null;
// Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
// getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
// header spans its leaf children (B12). The group id falls back to its header text so it
// stays addressable (no accessor; group columns carry no data).
if (Array.isArray(c.columns)) {
const gid = c.id != null ? c.id : c.header;
if (gid == null) return null;
const id = String(gid);
if (!this.isSafeKey(id)) return null;
const kids = [];
for (const child of c.columns as any) {
const cd = this.buildConfigDef(child);
if (cd) kids.push(cd);
}
if (!kids.length) return null;
return {
id,
header: c.header != null ? c.header : id,
columns: kids
};
}
const rawId = c.id != null ? c.id : c.field;
if (rawId == null) return null;
const id = String(rawId);
if (!this.isSafeKey(id)) return null;
return {
id,
accessorKey: c.field != null ? c.field : id,
header: c.header != null ? c.header : id,
enableSorting: c.sortable === true,
// per-column filter opt-in (req-5). table-core gates the filter input + value
// funnel on enableColumnFilter; a column with filterable !== true cannot be
// filtered (and renders no per-column filter input in the chrome below).
enableColumnFilter: c.filterable === true,
filterable: c.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: c.expandable === true,
// Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
// so every data column is offered to the headless #groupBar by default; the per-column
// aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
// a custom fn defensively wrapped (T-50-04).
groupable: c.groupable !== false,
aggregationFn: this.wrapAggregationFn(c.aggregationFn),
pinned: c.pinned != null ? c.pinned : '',
width: c.width != null ? c.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
// metadata carrier the display↔editor branch + runValidator read. Off by default.
meta: {
editable: c.editable === true,
editor: c.editor != null ? c.editor : 'text',
editorOptions: c.editorOptions != null ? c.editorOptions : [],
validate: typeof c.validate === 'function' ? c.validate : null
}
};
};
columnDefs = () => {
const byId = Object.create(null);
const order = [];
const cfg = this.columns() || [];
for (const c of cfg as any) {
const def = this.buildConfigDef(c);
if (!def) continue;
const id = def.id;
if (!(id in byId)) order.push(id);
byId[id] = def;
}
const reg = this.colReg() || {};
for (const id in reg) {
if (!this.isSafeKey(id)) continue;
const spec = reg[id];
if (!spec) continue;
if (!(id in byId)) order.push(id);
byId[id] = {
id,
accessorKey: spec.field != null ? spec.field : id,
header: spec.header != null ? spec.header : id,
enableSorting: spec.sortable === true,
enableColumnFilter: spec.filterable === true,
filterable: spec.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: spec.expandable === true,
// Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
groupable: spec.groupable !== false,
aggregationFn: this.wrapAggregationFn(spec.aggregationFn),
pinned: spec.pinned != null ? spec.pinned : '',
width: spec.width != null ? spec.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
meta: {
editable: spec.editable === true,
editor: spec.editor != null ? spec.editor : 'text',
editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
validate: typeof spec.validate === 'function' ? spec.validate : null
}
};
}
const out = [];
for (const id of order as any) if (byId[id]) out.push(byId[id]);
return out;
};
SELECT_COL_ID = '__rdt_select';
EXPANDER_COL_ID = '__rdt_expander';
selectionEnabled = () => this.selectionMode() === 'single' || this.selectionMode() === 'multiple';
tableColumns = () => {
const cols = this.columnDefs();
// Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
// isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
// value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
// → byte-identical-off (req-10).
let withExpander = cols;
if (this.expandable() === true) {
const expanderCol = {
id: this.EXPANDER_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isExpanderColumn: true,
pinned: '',
width: ''
};
withExpander = [expanderCol].concat(cols);
}
if (this.selectionEnabled()) {
const selectCol = {
id: this.SELECT_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isSelectColumn: true,
pinned: '',
width: ''
};
return [selectCol].concat(withExpander);
}
return withExpander;
};
writeSorting = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.sortingDefault.set(next); // fresh array only (never in-place)
this.sorting.set(next); // two-way emit if bound (no-op-diff if not)
this.sortChange.emit(next);
this.programmatic--;
};
applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
writeExpanded = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
// Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
// the user now owns the expanded state, so currentState() stops defaulting grouped rows to
// the `true` expand-all literal and honors $data.expandedDefault from here on.
this.expandedTouched = true;
this.expandedDefault.set(next); // fresh value only (never in-place)
this.expanded.set(next); // two-way emit if bound (no-op-diff if not)
// Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
// prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
// interface, and an `expanded-change` event would camelCase to the SAME identifier
// → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
// sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
// sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
this.expandChange.emit(next);
this.programmatic--;
};
writeGrouping = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.groupingDefault.set(next); // fresh ordered array only (never in-place push)
this.grouping.set(next); // two-way emit if bound (no-op-diff if not)
this.groupChange.emit(next);
this.programmatic--;
};
writeGlobalFilter = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.globalFilterDefault.set(next);
this.globalFilter.set(next);
this.filterChange.emit({
globalFilter: next
});
this.programmatic--;
};
writeColumnFilters = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.columnFiltersDefault.set(next);
this.columnFilters.set(next);
this.filterChange.emit({
columnFilters: next
});
this.programmatic--;
};
writePagination = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.paginationDefault.set(next);
this.pagination.set(next);
this.pageChange.emit(next);
this.programmatic--;
};
writeRowSelection = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.rowSelectionDefault.set(next);
this.rowSelection.set(next);
this.selectionChange.emit(next);
this.programmatic--;
};
writeColumnVisibility = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.columnVisibilityDefault.set(next);
this.columnVisibility.set(next);
this.visibilityChange.emit(next);
this.programmatic--;
};
writeColumnSizing = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.columnSizingDefault.set(next);
this.columnSizing.set(next);
this.resizeChange.emit(next);
this.programmatic--;
};
writeColumnOrder = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.columnOrderDefault.set(next);
this.columnOrder.set(next);
this.reorderChange.emit(next);
this.programmatic--;
};
writeColumnPinning = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.columnPinningDefault.set(next);
this.columnPinning.set(next);
this.pinChange.emit(next);
this.programmatic--;
};
writeData = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this.dataDefault.set(next); // fresh array only (never in-place)
this.data.set(next); // two-way emit if bound (no-op-diff if not)
this.programmatic--;
};
columnFilterValue = (colId: any) => {
const cf = this.currentState().columnFilters || [];
for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
return '';
};
setColumnFilter = (colId: any, value: any) => {
const prev = this.currentState().columnFilters || [];
const next = [];
for (const f of prev as any) if (f && f.id !== colId) next.push(f);
if (value != null && value !== '') next.push({
id: colId,
value
});
this.writeColumnFilters(next);
};
refreshRowModel: any = null;
onSortingChangeCb = (updater: any) => {
this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
};
onExpandedChangeCb = (updater: any) => {
this.writeExpanded(this.applyUpdater(updater, this.currentState().expanded));
};
onGroupingChangeCb = (updater: any) => {
this.writeGrouping(this.applyUpdater(updater, this.currentState().grouping));
};
onGlobalFilterChangeCb = (updater: any) => {
this.writeGlobalFilter(this.applyUpdater(updater, this.currentState().globalFilter));
};
onColumnFiltersChangeCb = (updater: any) => {
this.writeColumnFilters(this.applyUpdater(updater, this.currentState().columnFilters));
};
onPaginationChangeCb = (updater: any) => {
this.writePagination(this.applyUpdater(updater, this.currentState().pagination));
};
onRowSelectionChangeCb = (updater: any) => {
this.writeRowSelection(this.applyUpdater(updater, this.currentState().rowSelection));
};
onColumnVisibilityChangeCb = (updater: any) => {
this.writeColumnVisibility(this.applyUpdater(updater, this.currentState().columnVisibility));
};
onColumnSizingChangeCb = (updater: any) => {
this.writeColumnSizing(this.applyUpdater(updater, this.currentState().columnSizing));
};
onColumnOrderChangeCb = (updater: any) => {
this.writeColumnOrder(this.applyUpdater(updater, this.currentState().columnOrder));
};
onColumnPinningChangeCb = (updater: any) => {
this.writeColumnPinning(this.applyUpdater(updater, this.currentState().columnPinning));
};
onColumnSizingInfoChangeCb = (updater: any) => {
const next = this.applyUpdater(updater, this.columnSizingInfo());
this.columnSizingInfo.set(next != null ? next : this.columnSizingInfo());
};
windowSource = () => {
if (!this.table) return [];
if (this.virtual()) return this.table.getPrePaginationRowModel().rows;
return this.table.getRowModel().rows;
};
scheduleRemeasure = () => {
if (this.remeasurePending) return;
this.remeasurePending = true;
let ranMicro = false;
const microPass = () => {
this.remeasureWindow();
};
const rafPass = () => {
this.remeasurePending = false;
this.remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
};
pinnedEditIndex = () => {
const __editingRow = this.editingRow();
const __editingRowIndex = this.editingRowIndex();
if (__editingRow >= 0) return __editingRow;
if (__editingRowIndex != null) return __editingRowIndex;
return -1;
};
pinnedMeasurement = (pin: any) => {
if (!this.virtualizer || pin < 0) return null;
const ms = this.virtualizer.getMeasurements();
return ms && ms[pin] ? ms[pin] : null;
};
remeasureWindow = () => {
if (!this.virtualizer || !this.gridRoot) return;
// Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
// exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
// null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
// scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
// resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
// regression); the next settled onChange re-measures the stable window. Manual-scroll
// recycling (the CR-01 case) has scrollState === null, so it measures normally.
if (this.virtualizer.scrollState) return;
const trs = this.gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
for (const tr of trs as any) this.virtualizer.measureElement(tr);
};
virtualItemKey = (i: any) => {
const src = this.windowSource();
return src && src[i] ? src[i].id : undefined;
};
virtualizerOptions = (): any => ({
count: this.windowSource().length,
getScrollElement: () => this.gridScrollEl,
estimateSize: () => this.estimateRowHeight(),
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: this.virtualItemKey,
onChange: () => {
this.windowVer.set(this.windowVer() + 1);
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
this.scheduleRemeasure();
}
});
pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => this.pinnedMeasurement(pin);
windowedRows = () => {
const __rows = this.rows();
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void this.windowVer();
void this.editVer();
if (!this.virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!this.virtual()) {
const rowList = __rows || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = this.virtualizer.getVirtualItems();
const rowList = __rows || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = this.pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = this.pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void this.windowVer();
void this.editVer();
if (!this.virtual() || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void this.windowVer();
void this.editVer();
if (!this.virtual() || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
rowIsOutsideWindow = (r: any) => {
if (!this.virtual() || !this.virtualizer) return false;
const items = this.virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
reFeed = () => {
if (!this.table) return;
this.table.setOptions((prev: any) => ({
...prev,
data: this.currentData(),
columns: this.tableColumns(),
state: this.currentState(),
enableRowSelection: this.selectionMode() !== 'none',
enableMultiRowSelection: this.selectionMode() === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (this.getSubRows() || undefined) as any,
getRowCanExpand: this.expandable() === true && this.getSubRows() == null ? () => true : undefined,
onExpandedChange: this.onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: this.onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: this.onSortingChangeCb,
onGlobalFilterChange: this.onGlobalFilterChangeCb,
onColumnFiltersChange: this.onColumnFiltersChangeCb,
onPaginationChange: this.onPaginationChangeCb,
onRowSelectionChange: this.onRowSelectionChangeCb,
onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
onColumnSizingChange: this.onColumnSizingChangeCb,
onColumnOrderChange: this.onColumnOrderChangeCb,
onColumnPinningChange: this.onColumnPinningChangeCb,
onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb
}));
if (this.refreshRowModel) this.refreshRowModel();
};
lastData: any = null;
lastDataLen = -1;
onHeaderSort = (colId: any, evt: any) => {
if (!this.table) return;
const col = this.table.getColumn(colId);
if (!col || !col.getCanSort()) return;
const multi = !!(evt && evt.shiftKey);
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi);
};
tick = () => this.rowModelVer();
ariaSortFor = (colId: any) => {
if (this.tick() < 0 || !this.table) return 'none';
const col = this.table.getColumn(colId);
if (!col) return 'none';
const dir = col.getIsSorted();
if (dir === 'asc') return 'ascending';
if (dir === 'desc') return 'descending';
return 'none';
};
sortIndicator = (colId: any) => {
if (this.tick() < 0 || !this.table) return '';
const col = this.table.getColumn(colId);
if (!col) return '';
const dir = col.getIsSorted();
if (dir === 'asc') return '▲';
if (dir === 'desc') return '▼';
return '';
};
defFor = (colId: any) => {
const defs = this.columnDefs();
for (const d of defs as any) if (d.id === colId) return d;
return null;
};
visibleCellsFor = (row: any) => this.rowModelVer() >= 0 ? row.getVisibleCells() : [];
editMetaOf = (colId: any) => {
const d = this.defFor(colId);
return d && d.meta ? d.meta : null;
};
columnEditable = (colId: any) => {
const m = this.editMetaOf(colId);
return !!(m && m.editable === true);
};
editorTypeOf = (colId: any) => {
const m = this.editMetaOf(colId);
return m && m.editor != null ? m.editor : 'text';
};
editorOptionsOf = (colId: any) => {
const m = this.editMetaOf(colId);
return m && m.editorOptions != null ? m.editorOptions : [];
};
hasEditorSlot = (colId: any) => this.editorTypeOf(colId) === 'custom' && !!(this.editorTpl ?? this.templates()?.['editor']);
columnIsFilterable = (colId: any) => {
const d = this.defFor(colId);
return !!(d && d.filterable);
};
headerLabel = (colId: any) => {
const d = this.defFor(colId);
return d ? d.header : colId;
};
headerWidth = (colId: any) => {
if (this.tick() < 0 || !this.table) return null;
const col = this.table.getColumn(colId);
if (!col) return null;
const w = col.getSize();
return w != null && w > 0 ? w + 'px' : null;
};
onResizeStart = (colId: any, evt: any) => {
// stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!this.table) return;
const header = this.findHeader(colId);
if (!header || !header.getResizeHandler) return;
const handler = header.getResizeHandler();
if (handler) handler(evt);
};
findHeader = (colId: any) => {
const groups = this.headerGroups() || [];
for (const hg of groups as any) {
const hs = hg.headers || [];
for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
}
return null;
};
columnIsResizing = (colId: any) => {
if (this.tick() < 0 || !this.table) return false;
const header = this.findHeader(colId);
return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};
columnIsVisible = (colId: any) => {
if (this.tick() < 0 || !this.table) return true;
const col = this.table.getColumn(colId);
return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};
onToggleVisibility = (colId: any) => {
if (!this.table) return;
const col = this.table.getColumn(colId);
if (col && col.toggleVisibility) col.toggleVisibility();
};
allLeafColumns = () => {
if (this.tick() < 0 || !this.table) return [];
const cols = this.table.getAllLeafColumns ? this.table.getAllLeafColumns() : [];
const out = [];
for (const c of cols as any) {
if (!c || c.id === this.SELECT_COL_ID) continue;
out.push({
id: c.id,
label: this.headerLabel(c.id),
visible: !!(c.getIsVisible && c.getIsVisible())
});
}
return out;
};
columnPinSide = (colId: any) => {
if (this.tick() < 0 || !this.table) return false;
const col = this.table.getColumn(colId);
if (!col || !col.getIsPinned) return false;
return col.getIsPinned();
};
onPinColumn = (colId: any, side: any, evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!this.table) return;
const col = this.table.getColumn(colId);
if (col && col.pin) col.pin(side);
};
pinStyle = (colId: any) => {
if (this.tick() < 0 || !this.table) return '';
const col = this.table.getColumn(colId);
if (!col || !col.getIsPinned) return '';
const side = col.getIsPinned();
if (side === 'left') {
const left = col.getStart ? col.getStart('left') : 0;
return 'position:sticky;left:' + left + 'px;z-index:1;';
}
if (side === 'right') {
const right = col.getAfter ? col.getAfter('right') : 0;
return 'position:sticky;right:' + right + 'px;z-index:1;';
}
return '';
};
thStyle = (colId: any) => {
let s = '';
const w = this.headerWidth(colId);
if (w) s += 'width:' + w + ';';
s += this.pinStyle(colId);
return s;
};
onGlobalFilterInput = (evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
if (this.table) {
this.table.setGlobalFilter(value);
return;
}
this.writeGlobalFilter(value);
};
onColumnFilterInput = (colId: any, evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
this.setColumnFilter(colId, value);
};
globalFilterValue = () => {
const v = this.currentState().globalFilter;
return v != null ? v : '';
};
pageIndex = () => {
if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageIndex;
const p = this.currentState().pagination;
return p && p.pageIndex != null ? p.pageIndex : 0;
};
pageSize = () => {
if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageSize;
const p = this.currentState().pagination;
return p && p.pageSize != null ? p.pageSize : 10;
};
pageCount = () => {
if (this.tick() < 0 || !this.table) return 1;
const c = this.table.getPageCount();
return c != null && c > 0 ? c : 1;
};
canPrevPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanPreviousPage());
canNextPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanNextPage());
onPrevPage = () => {
if (this.table) this.table.previousPage();
};
onNextPage = () => {
if (this.table) this.table.nextPage();
};
onPageSizeChange = (evt: any) => {
if (!this.table) return;
const v = evt && evt.target ? evt.target.value : '';
const n = parseInt(v, 10);
this.table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};
isSelectColumn = (colId: any) => colId === this.SELECT_COL_ID;
isExpanderColumn = (colId: any) => colId === this.EXPANDER_COL_ID;
rowCanExpand = (row: any) => !!(this.tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
rowShowsDetail = (row: any) => this.getSubRows() == null && this.rowIsExpanded(row);
onToggleExpand = (row: any, evt: any) => {
if (!row || !row.toggleExpanded) return;
// Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
// expanded-state re-render. On Solid the expander <td>/<button> is RECREATED on that
// re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
// pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
// breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
// Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
// the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
// reactive flush so the fresh node exists. Harmless on the targets that keep the node
// (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
row.toggleExpanded();
if (ownerRow && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
const btn = ownerRow.querySelector('[data-expander]');
if (btn) btn.focus();
});
}
};
bodyCellStyle = (row: any, colId: any) => {
const base = this.pinStyle(colId);
if (this.isExpanderColumn(colId) && row && row.depth) {
const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
return base ? base + ';' + pad : pad;
}
return base;
};
rowIsGrouped = (row: any) => !!(this.tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
groupingActive = () => this.tick() >= 0 && (this.currentState().grouping || []).length > 0;
cellIsGrouped = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
cellIsAggregated = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
groupSubRowCount = (row: any) => row && row.subRows ? row.subRows.length : 0;
groupingKeys = () => this.currentState().grouping || [];
groupableColumns = () => {
const out = [];
const defs = this.columnDefs();
for (const d of defs as any) {
if (!d || d.groupable === false) continue;
out.push({
id: d.id,
label: d.header != null ? d.header : d.id
});
}
return out;
};
stopEvent = (evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
};
isAllRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsAllRowsSelected());
isSomeRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsSomeRowsSelected());
onToggleAllRows = (evt: any) => {
if (!this.table) return;
this.table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};
rowIsSelected = (row: any) => {
if (!row) return false;
const id = row.id;
const sel = this.currentState().rowSelection || {};
if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
return !!(row.getIsSelected && row.getIsSelected());
};
onToggleRow = (row: any, evt: any) => {
if (!row || !row.toggleSelected) return;
row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};
selectAllBox: any = null;
syncIndeterminate = () => {
if (!this.__rozieRoot()?.nativeElement || !this.__rozieRoot()!.nativeElement.querySelector) return;
this.selectAllBox = this.__rozieRoot()!.nativeElement.querySelector('.rdt-select-all');
if (this.selectAllBox) this.selectAllBox.indeterminate = this.isSomeRowsSelected() && !this.isAllRowsSelected();
};
sortColumn = (colId: any, desc: any) => {
if (this.table) this.table.getColumn(colId) && this.table.getColumn(colId).toggleSorting(desc, false);
};
clearSorting = () => {
if (this.table) this.table.resetSorting(true);
};
getColumnDefs = () => this.columnDefs();
toggleAllRows = (value: any) => {
if (this.table) this.table.toggleAllRowsSelected(value);
};
clearSelection = () => {
if (this.table) this.table.resetRowSelection(true);
};
getSelectedRows = () => this.table ? this.table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
setPage = (idx: any) => {
if (this.table) this.table.setPageIndex(idx);
};
setRowsPerPage = (size: any) => {
if (this.table) this.table.setPageSize(size);
};
toggleColumnVisibility = (colId: any) => {
if (this.table) {
const c = this.table.getColumn(colId);
if (c && c.toggleVisibility) c.toggleVisibility();
}
};
applyColumnOrder = (order: any) => {
if (this.table) this.table.setColumnOrder(order);
};
resetColumnSizing = () => {
if (this.table) this.table.resetColumnSizing(true);
};
pinColumn = (colId: any, side: any) => {
if (this.table) {
const c = this.table.getColumn(colId);
if (c && c.pin) c.pin(side);
}
};
getRowIndexRelativeToPage = (absRow: any) => {
const abs = absRow == null ? this.toAbsRow(this.activeRow()) : Math.trunc(Number(absRow)) || 0;
if (this.virtual()) return abs;
return abs - this.pageRowOffset();
};
cut = () => this.cutRange();
isGrid = () => this.interactionMode() === 'grid';
tableRole = () => this.isGrid() ? 'grid' : 'table';
cellRole = () => this.isGrid() ? 'gridcell' : 'cell';
rowIndexOf = (row: any) => this.tick() >= 0 ? (this.rows() || []).indexOf(row) : -1;
colIndexOf = (row: any, cellCtx: any) => this.tick() >= 0 ? this.visibleCellsFor(row).indexOf(cellCtx) : -1;
headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
pageRowOffset = () => {
if (!this.isGrid() || this.virtual()) return 0;
return this.pageIndex() * this.pageSize();
};
toAbsRow = (localRow: any) => localRow + this.pageRowOffset();
absRowIndexOf = (row: any) => this.rowIndexOf(row) + this.pageRowOffset();
prePaginationRowCount = () => {
if (!this.table || this.virtual()) return this.bodyRowCount();
const pm = this.table.getPrePaginationRowModel();
return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
};
cellTabindex = (rowKey: any, colIndex: any, level: any = null) => {
const __activeColIndex = this.activeColIndex();
if (!this.isGrid()) return null;
// B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
// the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
// tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
if (this.bodyRowCount() === 0) {
return rowKey === '__header' && colIndex === 0 && level === this.headerLeafLevel() ? 0 : -1;
}
// B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
// grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
// lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
// tab-stops (the roving invariant broke under grouped headers).
if (this.activeIsHeader()) {
if (rowKey !== '__header') return -1;
return colIndex === __activeColIndex && level === this.activeHeaderLevel() ? 0 : -1;
}
const isActive = rowKey === String(this.activeRow()) && colIndex === __activeColIndex;
return isActive ? 0 : -1;
};
resolveCellEl = (rowKey: any, colIndex: any, level: any = null) => {
if (!this.gridRoot) return null;
// B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
// same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
// integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
// match (the parent) when the leaf is meant. level is an integer (NO consumer string is
// interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
// byte-unchanged.
let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
return this.gridRoot.querySelector(sel);
};
focusActiveCell = (nextRow: any = null, nextCol: any = null, nextIsHeader: any = null, nextLevel: any = null) => {
if (!this.isGrid() || !this.gridRoot) return;
const r = nextRow == null ? this.activeRow() : nextRow;
const c = nextCol == null ? this.activeColIndex() : nextCol;
// B12: thread the FRESH post-write header level (the grouped-header analog of the
// nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
// level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
const lvl = nextLevel == null ? this.activeHeaderLevel() : nextLevel;
// Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
// crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
// Angular's signal write are async within one handler — re-reading $data.activeIsHeader
// here returns the PRE-write value, resolving focus to the BODY cell instead of the
// header. Callers pass the fresh isHeader local; falls back to $data when omitted.
const header = nextIsHeader == null ? this.activeIsHeader() : nextIsHeader;
// ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
// rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
// double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
// in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
// The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
// while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
if (this.virtual() && this.virtualizer && !header && this.rowIsOutsideWindow(r)) {
this.virtualizer.scrollToIndex(r, {
align: 'center'
});
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
let focusAttempts = 0;
const focusWhenReady = () => {
const el = this.resolveCellEl(String(r), c);
if (el) {
el.focus();
return;
}
focusAttempts = focusAttempts + 1;
if (focusAttempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
return;
}
const rowKey = header ? '__header' : String(r);
const el = this.resolveCellEl(rowKey, c, header ? lvl : null);
if (el) el.focus();
};
totalRowCount = () => {
const __rows = this.rows();
if (!this.table) return (__rows || []).length;
const fm = this.table.getFilteredRowModel();
return fm && fm.rows ? fm.rows.length : (__rows || []).length;
};
visibleColCount = () => {
// NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
// state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
// self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
const rowList = this.rows() || [];
if (rowList.length) return rowList[0].getVisibleCells().length;
const hg = this.headerGroups() || [];
return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};
bodyRowCount = () => (this.rows() || []).length;
clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
headerLeafLevel = () => {
const hg = this.headerGroups() || [];
return hg.length ? hg.length - 1 : 0;
};
headerAt = (level: any, colIndex: any) => {
const hg = this.headerGroups() || [];
const grp = hg[level];
if (!grp || !grp.headers) return null;
return grp.headers[colIndex] || null;
};
parentHeaderColIndex = (level: any, colIndex: any) => {
if (level <= 0) return -1;
const h = this.headerAt(level, colIndex);
if (!h || !h.column || !h.column.parent) return -1;
const parentId = h.column.parent.id;
const hg = this.headerGroups() || [];
const pg = hg[level - 1];
if (!pg || !pg.headers) return -1;
for (let i = 0; i < pg.headers.length; i++) {
const ph = pg.headers[i];
if (ph && ph.column && ph.column.id === parentId) return i;
}
return -1;
};
firstChildHeaderColIndex = (level: any, colIndex: any) => {
const h = this.headerAt(level, colIndex);
if (!h || !h.column) return -1;
const kids = h.column.columns || [];
if (!kids.length) return -1;
const childId = kids[0].id;
const hg = this.headerGroups() || [];
const cg = hg[level + 1];
if (!cg || !cg.headers) return -1;
for (let i = 0; i < cg.headers.length; i++) {
const ch = cg.headers[i];
if (ch && ch.column && ch.column.id === childId) return i;
}
return -1;
};
moveCol = (delta: any) => {
const max = this.visibleColCount() - 1;
const nextCol = this.clamp(this.activeColIndex() + delta, 0, max < 0 ? 0 : max);
this.activeColIndex.set(nextCol);
return nextCol;
};
moveRow = (delta: any) => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const leafLevel = this.headerLeafLevel();
if (this.activeIsHeader()) {
if (delta > 0) {
// B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
// level down); from the LEAF header level, drop into the body (row 0). A header-level
// move re-targets activeColIndex (parent↔child column indices differ), so the fresh
// col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
if (this.activeHeaderLevel() < leafLevel) {
const childCol = this.firstChildHeaderColIndex(this.activeHeaderLevel(), this.activeColIndex());
if (childCol >= 0) {
const nextLevel = this.activeHeaderLevel() + 1;
this.activeHeaderLevel.set(nextLevel);
this.activeColIndex.set(childCol);
return {
row: this.activeRow(),
col: childCol,
isHeader: true,
level: nextLevel
};
}
}
// At the leaf header: an empty grid has no body to drop into → stay put.
if (this.bodyRowCount() === 0) return {
row: this.activeRow(),
col: this.activeColIndex(),
isHeader: true,
level: this.activeHeaderLevel()
};
// B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
// (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
// page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
// (delta-1 = 0); clamped to the page-last body row.
const landRow = this.clamp(delta - 1, 0, maxRow);
this.activeIsHeader.set(false);
this.activeRow.set(landRow);
return {
row: landRow,
col: this.activeColIndex(),
isHeader: false,
level: 0
};
}
// B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
// spans the active column; at the top level (or no real parent) stay put. The parent col
// index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
const parentCol = this.parentHeaderColIndex(this.activeHeaderLevel(), this.activeColIndex());
if (parentCol >= 0) {
const nextLevel = this.activeHeaderLevel() - 1;
this.activeHeaderLevel.set(nextLevel);
this.activeColIndex.set(parentCol);
return {
row: this.activeRow(),
col: parentCol,
isHeader: true,
level: nextLevel
};
}
return {
row: this.activeRow(),
col: this.activeColIndex(),
isHeader: true,
level: this.activeHeaderLevel()
};
}
// In the body: an upward move from row 0 crosses into the LEAF header level (the header row
// adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
// activeColIndex carries over unchanged.
if (delta < 0 && this.activeRow() === 0) {
this.activeIsHeader.set(true);
this.activeHeaderLevel.set(leafLevel);
return {
row: this.activeRow(),
col: this.activeColIndex(),
isHeader: true,
level: leafLevel
};
}
const nextRow = this.clamp(this.activeRow() + delta, 0, maxRow);
this.activeRow.set(nextRow);
this.activeIsHeader.set(false);
return {
row: nextRow,
col: this.activeColIndex(),
isHeader: false,
level: 0
};
};
gotoColEdge = (toEnd: any) => {
const max = this.visibleColCount() - 1;
const nextCol = toEnd ? max < 0 ? 0 : max : 0;
this.activeColIndex.set(nextCol);
return nextCol;
};
gotoStart = () => {
this.activeIsHeader.set(false);
this.activeRow.set(0);
this.activeColIndex.set(0);
return {
row: 0,
col: 0
};
};
gotoEnd = () => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const max = this.visibleColCount() - 1;
const maxCol = max < 0 ? 0 : max;
this.activeIsHeader.set(false);
this.activeRow.set(maxRow);
this.activeColIndex.set(maxCol);
return {
row: maxRow,
col: maxCol
};
};
currentCellEl = () => {
const __activeIsHeader = this.activeIsHeader();
const rowKey = __activeIsHeader ? '__header' : String(this.activeRow());
return this.resolveCellEl(rowKey, this.activeColIndex(), __activeIsHeader ? this.activeHeaderLevel() : null);
};
focusables = (cellEl: any) => {
if (!cellEl || !cellEl.querySelectorAll) return [];
const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
return list.filter((n: any) => !n.disabled);
};
enterControl = () => {
const cellEl = this.currentCellEl();
const list = this.focusables(cellEl);
if (!list.length) return;
this.activeInControl.set(true);
list[0].focus();
};
cycleWithinCell = (cellEl: any, forward: any) => {
const list = this.focusables(cellEl);
if (!list.length) return;
const active = this.gridRoot ? this.gridRoot.getRootNode().activeElement : null;
const cur = list.indexOf(active);
let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
if (i >= list.length) i = 0;
if (i < 0) i = list.length - 1;
list[i].focus();
};
onGridKeyDown = (e: any) => {
const __activeRow = this.activeRow();
const __activeColIndex = this.activeColIndex();
const __activeIsHeader = this.activeIsHeader();
const __rows = this.rows();
if (!this.isGrid() || !e) return;
const key = e.key;
// Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
// via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
// early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
// modes (editing / in-control / navigation) stay mutually exclusive and ordered.
if (this.editingRow() >= 0) return;
// Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
// editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
// keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
if (this.editingRowIndex() != null) return;
// Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
if (this.activeInControl()) {
if (key === 'Escape') {
e.preventDefault();
this.activeInControl.set(false);
// Return focus to the OWNING cell (no move happened) — pass the current indices
// explicitly (the React-emitted seam types both params as required; a zero-arg call
// is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
this.focusActiveCell(__activeRow, __activeColIndex);
} else if (key === 'Tab') {
e.preventDefault();
this.cycleWithinCell(this.currentCellEl(), !e.shiftKey);
}
return;
}
// WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
// grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
// user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
// NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
// focused node; if it is not itself a [data-grid-cell], let the event pass through.
const tgt = e.target;
if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
// Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
// nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
// async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
// plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
// in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
// fires ONLY on a real move (a clamped no-op edge move leaves them identical).
const prevRow = __activeRow;
const prevCol = __activeColIndex;
const prevIsHeader = __activeIsHeader;
const prevLevel = this.activeHeaderLevel();
let nextRow = prevRow;
let nextCol = prevCol;
let nextIsHeader = prevIsHeader;
// B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
// threaded into the focus seam so a leaf↔parent header move lands focus at the correct
// level. moveRow returns it; the non-vertical branches keep the pre-move level.
let nextLevel = prevLevel;
// ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
// the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
// fall through to a plain navigation move). Body cells only (no range from a header). The
// extendRange call owns focus + the range-change emit, so return immediately. ──────────
if (key === 'ArrowRight' && e.shiftKey && !__activeIsHeader) {
e.preventDefault();
this.extendRange(0, 1);
return;
} else if (key === 'ArrowLeft' && e.shiftKey && !__activeIsHeader) {
e.preventDefault();
this.extendRange(0, -1);
return;
} else if (key === 'ArrowDown' && e.shiftKey && !__activeIsHeader) {
e.preventDefault();
this.extendRange(1, 0);
return;
} else if (key === 'ArrowUp' && e.shiftKey && !__activeIsHeader) {
e.preventDefault();
this.extendRange(-1, 0);
return;
} else if (key === 'ArrowRight') {
e.preventDefault();
this.clearRange();
nextCol = this.moveCol(1);
} else if (key === 'ArrowLeft') {
e.preventDefault();
this.clearRange();
nextCol = this.moveCol(-1);
} else if (key === 'ArrowDown') {
e.preventDefault();
this.clearRange();
const m = this.moveRow(1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'ArrowUp') {
e.preventDefault();
this.clearRange();
const m = this.moveRow(-1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageDown') {
e.preventDefault();
const m = this.moveRow(this.GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageUp') {
e.preventDefault();
const m = this.moveRow(-this.GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'Home') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const s = this.gotoStart();
nextRow = s.row;
nextCol = s.col;
nextIsHeader = false;
} else {
nextCol = this.gotoColEdge(false);
}
} else if (key === 'End') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const en = this.gotoEnd();
nextRow = en.row;
nextCol = en.col;
nextIsHeader = false;
} else {
nextCol = this.gotoColEdge(true);
}
}
// ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
// pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
// edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
// type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
// gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
// falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
// (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
e.preventDefault();
this.copyRange();
return;
} else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
e.preventDefault();
this.pasteRange();
return;
}
// ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
// source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
// Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
// and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
// shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
e.preventDefault();
this.cutRange();
return;
}
// ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
// EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
// (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
// collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
// at least the active editable column); a non-editable active cell falls through unchanged.
else if (key === 'F2' && e.shiftKey && this.isActiveCellEditable()) {
e.preventDefault();
this.beginRowEdit((__rows || [])[__activeRow]);
return;
}
// ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
// Gated by isActiveCellEditable(): a non-editable active cell falls through to
// enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
// (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
else if ((key === 'Enter' || key === 'F2') && this.isActiveCellEditable()) {
e.preventDefault();
this.beginEdit(__activeRow, __activeColIndex, null);
return;
} else if (this.isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
// checkbox/select/date editor must NOT take the typed char as its value (it would
// force-check the checkbox, seed a garbage select option, or corrupt the date) — open
// those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
e.preventDefault();
const editType = this.editorTypeOf(this.activeCellColumnId());
const seed = editType === 'text' || editType === 'number' ? key : null;
this.beginEdit(__activeRow, __activeColIndex, seed);
return;
}
// ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
// expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
// verified invariant) so it never hits the edit branches above and would otherwise fall to
// enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
// Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
// model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
// ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
// full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
else if (key === 'Enter' && !__activeIsHeader && this.rowIsGrouped((__rows || [])[__activeRow])) {
e.preventDefault();
// C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
// active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
// the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
// row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
// fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
// DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
// while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
// settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
// onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
// SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
// until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
// already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
const grpRow = __activeRow;
const grpCol = __activeColIndex;
this.onToggleExpand((__rows || [])[__activeRow], e);
this.recoverGridFocus(String(grpRow), grpCol, null);
return;
} else if (key === 'Enter' || key === 'F2') {
e.preventDefault();
this.enterControl();
return;
} else return;
// THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
// focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
this.focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
// WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
// changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
// row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
// B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
// C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
// keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
// toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
// The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
this.activecellChange.emit({
rowIndex: this.toAbsRow(nextRow),
colIndex: nextCol
});
}
};
syncActiveFromEvent = (e: any) => {
if (!this.isGrid() || !e) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null) return;
const col = parseInt(colAttr, 10);
if (!Number.isFinite(col)) return;
const isHeader = rowAttr === '__header';
this.activeIsHeader.set(isHeader);
if (isHeader) {
// B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
// roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
// one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
const lvlAttr = cellEl.getAttribute('data-header-level');
const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : this.headerLeafLevel();
this.activeHeaderLevel.set(Number.isFinite(lvl) ? lvl : this.headerLeafLevel());
} else {
const row = parseInt(rowAttr, 10);
if (Number.isFinite(row)) this.activeRow.set(row);
}
this.activeColIndex.set(col);
// A plain focus collapses any range back to the single active cell — EXCEPT (a) the
// programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
// ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
// focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
// BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
// path owns the shift case and flags it here so the collapse is skipped.
if (this.rangeTransition) {
this.rangeTransition = false;
} else if (this.rangeClickPending) {
this.rangeClickPending = false;
} else {
this.clearRange();
}
// The cell box (not an inner control) receiving focus = navigation mode.
if (tgt === cellEl) this.activeInControl.set(false);
};
onGridMouseDown = (e: any) => {
if (!this.isGrid() || !e || !e.shiftKey) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
const row = parseInt(rowAttr, 10);
const col = parseInt(colAttr, 10);
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
this.setRangeFocus(row, col);
this.activeIsHeader.set(false);
this.activeRow.set(row);
this.activeColIndex.set(col);
this.rangeClickPending = true;
};
onGridFocusOut = (e: any) => {
if (!this.isGrid() || !this.activeInControl()) return;
const next = e ? e.relatedTarget : null;
const cellEl = this.currentCellEl();
if (!cellEl || !next || !cellEl.contains(next)) this.activeInControl.set(false);
};
recoverGridFocus = (rowKey: any, col: any, level: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = this.resolveCellEl(rowKey, col, level);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
clampActiveCell = (rowCount: any, colCount: any) => {
if (!this.isGrid()) return;
// B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
// just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
// async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
// and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
// out of bounds on React only). Falls back to the live helpers when called without bounds.
const colN = colCount != null ? colCount : this.visibleColCount();
const rowN = rowCount != null ? rowCount : this.bodyRowCount();
// B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
// shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
// framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
// — true on all six, incl React's async setState), so the doomed cell + its focus are still
// observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
// programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
// Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
// doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
// The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
// NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
// shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
// DOM coords are always fresh.
let recoverFocus = false;
let doomedRow = -1;
let doomedCol = 0;
if (this.gridRoot) {
const rootNode = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
const focusedEl = rootNode ? rootNode.activeElement : null;
const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
if (focusedCell && this.gridRoot.contains(focusedCell)) {
const fRowAttr = focusedCell.getAttribute('data-row');
const fColAttr = focusedCell.getAttribute('data-col-index');
if (fRowAttr != null && fRowAttr !== '__header') {
const fr = parseInt(fRowAttr, 10);
const fc = parseInt(fColAttr, 10);
if (Number.isFinite(fr) && fr > rowN - 1) {
recoverFocus = true;
doomedRow = fr;
doomedCol = Number.isFinite(fc) ? fc : 0;
}
}
}
}
const maxCol = colN - 1;
const col = this.clamp(this.activeColIndex(), 0, maxCol < 0 ? 0 : maxCol);
if (col !== this.activeColIndex()) this.activeColIndex.set(col);
// B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
// cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
// an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
// re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
if (rowN <= 0) {
this.activeIsHeader.set(true);
this.activeHeaderLevel.set(this.headerLeafLevel());
this.activeColIndex.set(0);
// B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
// reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
// async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
// class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
// recovery branch below actually runs on React too.
this.gridEmptyFallback = true;
this.clampRange(rowN - 1, colN - 1);
// B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
// reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
// Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
// (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
// the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
return;
}
// B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
// re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
// cell. A user-driven header position (not the empty fallback) is left untouched.
if (this.gridEmptyFallback) {
this.gridEmptyFallback = false;
this.activeIsHeader.set(false);
this.activeRow.set(0);
}
if (!this.activeIsHeader()) {
const lastRow = rowN - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const row = this.clamp(this.activeRow(), 0, maxRow);
if (row !== this.activeRow()) this.activeRow.set(row);
}
// B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
// shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
// out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
this.clampRange(rowN - 1, colN - 1);
// B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
// when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
// into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
if (recoverFocus) {
const recRow = this.clamp(doomedRow, 0, rowN - 1);
const recCol = this.clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
this.recoverGridFocus(String(recRow), recCol, null);
}
};
gridEmptyFallback = false;
rangeTransition = false;
rangeClickPending = false;
rangeActive = false;
inRange = (rIdx: any, cIdx: any) => {
const a = this.rangeAnchor();
const f = this.rangeFocus();
if (!a || !f) return false;
const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};
getSelectedRange = () => {
// B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
// payload) never reports a corner past a shrunken model — React-stale-safe (the eager
// refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
const a = this.rangeAnchor();
const f = this.rangeFocus();
if (!a && !f) return {
anchor: null,
focus: null
};
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
anchor: null,
focus: null
};
const clampCorner = (c: any) => c == null ? null : {
rowIndex: this.clamp(c.rowIndex, 0, maxRow),
colIndex: this.clamp(c.colIndex, 0, maxCol)
};
return {
anchor: clampCorner(a),
focus: clampCorner(f)
};
};
isFillHandleCell = (rIdx: any, cIdx: any) => {
const a = this.rangeAnchor();
const f = this.rangeFocus();
if (!a || !f) return false;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx === r1 && cIdx === c1;
};
emitRangeChange = (anchor: any, focus: any) => {
this.rangeChange.emit({
anchor,
focus
});
};
extendRange = (dRow: any, dCol: any) => {
if (this.activeIsHeader()) return;
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
// Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
let anchor = this.rangeAnchor();
let focus = this.rangeFocus();
const hadRange = !!(anchor && focus);
if (!anchor || !focus) {
anchor = {
rowIndex: this.activeRow(),
colIndex: this.activeColIndex()
};
focus = {
rowIndex: this.activeRow(),
colIndex: this.activeColIndex()
};
}
const nextRow = this.clamp(focus.rowIndex + dRow, 0, maxRow);
const nextCol = this.clamp(focus.colIndex + dCol, 0, maxCol);
const nextFocus = {
rowIndex: nextRow,
colIndex: nextCol
};
this.rangeAnchor.set(anchor);
this.rangeFocus.set(nextFocus);
this.rangeActive = true;
// Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
// from the range's leading edge, the spreadsheet convention).
this.activeRow.set(nextRow);
this.activeColIndex.set(nextCol);
// Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
// settle on the new focus corner is part of THIS range extension, not a fresh navigation.
this.rangeTransition = true;
this.focusActiveCell(nextRow, nextCol, false);
// B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
// and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
// change → no emit. Seeding a brand-new range (no prior range) is always a change (the
// rectangle came into existence) even if its first corner is a degenerate 1×1.
if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
this.emitRangeChange(anchor, nextFocus);
}
};
setRangeFocus = (rIdx: any, cIdx: any) => {
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
let anchor = this.rangeAnchor();
if (!anchor) anchor = {
rowIndex: this.activeRow(),
colIndex: this.activeColIndex()
};
const r = this.clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
const c = this.clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
const nextFocus = {
rowIndex: r,
colIndex: c
};
this.rangeAnchor.set(anchor);
this.rangeFocus.set(nextFocus);
this.rangeActive = true;
this.emitRangeChange(anchor, nextFocus);
};
clearRange = () => {
// B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
// in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
// move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
// would let the SECOND call through and emit a duplicate range-change. rangeActive flips
// synchronously → the second call returns here.
if (!this.rangeActive) return;
this.rangeActive = false;
this.rangeAnchor.set(null);
this.rangeFocus.set(null);
this.emitRangeChange(null, null);
};
clampRange = (maxRowArg: any, maxColArg: any) => {
const a = this.rangeAnchor();
const f = this.rangeFocus();
if (!a && !f) return;
// Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
// shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
const maxRow = maxRowArg != null ? maxRowArg : this.bodyRowCount() - 1;
const maxCol = maxColArg != null ? maxColArg : this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) {
this.rangeAnchor.set(null);
this.rangeFocus.set(null);
this.rangeActive = false;
return;
}
if (a) {
const ar = this.clamp(a.rowIndex, 0, maxRow);
const ac = this.clamp(a.colIndex, 0, maxCol);
if (ar !== a.rowIndex || ac !== a.colIndex) this.rangeAnchor.set({
rowIndex: ar,
colIndex: ac
});
}
if (f) {
const fr = this.clamp(f.rowIndex, 0, maxRow);
const fc = this.clamp(f.colIndex, 0, maxCol);
if (fr !== f.rowIndex || fc !== f.colIndex) this.rangeFocus.set({
rowIndex: fr,
colIndex: fc
});
}
};
announce = (msg: any) => {
this.pasteAnnounce.set(msg != null ? msg : '');
};
clipboardActiveAllowed = () => !this.activeIsHeader();
fieldOfColId = (colId: any) => {
const d = this.defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
normalizedRange = () => {
const a = this.rangeAnchor();
const f = this.rangeFocus();
if (!a || !f) return null;
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return null;
const ar = this.clamp(a.rowIndex, 0, maxRow);
const ac = this.clamp(a.colIndex, 0, maxCol);
const fr = this.clamp(f.rowIndex, 0, maxRow);
const fc = this.clamp(f.colIndex, 0, maxCol);
return {
r0: ar < fr ? ar : fr,
r1: ar > fr ? ar : fr,
c0: ac < fc ? ac : fc,
c1: ac > fc ? ac : fc
};
};
escapeTsvField = (s: any) => {
if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
rangeToTsv = () => {
const __activeRow = this.activeRow();
const __activeColIndex = this.activeColIndex();
const box = this.normalizedRange();
const r0 = box ? box.r0 : __activeRow;
const r1 = box ? box.r1 : __activeRow;
const c0 = box ? box.c0 : __activeColIndex;
const c1 = box ? box.c1 : __activeColIndex;
const lines = [];
for (let r = r0; r <= r1; r++) {
const cells = [];
for (let c = c0; c <= c1; c++) {
const v = this.cellValueAt(r, c);
cells.push(this.escapeTsvField(v == null ? '' : String(v)));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
};
parseTsv = (text: any) => {
const str = text != null ? String(text) : '';
// CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
// large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
// single-pass scan allocates a cell-per-character grid.
if (str === '' || str.length > 2000000) return [];
// B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
// cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
// newlines, and doubled quotes ("") inside it are literal content until the closing quote;
// an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = str.length;
while (i < n) {
const ch = str[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < n && str[i + 1] === '"') {
field = field + '"';
i = i + 2;
continue;
}
inQuotes = false;
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
continue;
}
if (ch === '"' && field === '') {
inQuotes = true;
i = i + 1;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
i = i + 1;
continue;
}
if (ch === '\r') {
if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
if (ch === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
}
// Flush the trailing field + row.
row.push(field);
rows.push(row);
// Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
if (rows.length > 1) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
};
copyRange = () => {
// B11: never copy from a header-active state (the reusable clipboard guard).
if (!this.clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
try {
const p = navigator.clipboard.writeText(this.rangeToTsv());
if (p && p.catch) p.catch(() => {});
} catch (err: any) {/* best-effort copy */}
};
applyGridToRange = (grid: any, originRow: any, originCol: any) => {
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
wrote: 0,
total: 0
};
let total = 0;
let wrote = 0;
const committed = [];
// Build the fresh data array incrementally so the whole paste is ONE writeData.
let next = this.currentData();
for (let gr = 0; gr < grid.length; gr++) {
const r = originRow + gr;
if (r > maxRow) break;
const cols = grid[gr] || [];
for (let gc = 0; gc < cols.length; gc++) {
const c = originCol + gc;
if (c > maxCol) break;
total = total + 1;
const colId = this.columnIdAt(r, c);
if (colId == null || !this.columnEditable(colId)) continue;
const rowObj = this.rowOriginalAt(r);
// B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
// single-cell commit coercion) — a numeric column commits a real Number, an empty cell
// commits null; every other editor type passes through verbatim. No mixed/garbage types
// ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
const value = this.coerceCellValue(colId, cols[gc]);
// T-51-01: validate the pasted value as plain DATA before any write.
if (this.runValidator(colId, value, rowObj) !== true) continue;
const field = this.fieldOfColId(colId);
const srcIndex = this.sourceIndexOfRow(r);
const oldValue = rowObj ? rowObj[field] : null;
next = this.replaceRowValue(next, srcIndex, field, value);
committed.push({
rowId: this.rowIdAt(r),
columnId: colId,
oldValue,
newValue: value
});
wrote = wrote + 1;
}
}
if (wrote > 0) {
this.editTransition = true;
this.writeData(next);
this.editTransition = false;
// One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
for (let i = 0; i < committed.length; i++) this.cellEditCommit.emit(committed[i]);
}
// WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
// targeted real cells but every one was skipped (validation-failed / non-editable), announce a
// distinct validation-failed message instead of a misleading "0 of M cells pasted".
if (wrote > 0) this.announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) this.announce('No cells pasted — ' + total + ' cells were invalid or read-only');
return {
wrote,
total
};
};
rowOriginalAt = (rowIndex: any) => {
const rowList = this.rows() || [];
const row = rowList[rowIndex];
return row ? row.original : null;
};
rowIdAt = (rowIndex: any) => {
const rowList = this.rows() || [];
const row = rowList[rowIndex];
return row ? row.id : null;
};
tileGridToBox = (grid: any, box: any) => {
const srcRows = grid.length;
const srcCols = srcRows > 0 ? grid[0].length : 0;
if (srcRows <= 0 || srcCols <= 0) return grid;
const boxRows = box.r1 - box.r0 + 1;
const boxCols = box.c1 - box.c0 + 1;
const rows = boxRows > srcRows ? boxRows : srcRows;
const cols = boxCols > srcCols ? boxCols : srcCols;
const out = [];
for (let r = 0; r < rows; r++) {
const srcLine = grid[r % srcRows] || [];
const line = [];
for (let c = 0; c < cols; c++) {
const v = srcLine[c % srcCols];
line.push(v != null ? v : '');
}
out.push(line);
}
return out;
};
pasteRange = () => {
// B11: never paste into a header-active state (the reusable clipboard guard) — a header
// anchor would silently write body row 0 at the header's column.
if (!this.clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
// CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
// C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
// single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
// on React; re-reading them inside the async .then() returns the mount-render stale value, so a
// selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
// the box + anchor now and pass them into tileGridToBox / applyGridToRange.
const box = this.normalizedRange();
const anchorRow = box ? box.r0 : this.activeRow();
const anchorCol = box ? box.c0 : this.activeColIndex();
const destBox = box || {
r0: anchorRow,
r1: anchorRow,
c0: anchorCol,
c1: anchorCol
};
let p: any = null;
try {
p = navigator.clipboard.readText();
} catch (err: any) {
return;
}
if (!p || !p.then) return;
p.then((text: any) => {
const grid = this.parseTsv(text);
if (!grid.length) return;
// C3: tile the clipboard block to fill the destination range (single→range fill,
// smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
const tiled = this.tileGridToBox(grid, destBox);
this.applyGridToRange(tiled, anchorRow, anchorCol);
}).catch(() => {});
};
cutRange = () => {
const __activeRow = this.activeRow();
const __activeColIndex = this.activeColIndex();
if (!this.clipboardActiveAllowed()) return;
// Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
const box = this.normalizedRange();
const r0 = box ? box.r0 : __activeRow;
const r1 = box ? box.r1 : __activeRow;
const c0 = box ? box.c0 : __activeColIndex;
const c1 = box ? box.c1 : __activeColIndex;
// Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
const cp = navigator.clipboard.writeText(this.rangeToTsv());
if (cp && cp.catch) cp.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// Clear the source: a grid of empty strings sized to the range, applied at the top-left.
const grid = [];
for (let r = r0; r <= r1; r++) {
const cols = [];
for (let c = c0; c <= c1; c++) cols.push('');
grid.push(cols);
}
this.applyGridToRange(grid, r0, c0);
};
tileIndex = (i: any, lo: any, hi: any) => {
const span = hi - lo + 1;
if (span <= 1) return lo;
let k = (i - lo) % span;
if (k < 0) k = k + span;
return lo + k;
};
fillRange = (sourceBox: any, endCell: any) => {
// B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
// the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
// React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
// grows), so deriving the box from the threaded endpoints is what makes the fill cover the
// dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
let box;
if (sourceBox && sourceBox.r0 != null && endCell) {
let r0 = sourceBox.r0;
let r1 = sourceBox.r1;
let c0 = sourceBox.c0;
let c1 = sourceBox.c1;
if (endCell.r < r0) r0 = endCell.r;
if (endCell.r > r1) r1 = endCell.r;
if (endCell.c < c0) c0 = endCell.c;
if (endCell.c > c1) c1 = endCell.c;
box = {
r0,
r1,
c0,
c1
};
} else {
box = this.normalizedRange();
}
if (!box) return;
const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
r0: box.r0,
r1: box.r0,
c0: box.c0,
c1: box.c0
};
const grid = [];
for (let r = box.r0; r <= box.r1; r++) {
const cols = [];
for (let c = box.c0; c <= box.c1; c++) {
const sr = this.tileIndex(r, src.r0, src.r1);
const sc = this.tileIndex(c, src.c0, src.c1);
const v = this.cellValueAt(sr, sc);
cols.push(v == null ? '' : String(v));
}
grid.push(cols);
}
this.applyGridToRange(grid, box.r0, box.c0);
};
fillDragging = false;
fillDragMove: any = null;
fillDragUp: any = null;
teardownFillDrag = () => {
if (typeof document !== 'undefined') {
if (this.fillDragMove) document.removeEventListener('pointermove', this.fillDragMove);
if (this.fillDragUp) document.removeEventListener('pointerup', this.fillDragUp);
}
this.fillDragMove = null;
this.fillDragUp = null;
this.fillDragging = false;
};
cellIndexFromPoint = (clientX: any, clientY: any) => {
if (typeof document === 'undefined' || !document.elementFromPoint) return null;
let el = document.elementFromPoint(clientX, clientY);
// Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
// a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
// and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
// deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
if (!inner || inner === el) break;
el = inner;
}
if (!el || !el.closest) return null;
const cellEl = el.closest('[data-grid-cell]');
if (!cellEl) return null;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
const r = parseInt(rowAttr, 10);
const c = parseInt(colAttr, 10);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return {
r,
c
};
};
onFillHandlePointerDown = (e: any) => {
if (!e) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
this.fillDragging = true;
// B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
// range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
// up/left drag copies from the real origin (not the post-drag corner that would flip to a
// target cell). Captured per-gesture in the closure (no module-let needed).
const sourceBox = this.normalizedRange();
// B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
// the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
let lastCell = sourceBox ? {
r: sourceBox.r1,
c: sourceBox.c1
} : null;
const move = (ev: any) => {
if (!this.fillDragging) return;
const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
// B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
// pointermove (the pointer fires many per cell) spams the event with identical payloads.
// Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
// seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
// or re-enters the same cell is suppressed (the range is unchanged).
if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
lastCell = cell;
this.setRangeFocus(cell.r, cell.c);
}
};
const up = () => {
// teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
this.teardownFillDrag();
this.fillRange(sourceBox, lastCell);
};
// Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
this.fillDragMove = move;
this.fillDragUp = up;
if (typeof document !== 'undefined') {
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
};
activeCellColumnId = () => {
if (this.activeIsHeader()) return null;
const rowList = this.rows() || [];
const row = rowList[this.activeRow()];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[this.activeColIndex()];
return cell && cell.column ? cell.column.id : null;
};
isActiveCellEditable = () => {
const colId = this.activeCellColumnId();
return colId != null && this.columnEditable(colId);
};
isEditing = (rowIndex: any, colIndex: any) => {
const __editingRowIndex = this.editingRowIndex();
if (this.editVer() < 0) return false;
if (__editingRowIndex != null && __editingRowIndex === rowIndex) {
const colId = this.columnIdAt(rowIndex, colIndex);
return colId != null && this.columnEditable(colId);
}
return this.editingRow() === rowIndex && this.editingCol() === colIndex;
};
cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => this.isEditing(rowIndex, colIndex) && !!this.invalidMsg() ? 'true' : null;
runValidator = (colId: any, value: any, row: any) => {
const m = this.editMetaOf(colId);
const v = m ? m.validate : null;
if (typeof v !== 'function') return true;
let r: any = null;
try {
r = v(value, row);
} catch (err: any) {
return 'Invalid value';
}
if (r === true) return true;
if (typeof r === 'string') return r;
return 'Invalid value';
};
setInvalid = (msg: any) => {
this.invalidMsg.set(msg != null ? msg : '');
};
replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
const src = rows || [];
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
// and would copy inherited enumerable props of typed/class-instance row objects.
out.push({
...(src[i] || {}),
[field]: value
});
} else {
out.push(src[i]);
}
}
return out;
};
sourceIndexOfRow = (visibleRowIndex: any) => {
const rowList = this.rows() || [];
const row = rowList[visibleRowIndex];
if (!row) return visibleRowIndex;
const orig = row.original;
const data = this.currentData() || [];
const idx = data.indexOf(orig);
return idx >= 0 ? idx : visibleRowIndex;
};
editingColumnId = () => {
const rowList = this.rows() || [];
const row = rowList[this.editingRow()];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[this.editingCol()];
return cell && cell.column ? cell.column.id : null;
};
editingColumnField = () => {
const colId = this.editingColumnId();
if (colId == null) return null;
const d = this.defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
editingCellValue = () => {
const rowList = this.rows() || [];
const row = rowList[this.editingRow()];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[this.editingCol()];
return cell ? cell.getValue() : null;
};
editingRowOriginal = () => {
const rowList = this.rows() || [];
const row = rowList[this.editingRow()];
return row ? row.original : null;
};
editingRowId = () => {
const rowList = this.rows() || [];
const row = rowList[this.editingRow()];
return row ? row.id : null;
};
focusEditorWhenReady = (selectAll: any = true) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = this.gridRoot ? this.gridRoot.querySelector('[data-editing-cell]') : null;
if (el) {
el.focus();
if (selectAll && el.select) {
try {
el.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
columnIdAt = (rowIndex: any, colIndex: any) => {
const rowList = this.rows() || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[colIndex];
return cell && cell.column ? cell.column.id : null;
};
cellValueAt = (rowIndex: any, colIndex: any) => {
const rowList = this.rows() || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[colIndex];
return cell ? cell.getValue() : null;
};
beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
const colId = this.columnIdAt(rowIndex, colIndex);
if (colId == null || !this.columnEditable(colId)) return;
this.setInvalid('');
// Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
// editor clears any row-edit state so isEditing never resolves both modes for one cell.
this.editingRowIndex.set(null);
this.rowDraft.set({});
this.editingRow.set(rowIndex);
this.editingCol.set(colIndex);
this.draftValue.set(seed != null ? seed : this.cellValueAt(rowIndex, colIndex));
this.activeInControl.set(true);
this.editVer.set(this.editVer() + 1);
// B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
// seeded char so subsequent typing appends instead of replacing it.
this.focusEditorWhenReady(seed == null);
};
focusCellWhenReady = (row: any, col: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = this.resolveCellEl(String(row), col);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
const list = rows || [];
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (!r) continue;
if (rowId != null && r.id === rowId) return i;
if (rowOriginal != null && r.original === rowOriginal) return i;
}
return -1;
};
endEdit = () => {
this.editingRow.set(-1);
this.editingCol.set(-1);
this.draftValue.set(null);
this.invalidMsg.set('');
this.activeInControl.set(false);
this.editVer.set(this.editVer() + 1);
};
endRowEdit = () => {
this.editingRowIndex.set(null);
this.rowDraft.set({});
this.invalidMsg.set('');
this.activeInControl.set(false);
this.editVer.set(this.editVer() + 1);
};
coerceCellValue = (colId: any, raw: any) => {
if (this.editorTypeOf(colId) !== 'number') return raw;
if (raw == null) return null;
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
};
commitEdit = (overrideValue: any = undefined, skipFocusReturn: any = false) => {
const __editingRow = this.editingRow();
if (__editingRow < 0) return false;
const colId = this.editingColumnId();
if (colId == null) {
this.endEdit();
return false;
}
const field = this.editingColumnField();
const oldValue = this.editingCellValue();
const rowOriginal = this.editingRowOriginal();
const rowId = this.editingRowId();
// B3: coerce by the column's editor type BEFORE validation + write so the validator
// and the model both see the typed value (number/null), not the raw draft string.
const rawValue = overrideValue !== undefined ? overrideValue : this.draftValue();
const newValue = this.coerceCellValue(colId, rawValue);
const err = this.runValidator(colId, newValue, rowOriginal);
if (err !== true) {
// D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
this.setInvalid(err);
this.focusEditorWhenReady();
return false;
}
this.setInvalid('');
const srcIndex = this.sourceIndexOfRow(__editingRow);
const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
// Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
const focusRow = __editingRow;
const focusCol = this.editingCol();
// Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
// must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
this.editTransition = true;
this.writeData(next);
// Exactly one emit per commit, from this single call site (writeData does NOT emit).
this.cellEditCommit.emit({
rowId,
columnId: colId,
oldValue,
newValue
});
this.endEdit();
this.editTransition = false;
// Defer the focus return so the display↔editor re-render commits first (async on
// React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
// editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
// Tab-advance (the caller immediately opens the next editor and focuses THAT).
// B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
// RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
// index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
// consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
// fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
// active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
// index → byte-behaviorally identical to before.
if (skipFocusReturn !== true) this.pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
cancelEdit = () => {
const __editingRow = this.editingRow();
if (__editingRow < 0) return;
// CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
// Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
// an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
// instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
const focusRow = __editingRow;
const focusCol = this.editingCol();
this.editTransition = true;
this.endEdit();
this.editTransition = false;
this.focusCellWhenReady(focusRow, focusCol);
};
editableColumnsForRow = (rowIndex: any) => {
const rowList = this.rows() || [];
const row = rowList[rowIndex];
if (!row) return [];
const cells = this.visibleCellsFor(row);
const out = [];
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
const colId = cell && cell.column ? cell.column.id : null;
if (colId == null || !this.columnEditable(colId)) continue;
const d = this.defFor(colId);
const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
// colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
// Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
// can address a SPECIFIC editor by column, not just the first [data-editing-cell].
out.push({
colId,
field,
colIndex: c
});
}
return out;
};
focusRowEditorAt = (rowIndex: any, colIndex: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const cellEl = this.resolveCellEl(String(rowIndex), colIndex);
const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
if (ed) {
ed.focus();
if (ed.select) {
try {
ed.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
beginRowEdit = (row: any) => {
const rowIndex = this.rowIndexOf(row);
if (rowIndex < 0) return;
const editable = this.editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
// Clear any single-cell editor first (mutual exclusivity).
this.editingRow.set(-1);
this.editingCol.set(-1);
this.draftValue.set(null);
this.setInvalid('');
// Seed each editable cell's draft from its current value.
const draft = {};
const rowList = this.rows() || [];
const r = rowList[rowIndex];
const orig = r ? r.original : null;
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
draft[ec.colId] = orig ? orig[ec.field] : null;
}
this.rowDraft.set(draft);
this.editingRowIndex.set(rowIndex);
this.activeInControl.set(true);
this.editVer.set(this.editVer() + 1);
this.focusEditorWhenReady();
};
commitRow = () => {
const __editingRowIndex = this.editingRowIndex();
if (__editingRowIndex == null) return false;
const rowIndex = __editingRowIndex;
const editable = this.editableColumnsForRow(rowIndex);
if (editable.length === 0) {
this.endRowEdit();
return false;
}
const rowList = this.rows() || [];
const r = rowList[rowIndex];
const rowOriginal = r ? r.original : null;
const rowId = r ? r.id : null;
const draft = this.rowDraft() || {};
// Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
// B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
// 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
// commitEdit already coerces via coerceCellValue; the row path silently committed strings →
// a number column ended up holding '99'). Coerce once here so the validator and the model both
// see the typed value, identical to the single-cell funnel.
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
const err = this.runValidator(ec.colId, this.coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
if (err !== true) {
this.setInvalid(err);
// B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
// unconditionally the first editor (focusEditorWhenReady resolves the first
// [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
this.focusRowEditorAt(rowIndex, ec.colIndex);
return false;
}
}
this.setInvalid('');
// Build the changes payload (only the columns whose value actually changed) + the field→
// value map for the single row-object replace.
const changes = [];
const fieldValues = {};
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
// B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
// string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
const newValue = this.coerceCellValue(ec.colId, draft[ec.colId]);
const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
fieldValues[ec.field] = newValue;
if (oldValue !== newValue) changes.push({
columnId: ec.colId,
oldValue,
newValue
});
}
// ONE fresh-array replace of the SINGLE row object with all field values applied at once.
const srcIndex = this.sourceIndexOfRow(rowIndex);
const next = this.replaceRowValues(this.currentData(), srcIndex, fieldValues);
// Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
// active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
const focusCol = this.activeColIndex();
this.editTransition = true;
this.writeData(next);
// EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
this.rowEditCommit.emit({
rowId,
changes
});
this.endRowEdit();
this.editTransition = false;
// WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
// like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
// col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
// $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
// DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
// refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
// display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
// the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
// poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
this.pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
cancelRow = () => {
if (this.editingRowIndex() == null) return;
const focusRow = this.activeRow();
const focusCol = this.activeColIndex();
this.editTransition = true;
this.endRowEdit();
this.editTransition = false;
this.focusCellWhenReady(focusRow, focusCol);
};
replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
const src = rows || [];
const fv = fieldValues || {};
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
// prototype-walking copy. Spread copies own enumerable props only.
out.push({
...(src[i] || {}),
...fv
});
} else {
out.push(src[i]);
}
}
return out;
};
nextEditableCell = (fromRow: any, fromCol: any) => {
const rowList = this.rows() || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol + 1;
while (r < rowCount) {
const row = rowList[r];
const cells = row ? this.visibleCellsFor(row) : [];
while (c < cells.length) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && this.columnEditable(cid)) return {
row: r,
col: c
};
c = c + 1;
}
r = r + 1;
c = 0;
}
return null;
};
prevEditableCell = (fromRow: any, fromCol: any) => {
const rowList = this.rows() || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol - 1;
while (r >= 0) {
const row = rowList[r];
const cells = row ? this.visibleCellsFor(row) : [];
while (c >= 0) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && this.columnEditable(cid)) return {
row: r,
col: c
};
c = c - 1;
}
r = r - 1;
if (r >= 0) {
const prow = rowList[r];
const pcells = prow ? this.visibleCellsFor(prow) : [];
c = pcells.length - 1;
}
}
return null;
};
editTransition = false;
pendingEditFollow: any = null;
inRowEdit = () => this.editingRowIndex() != null;
editorValueFor = (colId: any) => this.inRowEdit() ? this.rowDraft() ? this.rowDraft()[colId] : null : this.draftValue();
editorCheckedFor = (colId: any) => !!(this.inRowEdit() ? this.rowDraft() ? this.rowDraft()[colId] : null : this.draftValue());
editorCommitFor = (colId: any) => (value: any) => {
if (this.inRowEdit()) {
this.setRowDraft(colId, value);
return;
}
this.commitEdit(value);
};
editorCancelFor = () => () => {
if (this.inRowEdit()) {
this.cancelRow();
return;
}
this.cancelEdit();
};
onCellEditorInput = (colId: any, evt: any) => {
const v = evt && evt.target ? evt.target.value : '';
if (this.inRowEdit()) {
this.setRowDraft(colId, v);
return;
}
this.draftValue.set(v);
};
onCellEditorCheckbox = (colId: any, evt: any) => {
const v = !!(evt && evt.target && evt.target.checked);
if (this.inRowEdit()) {
this.setRowDraft(colId, v);
return;
}
this.draftValue.set(v);
};
setRowDraft = (colId: any, value: any) => {
const src = this.rowDraft() || {};
const next = {};
for (const k in src) next[k] = src[k];
next[colId] = value;
this.rowDraft.set(next);
};
rowEditTab = (target: any, backward: any) => {
const rowIndex = this.editingRowIndex();
if (rowIndex == null) return;
const editable = this.editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
const cols = editable.map((ec: any) => ec.colIndex);
const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
const curAttr = cell ? cell.getAttribute('data-col-index') : null;
const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
let pos = cols.indexOf(cur);
if (pos < 0) pos = 0;
const len = cols.length;
const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
this.focusRowEditorAt(rowIndex, cols[nextPos]);
};
onEditorKeyDown = (e: any) => {
if (!e) return;
const key = e.key;
// Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
// model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
// row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
// preventDefault it here.
if (this.inRowEdit()) {
if (key === 'Enter') {
e.preventDefault();
this.commitRow();
} else if (key === 'Escape') {
e.preventDefault();
this.cancelRow();
}
// B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
// editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
// Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
// first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
// tab order across a Lit shadow boundary).
else if (key === 'Tab') {
e.preventDefault();
this.rowEditTab(e.target, e.shiftKey);
}
return;
}
if (key === 'Enter') {
e.preventDefault();
this.commitEdit(undefined);
} else if (key === 'Tab') {
e.preventDefault();
// Resolve the advance target from the EDITING pair (the cell that is open), not the
// active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
// moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
// the editing pair BEFORE commit (commitEdit resets it to -1).
const fromRow = this.editingRow();
const fromCol = this.editingCol();
const target = e.shiftKey ? this.prevEditableCell(fromRow, fromCol) : this.nextEditableCell(fromRow, fromCol);
// skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
// straight into the next editable cell's editor below. Use the RETURN value (not a
// re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
// failure returns false and keeps the editor open (the user must fix the value first).
const committed = this.commitEdit(undefined, true);
if (committed && target) {
this.activeRow.set(target.row);
this.activeColIndex.set(target.col);
this.beginEdit(target.row, target.col, null);
} else if (committed) {
// B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
// grid by returning it to the just-committed cell instead of letting it drop to <body>.
this.focusCellWhenReady(fromRow, fromCol);
}
} else if (key === 'Escape') {
e.preventDefault();
this.cancelEdit();
}
};
onEditorBlur = (e: any) => {
const __editingRow = this.editingRow();
// Full-row mode (req-6): blur NEVER commits — the row commits as a UNIT only on an
// explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
// into N writes + N events, violating the one-write/one-event contract). Tabbing between
// the row's own editors is a normal focus move, not a commit.
if (this.inRowEdit()) return;
if (__editingRow < 0 || this.editTransition) return;
const next = e ? e.relatedTarget : null;
// A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
// keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
// a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
// conservative null=skip behavior.
if (next == null) return;
// Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
// the editor open on an invalid value).
if (!(this.gridRoot && this.gridRoot.contains && this.gridRoot.contains(next))) {
this.commitEdit(undefined);
return;
}
// Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
// keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
// (which must commit + close so the grid is not wedged with an open editor).
const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
// Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
// controlled move; skip. Also skip when either cell can't be resolved (an unmounting
// editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
if (!nextCell || !fromCell || nextCell === fromCell) return;
// A Tab-advance already committed the old editor and opened the next one, so the live
// editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
// editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
// double commit, WR-04).
const fromRow = fromCell.getAttribute('data-row');
const fromCol = fromCell.getAttribute('data-col-index');
if (fromRow !== String(__editingRow) || fromCol !== String(this.editingCol())) return;
// Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
// commit does NOT bounce focus back to the just-committed editing cell (which would fight
// the click destination). The commit's writeData re-renders the table and can DROP DOM
// focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
// DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
// (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
// late, which would steal focus back from a subsequent navigation.
const destRow = nextCell.getAttribute('data-row');
const destCol = nextCell.getAttribute('data-col-index');
this.commitEdit(undefined, true);
const reseatDestFocus = () => {
if (!this.gridRoot || destRow == null || destCol == null || destRow === '__header') return;
const root = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
const act = root && root.activeElement ? root.activeElement : null;
// Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
if (act && this.gridRoot.contains && this.gridRoot.contains(act)) return;
const el = this.resolveCellEl(destRow, parseInt(destCol, 10));
if (el) el.focus();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};
editCell = (rowIndex: any, colIndex: any) => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const maxCol = this.visibleColCount() - 1;
const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
this.activeIsHeader.set(false);
this.activeRow.set(r);
this.activeColIndex.set(c);
this.beginEdit(r, c, null);
};
commitEditing = () => {
if (this.editingRow() >= 0) this.commitEdit(undefined);
};
editRow = (rowIndex: any) => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const rowList = this.rows() || [];
const row = rowList[r];
if (!row) return;
this.activeIsHeader.set(false);
this.activeRow.set(r);
this.beginRowEdit(row);
};
focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const want = String(absRow + 1);
const tryFocus = () => {
const el = this.resolveCellEl(String(localRow), col);
if (el) {
const rowEl = el.closest ? el.closest('[role="row"]') : null;
const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
if (ari === want) {
el.focus();
return;
}
}
attempts = attempts + 1;
if (attempts >= 60) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
focusCell = (rowIndex: any, colIndex: any) => {
// B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
// is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
// isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
// instance does not leak a spurious activecell-change.
if (!this.isGrid()) return;
const maxCol = this.visibleColCount() - 1;
const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
// C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
const absLast = this.prePaginationRowCount() - 1;
const absRow = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
// B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
// real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
// active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
const prevAbs = this.toAbsRow(this.activeRow());
const prevIsHeader = this.activeIsHeader();
if (this.virtual()) {
// Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
// the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
// focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
this.activeIsHeader.set(false);
this.activeInControl.set(false);
this.activeRow.set(absRow);
this.activeColIndex.set(c);
this.focusActiveCell(absRow, c, false);
} else {
// Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
// in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
// body's data-row markers (and the roving tabindex) address.
const size = this.pageSize();
const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
const localRow = absRow - targetPage * size;
const switched = targetPage !== this.pageIndex();
if (switched) this.setPage(targetPage);
this.activeIsHeader.set(false);
this.activeInControl.set(false);
this.activeRow.set(localRow);
this.activeColIndex.set(c);
if (switched) {
// The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
// TARGET page's absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
// same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
this.focusAbsCellWhenReady(absRow, localRow, c);
} else {
// Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
// Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
// async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
this.focusActiveCell(localRow, c, false);
}
}
if (absRow !== prevAbs || prevIsHeader) {
this.activecellChange.emit({
rowIndex: absRow,
colIndex: c
});
}
};
getActiveCell = () => this.activeIsHeader() ? {
rowIndex: null,
colIndex: this.activeColIndex(),
isHeader: true
} : {
rowIndex: this.toAbsRow(this.activeRow()),
colIndex: this.activeColIndex(),
isHeader: false
};
clearActiveCell = () => {
if (!this.isGrid()) return;
this.activeIsHeader.set(false);
this.activeInControl.set(false);
this.activeRow.set(0);
this.activeColIndex.set(0);
};
toggleRowExpanded = (rowId: any) => {
if (!this.table) return;
const target = String(rowId);
const flat = this.table.getCoreRowModel().flatRows;
for (const r of flat as any) {
if (r.id === target || r.original && String(r.original.id) === target) {
r.toggleExpanded();
return;
}
}
};
expandAll = () => {
if (!this.table) return;
this.table.toggleAllRowsExpanded(true);
};
collapseAll = () => {
if (!this.table) return;
this.table.resetExpanded(true);
};
getExpandedRows = () => {
if (!this.table) return [];
const out = [];
const flat = this.table.getCoreRowModel().flatRows;
for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
return out;
};
applyGrouping = (cols: any) => {
if (this.table) this.table.setGrouping(cols);
};
clearGrouping = () => {
if (this.table) this.table.setGrouping([]);
};
getFacetedUniqueValues = (colId: any) => {
if (this.tick() < 0 || !this.table) return [];
const col = this.table.getColumn(colId);
if (!col || !col.getFacetedUniqueValues) return [];
const map = col.getFacetedUniqueValues(); // Map<any, number>
return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};
getFacetedMinMaxValues = (colId: any) => {
if (this.tick() < 0 || !this.table) return null;
const col = this.table.getColumn(colId);
if (!col || !col.getFacetedMinMaxValues) return null;
return col.getFacetedMinMaxValues() || null; // [number, number] | null
};
static ngTemplateContextGuard(
_dir: DataTable,
_ctx: unknown,
): _ctx is DefaultCtx | GroupBarCtx | SelectAllCtx | ColHeaderCtx | FilterCtx | SelectCellCtx | CellCtx | EditorCtx | DetailCtx {
return true;
}
protected get __style() {
const __maxHeight = this.maxHeight();
return __maxHeight ? 'max-height:' + __maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + __maxHeight : 'overflow:auto';
}
private _selectCell_ctx = (wr: any, cellCtx: any) => ({ $implicit: { row: wr.row.original, checked: this.rowIsSelected(wr.row), toggle: e => this.onToggleRow(wr.row, e) }, row: wr.row.original, checked: this.rowIsSelected(wr.row), toggle: e => this.onToggleRow(wr.row, e) });
private _selectCell_ctx_1 = (row: any, cellCtx: any) => ({ $implicit: { row: row.original, checked: this.rowIsSelected(row), toggle: e => this.onToggleRow(row, e) }, row: row.original, checked: this.rowIsSelected(row), toggle: e => this.onToggleRow(row, e) });
protected readonly String = String;
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default DataTable;tsx
import type { JSX } from 'solid-js';
import { For, Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieContext, rozieDisplay } from '@rozie/runtime-solid';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
__rozieInjectStyle('DataTable-d5dcab4c', `.rozie-data-table[data-rozie-s-d5dcab4c] {
border-collapse: collapse;
width: 100%;
font: var(--rdt-font, 14px system-ui, sans-serif);
color: var(--rdt-color, inherit);
}
.rdt-sr-live[data-rozie-s-d5dcab4c] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-cell-editor[data-rozie-s-d5dcab4c] {
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[aria-invalid="true"][data-rozie-s-d5dcab4c] {
outline: var(--rdt-invalid-outline, 2px solid #d33);
outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-in-range[data-rozie-s-d5dcab4c] {
background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-fill-handle[data-rozie-s-d5dcab4c] {
position: absolute;
right: -3px;
bottom: -3px;
width: 8px;
height: 8px;
background: var(--rdt-fill-handle-bg, #2563eb);
border: 1px solid #fff;
cursor: crosshair;
z-index: 1;
touch-action: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
text-align: left;
border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
font-weight: var(--rdt-header-weight, 600);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-btn[data-rozie-s-d5dcab4c] {
display: inline-flex;
align-items: center;
gap: var(--rdt-sort-gap, 0.35em);
background: none;
border: none;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-ind[data-rozie-s-d5dcab4c] {
font-size: 0.8em;
opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
position: sticky;
top: var(--rdt-sticky-top, 0);
z-index: var(--rdt-sticky-z, 2);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-scroll[data-rozie-s-d5dcab4c] {
max-height: var(--rozie-data-table-max-height);
overflow: auto;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-bar-host[data-rozie-s-d5dcab4c] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-token[data-rozie-s-d5dcab4c] {
display: inline-flex;
align-items: center;
padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
border-radius: var(--rdt-group-token-radius, 999px);
background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] {
background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-toggle[data-rozie-s-d5dcab4c] {
margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-count[data-rozie-s-d5dcab4c] {
margin-left: var(--rdt-group-count-gap, 0.375rem);
opacity: var(--rdt-group-count-opacity, 0.65);
font-weight: 400;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] {
display: flex;
flex-direction: column;
gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-toolbar[data-rozie-s-d5dcab4c] {
display: flex;
gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-global-filter[data-rozie-s-d5dcab4c],
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter[data-rozie-s-d5dcab4c] {
font: inherit;
padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-filter-radius, 4px);
background: var(--rdt-filter-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter[data-rozie-s-d5dcab4c] {
display: block;
margin-top: var(--rdt-col-filter-gap, 0.25rem);
width: 100%;
font-weight: normal;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-pagination[data-rozie-s-d5dcab4c] {
display: flex;
align-items: center;
gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c] {
font: inherit;
cursor: pointer;
padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-btn-radius, 4px);
background: var(--rdt-page-btn-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c]:disabled {
opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
cursor: default;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-status[data-rozie-s-d5dcab4c] {
font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-size[data-rozie-s-d5dcab4c] {
font: inherit;
padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-size-radius, 4px);
background: var(--rdt-page-size-bg, transparent);
color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c] {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--rdt-resize-handle-width, 6px);
padding: 0;
border: none;
background: none;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
display: block;
width: var(--rdt-resize-grip-width, 2px);
height: 100%;
margin: 0 auto;
background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c]:hover .rdt-resize-grip[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th-resizing[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-controls[data-rozie-s-d5dcab4c] {
display: inline-flex;
gap: var(--rdt-pin-gap, 0.1em);
margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[data-rozie-s-d5dcab4c] {
font: inherit;
font-size: var(--rdt-pin-btn-size, 0.8em);
line-height: 1;
cursor: pointer;
padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-pin-btn-radius, 3px);
background: var(--rdt-pin-btn-bg, transparent);
color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[aria-pressed='true'][data-rozie-s-d5dcab4c] {
background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
font-weight: 700;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-summary[data-rozie-s-d5dcab4c] {
cursor: pointer;
font: inherit;
padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-colvis-summary-radius, 4px);
list-style: none;
user-select: none;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-menu[data-rozie-s-d5dcab4c] {
position: absolute;
z-index: var(--rdt-colvis-menu-z, 5);
margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
display: flex;
flex-direction: column;
gap: var(--rdt-colvis-item-gap, 0.25rem);
border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-colvis-menu-radius, 4px);
background: var(--rdt-colvis-menu-bg, #fff);
box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-item[data-rozie-s-d5dcab4c] {
display: flex;
align-items: center;
gap: var(--rdt-colvis-label-gap, 0.4em);
cursor: pointer;
white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-td[data-rozie-s-d5dcab4c] {
width: var(--rdt-select-col-width, 1%);
text-align: var(--rdt-select-col-align, center);
white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-all[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-row[data-rozie-s-d5dcab4c] {
cursor: pointer;
accent-color: var(--rdt-select-accent, currentColor);
}`);
interface GroupBarSlotCtx { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any; }
interface SelectAllSlotCtx { checked: any; indeterminate: any; toggle: any; }
interface ColHeaderSlotCtx { columnId: any; column: any; label: any; }
interface FilterSlotCtx { columnId: any; uniqueValues: any; minMax: any; setFilter: any; }
interface SelectCellSlotCtx { row: any; checked: any; toggle: any; }
interface CellSlotCtx { columnId: any; column: any; row: any; value: any; }
interface EditorSlotCtx { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; }
interface DetailSlotCtx { row: any; }
interface DataTableProps {
/**
* 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).
* @example
* <DataTable r-model:data="rows" :columns="cols" />
*/
data: any[];
defaultData?: any[];
onDataChange?: (data: any[]) => void;
/**
* 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).
*/
columns?: any[];
/**
* Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
*/
selectionMode?: string;
/**
* `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
*/
sorting?: any[];
defaultSorting?: any[];
onSortingChange?: (sorting: any[]) => void;
/**
* The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
*/
globalFilter?: string;
defaultGlobalFilter?: string;
onGlobalFilterChange?: (globalFilter: string) => void;
/**
* `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
*/
columnFilters?: any[];
defaultColumnFilters?: any[];
onColumnFiltersChange?: (columnFilters: any[]) => void;
/**
* `{ 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`.
*/
pagination?: Record<string, any>;
defaultPagination?: Record<string, any>;
onPaginationChange?: (pagination: Record<string, any>) => void;
/**
* 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).
*/
manual?: boolean;
/**
* 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).
*/
expandable?: boolean;
/**
* `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.
*/
expanded?: (Record<string, any> | boolean) | null;
defaultExpanded?: (Record<string, any> | boolean) | null;
onExpandedChange?: (expanded: (Record<string, any> | boolean) | null) => void;
/**
* 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.
*/
getSubRows?: ((...args: unknown[]) => unknown) | null;
/**
* 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).
*/
groupable?: boolean;
/**
* `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.
*/
grouping?: (any[]) | null;
defaultGrouping?: (any[]) | null;
onGroupingChange?: (grouping: (any[]) | null) => void;
/**
* `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.
*/
rowSelection?: Record<string, any>;
defaultRowSelection?: Record<string, any>;
onRowSelectionChange?: (rowSelection: Record<string, any>) => void;
/**
* `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
*/
columnVisibility?: Record<string, any>;
defaultColumnVisibility?: Record<string, any>;
onColumnVisibilityChange?: (columnVisibility: Record<string, any>) => void;
/**
* `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
*/
columnSizing?: Record<string, any>;
defaultColumnSizing?: Record<string, any>;
onColumnSizingChange?: (columnSizing: Record<string, any>) => void;
/**
* `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
*/
columnOrder?: any[];
defaultColumnOrder?: any[];
onColumnOrderChange?: (columnOrder: any[]) => void;
/**
* `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
*/
columnPinning?: Record<string, any>;
defaultColumnPinning?: Record<string, any>;
onColumnPinningChange?: (columnPinning: Record<string, any>) => void;
/**
* Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
*/
stickyHeader?: boolean;
/**
* `'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
* @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
*/
interactionMode?: string;
/**
* 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.
*/
virtual?: boolean;
/**
* Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
estimateRowHeight?: number;
/**
* 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.
*/
maxHeight?: string;
onSortChange?: (...args: unknown[]) => void;
onExpandChange?: (...args: unknown[]) => void;
onGroupChange?: (...args: unknown[]) => void;
onFilterChange?: (...args: unknown[]) => void;
onPageChange?: (...args: unknown[]) => void;
onSelectionChange?: (...args: unknown[]) => void;
onVisibilityChange?: (...args: unknown[]) => void;
onResizeChange?: (...args: unknown[]) => void;
onReorderChange?: (...args: unknown[]) => void;
onPinChange?: (...args: unknown[]) => void;
onActivecellChange?: (...args: unknown[]) => void;
onRangeChange?: (...args: unknown[]) => void;
onCellEditCommit?: (...args: unknown[]) => void;
onRowEditCommit?: (...args: unknown[]) => void;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
groupBarSlot?: (ctx: GroupBarSlotCtx) => JSX.Element;
selectAllSlot?: (ctx: SelectAllSlotCtx) => JSX.Element;
colHeaderSlot?: (ctx: ColHeaderSlotCtx) => JSX.Element;
filterSlot?: (ctx: FilterSlotCtx) => JSX.Element;
selectCellSlot?: (ctx: SelectCellSlotCtx) => JSX.Element;
cellSlot?: (ctx: CellSlotCtx) => JSX.Element;
editorSlot?: (ctx: EditorSlotCtx) => JSX.Element;
detailSlot?: (ctx: DetailSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: DataTableHandle) => void;
}
export interface DataTableHandle {
sortColumn: (...args: any[]) => any;
clearSorting: (...args: any[]) => any;
toggleRowExpanded: (...args: any[]) => any;
expandAll: (...args: any[]) => any;
collapseAll: (...args: any[]) => any;
getExpandedRows: (...args: any[]) => any;
applyGrouping: (...args: any[]) => any;
clearGrouping: (...args: any[]) => any;
getFacetedUniqueValues: (...args: any[]) => any;
getFacetedMinMaxValues: (...args: any[]) => any;
getColumnDefs: (...args: any[]) => any;
toggleAllRows: (...args: any[]) => any;
clearSelection: (...args: any[]) => any;
getSelectedRows: (...args: any[]) => any;
setPage: (...args: any[]) => any;
setRowsPerPage: (...args: any[]) => any;
toggleColumnVisibility: (...args: any[]) => any;
applyColumnOrder: (...args: any[]) => any;
resetColumnSizing: (...args: any[]) => any;
pinColumn: (...args: any[]) => any;
focusCell: (...args: any[]) => any;
getActiveCell: (...args: any[]) => any;
clearActiveCell: (...args: any[]) => any;
getRowIndexRelativeToPage: (...args: any[]) => any;
editCell: (...args: any[]) => any;
commitEditing: (...args: any[]) => any;
editRow: (...args: any[]) => any;
getSelectedRange: (...args: any[]) => any;
cut: (...args: any[]) => any;
}
export default function DataTable(_props: DataTableProps): JSX.Element {
const _merged = mergeProps({ columns: (() => [])(), selectionMode: 'none', manual: false, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', virtual: false, estimateRowHeight: 40, maxHeight: '' }, _props);
const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'expandable', 'expanded', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'virtual', 'estimateRowHeight', 'maxHeight', 'children', 'ref']);
const resolved = () => local.children;
onMount(() => { local.ref?.({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut }); });
const __ctx_data_table_columns = rozieContext("data-table:columns");
const [data, setData] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'data', []);
const [sorting, setSorting] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'sorting', (() => [])());
const [globalFilter, setGlobalFilter] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'globalFilter', '');
const [columnFilters, setColumnFilters] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'columnFilters', (() => [])());
const [pagination, setPagination] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'pagination', (() => ({
pageIndex: 0,
pageSize: 10
}))());
const [expanded, setExpanded] = createControllableSignal<Record<string, any> | boolean>(_props as unknown as Record<string, unknown>, 'expanded', null);
const [grouping, setGrouping] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'grouping', null);
const [rowSelection, setRowSelection] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'rowSelection', (() => ({}))());
const [columnVisibility, setColumnVisibility] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'columnVisibility', (() => ({}))());
const [columnSizing, setColumnSizing] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'columnSizing', (() => ({}))());
const [columnOrder, setColumnOrder] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'columnOrder', (() => [])());
const [columnPinning, setColumnPinning] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'columnPinning', (() => ({
left: [],
right: []
}))());
const [dataDefault, setDataDefault] = createSignal<any[]>([]);
const [sortingDefault, setSortingDefault] = createSignal<any[]>([]);
const [globalFilterDefault, setGlobalFilterDefault] = createSignal('');
const [columnFiltersDefault, setColumnFiltersDefault] = createSignal<any[]>([]);
const [paginationDefault, setPaginationDefault] = createSignal({
pageIndex: 0,
pageSize: 10
});
const [rowSelectionDefault, setRowSelectionDefault] = createSignal<Record<string, any>>({});
const [expandedDefault, setExpandedDefault] = createSignal<Record<string, any>>({});
const [groupingDefault, setGroupingDefault] = createSignal<any[]>([]);
const [columnVisibilityDefault, setColumnVisibilityDefault] = createSignal<Record<string, any>>({});
const [columnSizingDefault, setColumnSizingDefault] = createSignal<Record<string, any>>({});
const [columnOrderDefault, setColumnOrderDefault] = createSignal<any[]>([]);
const [columnPinningDefault, setColumnPinningDefault] = createSignal({
left: [],
right: []
});
const [columnSizingInfo, setColumnSizingInfo] = createSignal({
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: []
});
const [colReg, setColReg] = createSignal<Record<string, any>>({});
const [rows, setRows] = createSignal<any[]>([]);
const [headerGroups, setHeaderGroups] = createSignal<any[]>([]);
const [rowModelVer, setRowModelVer] = createSignal(0);
const [windowVer, setWindowVer] = createSignal(0);
const [activeRow, setActiveRow] = createSignal(0);
const [activeColIndex, setActiveColIndex] = createSignal(0);
const [activeIsHeader, setActiveIsHeader] = createSignal(false);
const [activeHeaderLevel, setActiveHeaderLevel] = createSignal(0);
const [activeInControl, setActiveInControl] = createSignal(false);
const [editingRow, setEditingRow] = createSignal(-1);
const [editingCol, setEditingCol] = createSignal(-1);
const [draftValue, setDraftValue] = createSignal<any>(null);
const [invalidMsg, setInvalidMsg] = createSignal('');
const [editVer, setEditVer] = createSignal(0);
const [editingRowIndex, setEditingRowIndex] = createSignal<any>(null);
const [rowDraft, setRowDraft] = createSignal<Record<string, any>>({});
const [rangeAnchor, setRangeAnchor] = createSignal<any>(null);
const [rangeFocus, setRangeFocus] = createSignal<any>(null);
const [pasteAnnounce, setPasteAnnounce] = createSignal('');
onMount(() => {
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
setDataDefault(data() || []);
// Build the table instance HERE so the closures below capture the live `table`.
table = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: currentData(),
columns: tableColumns(),
state: currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (local.getSubRows || undefined) as any,
getRowCanExpand: local.expandable === true && local.getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: local.manual === true,
manualFiltering: local.manual === true,
manualSorting: local.manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: local.selectionMode !== 'none',
enableMultiRowSelection: local.selectionMode === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {}
});
refreshRowModel = () => {
if (!table) return;
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = windowSource().slice();
const nextGroups = table.getHeaderGroups().slice();
setRows(nextRows);
setHeaderGroups(nextGroups);
setRowModelVer(rowModelVer() + 1);
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if (local.virtual && virtualizer) {
virtualizer.setOptions(virtualizerOptions());
virtualizer._willUpdate();
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length;
const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
clampActiveCell(nextRowCount, nextColCount);
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (pendingEditFollow && isGrid()) {
const follow = pendingEditFollow;
pendingEditFollow = null;
const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
syncIndeterminate();
if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
};
// initial pull
refreshRowModel();
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
gridRoot = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-data-table') : null;
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if (local.virtual) {
gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rdt-scroll') : null;
virtualizer = new Virtualizer(virtualizerOptions());
virtualizerCleanup = virtualizer._didMount();
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
setWindowVer(windowVer() + 1);
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
remeasureWindow();
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = pagination();
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
if (local.manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
}
});
onCleanup(() => {
if (virtualizerCleanup) virtualizerCleanup();
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
teardownFillDrag();
});
createEffect(() => {
if (!table) return;
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = currentData() || [];
if (d === lastData && d.length === lastDataLen) return;
lastData = d;
lastDataLen = d.length;
reFeed();
});
createEffect(on(() => (() => [sorting(), globalFilter(), columnFilters(), pagination(), rowSelection(), expanded(), local.expandable, grouping(), local.groupable, columnVisibility(), columnSizing(), columnOrder(), columnPinning(), local.selectionMode, (data() || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
data(), dataDefault(), colReg()])(), (v) => untrack(() => (() => {
reFeed();
})()), { defer: true }));
let __rozieRootRef: HTMLElement | null = null;
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table: any = null;
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false;
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10;
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot: any = null;
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0;
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false;
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
function groupingActiveDefault() {
return ((grouping() != null ? grouping() : groupingDefault()) || []).length > 0;
}
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
function currentState(): any {
return {
sorting: sorting() != null ? sorting() : sortingDefault(),
globalFilter: globalFilter() != null ? globalFilter() : globalFilterDefault(),
columnFilters: columnFilters() != null ? columnFilters() : columnFiltersDefault(),
pagination: pagination() != null ? pagination() : paginationDefault(),
rowSelection: rowSelection() != null ? rowSelection() : rowSelectionDefault(),
// expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
// literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
// guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
// GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
// `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
// literal so the grouped subtree is visible by default; the first toggle latches
// expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
// unchanged → byte-identical-off (the table + the expandable-rows feature both keep
// $data.expandedDefault).
expanded: expanded() != null ? expanded() : groupingActiveDefault() && !expandedTouched ? true : expandedDefault(),
// grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
// to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
// is inert when this is empty (byte-identical-off, req-10).
grouping: grouping() != null ? grouping() : groupingDefault(),
columnVisibility: columnVisibility() != null ? columnVisibility() : columnVisibilityDefault(),
columnSizing: columnSizing() != null ? columnSizing() : columnSizingDefault(),
columnOrder: columnOrder() != null ? columnOrder() : columnOrderDefault(),
columnPinning: columnPinning() != null ? columnPinning() : columnPinningDefault(),
// columnSizingInfo: table-core's transient resize-gesture state. We pass an
// EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
// `column.getIsResizing()` / `getResizeHandler()` read
// `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
// absent. Seed the default shape (matches table-core's
// getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
// every render. Not a two-way model slice (transient gesture state, not consumer
// state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
columnSizingInfo: columnSizingInfo()
};
}
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
function currentData(): any {
return data() != null ? data() : dataDefault();
}
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
function isSafeKey(k: any) {
return k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
}
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
function wrapAggregationFn(fn: any) {
if (typeof fn === 'string') return fn;
if (typeof fn !== 'function') return undefined;
return (columnId: any, leafRows: any, childRows: any) => {
try {
return fn(columnId, leafRows, childRows);
} catch (err: any) {
return undefined;
}
};
}
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
function buildConfigDef(c: any) {
if (!c) return null;
// Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
// getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
// header spans its leaf children (B12). The group id falls back to its header text so it
// stays addressable (no accessor; group columns carry no data).
if (Array.isArray(c.columns)) {
const gid = c.id != null ? c.id : c.header;
if (gid == null) return null;
const id = String(gid);
if (!isSafeKey(id)) return null;
const kids = [];
for (const child of c.columns as any) {
const cd = buildConfigDef(child);
if (cd) kids.push(cd);
}
if (!kids.length) return null;
return {
id,
header: c.header != null ? c.header : id,
columns: kids
};
}
const rawId = c.id != null ? c.id : c.field;
if (rawId == null) return null;
const id = String(rawId);
if (!isSafeKey(id)) return null;
return {
id,
accessorKey: c.field != null ? c.field : id,
header: c.header != null ? c.header : id,
enableSorting: c.sortable === true,
// per-column filter opt-in (req-5). table-core gates the filter input + value
// funnel on enableColumnFilter; a column with filterable !== true cannot be
// filtered (and renders no per-column filter input in the chrome below).
enableColumnFilter: c.filterable === true,
filterable: c.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: c.expandable === true,
// Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
// so every data column is offered to the headless #groupBar by default; the per-column
// aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
// a custom fn defensively wrapped (T-50-04).
groupable: c.groupable !== false,
aggregationFn: wrapAggregationFn(c.aggregationFn),
pinned: c.pinned != null ? c.pinned : '',
width: c.width != null ? c.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
// metadata carrier the display↔editor branch + runValidator read. Off by default.
meta: {
editable: c.editable === true,
editor: c.editor != null ? c.editor : 'text',
editorOptions: c.editorOptions != null ? c.editorOptions : [],
validate: typeof c.validate === 'function' ? c.validate : null
}
};
}
function columnDefs() {
const byId = Object.create(null);
const order = [];
const cfg = local.columns || [];
for (const c of cfg as any) {
const def = buildConfigDef(c);
if (!def) continue;
const id = def.id;
if (!(id in byId)) order.push(id);
byId[id] = def;
}
const reg = colReg() || {};
for (const id in reg) {
if (!isSafeKey(id)) continue;
const spec = reg[id];
if (!spec) continue;
if (!(id in byId)) order.push(id);
byId[id] = {
id,
accessorKey: spec.field != null ? spec.field : id,
header: spec.header != null ? spec.header : id,
enableSorting: spec.sortable === true,
enableColumnFilter: spec.filterable === true,
filterable: spec.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: spec.expandable === true,
// Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
groupable: spec.groupable !== false,
aggregationFn: wrapAggregationFn(spec.aggregationFn),
pinned: spec.pinned != null ? spec.pinned : '',
width: spec.width != null ? spec.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
meta: {
editable: spec.editable === true,
editor: spec.editor != null ? spec.editor : 'text',
editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
validate: typeof spec.validate === 'function' ? spec.validate : null
}
};
}
const out = [];
for (const id of order as any) if (byId[id]) out.push(byId[id]);
return out;
}
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
const SELECT_COL_ID = '__rdt_select';
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
const EXPANDER_COL_ID = '__rdt_expander';
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
function selectionEnabled() {
return local.selectionMode === 'single' || local.selectionMode === 'multiple';
}
function tableColumns() {
const cols = columnDefs();
// Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
// isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
// value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
// → byte-identical-off (req-10).
let withExpander = cols;
if (local.expandable === true) {
const expanderCol = {
id: EXPANDER_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isExpanderColumn: true,
pinned: '',
width: ''
};
withExpander = [expanderCol].concat(cols);
}
if (selectionEnabled()) {
const selectCol = {
id: SELECT_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isSelectColumn: true,
pinned: '',
width: ''
};
return [selectCol].concat(withExpander);
}
return withExpander;
}
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
function writeSorting(next: any) {
if (programmatic) return;
programmatic++;
setSortingDefault(next); // fresh array only (never in-place)
setSorting(next); // two-way emit if bound (no-op-diff if not)
_props.onSortChange?.(next);
programmatic--;
}
function applyUpdater(updater: any, current: any) {
return typeof updater === 'function' ? updater(current) : updater;
}
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
function writeExpanded(next: any) {
if (programmatic) return;
programmatic++;
// Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
// the user now owns the expanded state, so currentState() stops defaulting grouped rows to
// the `true` expand-all literal and honors $data.expandedDefault from here on.
expandedTouched = true;
setExpandedDefault(next); // fresh value only (never in-place)
setExpanded(next); // two-way emit if bound (no-op-diff if not)
// Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
// prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
// interface, and an `expanded-change` event would camelCase to the SAME identifier
// → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
// sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
// sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
_props.onExpandChange?.(next);
programmatic--;
}
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
function writeGrouping(next: any) {
if (programmatic) return;
programmatic++;
setGroupingDefault(next); // fresh ordered array only (never in-place push)
setGrouping(next); // two-way emit if bound (no-op-diff if not)
_props.onGroupChange?.(next);
programmatic--;
}
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
function writeGlobalFilter(next: any) {
if (programmatic) return;
programmatic++;
setGlobalFilterDefault(next);
setGlobalFilter(next);
_props.onFilterChange?.({
globalFilter: next
});
programmatic--;
}
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
function writeColumnFilters(next: any) {
if (programmatic) return;
programmatic++;
setColumnFiltersDefault(next);
setColumnFilters(next);
_props.onFilterChange?.({
columnFilters: next
});
programmatic--;
}
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
function writePagination(next: any) {
if (programmatic) return;
programmatic++;
setPaginationDefault(next);
setPagination(next);
_props.onPageChange?.(next);
programmatic--;
}
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
function writeRowSelection(next: any) {
if (programmatic) return;
programmatic++;
setRowSelectionDefault(next);
setRowSelection(next);
_props.onSelectionChange?.(next);
programmatic--;
}
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
function writeColumnVisibility(next: any) {
if (programmatic) return;
programmatic++;
setColumnVisibilityDefault(next);
setColumnVisibility(next);
_props.onVisibilityChange?.(next);
programmatic--;
}
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
function writeColumnSizing(next: any) {
if (programmatic) return;
programmatic++;
setColumnSizingDefault(next);
setColumnSizing(next);
_props.onResizeChange?.(next);
programmatic--;
}
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
function writeColumnOrder(next: any) {
if (programmatic) return;
programmatic++;
setColumnOrderDefault(next);
setColumnOrder(next);
_props.onReorderChange?.(next);
programmatic--;
}
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
function writeColumnPinning(next: any) {
if (programmatic) return;
programmatic++;
setColumnPinningDefault(next);
setColumnPinning(next);
_props.onPinChange?.(next);
programmatic--;
}
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
function writeData(next: any) {
if (programmatic) return;
programmatic++;
setDataDefault(next); // fresh array only (never in-place)
setData(next); // two-way emit if bound (no-op-diff if not)
programmatic--;
}
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
function columnFilterValue(colId: any) {
const cf = currentState().columnFilters || [];
for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
return '';
}
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
function setColumnFilter(colId: any, value: any) {
const prev = currentState().columnFilters || [];
const next = [];
for (const f of prev as any) if (f && f.id !== colId) next.push(f);
if (value != null && value !== '') next.push({
id: colId,
value
});
writeColumnFilters(next);
}
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel: any = null;
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
function onSortingChangeCb(updater: any) {
writeSorting(applyUpdater(updater, currentState().sorting));
}
function onExpandedChangeCb(updater: any) {
writeExpanded(applyUpdater(updater, currentState().expanded));
}
function onGroupingChangeCb(updater: any) {
writeGrouping(applyUpdater(updater, currentState().grouping));
}
function onGlobalFilterChangeCb(updater: any) {
writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
}
function onColumnFiltersChangeCb(updater: any) {
writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
}
function onPaginationChangeCb(updater: any) {
writePagination(applyUpdater(updater, currentState().pagination));
}
function onRowSelectionChangeCb(updater: any) {
writeRowSelection(applyUpdater(updater, currentState().rowSelection));
}
function onColumnVisibilityChangeCb(updater: any) {
writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
}
function onColumnSizingChangeCb(updater: any) {
writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
}
function onColumnOrderChangeCb(updater: any) {
writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
}
function onColumnPinningChangeCb(updater: any) {
writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
}
function onColumnSizingInfoChangeCb(updater: any) {
const next = applyUpdater(updater, columnSizingInfo());
setColumnSizingInfo(next != null ? next : columnSizingInfo());
}
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
function windowSource() {
if (!table) return [];
if (local.virtual) return table.getPrePaginationRowModel().rows;
return table.getRowModel().rows;
}
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
// - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
// tick that the windowVer bump triggers, so the recycled nodes already exist by the next
// microtask — measuring there observes them while they are still connected, BEFORE the next
// fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
// fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
// frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
// total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
// - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
// committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
function scheduleRemeasure() {
if (remeasurePending) return;
remeasurePending = true;
let ranMicro = false;
const microPass = () => {
remeasureWindow();
};
const rafPass = () => {
remeasurePending = false;
remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
}
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
function pinnedEditIndex() {
if (editingRow() >= 0) return editingRow();
if (editingRowIndex() != null) return editingRowIndex();
return -1;
}
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
function pinnedMeasurement(pin: any) {
if (!virtualizer || pin < 0) return null;
const ms = virtualizer.getMeasurements();
return ms && ms[pin] ? ms[pin] : null;
}
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
function remeasureWindow() {
if (!virtualizer || !gridRoot) return;
// Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
// exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
// null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
// scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
// resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
// regression); the next settled onChange re-measures the stable window. Manual-scroll
// recycling (the CR-01 case) has scrollState === null, so it measures normally.
if (virtualizer.scrollState) return;
const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
for (const tr of trs as any) virtualizer.measureElement(tr);
}
// D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
// math (windowedRows / padTop / padBottom / pmIndexInWindow / rowIsOutsideWindow / virtualizerOptions
// / virtualItemKey) is imported DIRECTLY by the host (DataTable.rozie) from
// `@rozie-ui/headless-core/windowing.rzts` via bare specifier — the P0-proven cross-package inline
// path that DISSOLVES the partial into the leaf (a re-export-from THROUGH this shell would survive as
// a runtime import, not inline — verified). The math closes over these host symbols by convention.
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
// - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
// returns its pre-pagination row model, listbox/combobox return the
// filtered options). This partial MUST NOT reach into the host data engine
// directly — rows arrive ONLY through windowSource().
// - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
// - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
// - gridScrollEl — the scroll-container element handle.
// - virtualizer — the host virtual-core instance (built in $onMount from the ref).
// - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
// - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
// - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
// defaulting to no-op): the DataTable host passes its edit-pinning hooks;
// listbox passes nothing. Routing pinning through this host hook (NOT
// inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
function virtualItemKey(i: any) {
const src = windowSource();
return src && src[i] ? src[i].id : undefined;
}
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
function virtualizerOptions(): any {
return {
count: windowSource().length,
getScrollElement: () => gridScrollEl,
estimateSize: () => local.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: virtualItemKey,
onChange: () => {
setWindowVer(windowVer() + 1);
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
scheduleRemeasure();
}
};
}
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
function pinMeasurement(pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null {
return pinnedMeasurement(pin);
}
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
function windowedRows() {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void windowVer();
void editVer();
if (!virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!local.virtual) {
const rowList = rows() || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = virtualizer.getVirtualItems();
const rowList = rows() || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
}
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
function padTop() {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void windowVer();
void editVer();
if (!local.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
}
function padBottom() {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void windowVer();
void editVer();
if (!local.virtual || !virtualizer) return 0;
const items = virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = pinnedEditIndex();
if (pin >= 0) {
const pm = pinMeasurement(pin);
const inWindow = pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
}
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
function pmIndexInWindow(items: any, idx: any) {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
}
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
function rowIsOutsideWindow(r: any) {
if (!local.virtual || !virtualizer) return false;
const items = virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
}
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
function reFeed() {
if (!table) return;
table.setOptions((prev: any) => ({
...prev,
data: currentData(),
columns: tableColumns(),
state: currentState(),
enableRowSelection: local.selectionMode !== 'none',
enableMultiRowSelection: local.selectionMode === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (local.getSubRows || undefined) as any,
getRowCanExpand: local.expandable === true && local.getSubRows == null ? () => true : undefined,
onExpandedChange: onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: onSortingChangeCb,
onGlobalFilterChange: onGlobalFilterChangeCb,
onColumnFiltersChange: onColumnFiltersChangeCb,
onPaginationChange: onPaginationChangeCb,
onRowSelectionChange: onRowSelectionChangeCb,
onColumnVisibilityChange: onColumnVisibilityChangeCb,
onColumnSizingChange: onColumnSizingChangeCb,
onColumnOrderChange: onColumnOrderChangeCb,
onColumnPinningChange: onColumnPinningChangeCb,
onColumnSizingInfoChange: onColumnSizingInfoChangeCb
}));
if (refreshRowModel) refreshRowModel();
}
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
let lastData: any = null;
let lastDataLen = -1;
// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
function onHeaderSort(colId: any, evt: any) {
if (!table) return;
const col = table.getColumn(colId);
if (!col || !col.getCanSort()) return;
const multi = !!(evt && evt.shiftKey);
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi);
}
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
function tick() {
return rowModelVer();
}
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
function ariaSortFor(colId: any) {
if (tick() < 0 || !table) return 'none';
const col = table.getColumn(colId);
if (!col) return 'none';
const dir = col.getIsSorted();
if (dir === 'asc') return 'ascending';
if (dir === 'desc') return 'descending';
return 'none';
}
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
function sortIndicator(colId: any) {
if (tick() < 0 || !table) return '';
const col = table.getColumn(colId);
if (!col) return '';
const dir = col.getIsSorted();
if (dir === 'asc') return '▲';
if (dir === 'desc') return '▼';
return '';
}
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
function defFor(colId: any) {
const defs = columnDefs();
for (const d of defs as any) if (d.id === colId) return d;
return null;
}
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. Solid's reference-keyed <For> keeps the existing <tr> and will NOT re-run a
// child loop whose `each` reads no signal — so a bare `row.getVisibleCells()` goes
// stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
function visibleCellsFor(row: any) {
return rowModelVer() >= 0 ? row.getVisibleCells() : [];
}
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
function editMetaOf(colId: any) {
const d = defFor(colId);
return d && d.meta ? d.meta : null;
}
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
function columnEditable(colId: any) {
const m = editMetaOf(colId);
return !!(m && m.editable === true);
}
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
function editorTypeOf(colId: any) {
const m = editMetaOf(colId);
return m && m.editor != null ? m.editor : 'text';
}
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
function editorOptionsOf(colId: any) {
const m = editMetaOf(colId);
return m && m.editorOptions != null ? m.editorOptions : [];
}
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
function hasEditorSlot(colId: any) {
return editorTypeOf(colId) === 'custom' && !!(_props.editorSlot ?? _props.slots?.["editor"]);
}
function columnIsFilterable(colId: any) {
const d = defFor(colId);
return !!(d && d.filterable);
}
function headerLabel(colId: any) {
const d = defFor(colId);
return d ? d.header : colId;
}
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
function headerWidth(colId: any) {
if (tick() < 0 || !table) return null;
const col = table.getColumn(colId);
if (!col) return null;
const w = col.getSize();
return w != null && w > 0 ? w + 'px' : null;
}
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
function onResizeStart(colId: any, evt: any) {
// stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table) return;
const header = findHeader(colId);
if (!header || !header.getResizeHandler) return;
const handler = header.getResizeHandler();
if (handler) handler(evt);
}
// Find the live header object for a column id across the rendered header groups.
function findHeader(colId: any) {
const groups = headerGroups() || [];
for (const hg of groups as any) {
const hs = hg.headers || [];
for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
}
return null;
}
function columnIsResizing(colId: any) {
if (tick() < 0 || !table) return false;
const header = findHeader(colId);
return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
}
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
function columnIsVisible(colId: any) {
if (tick() < 0 || !table) return true;
const col = table.getColumn(colId);
return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
}
function onToggleVisibility(colId: any) {
if (!table) return;
const col = table.getColumn(colId);
if (col && col.toggleVisibility) col.toggleVisibility();
}
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
function allLeafColumns() {
if (tick() < 0 || !table) return [];
const cols = table.getAllLeafColumns ? table.getAllLeafColumns() : [];
const out = [];
for (const c of cols as any) {
if (!c || c.id === SELECT_COL_ID) continue;
out.push({
id: c.id,
label: headerLabel(c.id),
visible: !!(c.getIsVisible && c.getIsVisible())
});
}
return out;
}
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
function columnPinSide(colId: any) {
if (tick() < 0 || !table) return false;
const col = table.getColumn(colId);
if (!col || !col.getIsPinned) return false;
return col.getIsPinned();
}
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
function onPinColumn(colId: any, side: any, evt: any) {
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!table) return;
const col = table.getColumn(colId);
if (col && col.pin) col.pin(side);
}
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
function pinStyle(colId: any) {
if (tick() < 0 || !table) return '';
const col = table.getColumn(colId);
if (!col || !col.getIsPinned) return '';
const side = col.getIsPinned();
if (side === 'left') {
const left = col.getStart ? col.getStart('left') : 0;
return 'position:sticky;left:' + left + 'px;z-index:1;';
}
if (side === 'right') {
const right = col.getAfter ? col.getAfter('right') : 0;
return 'position:sticky;right:' + right + 'px;z-index:1;';
}
return '';
}
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
function thStyle(colId: any) {
let s = '';
const w = headerWidth(colId);
if (w) s += 'width:' + w + ';';
s += pinStyle(colId);
return s;
}
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
function onGlobalFilterInput(evt: any) {
const value = evt && evt.target ? evt.target.value : '';
if (table) {
table.setGlobalFilter(value);
return;
}
writeGlobalFilter(value);
}
// Per-column filter input → setColumnFilter (fresh-array funnel).
function onColumnFilterInput(colId: any, evt: any) {
const value = evt && evt.target ? evt.target.value : '';
setColumnFilter(colId, value);
}
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
function globalFilterValue() {
const v = currentState().globalFilter;
return v != null ? v : '';
}
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
function pageIndex() {
if (tick() >= 0 && table) return table.getState().pagination.pageIndex;
const p = currentState().pagination;
return p && p.pageIndex != null ? p.pageIndex : 0;
}
function pageSize() {
if (tick() >= 0 && table) return table.getState().pagination.pageSize;
const p = currentState().pagination;
return p && p.pageSize != null ? p.pageSize : 10;
}
function pageCount() {
if (tick() < 0 || !table) return 1;
const c = table.getPageCount();
return c != null && c > 0 ? c : 1;
}
function canPrevPage() {
return !!(tick() >= 0 && table && table.getCanPreviousPage());
}
function canNextPage() {
return !!(tick() >= 0 && table && table.getCanNextPage());
}
function onPrevPage() {
if (table) table.previousPage();
}
function onNextPage() {
if (table) table.nextPage();
}
function onPageSizeChange(evt: any) {
if (!table) return;
const v = evt && evt.target ? evt.target.value : '';
const n = parseInt(v, 10);
table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
}
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
function isSelectColumn(colId: any) {
return colId === SELECT_COL_ID;
}
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
function isExpanderColumn(colId: any) {
return colId === EXPANDER_COL_ID;
}
function rowCanExpand(row: any) {
return !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
}
function rowIsExpanded(row: any) {
return !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
}
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. With getSubRows the children arrive as ordinary depth-indented rows in
// $data.rows (table-core flattens) — NO additive detail row, NO nested r-for (Pitfall 1).
function rowShowsDetail(row: any) {
return local.getSubRows == null && rowIsExpanded(row);
}
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
function onToggleExpand(row: any, evt: any) {
if (!row || !row.toggleExpanded) return;
// Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
// expanded-state re-render. On Solid the expander <td>/<button> is RECREATED on that
// re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
// pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
// breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
// Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
// the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
// reactive flush so the fresh node exists. Harmless on the targets that keep the node
// (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
row.toggleExpanded();
if (ownerRow && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
const btn = ownerRow.querySelector('[data-expander]');
if (btn) btn.focus();
});
}
}
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
function bodyCellStyle(row: any, colId: any) {
const base = pinStyle(colId);
if (isExpanderColumn(colId) && row && row.depth) {
const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
return base ? base + ';' + pad : pad;
}
return base;
}
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
function rowIsGrouped(row: any) {
return !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
}
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
function groupingActive() {
return tick() >= 0 && (currentState().grouping || []).length > 0;
}
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
function cellIsGrouped(cellCtx: any) {
return !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
}
function cellIsAggregated(cellCtx: any) {
return !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
}
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
function groupSubRowCount(row: any) {
return row && row.subRows ? row.subRows.length : 0;
}
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
function groupingKeys() {
return currentState().grouping || [];
}
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
function groupableColumns() {
const out = [];
const defs = columnDefs();
for (const d of defs as any) {
if (!d || d.groupable === false) continue;
out.push({
id: d.id,
label: d.header != null ? d.header : d.id
});
}
return out;
}
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
function stopEvent(evt: any) {
if (evt && evt.stopPropagation) evt.stopPropagation();
}
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
function isAllRowsSelected() {
return !!(tick() >= 0 && table && table.getIsAllRowsSelected());
}
function isSomeRowsSelected() {
return !!(tick() >= 0 && table && table.getIsSomeRowsSelected());
}
function onToggleAllRows(evt: any) {
if (!table) return;
table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
}
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
function rowIsSelected(row: any) {
if (!row) return false;
const id = row.id;
const sel = currentState().rowSelection || {};
if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
return !!(row.getIsSelected && row.getIsSelected());
}
function onToggleRow(row: any, evt: any) {
if (!row || !row.toggleSelected) return;
row.toggleSelected(!!(evt && evt.target && evt.target.checked));
}
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox: any = null;
function syncIndeterminate() {
if (!__rozieRootRef! || !__rozieRootRef!.querySelector) return;
selectAllBox = __rozieRootRef!.querySelector('.rdt-select-all');
if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
}
// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
// Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
// `const` (the canonical $expose contract — `$expose({ name })` references a
// binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
// is dropped on ALL SIX targets, only the by-reference key survives → a
// runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
// a fresh column-def readout, selection, pagination, and column-management verbs.
function sortColumn(colId: any, desc: any) {
if (table) table.getColumn(colId) && table.getColumn(colId).toggleSorting(desc, false);
}
function clearSorting() {
if (table) table.resetSorting(true);
}
function getColumnDefs() {
return columnDefs();
}
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
function toggleAllRows(value: any) {
if (table) table.toggleAllRowsSelected(value);
}
function clearSelection() {
if (table) table.resetRowSelection(true);
}
function getSelectedRows() {
return table ? table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
}
// pagination verbs.
function setPage(idx: any) {
if (table) table.setPageIndex(idx);
}
function setRowsPerPage(size: any) {
if (table) table.setPageSize(size);
}
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
function toggleColumnVisibility(colId: any) {
if (table) {
const c = table.getColumn(colId);
if (c && c.toggleVisibility) c.toggleVisibility();
}
}
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
function applyColumnOrder(order: any) {
if (table) table.setColumnOrder(order);
}
function resetColumnSizing() {
if (table) table.resetColumnSizing(true);
}
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
function pinColumn(colId: any, side: any) {
if (table) {
const c = table.getColumn(colId);
if (c && c.pin) c.pin(side);
}
}
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
function getRowIndexRelativeToPage(absRow: any) {
const abs = absRow == null ? toAbsRow(activeRow()) : Math.trunc(Number(absRow)) || 0;
if (local.virtual) return abs;
return abs - pageRowOffset();
}
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
function cut() {
return cutRange();
}
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
function isGrid() {
return local.interactionMode === 'grid';
}
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
function tableRole() {
return isGrid() ? 'grid' : 'table';
}
function cellRole() {
return isGrid() ? 'gridcell' : 'cell';
}
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
function rowIndexOf(row: any) {
return tick() >= 0 ? (rows() || []).indexOf(row) : -1;
}
// colIndexOf: a body cell's position in its row's visible cell list.
function colIndexOf(row: any, cellCtx: any) {
return tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
}
// headerColIndexOf: a header cell's position in its header group's leaf headers.
function headerColIndexOf(hg: any, header: any) {
return (hg && hg.headers ? hg.headers : []).indexOf(header);
}
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
// - virtual mode: activeRow is already the full pre-pagination index → offset 0.
// - non-virtual: activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
function pageRowOffset() {
if (!isGrid() || local.virtual) return 0;
return pageIndex() * pageSize();
}
// page-relative active row → absolute (display-order) index.
function toAbsRow(localRow: any) {
return localRow + pageRowOffset();
}
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
function absRowIndexOf(row: any) {
return rowIndexOf(row) + pageRowOffset();
}
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
function prePaginationRowCount() {
if (!table || local.virtual) return bodyRowCount();
const pm = table.getPrePaginationRowModel();
return pm && pm.rows ? pm.rows.length : bodyRowCount();
}
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
function cellTabindex(rowKey: any, colIndex: any, level = null) {
if (!isGrid()) return null;
// B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
// the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
// tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
if (bodyRowCount() === 0) {
return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
}
// B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
// grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
// lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
// tab-stops (the roving invariant broke under grouped headers).
if (activeIsHeader()) {
if (rowKey !== '__header') return -1;
return colIndex === activeColIndex() && level === activeHeaderLevel() ? 0 : -1;
}
const isActive = rowKey === String(activeRow()) && colIndex === activeColIndex();
return isActive ? 0 : -1;
}
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
function resolveCellEl(rowKey: any, colIndex: any, level = null) {
if (!gridRoot) return null;
// B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
// same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
// integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
// match (the parent) when the leaf is meant. level is an integer (NO consumer string is
// interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
// byte-unchanged.
let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
return gridRoot.querySelector(sel);
}
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
function focusActiveCell(nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) {
if (!isGrid() || !gridRoot) return;
const r = nextRow == null ? activeRow() : nextRow;
const c = nextCol == null ? activeColIndex() : nextCol;
// B12: thread the FRESH post-write header level (the grouped-header analog of the
// nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
// level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
const lvl = nextLevel == null ? activeHeaderLevel() : nextLevel;
// Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
// crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
// Angular's signal write are async within one handler — re-reading $data.activeIsHeader
// here returns the PRE-write value, resolving focus to the BODY cell instead of the
// header. Callers pass the fresh isHeader local; falls back to $data when omitted.
const header = nextIsHeader == null ? activeIsHeader() : nextIsHeader;
// ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
// rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
// double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
// in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
// The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
// while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
if (local.virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
virtualizer.scrollToIndex(r, {
align: 'center'
});
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
let focusAttempts = 0;
const focusWhenReady = () => {
const el = resolveCellEl(String(r), c);
if (el) {
el.focus();
return;
}
focusAttempts = focusAttempts + 1;
if (focusAttempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
return;
}
const rowKey = header ? '__header' : String(r);
const el = resolveCellEl(rowKey, c, header ? lvl : null);
if (el) el.focus();
}
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).
// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
function totalRowCount() {
if (!table) return (rows() || []).length;
const fm = table.getFilteredRowModel();
return fm && fm.rows ? fm.rows.length : (rows() || []).length;
}
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
function visibleColCount() {
// NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
// state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
// self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
const rowList = rows() || [];
if (rowList.length) return rowList[0].getVisibleCells().length;
const hg = headerGroups() || [];
return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
}
function bodyRowCount() {
return (rows() || []).length;
}
function clamp(v: any, lo: any, hi: any) {
return v < lo ? lo : v > hi ? hi : v;
}
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
function headerLeafLevel() {
const hg = headerGroups() || [];
return hg.length ? hg.length - 1 : 0;
}
function headerAt(level: any, colIndex: any) {
const hg = headerGroups() || [];
const grp = hg[level];
if (!grp || !grp.headers) return null;
return grp.headers[colIndex] || null;
}
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
function parentHeaderColIndex(level: any, colIndex: any) {
if (level <= 0) return -1;
const h = headerAt(level, colIndex);
if (!h || !h.column || !h.column.parent) return -1;
const parentId = h.column.parent.id;
const hg = headerGroups() || [];
const pg = hg[level - 1];
if (!pg || !pg.headers) return -1;
for (let i = 0; i < pg.headers.length; i++) {
const ph = pg.headers[i];
if (ph && ph.column && ph.column.id === parentId) return i;
}
return -1;
}
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
function firstChildHeaderColIndex(level: any, colIndex: any) {
const h = headerAt(level, colIndex);
if (!h || !h.column) return -1;
const kids = h.column.columns || [];
if (!kids.length) return -1;
const childId = kids[0].id;
const hg = headerGroups() || [];
const cg = hg[level + 1];
if (!cg || !cg.headers) return -1;
for (let i = 0; i < cg.headers.length; i++) {
const ch = cg.headers[i];
if (ch && ch.column && ch.column.id === childId) return i;
}
return -1;
}
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────
// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
function moveCol(delta: any) {
const max = visibleColCount() - 1;
const nextCol = clamp(activeColIndex() + delta, 0, max < 0 ? 0 : max);
setActiveColIndex(nextCol);
return nextCol;
}
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
// - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
// - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
// - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
function moveRow(delta: any) {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const leafLevel = headerLeafLevel();
if (activeIsHeader()) {
if (delta > 0) {
// B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
// level down); from the LEAF header level, drop into the body (row 0). A header-level
// move re-targets activeColIndex (parent↔child column indices differ), so the fresh
// col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
if (activeHeaderLevel() < leafLevel) {
const childCol = firstChildHeaderColIndex(activeHeaderLevel(), activeColIndex());
if (childCol >= 0) {
const nextLevel = activeHeaderLevel() + 1;
setActiveHeaderLevel(nextLevel);
setActiveColIndex(childCol);
return {
row: activeRow(),
col: childCol,
isHeader: true,
level: nextLevel
};
}
}
// At the leaf header: an empty grid has no body to drop into → stay put.
if (bodyRowCount() === 0) return {
row: activeRow(),
col: activeColIndex(),
isHeader: true,
level: activeHeaderLevel()
};
// B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
// (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
// page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
// (delta-1 = 0); clamped to the page-last body row.
const landRow = clamp(delta - 1, 0, maxRow);
setActiveIsHeader(false);
setActiveRow(landRow);
return {
row: landRow,
col: activeColIndex(),
isHeader: false,
level: 0
};
}
// B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
// spans the active column; at the top level (or no real parent) stay put. The parent col
// index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
const parentCol = parentHeaderColIndex(activeHeaderLevel(), activeColIndex());
if (parentCol >= 0) {
const nextLevel = activeHeaderLevel() - 1;
setActiveHeaderLevel(nextLevel);
setActiveColIndex(parentCol);
return {
row: activeRow(),
col: parentCol,
isHeader: true,
level: nextLevel
};
}
return {
row: activeRow(),
col: activeColIndex(),
isHeader: true,
level: activeHeaderLevel()
};
}
// In the body: an upward move from row 0 crosses into the LEAF header level (the header row
// adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
// activeColIndex carries over unchanged.
if (delta < 0 && activeRow() === 0) {
setActiveIsHeader(true);
setActiveHeaderLevel(leafLevel);
return {
row: activeRow(),
col: activeColIndex(),
isHeader: true,
level: leafLevel
};
}
const nextRow = clamp(activeRow() + delta, 0, maxRow);
setActiveRow(nextRow);
setActiveIsHeader(false);
return {
row: nextRow,
col: activeColIndex(),
isHeader: false,
level: 0
};
}
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
function gotoColEdge(toEnd: any) {
const max = visibleColCount() - 1;
const nextCol = toEnd ? max < 0 ? 0 : max : 0;
setActiveColIndex(nextCol);
return nextCol;
}
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
function gotoStart() {
setActiveIsHeader(false);
setActiveRow(0);
setActiveColIndex(0);
return {
row: 0,
col: 0
};
}
function gotoEnd() {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const max = visibleColCount() - 1;
const maxCol = max < 0 ? 0 : max;
setActiveIsHeader(false);
setActiveRow(maxRow);
setActiveColIndex(maxCol);
return {
row: maxRow,
col: maxCol
};
}
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
function currentCellEl() {
const rowKey = activeIsHeader() ? '__header' : String(activeRow());
return resolveCellEl(rowKey, activeColIndex(), activeIsHeader() ? activeHeaderLevel() : null);
}
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
function focusables(cellEl: any) {
if (!cellEl || !cellEl.querySelectorAll) return [];
const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
return list.filter((n: any) => !n.disabled);
}
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
function enterControl() {
const cellEl = currentCellEl();
const list = focusables(cellEl);
if (!list.length) return;
setActiveInControl(true);
list[0].focus();
}
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
function cycleWithinCell(cellEl: any, forward: any) {
const list = focusables(cellEl);
if (!list.length) return;
const active = gridRoot ? gridRoot.getRootNode().activeElement : null;
const cur = list.indexOf(active);
let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
if (i >= list.length) i = 0;
if (i < 0) i = list.length - 1;
list[i].focus();
}
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
function onGridKeyDown(e: any) {
if (!isGrid() || !e) return;
const key = e.key;
// Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
// via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
// early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
// modes (editing / in-control / navigation) stay mutually exclusive and ordered.
if (editingRow() >= 0) return;
// Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
// editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
// keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
if (editingRowIndex() != null) return;
// Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
if (activeInControl()) {
if (key === 'Escape') {
e.preventDefault();
setActiveInControl(false);
// Return focus to the OWNING cell (no move happened) — pass the current indices
// explicitly (the React-emitted seam types both params as required; a zero-arg call
// is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
focusActiveCell(activeRow(), activeColIndex());
} else if (key === 'Tab') {
e.preventDefault();
cycleWithinCell(currentCellEl(), !e.shiftKey);
}
return;
}
// WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
// grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
// user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
// NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
// focused node; if it is not itself a [data-grid-cell], let the event pass through.
const tgt = e.target;
if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
// Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
// nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
// async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
// plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
// in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
// fires ONLY on a real move (a clamped no-op edge move leaves them identical).
const prevRow = activeRow();
const prevCol = activeColIndex();
const prevIsHeader = activeIsHeader();
const prevLevel = activeHeaderLevel();
let nextRow = prevRow;
let nextCol = prevCol;
let nextIsHeader = prevIsHeader;
// B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
// threaded into the focus seam so a leaf↔parent header move lands focus at the correct
// level. moveRow returns it; the non-vertical branches keep the pre-move level.
let nextLevel = prevLevel;
// ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
// the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
// fall through to a plain navigation move). Body cells only (no range from a header). The
// extendRange call owns focus + the range-change emit, so return immediately. ──────────
if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader()) {
e.preventDefault();
extendRange(0, 1);
return;
} else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader()) {
e.preventDefault();
extendRange(0, -1);
return;
} else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader()) {
e.preventDefault();
extendRange(1, 0);
return;
} else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader()) {
e.preventDefault();
extendRange(-1, 0);
return;
} else if (key === 'ArrowRight') {
e.preventDefault();
clearRange();
nextCol = moveCol(1);
} else if (key === 'ArrowLeft') {
e.preventDefault();
clearRange();
nextCol = moveCol(-1);
} else if (key === 'ArrowDown') {
e.preventDefault();
clearRange();
const m = moveRow(1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'ArrowUp') {
e.preventDefault();
clearRange();
const m = moveRow(-1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageDown') {
e.preventDefault();
const m = moveRow(GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageUp') {
e.preventDefault();
const m = moveRow(-GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'Home') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const s = gotoStart();
nextRow = s.row;
nextCol = s.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(false);
}
} else if (key === 'End') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const en = gotoEnd();
nextRow = en.row;
nextCol = en.col;
nextIsHeader = false;
} else {
nextCol = gotoColEdge(true);
}
}
// ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
// pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
// edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
// type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
// gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
// falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
// (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
copyRange();
return;
} else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
pasteRange();
return;
}
// ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
// source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
// Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
// and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
// shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
e.preventDefault();
cutRange();
return;
}
// ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
// EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
// (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
// collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
// at least the active editable column); a non-editable active cell falls through unchanged.
else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
e.preventDefault();
beginRowEdit((rows() || [])[activeRow()]);
return;
}
// ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
// Gated by isActiveCellEditable(): a non-editable active cell falls through to
// enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
// (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
e.preventDefault();
beginEdit(activeRow(), activeColIndex(), null);
return;
} else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
// checkbox/select/date editor must NOT take the typed char as its value (it would
// force-check the checkbox, seed a garbage select option, or corrupt the date) — open
// those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
e.preventDefault();
const editType = editorTypeOf(activeCellColumnId());
const seed = editType === 'text' || editType === 'number' ? key : null;
beginEdit(activeRow(), activeColIndex(), seed);
return;
}
// ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
// expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
// verified invariant) so it never hits the edit branches above and would otherwise fall to
// enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
// Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
// model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
// ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
// full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
else if (key === 'Enter' && !activeIsHeader() && rowIsGrouped((rows() || [])[activeRow()])) {
e.preventDefault();
// C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
// active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
// the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
// row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
// fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
// DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
// while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
// settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
// onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
// SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
// until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
// already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
const grpRow = activeRow();
const grpCol = activeColIndex();
onToggleExpand((rows() || [])[activeRow()], e);
recoverGridFocus(String(grpRow), grpCol, null);
return;
} else if (key === 'Enter' || key === 'F2') {
e.preventDefault();
enterControl();
return;
} else return;
// THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
// focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
// WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
// changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
// row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
// B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
// C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
// keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
// toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
// The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
_props.onActivecellChange?.({
rowIndex: toAbsRow(nextRow),
colIndex: nextCol
});
}
}
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
function syncActiveFromEvent(e: any) {
if (!isGrid() || !e) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null) return;
const col = parseInt(colAttr, 10);
if (!Number.isFinite(col)) return;
const isHeader = rowAttr === '__header';
setActiveIsHeader(isHeader);
if (isHeader) {
// B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
// roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
// one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
const lvlAttr = cellEl.getAttribute('data-header-level');
const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
setActiveHeaderLevel(Number.isFinite(lvl) ? lvl : headerLeafLevel());
} else {
const row = parseInt(rowAttr, 10);
if (Number.isFinite(row)) setActiveRow(row);
}
setActiveColIndex(col);
// A plain focus collapses any range back to the single active cell — EXCEPT (a) the
// programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
// ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
// focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
// BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
// path owns the shift case and flags it here so the collapse is skipped.
if (rangeTransition) {
rangeTransition = false;
} else if (rangeClickPending) {
rangeClickPending = false;
} else {
clearRange();
}
// The cell box (not an inner control) receiving focus = navigation mode.
if (tgt === cellEl) setActiveInControl(false);
}
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). A focusin
// event carries no reliable `shiftKey`, so the modifier MUST be read off the pointer event
// — @mousedown fires BEFORE the cell's focusin and DOES carry shiftKey. A shift-held
// mousedown on a BODY cell sets the range's moving corner to that cell (keeping the anchor),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
function onGridMouseDown(e: any) {
if (!isGrid() || !e || !e.shiftKey) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
const row = parseInt(rowAttr, 10);
const col = parseInt(colAttr, 10);
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
setRangeFocus$local(row, col);
setActiveIsHeader(false);
setActiveRow(row);
setActiveColIndex(col);
rangeClickPending = true;
}
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
function onGridFocusOut(e: any) {
if (!isGrid() || !activeInControl()) return;
const next = e ? e.relatedTarget : null;
const cellEl = currentCellEl();
if (!cellEl || !next || !cellEl.contains(next)) setActiveInControl(false);
}
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
function recoverGridFocus(rowKey: any, col: any, level: any) {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(rowKey, col, level);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
function clampActiveCell(rowCount: any, colCount: any) {
if (!isGrid()) return;
// B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
// just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
// async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
// and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
// out of bounds on React only). Falls back to the live helpers when called without bounds.
const colN = colCount != null ? colCount : visibleColCount();
const rowN = rowCount != null ? rowCount : bodyRowCount();
// B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
// shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
// framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
// — true on all six, incl React's async setState), so the doomed cell + its focus are still
// observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
// programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
// Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
// doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
// The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
// NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
// shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
// DOM coords are always fresh.
let recoverFocus = false;
let doomedRow = -1;
let doomedCol = 0;
if (gridRoot) {
const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
const focusedEl = rootNode ? rootNode.activeElement : null;
const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
if (focusedCell && gridRoot.contains(focusedCell)) {
const fRowAttr = focusedCell.getAttribute('data-row');
const fColAttr = focusedCell.getAttribute('data-col-index');
if (fRowAttr != null && fRowAttr !== '__header') {
const fr = parseInt(fRowAttr, 10);
const fc = parseInt(fColAttr, 10);
if (Number.isFinite(fr) && fr > rowN - 1) {
recoverFocus = true;
doomedRow = fr;
doomedCol = Number.isFinite(fc) ? fc : 0;
}
}
}
}
const maxCol = colN - 1;
const col = clamp(activeColIndex(), 0, maxCol < 0 ? 0 : maxCol);
if (col !== activeColIndex()) setActiveColIndex(col);
// B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
// cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
// an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
// re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
if (rowN <= 0) {
setActiveIsHeader(true);
setActiveHeaderLevel(headerLeafLevel());
setActiveColIndex(0);
// B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
// reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
// async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
// class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
// recovery branch below actually runs on React too.
gridEmptyFallback = true;
clampRange(rowN - 1, colN - 1);
// B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
// reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
// Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
// (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
// the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
return;
}
// B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
// re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
// cell. A user-driven header position (not the empty fallback) is left untouched.
if (gridEmptyFallback) {
gridEmptyFallback = false;
setActiveIsHeader(false);
setActiveRow(0);
}
if (!activeIsHeader()) {
const lastRow = rowN - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const row = clamp(activeRow(), 0, maxRow);
if (row !== activeRow()) setActiveRow(row);
}
// B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
// shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
// out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
clampRange(rowN - 1, colN - 1);
// B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
// when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
// into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
if (recoverFocus) {
const recRow = clamp(doomedRow, 0, rowN - 1);
const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
recoverGridFocus(String(recRow), recCol, null);
}
}
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false;
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).
// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false;
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false;
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false;
function inRange(rIdx: any, cIdx: any) {
const a = rangeAnchor();
const f = rangeFocus();
if (!a || !f) return false;
const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
}
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
function getSelectedRange() {
// B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
// payload) never reports a corner past a shrunken model — React-stale-safe (the eager
// refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
const a = rangeAnchor();
const f = rangeFocus();
if (!a && !f) return {
anchor: null,
focus: null
};
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
anchor: null,
focus: null
};
const clampCorner = (c: any) => c == null ? null : {
rowIndex: clamp(c.rowIndex, 0, maxRow),
colIndex: clamp(c.colIndex, 0, maxCol)
};
return {
anchor: clampCorner(a),
focus: clampCorner(f)
};
}
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
function isFillHandleCell(rIdx: any, cIdx: any) {
const a = rangeAnchor();
const f = rangeFocus();
if (!a || !f) return false;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx === r1 && cIdx === c1;
}
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
function emitRangeChange(anchor: any, focus: any) {
_props.onRangeChange?.({
anchor,
focus
});
}
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
function extendRange(dRow: any, dCol: any) {
if (activeIsHeader()) return;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
// Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
let anchor = rangeAnchor();
let focus = rangeFocus();
const hadRange = !!(anchor && focus);
if (!anchor || !focus) {
anchor = {
rowIndex: activeRow(),
colIndex: activeColIndex()
};
focus = {
rowIndex: activeRow(),
colIndex: activeColIndex()
};
}
const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
const nextFocus = {
rowIndex: nextRow,
colIndex: nextCol
};
setRangeAnchor(anchor);
setRangeFocus(nextFocus);
rangeActive = true;
// Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
// from the range's leading edge, the spreadsheet convention).
setActiveRow(nextRow);
setActiveColIndex(nextCol);
// Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
// settle on the new focus corner is part of THIS range extension, not a fresh navigation.
rangeTransition = true;
focusActiveCell(nextRow, nextCol, false);
// B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
// and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
// change → no emit. Seeding a brand-new range (no prior range) is always a change (the
// rectangle came into existence) even if its first corner is a degenerate 1×1.
if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
emitRangeChange(anchor, nextFocus);
}
}
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
function setRangeFocus$local(rIdx: any, cIdx: any) {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
let anchor = rangeAnchor();
if (!anchor) anchor = {
rowIndex: activeRow(),
colIndex: activeColIndex()
};
const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
const nextFocus = {
rowIndex: r,
colIndex: c
};
setRangeAnchor(anchor);
setRangeFocus(nextFocus);
rangeActive = true;
emitRangeChange(anchor, nextFocus);
}
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
function clearRange() {
// B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
// in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
// move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
// would let the SECOND call through and emit a duplicate range-change. rangeActive flips
// synchronously → the second call returns here.
if (!rangeActive) return;
rangeActive = false;
setRangeAnchor(null);
setRangeFocus(null);
emitRangeChange(null, null);
}
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
function clampRange(maxRowArg: any, maxColArg: any) {
const a = rangeAnchor();
const f = rangeFocus();
if (!a && !f) return;
// Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
// shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) {
setRangeAnchor(null);
setRangeFocus(null);
rangeActive = false;
return;
}
if (a) {
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
if (ar !== a.rowIndex || ac !== a.colIndex) setRangeAnchor({
rowIndex: ar,
colIndex: ac
});
}
if (f) {
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
if (fr !== f.rowIndex || fc !== f.colIndex) setRangeFocus({
rowIndex: fr,
colIndex: fc
});
}
}
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
function announce(msg: any) {
setPasteAnnounce(msg != null ? msg : '');
}
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
function clipboardActiveAllowed() {
return !activeIsHeader();
}
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
function fieldOfColId(colId: any) {
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
}
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
function normalizedRange() {
const a = rangeAnchor();
const f = rangeFocus();
if (!a || !f) return null;
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return null;
const ar = clamp(a.rowIndex, 0, maxRow);
const ac = clamp(a.colIndex, 0, maxCol);
const fr = clamp(f.rowIndex, 0, maxRow);
const fc = clamp(f.colIndex, 0, maxCol);
return {
r0: ar < fr ? ar : fr,
r1: ar > fr ? ar : fr,
c0: ac < fc ? ac : fc,
c1: ac > fc ? ac : fc
};
}
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
function escapeTsvField(s: any) {
if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
function rangeToTsv() {
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow();
const r1 = box ? box.r1 : activeRow();
const c0 = box ? box.c0 : activeColIndex();
const c1 = box ? box.c1 : activeColIndex();
const lines = [];
for (let r = r0; r <= r1; r++) {
const cells = [];
for (let c = c0; c <= c1; c++) {
const v = cellValueAt(r, c);
cells.push(escapeTsvField(v == null ? '' : String(v)));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
}
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
function parseTsv(text: any) {
const str = text != null ? String(text) : '';
// CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
// large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
// single-pass scan allocates a cell-per-character grid.
if (str === '' || str.length > 2000000) return [];
// B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
// cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
// newlines, and doubled quotes ("") inside it are literal content until the closing quote;
// an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = str.length;
while (i < n) {
const ch = str[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < n && str[i + 1] === '"') {
field = field + '"';
i = i + 2;
continue;
}
inQuotes = false;
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
continue;
}
if (ch === '"' && field === '') {
inQuotes = true;
i = i + 1;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
i = i + 1;
continue;
}
if (ch === '\r') {
if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
if (ch === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
}
// Flush the trailing field + row.
row.push(field);
rows.push(row);
// Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
if (rows.length > 1) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
}
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
function copyRange() {
// B11: never copy from a header-active state (the reusable clipboard guard).
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
try {
const p = navigator.clipboard.writeText(rangeToTsv());
if (p && p.catch) p.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
function applyGridToRange(grid: any, originRow: any, originCol: any) {
const maxRow = bodyRowCount() - 1;
const maxCol = visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
wrote: 0,
total: 0
};
let total = 0;
let wrote = 0;
const committed = [];
// Build the fresh data array incrementally so the whole paste is ONE writeData.
let next = currentData();
for (let gr = 0; gr < grid.length; gr++) {
const r = originRow + gr;
if (r > maxRow) break;
const cols = grid[gr] || [];
for (let gc = 0; gc < cols.length; gc++) {
const c = originCol + gc;
if (c > maxCol) break;
total = total + 1;
const colId = columnIdAt(r, c);
if (colId == null || !columnEditable(colId)) continue;
const rowObj = rowOriginalAt(r);
// B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
// single-cell commit coercion) — a numeric column commits a real Number, an empty cell
// commits null; every other editor type passes through verbatim. No mixed/garbage types
// ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
const value = coerceCellValue(colId, cols[gc]);
// T-51-01: validate the pasted value as plain DATA before any write.
if (runValidator(colId, value, rowObj) !== true) continue;
const field = fieldOfColId(colId);
const srcIndex = sourceIndexOfRow(r);
const oldValue = rowObj ? rowObj[field] : null;
next = replaceRowValue(next, srcIndex, field, value);
committed.push({
rowId: rowIdAt(r),
columnId: colId,
oldValue,
newValue: value
});
wrote = wrote + 1;
}
}
if (wrote > 0) {
editTransition = true;
writeData(next);
editTransition = false;
// One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
for (let i = 0; i < committed.length; i++) _props.onCellEditCommit?.(committed[i]);
}
// WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
// targeted real cells but every one was skipped (validation-failed / non-editable), announce a
// distinct validation-failed message instead of a misleading "0 of M cells pasted".
if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
return {
wrote,
total
};
}
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
function rowOriginalAt(rowIndex: any) {
const rowList = rows() || [];
const row = rowList[rowIndex];
return row ? row.original : null;
}
function rowIdAt(rowIndex: any) {
const rowList = rows() || [];
const row = rowList[rowIndex];
return row ? row.id : null;
}
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
function tileGridToBox(grid: any, box: any) {
const srcRows = grid.length;
const srcCols = srcRows > 0 ? grid[0].length : 0;
if (srcRows <= 0 || srcCols <= 0) return grid;
const boxRows = box.r1 - box.r0 + 1;
const boxCols = box.c1 - box.c0 + 1;
const rows = boxRows > srcRows ? boxRows : srcRows;
const cols = boxCols > srcCols ? boxCols : srcCols;
const out = [];
for (let r = 0; r < rows; r++) {
const srcLine = grid[r % srcRows] || [];
const line = [];
for (let c = 0; c < cols; c++) {
const v = srcLine[c % srcCols];
line.push(v != null ? v : '');
}
out.push(line);
}
return out;
}
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
function pasteRange() {
// B11: never paste into a header-active state (the reusable clipboard guard) — a header
// anchor would silently write body row 0 at the header's column.
if (!clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
// CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
// C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
// single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
// on React; re-reading them inside the async .then() returns the mount-render stale value, so a
// selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
// the box + anchor now and pass them into tileGridToBox / applyGridToRange.
const box = normalizedRange();
const anchorRow = box ? box.r0 : activeRow();
const anchorCol = box ? box.c0 : activeColIndex();
const destBox = box || {
r0: anchorRow,
r1: anchorRow,
c0: anchorCol,
c1: anchorCol
};
let p: any = null;
try {
p = navigator.clipboard.readText();
} catch (err: any) {
return;
}
if (!p || !p.then) return;
p.then((text: any) => {
const grid = parseTsv(text);
if (!grid.length) return;
// C3: tile the clipboard block to fill the destination range (single→range fill,
// smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
const tiled = tileGridToBox(grid, destBox);
applyGridToRange(tiled, anchorRow, anchorCol);
}).catch(() => {});
}
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
function cutRange() {
if (!clipboardActiveAllowed()) return;
// Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
const box = normalizedRange();
const r0 = box ? box.r0 : activeRow();
const r1 = box ? box.r1 : activeRow();
const c0 = box ? box.c0 : activeColIndex();
const c1 = box ? box.c1 : activeColIndex();
// Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
const cp = navigator.clipboard.writeText(rangeToTsv());
if (cp && cp.catch) cp.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// Clear the source: a grid of empty strings sized to the range, applied at the top-left.
const grid = [];
for (let r = r0; r <= r1; r++) {
const cols = [];
for (let c = c0; c <= c1; c++) cols.push('');
grid.push(cols);
}
applyGridToRange(grid, r0, c0);
}
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
function tileIndex(i: any, lo: any, hi: any) {
const span = hi - lo + 1;
if (span <= 1) return lo;
let k = (i - lo) % span;
if (k < 0) k = k + span;
return lo + k;
}
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
function fillRange(sourceBox: any, endCell: any) {
// B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
// the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
// React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
// grows), so deriving the box from the threaded endpoints is what makes the fill cover the
// dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
let box;
if (sourceBox && sourceBox.r0 != null && endCell) {
let r0 = sourceBox.r0;
let r1 = sourceBox.r1;
let c0 = sourceBox.c0;
let c1 = sourceBox.c1;
if (endCell.r < r0) r0 = endCell.r;
if (endCell.r > r1) r1 = endCell.r;
if (endCell.c < c0) c0 = endCell.c;
if (endCell.c > c1) c1 = endCell.c;
box = {
r0,
r1,
c0,
c1
};
} else {
box = normalizedRange();
}
if (!box) return;
const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
r0: box.r0,
r1: box.r0,
c0: box.c0,
c1: box.c0
};
const grid = [];
for (let r = box.r0; r <= box.r1; r++) {
const cols = [];
for (let c = box.c0; c <= box.c1; c++) {
const sr = tileIndex(r, src.r0, src.r1);
const sc = tileIndex(c, src.c0, src.c1);
const v = cellValueAt(sr, sc);
cols.push(v == null ? '' : String(v));
}
grid.push(cols);
}
applyGridToRange(grid, box.r0, box.c0);
}
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false;
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove: any = null;
let fillDragUp: any = null;
function teardownFillDrag() {
if (typeof document !== 'undefined') {
if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
if (fillDragUp) document.removeEventListener('pointerup', fillDragUp);
}
fillDragMove = null;
fillDragUp = null;
fillDragging = false;
}
function cellIndexFromPoint(clientX: any, clientY: any) {
if (typeof document === 'undefined' || !document.elementFromPoint) return null;
let el = document.elementFromPoint(clientX, clientY);
// Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
// a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
// and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
// deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
if (!inner || inner === el) break;
el = inner;
}
if (!el || !el.closest) return null;
const cellEl = el.closest('[data-grid-cell]');
if (!cellEl) return null;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
const r = parseInt(rowAttr, 10);
const c = parseInt(colAttr, 10);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return {
r,
c
};
}
function onFillHandlePointerDown(e: any) {
if (!e) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
fillDragging = true;
// B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
// range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
// up/left drag copies from the real origin (not the post-drag corner that would flip to a
// target cell). Captured per-gesture in the closure (no module-let needed).
const sourceBox = normalizedRange();
// B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
// the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
let lastCell = sourceBox ? {
r: sourceBox.r1,
c: sourceBox.c1
} : null;
const move = (ev: any) => {
if (!fillDragging) return;
const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
// B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
// pointermove (the pointer fires many per cell) spams the event with identical payloads.
// Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
// seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
// or re-enters the same cell is suppressed (the range is unchanged).
if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
lastCell = cell;
setRangeFocus$local(cell.r, cell.c);
}
};
const up = () => {
// teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
teardownFillDrag();
fillRange(sourceBox, lastCell);
};
// Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
fillDragMove = move;
fillDragUp = up;
if (typeof document !== 'undefined') {
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
}
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).
// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
function activeCellColumnId() {
if (activeIsHeader()) return null;
const rowList = rows() || [];
const row = rowList[activeRow()];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[activeColIndex()];
return cell && cell.column ? cell.column.id : null;
}
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
function isActiveCellEditable() {
const colId = activeCellColumnId();
return colId != null && columnEditable(colId);
}
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
// - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
// so EVERY editable cell in the row enters edit simultaneously (the editor template branch
// re-uses this gate verbatim, no template fork);
// - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
function isEditing(rowIndex: any, colIndex: any) {
if (editVer() < 0) return false;
if (editingRowIndex() != null && editingRowIndex() === rowIndex) {
const colId = columnIdAt(rowIndex, colIndex);
return colId != null && columnEditable(colId);
}
return editingRow() === rowIndex && editingCol() === colIndex;
}
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
function cellAriaInvalid(rowIndex: any, colIndex: any): 'true' | null {
return isEditing(rowIndex, colIndex) && !!invalidMsg() ? 'true' : null;
}
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
function runValidator(colId: any, value: any, row: any) {
const m = editMetaOf(colId);
const v = m ? m.validate : null;
if (typeof v !== 'function') return true;
let r: any = null;
try {
r = v(value, row);
} catch (err: any) {
return 'Invalid value';
}
if (r === true) return true;
if (typeof r === 'string') return r;
return 'Invalid value';
}
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
function setInvalid(msg: any) {
setInvalidMsg(msg != null ? msg : '');
}
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
function replaceRowValue(rows: any, rowIndex: any, field: any, value: any) {
const src = rows || [];
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
// and would copy inherited enumerable props of typed/class-instance row objects.
out.push({
...(src[i] || {}),
[field]: value
});
} else {
out.push(src[i]);
}
}
return out;
}
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
function sourceIndexOfRow(visibleRowIndex: any) {
const rowList = rows() || [];
const row = rowList[visibleRowIndex];
if (!row) return visibleRowIndex;
const orig = row.original;
const data = currentData() || [];
const idx = data.indexOf(orig);
return idx >= 0 ? idx : visibleRowIndex;
}
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
function editingColumnId() {
const rowList = rows() || [];
const row = rowList[editingRow()];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol()];
return cell && cell.column ? cell.column.id : null;
}
function editingColumnField() {
const colId = editingColumnId();
if (colId == null) return null;
const d = defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
}
function editingCellValue() {
const rowList = rows() || [];
const row = rowList[editingRow()];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[editingCol()];
return cell ? cell.getValue() : null;
}
function editingRowOriginal() {
const rowList = rows() || [];
const row = rowList[editingRow()];
return row ? row.original : null;
}
function editingRowId() {
const rowList = rows() || [];
const row = rowList[editingRow()];
return row ? row.id : null;
}
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
function focusEditorWhenReady(selectAll = true) {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
if (el) {
el.focus();
if (selectAll && el.select) {
try {
el.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
function columnIdAt(rowIndex: any, colIndex: any) {
const rowList = rows() || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell && cell.column ? cell.column.id : null;
}
function cellValueAt(rowIndex: any, colIndex: any) {
const rowList = rows() || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = visibleCellsFor(row);
const cell = cells[colIndex];
return cell ? cell.getValue() : null;
}
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
function beginEdit(rowIndex: any, colIndex: any, seed: any) {
const colId = columnIdAt(rowIndex, colIndex);
if (colId == null || !columnEditable(colId)) return;
setInvalid('');
// Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
// editor clears any row-edit state so isEditing never resolves both modes for one cell.
setEditingRowIndex(null);
setRowDraft({});
setEditingRow(rowIndex);
setEditingCol(colIndex);
setDraftValue(seed != null ? seed : cellValueAt(rowIndex, colIndex));
setActiveInControl(true);
setEditVer(editVer() + 1);
// B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
// seeded char so subsequent typing appends instead of replacing it.
focusEditorWhenReady(seed == null);
}
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
function focusCellWhenReady(row: any, col: any) {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = resolveCellEl(String(row), col);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
function indexOfRowIn(rows: any, rowOriginal: any, rowId: any) {
const list = rows || [];
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (!r) continue;
if (rowId != null && r.id === rowId) return i;
if (rowOriginal != null && r.original === rowOriginal) return i;
}
return -1;
}
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
function endEdit() {
setEditingRow(-1);
setEditingCol(-1);
setDraftValue(null);
setInvalidMsg('');
setActiveInControl(false);
setEditVer(editVer() + 1);
}
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
function endRowEdit() {
setEditingRowIndex(null);
setRowDraft({});
setInvalidMsg('');
setActiveInControl(false);
setEditVer(editVer() + 1);
}
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
function coerceCellValue(colId: any, raw: any) {
if (editorTypeOf(colId) !== 'number') return raw;
if (raw == null) return null;
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
function commitEdit(overrideValue = undefined, skipFocusReturn = false) {
if (editingRow() < 0) return false;
const colId = editingColumnId();
if (colId == null) {
endEdit();
return false;
}
const field = editingColumnField();
const oldValue = editingCellValue();
const rowOriginal = editingRowOriginal();
const rowId = editingRowId();
// B3: coerce by the column's editor type BEFORE validation + write so the validator
// and the model both see the typed value (number/null), not the raw draft string.
const rawValue = overrideValue !== undefined ? overrideValue : draftValue();
const newValue = coerceCellValue(colId, rawValue);
const err = runValidator(colId, newValue, rowOriginal);
if (err !== true) {
// D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
setInvalid(err);
focusEditorWhenReady();
return false;
}
setInvalid('');
const srcIndex = sourceIndexOfRow(editingRow());
const next = replaceRowValue(currentData(), srcIndex, field, newValue);
// Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
const focusRow = editingRow();
const focusCol = editingCol();
// Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
// must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
editTransition = true;
writeData(next);
// Exactly one emit per commit, from this single call site (writeData does NOT emit).
_props.onCellEditCommit?.({
rowId,
columnId: colId,
oldValue,
newValue
});
endEdit();
editTransition = false;
// Defer the focus return so the display↔editor re-render commits first (async on
// React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
// editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
// Tab-advance (the caller immediately opens the next editor and focuses THAT).
// B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
// RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
// index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
// consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
// fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
// active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
// index → byte-behaviorally identical to before.
if (skipFocusReturn !== true) pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
}
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
function cancelEdit() {
if (editingRow() < 0) return;
// CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
// Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
// an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
// instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
const focusRow = editingRow();
const focusCol = editingCol();
editTransition = true;
endEdit();
editTransition = false;
focusCellWhenReady(focusRow, focusCol);
}
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
function editableColumnsForRow(rowIndex: any) {
const rowList = rows() || [];
const row = rowList[rowIndex];
if (!row) return [];
const cells = visibleCellsFor(row);
const out = [];
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
const colId = cell && cell.column ? cell.column.id : null;
if (colId == null || !columnEditable(colId)) continue;
const d = defFor(colId);
const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
// colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
// Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
// can address a SPECIFIC editor by column, not just the first [data-editing-cell].
out.push({
colId,
field,
colIndex: c
});
}
return out;
}
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
function focusRowEditorAt(rowIndex: any, colIndex: any) {
if (!gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const cellEl = resolveCellEl(String(rowIndex), colIndex);
const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
if (ed) {
ed.focus();
if (ed.select) {
try {
ed.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
function beginRowEdit(row: any) {
const rowIndex = rowIndexOf(row);
if (rowIndex < 0) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
// Clear any single-cell editor first (mutual exclusivity).
setEditingRow(-1);
setEditingCol(-1);
setDraftValue(null);
setInvalid('');
// Seed each editable cell's draft from its current value.
const draft = {};
const rowList = rows() || [];
const r = rowList[rowIndex];
const orig = r ? r.original : null;
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
draft[ec.colId] = orig ? orig[ec.field] : null;
}
setRowDraft(draft);
setEditingRowIndex(rowIndex);
setActiveInControl(true);
setEditVer(editVer() + 1);
focusEditorWhenReady();
}
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
function commitRow() {
if (editingRowIndex() == null) return false;
const rowIndex = editingRowIndex();
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) {
endRowEdit();
return false;
}
const rowList = rows() || [];
const r = rowList[rowIndex];
const rowOriginal = r ? r.original : null;
const rowId = r ? r.id : null;
const draft = rowDraft() || {};
// Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
// B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
// 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
// commitEdit already coerces via coerceCellValue; the row path silently committed strings →
// a number column ended up holding '99'). Coerce once here so the validator and the model both
// see the typed value, identical to the single-cell funnel.
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
if (err !== true) {
setInvalid(err);
// B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
// unconditionally the first editor (focusEditorWhenReady resolves the first
// [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
focusRowEditorAt(rowIndex, ec.colIndex);
return false;
}
}
setInvalid('');
// Build the changes payload (only the columns whose value actually changed) + the field→
// value map for the single row-object replace.
const changes = [];
const fieldValues = {};
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
// B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
// string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
fieldValues[ec.field] = newValue;
if (oldValue !== newValue) changes.push({
columnId: ec.colId,
oldValue,
newValue
});
}
// ONE fresh-array replace of the SINGLE row object with all field values applied at once.
const srcIndex = sourceIndexOfRow(rowIndex);
const next = replaceRowValues(currentData(), srcIndex, fieldValues);
// Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
// active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
const focusCol = activeColIndex();
editTransition = true;
writeData(next);
// EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
_props.onRowEditCommit?.({
rowId,
changes
});
endRowEdit();
editTransition = false;
// WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
// like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
// col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
// $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
// DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
// refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
// display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
// the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
// poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
}
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
function cancelRow() {
if (editingRowIndex() == null) return;
const focusRow = activeRow();
const focusCol = activeColIndex();
editTransition = true;
endRowEdit();
editTransition = false;
focusCellWhenReady(focusRow, focusCol);
}
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
function replaceRowValues(rows: any, rowIndex: any, fieldValues: any) {
const src = rows || [];
const fv = fieldValues || {};
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
// prototype-walking copy. Spread copies own enumerable props only.
out.push({
...(src[i] || {}),
...fv
});
} else {
out.push(src[i]);
}
}
return out;
}
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
function nextEditableCell(fromRow: any, fromCol: any) {
const rowList = rows() || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol + 1;
while (r < rowCount) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c < cells.length) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c + 1;
}
r = r + 1;
c = 0;
}
return null;
}
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
function prevEditableCell(fromRow: any, fromCol: any) {
const rowList = rows() || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol - 1;
while (r >= 0) {
const row = rowList[r];
const cells = row ? visibleCellsFor(row) : [];
while (c >= 0) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && columnEditable(cid)) return {
row: r,
col: c
};
c = c - 1;
}
r = r - 1;
if (r >= 0) {
const prow = rowList[r];
const pcells = prow ? visibleCellsFor(prow) : [];
c = pcells.length - 1;
}
}
return null;
}
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false;
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow: any = null;
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
function inRowEdit() {
return editingRowIndex() != null;
}
function editorValueFor(colId: any) {
return inRowEdit() ? rowDraft() ? rowDraft()[colId] : null : draftValue();
}
function editorCheckedFor(colId: any) {
return !!(inRowEdit() ? rowDraft() ? rowDraft()[colId] : null : draftValue());
}
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
function editorCommitFor(colId: any) {
return (value: any) => {
if (inRowEdit()) {
setRowDraft$local(colId, value);
return;
}
commitEdit(value);
};
}
function editorCancelFor() {
return () => {
if (inRowEdit()) {
cancelRow();
return;
}
cancelEdit();
};
}
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
function onCellEditorInput(colId: any, evt: any) {
const v = evt && evt.target ? evt.target.value : '';
if (inRowEdit()) {
setRowDraft$local(colId, v);
return;
}
setDraftValue(v);
}
function onCellEditorCheckbox(colId: any, evt: any) {
const v = !!(evt && evt.target && evt.target.checked);
if (inRowEdit()) {
setRowDraft$local(colId, v);
return;
}
setDraftValue(v);
}
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
function setRowDraft$local(colId: any, value: any) {
const src = rowDraft() || {};
const next = {};
for (const k in src) next[k] = src[k];
next[colId] = value;
setRowDraft(next);
}
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
function rowEditTab(target: any, backward: any) {
const rowIndex = editingRowIndex();
if (rowIndex == null) return;
const editable = editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
const cols = editable.map((ec: any) => ec.colIndex);
const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
const curAttr = cell ? cell.getAttribute('data-col-index') : null;
const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
let pos = cols.indexOf(cur);
if (pos < 0) pos = 0;
const len = cols.length;
const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
focusRowEditorAt(rowIndex, cols[nextPos]);
}
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
function onEditorKeyDown(e: any) {
if (!e) return;
const key = e.key;
// Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
// model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
// row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
// preventDefault it here.
if (inRowEdit()) {
if (key === 'Enter') {
e.preventDefault();
commitRow();
} else if (key === 'Escape') {
e.preventDefault();
cancelRow();
}
// B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
// editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
// Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
// first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
// tab order across a Lit shadow boundary).
else if (key === 'Tab') {
e.preventDefault();
rowEditTab(e.target, e.shiftKey);
}
return;
}
if (key === 'Enter') {
e.preventDefault();
commitEdit(undefined);
} else if (key === 'Tab') {
e.preventDefault();
// Resolve the advance target from the EDITING pair (the cell that is open), not the
// active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
// moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
// the editing pair BEFORE commit (commitEdit resets it to -1).
const fromRow = editingRow();
const fromCol = editingCol();
const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
// skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
// straight into the next editable cell's editor below. Use the RETURN value (not a
// re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
// failure returns false and keeps the editor open (the user must fix the value first).
const committed = commitEdit(undefined, true);
if (committed && target) {
setActiveRow(target.row);
setActiveColIndex(target.col);
beginEdit(target.row, target.col, null);
} else if (committed) {
// B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
// grid by returning it to the just-committed cell instead of letting it drop to <body>.
focusCellWhenReady(fromRow, fromCol);
}
} else if (key === 'Escape') {
e.preventDefault();
cancelEdit();
}
}
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
// - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
// - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
// or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
// async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
// the relatedTarget/containment check is the load-bearing guard, not the flag alone.
function onEditorBlur(e: any) {
// Full-row mode (req-6): blur NEVER commits — the row commits as a UNIT only on an
// explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
// into N writes + N events, violating the one-write/one-event contract). Tabbing between
// the row's own editors is a normal focus move, not a commit.
if (inRowEdit()) return;
if (editingRow() < 0 || editTransition) return;
const next = e ? e.relatedTarget : null;
// A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
// keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
// a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
// conservative null=skip behavior.
if (next == null) return;
// Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
// the editor open on an invalid value).
if (!(gridRoot && gridRoot.contains && gridRoot.contains(next))) {
commitEdit(undefined);
return;
}
// Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
// keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
// (which must commit + close so the grid is not wedged with an open editor).
const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
// Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
// controlled move; skip. Also skip when either cell can't be resolved (an unmounting
// editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
if (!nextCell || !fromCell || nextCell === fromCell) return;
// A Tab-advance already committed the old editor and opened the next one, so the live
// editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
// editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
// double commit, WR-04).
const fromRow = fromCell.getAttribute('data-row');
const fromCol = fromCell.getAttribute('data-col-index');
if (fromRow !== String(editingRow()) || fromCol !== String(editingCol())) return;
// Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
// commit does NOT bounce focus back to the just-committed editing cell (which would fight
// the click destination). The commit's writeData re-renders the table and can DROP DOM
// focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
// DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
// (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
// late, which would steal focus back from a subsequent navigation.
const destRow = nextCell.getAttribute('data-row');
const destCol = nextCell.getAttribute('data-col-index');
commitEdit(undefined, true);
const reseatDestFocus = () => {
if (!gridRoot || destRow == null || destCol == null || destRow === '__header') return;
const root = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
const act = root && root.activeElement ? root.activeElement : null;
// Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
if (act && gridRoot.contains && gridRoot.contains(act)) return;
const el = resolveCellEl(destRow, parseInt(destCol, 10));
if (el) el.focus();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
}
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
function editCell(rowIndex: any, colIndex: any) {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const maxCol = visibleColCount() - 1;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
setActiveIsHeader(false);
setActiveRow(r);
setActiveColIndex(c);
beginEdit(r, c, null);
}
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
function commitEditing() {
if (editingRow() >= 0) commitEdit(undefined);
}
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
function editRow(rowIndex: any) {
const lastRow = bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const rowList = rows() || [];
const row = rowList[r];
if (!row) return;
setActiveIsHeader(false);
setActiveRow(r);
beginRowEdit(row);
}
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────
// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries aria-rowindex === absRow+1 (i.e. the TARGET page has
// actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works for both
// controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames (~1s) to
// cover the controlled-state parent round-trip on React/Solid/Lit.
function focusAbsCellWhenReady(absRow: any, localRow: any, col: any) {
if (!gridRoot) return;
let attempts = 0;
const want = String(absRow + 1);
const tryFocus = () => {
const el = resolveCellEl(String(localRow), col);
if (el) {
const rowEl = el.closest ? el.closest('[role="row"]') : null;
const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
if (ari === want) {
el.focus();
return;
}
}
attempts = attempts + 1;
if (attempts >= 60) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
}
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
function focusCell(rowIndex: any, colIndex: any) {
// B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
// is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
// isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
// instance does not leak a spurious activecell-change.
if (!isGrid()) return;
const maxCol = visibleColCount() - 1;
const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
// C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
const absLast = prePaginationRowCount() - 1;
const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
// B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
// real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
// active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
const prevAbs = toAbsRow(activeRow());
const prevIsHeader = activeIsHeader();
if (local.virtual) {
// Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
// the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
// focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
setActiveIsHeader(false);
setActiveInControl(false);
setActiveRow(absRow);
setActiveColIndex(c);
focusActiveCell(absRow, c, false);
} else {
// Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
// in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
// body's data-row markers (and the roving tabindex) address.
const size = pageSize();
const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
const localRow = absRow - targetPage * size;
const switched = targetPage !== pageIndex();
if (switched) setPage(targetPage);
setActiveIsHeader(false);
setActiveInControl(false);
setActiveRow(localRow);
setActiveColIndex(c);
if (switched) {
// The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
// TARGET page's absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
// same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
focusAbsCellWhenReady(absRow, localRow, c);
} else {
// Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
// Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
// async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
focusActiveCell(localRow, c, false);
}
}
if (absRow !== prevAbs || prevIsHeader) {
_props.onActivecellChange?.({
rowIndex: absRow,
colIndex: c
});
}
}
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
function getActiveCell() {
return activeIsHeader() ? {
rowIndex: null,
colIndex: activeColIndex(),
isHeader: true
} : {
rowIndex: toAbsRow(activeRow()),
colIndex: activeColIndex(),
isHeader: false
};
}
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
function clearActiveCell() {
if (!isGrid()) return;
setActiveIsHeader(false);
setActiveInControl(false);
setActiveRow(0);
setActiveColIndex(0);
}
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────
// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
function toggleRowExpanded(rowId: any) {
if (!table) return;
const target = String(rowId);
const flat = table.getCoreRowModel().flatRows;
for (const r of flat as any) {
if (r.id === target || r.original && String(r.original.id) === target) {
r.toggleExpanded();
return;
}
}
}
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
function expandAll() {
if (!table) return;
table.toggleAllRowsExpanded(true);
}
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
function collapseAll() {
if (!table) return;
table.resetExpanded(true);
}
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
function getExpandedRows() {
if (!table) return [];
const out = [];
const flat = table.getCoreRowModel().flatRows;
for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
return out;
}
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
function applyGrouping(cols: any) {
if (table) table.setGrouping(cols);
}
function clearGrouping() {
if (table) table.setGrouping([]);
}
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
function getFacetedUniqueValues(colId: any) {
if (tick() < 0 || !table) return [];
const col = table.getColumn(colId);
if (!col || !col.getFacetedUniqueValues) return [];
const map = col.getFacetedUniqueValues(); // Map<any, number>
return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
}
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
function getFacetedMinMaxValues(colId: any) {
if (tick() < 0 || !table) return null;
const col = table.getColumn(colId);
if (!col || !col.getFacetedMinMaxValues) return null;
return col.getFacetedMinMaxValues() || null; // [number, number] | null
}
return (
<__ctx_data_table_columns.Provider value={{
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
setColReg({
...colReg(),
[key]: spec
});
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...colReg()
};
delete r[String(id)];
setColReg(r);
}
}}>
<>
<div class={"rozie-data-table-wrap"} ref={(el) => { __rozieRootRef = el as HTMLElement; }} data-rozie-s-d5dcab4c="">
<div class={"rdt-column-defs"} style={{ display: "none" }} aria-hidden="true" data-rozie-s-d5dcab4c="">{resolved()}</div>
{<Show when={!!invalidMsg()}><div class={"rdt-sr-live"} role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{invalidMsg()}</div></Show>}{<Show when={!!pasteAnnounce()}><div class={"rdt-sr-live rdt-sr-paste"} data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{pasteAnnounce()}</div></Show>}<div class={"rdt-toolbar"} data-rozie-s-d5dcab4c="">
<input type="text" role="searchbox" aria-label="Search table" class={"rdt-global-filter"} value={globalFilterValue()} onInput={($event) => { onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c="" />
{<Show when={allLeafColumns().length}><details class={"rdt-colvis"} data-rozie-s-d5dcab4c="">
<summary class={"rdt-colvis-summary"} data-rozie-s-d5dcab4c="">Columns</summary>
<div class={"rdt-colvis-menu"} role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c="">
<For each={allLeafColumns()}>{(lc) => <label class={"rdt-colvis-item"} data-rozie-s-d5dcab4c="">
<input type="checkbox" class={"rdt-colvis-checkbox"} checked={lc.visible} onChange={($event) => { onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c="" />
<span class={"rdt-colvis-label"} data-rozie-s-d5dcab4c="">{rozieDisplay(lc.label)}</span>
</label>}</For>
</div>
</details></Show>}</div>
{<Show when={local.groupable}><div class={"rdt-group-bar-host"} data-rozie-s-d5dcab4c="">
{(_props.groupBarSlot ?? _props.slots?.['groupBar'])?.({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping }) ?? <For each={groupingKeys()}>{(gk) => <span class={"rdt-group-token"} data-group-token="" data-rozie-s-d5dcab4c="">{rozieDisplay(gk)}</span>}</For>}
</div></Show>}{<Show when={local.virtual} fallback={<table aria-rowcount={rozieAttr(totalRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event) => { onGridKeyDown($event); }} onFocusIn={($event) => { syncActiveFromEvent($event); }} onFocusOut={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c="">
<thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
<For each={headerGroups()}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" data-rozie-s-d5dcab4c="">
<For each={hg.headers}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) })} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={parseInlineStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c="">
{<Show when={isSelectColumn(header.column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{<Show when={header.column.getCanSort && header.column.getCanSort()} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) ?? rozieDisplay(headerLabel(header.column.id))}
</span>
</span>}><button type="button" class={"rdt-sort-btn"} onClick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c="">
<span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) ?? rozieDisplay(headerLabel(header.column.id))}
</span>
<span class={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header.column.id))}</span>
</button></Show>}{<Show when={columnIsFilterable(header.column.id)}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} class={"rdt-col-filter"} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" /></Show>}{<Show when={columnIsFilterable(header.column.id)}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.filterSlot ?? _props.slots?.['filter'])?.({ columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
</span></Show>}<span class={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
<button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} class={"rdt-pin-btn rdt-pin-left"} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
<button type="button" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} class={"rdt-pin-btn rdt-pin-none"} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
<button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} class={"rdt-pin-btn rdt-pin-right"} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
</span>
<button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} class={"rdt-resize-handle"} onPointerDown={($event) => { onResizeStart(header.column.id, $event); }} onTouchStart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span class={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.selectAllSlot ?? _props.slots?.['selectAll'])?.({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) ?? <Show when={local.selectionMode === 'multiple'}><input type="checkbox" aria-label="Select all rows" class={"rdt-select-all"} checked={isAllRowsSelected()} onChange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
</span></Show>}</th>}</For>
</tr>}</For>
</thead>
<tbody class={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
<For each={rows()}>{(row) => <>
<tr class={"rdt-tr" + " " + rozieClass({ 'rdt-group-header': rowIsGrouped(row) })} role="row" data-depth={rozieAttr(row.depth)} aria-rowindex={rozieAttr(isGrid() ? absRowIndexOf(row) + 1 : null)} data-group-header={rozieAttr(rowIsGrouped(row) ? row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : null)} aria-expanded={(rowIsGrouped(row) ? !!rowIsExpanded(row) : null) ?? undefined} aria-level={rozieAttr(groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c="">
<For each={visibleCellsFor(row)}>{(cellCtx) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cellCtx))} tabIndex={rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx)))} style={parseInlineStyle(bodyCellStyle(row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c="">
{<Show when={isExpanderColumn(cellCtx.column.id)} fallback={<Show when={isSelectColumn(cellCtx.column.id)} fallback={<Show when={cellIsGrouped(cellCtx)} fallback={<Show when={isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
{(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.getValue())}
</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{<Show when={hasEditorSlot(cellCtx.column.id)} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'number'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'select'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'checkbox'} fallback={<input type="text" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}><input type="checkbox" data-editing-cell="" class={"rdt-cell-editor"} checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><select data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
<For each={editorOptionsOf(cellCtx.column.id)}>{(opt) => <option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>}</For>
</select></Show>}><input type="number" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.editorSlot ?? _props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
</span></Show>}</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<button type="button" data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} class={"rdt-expander rdt-group-toggle"} onClick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>
<span class={"rdt-group-value"} data-rozie-s-d5dcab4c="">
{(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.getValue())}
</span>
<span class={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span>
</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.selectCellSlot ?? _props.slots?.['selectCell'])?.({ row: row.original, checked: rowIsSelected(row), toggle: e => onToggleRow(row, e) }) ?? <input type="checkbox" aria-label="Select row" class={"rdt-select-row"} checked={rowIsSelected(row)} onChange={($event) => { onToggleRow(row, $event); }} data-rozie-s-d5dcab4c="" />}
</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{<Show when={rowCanExpand(row)}><button type="button" data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} class={"rdt-expander"} onClick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button></Show>}</span></Show>}{<Show when={isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</For>
</tr>
{<Show when={rowShowsDetail(row)}><tr class={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(row.id)} data-rozie-s-d5dcab4c="">
<td class={"rdt-detail-cell"} colSpan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c="">
{(_props.detailSlot ?? _props.slots?.['detail'])?.({ row: row.original })}
</td>
</tr></Show>}</>}</For>
</tbody>
</table>}><div class={"rdt-scroll"} style={parseInlineStyle(local.maxHeight ? 'max-height:' + local.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + local.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c="">
<table aria-rowcount={rows().length} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event) => { onGridKeyDown($event); }} onFocusIn={($event) => { syncActiveFromEvent($event); }} onFocusOut={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c="">
<thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
<For each={headerGroups()}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" data-rozie-s-d5dcab4c="">
<For each={hg.headers}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) })} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={parseInlineStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c="">
{<Show when={isSelectColumn(header.column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{<Show when={header.column.getCanSort && header.column.getCanSort()} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) ?? rozieDisplay(headerLabel(header.column.id))}
</span>
</span>}><button type="button" class={"rdt-sort-btn"} onClick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c="">
<span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
{(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) ?? rozieDisplay(headerLabel(header.column.id))}
</span>
<span class={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header.column.id))}</span>
</button></Show>}{<Show when={columnIsFilterable(header.column.id)}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} class={"rdt-col-filter"} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" /></Show>}{<Show when={columnIsFilterable(header.column.id)}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.filterSlot ?? _props.slots?.['filter'])?.({ columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
</span></Show>}<span class={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
<button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} class={"rdt-pin-btn rdt-pin-left"} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
<button type="button" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} class={"rdt-pin-btn rdt-pin-none"} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
<button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} class={"rdt-pin-btn rdt-pin-right"} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
</span>
<button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} class={"rdt-resize-handle"} onPointerDown={($event) => { onResizeStart(header.column.id, $event); }} onTouchStart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span class={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.selectAllSlot ?? _props.slots?.['selectAll'])?.({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) ?? <Show when={local.selectionMode === 'multiple'}><input type="checkbox" aria-label="Select all rows" class={"rdt-select-all"} checked={isAllRowsSelected()} onChange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
</span></Show>}</th>}</For>
</tr>}</For>
</thead>
<tbody class={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
<tr class={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
<td colSpan={rozieAttr(visibleColCount())} style={parseInlineStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
</tr>
<For each={windowedRows()}>{(wr) => <>
<tr class={"rdt-tr" + " " + rozieClass({ 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned })} role="row" data-row={rozieAttr(wr.vi.index)} aria-rowindex={rozieAttr(wr.vi.index + 1)} data-index={rozieAttr(wr.vi.index)} data-pinned={rozieAttr(wr.pinned ? 'true' : null)} data-depth={rozieAttr(wr.row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : null)} aria-expanded={(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null) ?? undefined} aria-level={rozieAttr(groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c="">
<For each={visibleCellsFor(wr.row)}>{(cellCtx) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cellCtx))} tabIndex={rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx)))} style={parseInlineStyle(bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c="">
{<Show when={isExpanderColumn(cellCtx.column.id)} fallback={<Show when={isSelectColumn(cellCtx.column.id)} fallback={<Show when={cellIsGrouped(cellCtx)} fallback={<Show when={isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
{(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.getValue())}
</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{<Show when={hasEditorSlot(cellCtx.column.id)} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'number'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'select'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'checkbox'} fallback={<input type="text" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}><input type="checkbox" data-editing-cell="" class={"rdt-cell-editor"} checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><select data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
<For each={editorOptionsOf(cellCtx.column.id)}>{(opt) => <option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>}</For>
</select></Show>}><input type="number" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.editorSlot ?? _props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
</span></Show>}</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
<button type="button" data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} class={"rdt-expander rdt-group-toggle"} onClick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>
<span class={"rdt-group-value"} data-rozie-s-d5dcab4c="">
{(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.getValue())}
</span>
<span class={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(wr.row) + ')')}</span>
</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{(_props.selectCellSlot ?? _props.slots?.['selectCell'])?.({ row: wr.row.original, checked: rowIsSelected(wr.row), toggle: e => onToggleRow(wr.row, e) }) ?? <input type="checkbox" aria-label="Select row" class={"rdt-select-row"} checked={rowIsSelected(wr.row)} onChange={($event) => { onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c="" />}
</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
{<Show when={rowCanExpand(wr.row)}><button type="button" data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} class={"rdt-expander"} onClick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button></Show>}</span></Show>}{<Show when={isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</For>
</tr>
{<Show when={rowShowsDetail(wr.row)}><tr class={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c="">
<td class={"rdt-detail-cell"} colSpan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c="">
{(_props.detailSlot ?? _props.slots?.['detail'])?.({ row: wr.row.original })}
</td>
</tr></Show>}</>}</For>
<tr class={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
<td colSpan={rozieAttr(visibleColCount())} style={parseInlineStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
</tr>
</tbody>
</table>
</div></Show>}{<Show when={!local.virtual}><div class={"rdt-pagination"} role="group" aria-label="Pagination" data-rozie-s-d5dcab4c="">
<button type="button" class={"rdt-page-btn rdt-page-prev"} disabled={!canPrevPage()} onClick={($event) => { onPrevPage(); }} data-rozie-s-d5dcab4c="">Prev</button>
<span class={"rdt-page-status"} aria-live="polite" data-rozie-s-d5dcab4c="">
{rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + pageCount())}
</span>
<button type="button" class={"rdt-page-btn rdt-page-next"} disabled={!canNextPage()} onClick={($event) => { onNextPage(); }} data-rozie-s-d5dcab4c="">Next</button>
<select aria-label="Rows per page" class={"rdt-page-size"} value={pageSize()} onChange={($event) => { onPageSizeChange($event); }} data-rozie-s-d5dcab4c="">
<option value={10} data-rozie-s-d5dcab4c="">10</option>
<option value={25} data-rozie-s-d5dcab4c="">25</option>
<option value={50} data-rozie-s-d5dcab4c="">50</option>
<option value={100} data-rozie-s-d5dcab4c="">100</option>
</select>
</div></Show>}</div>
</>
</__ctx_data_table_columns.Provider>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';
import { repeat } from 'lit/directives/repeat.js';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
const __rozieCtx_data_table_columns = createContext(Symbol.for("rozie:data-table:columns"));
interface RozieGroupBarSlotCtx {
grouping: unknown;
groupableColumns: unknown;
applyGrouping: unknown;
clearGrouping: unknown;
}
interface RozieSelectAllSlotCtx {
checked: unknown;
indeterminate: unknown;
toggle: unknown;
}
interface RozieColHeaderSlotCtx {
columnId: unknown;
column: unknown;
label: unknown;
}
interface RozieFilterSlotCtx {
columnId: unknown;
uniqueValues: unknown;
minMax: unknown;
setFilter: unknown;
}
interface RozieSelectCellSlotCtx {
row: unknown;
checked: unknown;
toggle: unknown;
}
interface RozieCellSlotCtx {
columnId: unknown;
column: unknown;
row: unknown;
value: unknown;
}
interface RozieEditorSlotCtx {
columnId: unknown;
column: unknown;
row: unknown;
value: unknown;
commit: unknown;
cancel: unknown;
}
interface RozieDetailSlotCtx {
row: unknown;
}
@customElement('rozie-data-table')
export default class DataTable extends SignalWatcher(LitElement) {
static styles = css`
.rozie-data-table[data-rozie-s-d5dcab4c] {
border-collapse: collapse;
width: 100%;
font: var(--rdt-font, 14px system-ui, sans-serif);
color: var(--rdt-color, inherit);
}
.rdt-sr-live[data-rozie-s-d5dcab4c] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-cell-editor[data-rozie-s-d5dcab4c] {
font: inherit;
width: 100%;
box-sizing: border-box;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[aria-invalid="true"][data-rozie-s-d5dcab4c] {
outline: var(--rdt-invalid-outline, 2px solid #d33);
outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-in-range[data-rozie-s-d5dcab4c] {
background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-fill-handle[data-rozie-s-d5dcab4c] {
position: absolute;
right: -3px;
bottom: -3px;
width: 8px;
height: 8px;
background: var(--rdt-fill-handle-bg, #2563eb);
border: 1px solid #fff;
cursor: crosshair;
z-index: 1;
touch-action: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
text-align: left;
border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
font-weight: var(--rdt-header-weight, 600);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-btn[data-rozie-s-d5dcab4c] {
display: inline-flex;
align-items: center;
gap: var(--rdt-sort-gap, 0.35em);
background: none;
border: none;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-ind[data-rozie-s-d5dcab4c] {
font-size: 0.8em;
opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
position: sticky;
top: var(--rdt-sticky-top, 0);
z-index: var(--rdt-sticky-z, 2);
background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-scroll[data-rozie-s-d5dcab4c] {
max-height: var(--rozie-data-table-max-height);
overflow: auto;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-bar-host[data-rozie-s-d5dcab4c] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-token[data-rozie-s-d5dcab4c] {
display: inline-flex;
align-items: center;
padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
border-radius: var(--rdt-group-token-radius, 999px);
background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] {
background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-toggle[data-rozie-s-d5dcab4c] {
margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-count[data-rozie-s-d5dcab4c] {
margin-left: var(--rdt-group-count-gap, 0.375rem);
opacity: var(--rdt-group-count-opacity, 0.65);
font-weight: 400;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] {
display: flex;
flex-direction: column;
gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-toolbar[data-rozie-s-d5dcab4c] {
display: flex;
gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-global-filter[data-rozie-s-d5dcab4c],
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter[data-rozie-s-d5dcab4c] {
font: inherit;
padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-filter-radius, 4px);
background: var(--rdt-filter-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter[data-rozie-s-d5dcab4c] {
display: block;
margin-top: var(--rdt-col-filter-gap, 0.25rem);
width: 100%;
font-weight: normal;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-pagination[data-rozie-s-d5dcab4c] {
display: flex;
align-items: center;
gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c] {
font: inherit;
cursor: pointer;
padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-btn-radius, 4px);
background: var(--rdt-page-btn-bg, transparent);
color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c]:disabled {
opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
cursor: default;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-status[data-rozie-s-d5dcab4c] {
font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-size[data-rozie-s-d5dcab4c] {
font: inherit;
padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-page-size-radius, 4px);
background: var(--rdt-page-size-bg, transparent);
color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c] {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--rdt-resize-handle-width, 6px);
padding: 0;
border: none;
background: none;
cursor: col-resize;
touch-action: none;
user-select: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
display: block;
width: var(--rdt-resize-grip-width, 2px);
height: 100%;
margin: 0 auto;
background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c]:hover .rdt-resize-grip[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th-resizing[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-controls[data-rozie-s-d5dcab4c] {
display: inline-flex;
gap: var(--rdt-pin-gap, 0.1em);
margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[data-rozie-s-d5dcab4c] {
font: inherit;
font-size: var(--rdt-pin-btn-size, 0.8em);
line-height: 1;
cursor: pointer;
padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-pin-btn-radius, 3px);
background: var(--rdt-pin-btn-bg, transparent);
color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[aria-pressed='true'][data-rozie-s-d5dcab4c] {
background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
font-weight: 700;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis[data-rozie-s-d5dcab4c] {
position: relative;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-summary[data-rozie-s-d5dcab4c] {
cursor: pointer;
font: inherit;
padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
border-radius: var(--rdt-colvis-summary-radius, 4px);
list-style: none;
user-select: none;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-menu[data-rozie-s-d5dcab4c] {
position: absolute;
z-index: var(--rdt-colvis-menu-z, 5);
margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
display: flex;
flex-direction: column;
gap: var(--rdt-colvis-item-gap, 0.25rem);
border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
border-radius: var(--rdt-colvis-menu-radius, 4px);
background: var(--rdt-colvis-menu-bg, #fff);
box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-item[data-rozie-s-d5dcab4c] {
display: flex;
align-items: center;
gap: var(--rdt-colvis-label-gap, 0.4em);
cursor: pointer;
white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-td[data-rozie-s-d5dcab4c] {
width: var(--rdt-select-col-width, 1%);
text-align: var(--rdt-select-col-align, center);
white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-all[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-row[data-rozie-s-d5dcab4c] {
cursor: pointer;
accent-color: var(--rdt-select-accent, currentColor);
}
`;
/**
* 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).
* @example
* <DataTable r-model:data="rows" :columns="cols" />
*/
@property({ type: Array, attribute: 'data' }) _data_attr!: any[];
private _dataControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'data-change', defaultValue: [], initialControlledValue: undefined });
/**
* 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).
*/
@property({ type: Array }) columns: any[] = [];
/**
* Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
*/
@property({ type: String, reflect: true }) selectionMode: string = 'none';
/**
* `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
*/
@property({ type: Array, attribute: 'sorting' }) _sorting_attr: any[] = [];
private _sortingControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'sorting-change', defaultValue: [], initialControlledValue: undefined });
/**
* The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
*/
@property({ type: String, attribute: 'global-filter' }) _globalFilter_attr: string = '';
private _globalFilterControllable = createLitControllableProperty<string>({ host: this, eventName: 'global-filter-change', defaultValue: '', initialControlledValue: undefined });
/**
* `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
*/
@property({ type: Array, attribute: 'column-filters' }) _columnFilters_attr: any[] = [];
private _columnFiltersControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'column-filters-change', defaultValue: [], initialControlledValue: undefined });
/**
* `{ 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`.
*/
@property({ type: Object, attribute: 'pagination' }) _pagination_attr: any = {
pageIndex: 0,
pageSize: 10
};
private _paginationControllable = createLitControllableProperty<any>({ host: this, eventName: 'pagination-change', defaultValue: {
pageIndex: 0,
pageSize: 10
}, initialControlledValue: undefined });
/**
* 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).
*/
@property({ type: Boolean, reflect: true }) manual: 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).
*/
@property({ type: Boolean, reflect: true }) expandable: boolean = false;
/**
* `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.
*/
@property({ type: Object, attribute: 'expanded' }) _expanded_attr: any | boolean = null;
private _expandedControllable = createLitControllableProperty<any | boolean>({ host: this, eventName: 'expanded-change', defaultValue: null, initialControlledValue: undefined });
/**
* 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.
*/
@property({ type: Function }) getSubRows: ((...args: unknown[]) => unknown) | null = null;
/**
* 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).
*/
@property({ type: Boolean, reflect: true }) groupable: boolean = false;
/**
* `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.
*/
@property({ type: Array, attribute: 'grouping' }) _grouping_attr: any[] | null = null;
private _groupingControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'grouping-change', defaultValue: null, initialControlledValue: undefined });
/**
* `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.
*/
@property({ type: Object, attribute: 'row-selection' }) _rowSelection_attr: any = {};
private _rowSelectionControllable = createLitControllableProperty<any>({ host: this, eventName: 'row-selection-change', defaultValue: {}, initialControlledValue: undefined });
/**
* `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
*/
@property({ type: Object, attribute: 'column-visibility' }) _columnVisibility_attr: any = {};
private _columnVisibilityControllable = createLitControllableProperty<any>({ host: this, eventName: 'column-visibility-change', defaultValue: {}, initialControlledValue: undefined });
/**
* `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
*/
@property({ type: Object, attribute: 'column-sizing' }) _columnSizing_attr: any = {};
private _columnSizingControllable = createLitControllableProperty<any>({ host: this, eventName: 'column-sizing-change', defaultValue: {}, initialControlledValue: undefined });
/**
* `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
*/
@property({ type: Array, attribute: 'column-order' }) _columnOrder_attr: any[] = [];
private _columnOrderControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'column-order-change', defaultValue: [], initialControlledValue: undefined });
/**
* `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
*/
@property({ type: Object, attribute: 'column-pinning' }) _columnPinning_attr: any = {
left: [],
right: []
};
private _columnPinningControllable = createLitControllableProperty<any>({ host: this, eventName: 'column-pinning-change', defaultValue: {
left: [],
right: []
}, initialControlledValue: undefined });
/**
* Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
*/
@property({ type: Boolean, reflect: true }) stickyHeader: boolean = false;
/**
* `'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
* @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
*/
@property({ type: String, reflect: true }) interactionMode: string = 'table';
/**
* 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.
*/
@property({ type: Boolean, reflect: true }) virtual: boolean = false;
/**
* Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
*/
@property({ type: Number, reflect: true }) estimateRowHeight: number = 40;
/**
* 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.
*/
@property({ type: String, reflect: true }) maxHeight: string = '';
private _dataDefault = signal<any[]>([]);
private _sortingDefault = signal<any[]>([]);
private _globalFilterDefault = signal('');
private _columnFiltersDefault = signal<any[]>([]);
private _paginationDefault = signal({
pageIndex: 0,
pageSize: 10
});
private _rowSelectionDefault = signal<any>({});
private _expandedDefault = signal<any>({});
private _groupingDefault = signal<any[]>([]);
private _columnVisibilityDefault = signal<any>({});
private _columnSizingDefault = signal<any>({});
private _columnOrderDefault = signal<any[]>([]);
private _columnPinningDefault = signal({
left: [],
right: []
});
private _columnSizingInfo = signal({
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: []
});
private _colReg = signal<any>({});
private _rows = signal<any[]>([]);
private _headerGroups = signal<any[]>([]);
private _rowModelVer = signal(0);
private _windowVer = signal(0);
private _activeRow = signal(0);
private _activeColIndex = signal(0);
private _activeIsHeader = signal(false);
private _activeHeaderLevel = signal(0);
private _activeInControl = signal(false);
private _editingRow = signal(-1);
private _editingCol = signal(-1);
private _draftValue = signal<any>(null);
private _invalidMsg = signal('');
private _editVer = signal(0);
private _editingRowIndex = signal<any>(null);
private _rowDraft = signal<any>({});
private _rangeAnchor = signal<any>(null);
private _rangeFocus = signal<any>(null);
private _pasteAnnounce = signal('');
@query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { context: __rozieCtx_data_table_columns, initialValue: ((__rozieCtxHost) => ({
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
__rozieCtxHost._colReg.value = {
...__rozieCtxHost._colReg.value,
[key]: spec
};
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...__rozieCtxHost._colReg.value
};
delete r[String(id)];
__rozieCtxHost._colReg.value = r;
}
}))(this) });
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@state() private _hasSlotGroupBar = false;
@queryAssignedElements({ slot: 'groupBar', flatten: true }) private _slotGroupBarElements!: Element[];
@property({ attribute: false }) groupBar?: (scope: { grouping: unknown; groupableColumns: unknown; applyGrouping: unknown; clearGrouping: unknown }) => unknown;
@state() private _hasSlotSelectAll = false;
@queryAssignedElements({ slot: 'selectAll', flatten: true }) private _slotSelectAllElements!: Element[];
@property({ attribute: false }) selectAll?: (scope: { checked: unknown; indeterminate: unknown; toggle: unknown }) => unknown;
@state() private _hasSlotColHeader = false;
@queryAssignedElements({ slot: 'colHeader', flatten: true }) private _slotColHeaderElements!: Element[];
@property({ attribute: false }) colHeader?: (scope: { columnId: unknown; column: unknown; label: unknown }) => unknown;
@state() private _hasSlotFilter = false;
@queryAssignedElements({ slot: 'filter', flatten: true }) private _slotFilterElements!: Element[];
@property({ attribute: false }) filter?: (scope: { columnId: unknown; uniqueValues: unknown; minMax: unknown; setFilter: unknown }) => unknown;
@state() private _hasSlotSelectCell = false;
@queryAssignedElements({ slot: 'selectCell', flatten: true }) private _slotSelectCellElements!: Element[];
@property({ attribute: false }) selectCell?: (scope: { row: unknown; checked: unknown; toggle: unknown }) => unknown;
@state() private _hasSlotCell = false;
@queryAssignedElements({ slot: 'cell', flatten: true }) private _slotCellElements!: Element[];
@property({ attribute: false }) cell?: (scope: { columnId: unknown; column: unknown; row: unknown; value: unknown }) => unknown;
@state() private _hasSlotEditor = false;
@queryAssignedElements({ slot: 'editor', flatten: true }) private _slotEditorElements!: Element[];
@property({ attribute: false }) editor?: (scope: { columnId: unknown; column: unknown; row: unknown; value: unknown; commit: unknown; cancel: unknown }) => unknown;
@state() private _hasSlotDetail = false;
@queryAssignedElements({ slot: 'detail', flatten: true }) private _slotDetailElements!: Element[];
@property({ attribute: false }) detail?: (scope: { row: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="groupBar"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotGroupBar = this._slotGroupBarElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="selectAll"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSelectAll = this._slotSelectAllElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="colHeader"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotColHeader = this._slotColHeaderElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="filter"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotFilter = this._slotFilterElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="selectCell"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSelectCell = this._slotSelectCellElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="cell"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotCell = this._slotCellElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="editor"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotEditor = this._slotEditorElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="detail"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDetail = this._slotDetailElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
this._hasSlotGroupBar = Array.from(this.children).some((el) => el.getAttribute('slot') === 'groupBar');
this._hasSlotSelectAll = Array.from(this.children).some((el) => el.getAttribute('slot') === 'selectAll');
this._hasSlotColHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'colHeader');
this._hasSlotFilter = Array.from(this.children).some((el) => el.getAttribute('slot') === 'filter');
this._hasSlotSelectCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'selectCell');
this._hasSlotCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'cell');
this._hasSlotEditor = Array.from(this.children).some((el) => el.getAttribute('slot') === 'editor');
this._hasSlotDetail = Array.from(this.children).some((el) => el.getAttribute('slot') === 'detail');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => [this.sorting, this.globalFilter, this.columnFilters, this.pagination, this.rowSelection, this.expanded, this.expandable, this.grouping, this.groupable, this.columnVisibility, this.columnSizing, this.columnOrder, this.columnPinning, this.selectionMode, (this.data || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
this.data, this._dataDefault.value, this._colReg.value])(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
this.reFeed();
})(); }); }));
this._disconnectCleanups.push(effect(() => { void this._colReg.value; this.__rozieCtxProvider_data_table_columns.setValue(((__rozieCtxHost) => ({
registerColumn: (id: any, spec: any) => {
if (id == null) return;
const key = String(id);
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
__rozieCtxHost._colReg.value = {
...__rozieCtxHost._colReg.value,
[key]: spec
};
},
unregisterColumn: (id: any) => {
if (id == null) return;
const r = {
...__rozieCtxHost._colReg.value
};
delete r[String(id)];
__rozieCtxHost._colReg.value = r;
}
}))(this)); }));
// Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
// edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
// a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
// the bound prop when controlled, this fallback otherwise.
this._dataDefault.value = this.data || [];
// Build the table instance HERE so the closures below capture the live `table`.
// Build the table instance HERE so the closures below capture the live `table`.
this.table = createTable({
// Plain value (NOT a `get data()` getter): an object-literal getter rebinds
// `this` to the options object, and the Angular/Lit emitters resolve $props via
// `this.data` — so `get data() { return $props.data }` lowers to `this.data`
// re-entering the getter → infinite recursion (max call stack). `data` is re-fed
// on every change by the watch's setOptions below, exactly like columns/state, so
// the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
// currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
// (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
data: this.currentData(),
columns: this.tableColumns(),
state: this.currentState(),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
// (mirrors the other models) — inert when `expanded` is empty + no getSubRows
// (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
// ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
// (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
// default `!!subRows.length` rule applies (only parents with children expand).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (this.getSubRows || undefined) as any,
getRowCanExpand: this.expandable === true && this.getSubRows == null ? () => true : undefined,
onExpandedChange: this.onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
// UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
// (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
// table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
// members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
// nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
// (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: this.onGroupingChangeCb,
// Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
// UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
// column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
// The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
// rows verbatim (no client-side filter/sort/paginate) and only emits the change
// events so the consumer can fetch the next page/filtered slice.
manualPagination: this.manual === true,
manualFiltering: this.manual === true,
manualSorting: this.manual === true,
// Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
// (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
// default, D-06 — NOT overridden).
enableRowSelection: this.selectionMode !== 'none',
enableMultiRowSelection: this.selectionMode === 'multiple',
// PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
// no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
// $watch so React reads fresh currentState (the stale-closure fix, F6).
onSortingChange: this.onSortingChangeCb,
onGlobalFilterChange: this.onGlobalFilterChangeCb,
onColumnFiltersChange: this.onColumnFiltersChangeCb,
onPaginationChange: this.onPaginationChangeCb,
onRowSelectionChange: this.onRowSelectionChangeCb,
onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
onColumnSizingChange: this.onColumnSizingChangeCb,
onColumnOrderChange: this.onColumnOrderChangeCb,
onColumnPinningChange: this.onColumnPinningChangeCb,
onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb,
// Resize mode: 'onChange' so the bound columnSizing model updates live during the
// drag (the behavioral width-delta assertion observes the in-progress width). Column
// resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
columnResizeMode: 'onChange',
enableColumnResizing: true,
renderFallbackValue: null,
// table-core's RESOLVED options type (TableOptionsResolved) requires a global
// onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
// callbacks above, so the global hook is a no-op. Present so the createTable() argument
// satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
onStateChange: () => {}
});
this.refreshRowModel = () => {
if (!this.table) return;
// Capture fresh locals; never write a $data key then re-read it in the same fn
// (ROZ138 / React stale-read — setState is async on React, the closure binds the
// PRE-write value).
// windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
// pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
const nextRows = this.windowSource().slice();
const nextGroups = this.table.getHeaderGroups().slice();
this._rows.value = nextRows;
this._headerGroups.value = nextGroups;
this._rowModelVer.value = this._rowModelVer.value + 1;
// Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
// into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
// NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
// setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
if (this.virtual && this.virtualizer) {
this.virtualizer.setOptions(this.virtualizerOptions());
this.virtualizer._willUpdate();
}
// D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
// clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
// no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
// B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
// async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
// the range corners on React too — never re-reading the pre-change model.
const nextRowCount = nextRows.length;
const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
this.clampActiveCell(nextRowCount, nextColCount);
// B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
// filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
// indices), so resolve the committed row's NEW index by identity HERE (never from the React-
// stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
// gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
// a no-relocation commit resolves the same index → byte-behaviorally identical to before.
if (this.pendingEditFollow && this.isGrid()) {
const follow = this.pendingEditFollow;
this.pendingEditFollow = null;
const followIdx = this.indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
if (followIdx >= 0) this.focusCellWhenReady(followIdx, follow.col);
}
// keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
// selection state (bound :indeterminate is inert on 5/6 targets). The box persists
// across selection changes; a microtask defer covers React's post-render DOM patch.
this.syncIndeterminate();
if (typeof queueMicrotask !== 'undefined') queueMicrotask(this.syncIndeterminate);else Promise.resolve().then(this.syncIndeterminate);
};
// initial pull
// initial pull
this.refreshRowModel();
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
// ── Grid mode: capture the table root ──────────────────────────────────────────────
// $el is the component root; the <table class="rozie-data-table"> is the grid root the
// cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
// (post-mount) so it is non-null and ROZ123-clean.
this.gridRoot = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-data-table') : null;
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
// WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
// page load AND was non-deterministic on React/Solid (the entry cell may not be
// committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
// cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
// mount"); the consumer drives focus by Tabbing/clicking in, never the component.
// ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
// Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
// getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
// when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
// the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
if (this.virtual) {
this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rdt-scroll') : null;
this.virtualizer = new Virtualizer(this.virtualizerOptions());
this.virtualizerCleanup = this.virtualizer._didMount();
// FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
// evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
// reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
// non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
// SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
// only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
// never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
// re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
// pulls getVirtualItems().
this._windowVer.value = this._windowVer.value + 1;
// After the first window commits (next frame), refine heights + fire the dev-mode warns
// ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
// code and these warns can never fire there (req-1 byte-identical-off preserved).
const afterFirstFrame = () => {
// D-10: measure the rendered rows.
this.remeasureWindow();
// D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
// bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
// process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
const h = this.gridScrollEl ? this.gridScrollEl.clientHeight : 0;
if (!h) {
console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
}
// D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
// CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
// virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
// ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
// detected as a pagination that DIFFERS from that default (a consumer who set a real page
// size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
// virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
const pg = this.pagination;
const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
if (this.manual !== true && pgConfigured) {
console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
}
}
updated(changedProperties: Map<string, unknown>): void {
if (!this.table) return;
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
// Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
// $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
// bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
// identical length) still re-feeds.
const d = this.currentData() || [];
if (d === this.lastData && d.length === this.lastDataLen) return;
this.lastData = d;
this.lastDataLen = d.length;
this.reFeed();
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
() => {
if (this.virtualizerCleanup) this.virtualizerCleanup();
// CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
this.teardownFillDrag();
};
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'data') this._dataControllable.notifyAttributeChange(value as unknown as any[]);
if (name === 'sorting') this._sortingControllable.notifyAttributeChange(value as unknown as any[]);
if (name === 'global-filter') this._globalFilterControllable.notifyAttributeChange(value as unknown as string);
if (name === 'column-filters') this._columnFiltersControllable.notifyAttributeChange(value as unknown as any[]);
if (name === 'pagination') this._paginationControllable.notifyAttributeChange(value as unknown as any);
if (name === 'expanded') this._expandedControllable.notifyAttributeChange(value as unknown as any | boolean);
if (name === 'grouping') this._groupingControllable.notifyAttributeChange(value as unknown as any[]);
if (name === 'row-selection') this._rowSelectionControllable.notifyAttributeChange(value as unknown as any);
if (name === 'column-visibility') this._columnVisibilityControllable.notifyAttributeChange(value as unknown as any);
if (name === 'column-sizing') this._columnSizingControllable.notifyAttributeChange(value as unknown as any);
if (name === 'column-order') this._columnOrderControllable.notifyAttributeChange(value as unknown as any[]);
if (name === 'column-pinning') this._columnPinningControllable.notifyAttributeChange(value as unknown as any);
}
render() {
return html`
<div class="rozie-data-table-wrap" data-rozie-ref="__rozieRoot" data-rozie-s-d5dcab4c>
<div class="rdt-column-defs" style="display:none" aria-hidden="true" data-rozie-s-d5dcab4c><slot></slot></div>
${!!this._invalidMsg.value ? html`<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>${this._invalidMsg.value}</div>` : nothing}${!!this._pasteAnnounce.value ? html`<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>${this._pasteAnnounce.value}</div>` : nothing}<div class="rdt-toolbar" data-rozie-s-d5dcab4c>
<input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" .value=${this.globalFilterValue()} @input=${($event: Event) => { this.onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c />
${this.allLeafColumns().length ? html`<details class="rdt-colvis" data-rozie-s-d5dcab4c>
<summary class="rdt-colvis-summary" data-rozie-s-d5dcab4c>Columns</summary>
<div class="rdt-colvis-menu" role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c>
${repeat<any>(this.allLeafColumns(), (lc, _idx) => lc.id, (lc, _idx) => html`<label class="rdt-colvis-item" key=${rozieAttr(lc.id)} data-rozie-s-d5dcab4c>
<input class="rdt-colvis-checkbox" type="checkbox" ?checked=${lc.visible} @change=${($event: Event) => { this.onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c />
<span class="rdt-colvis-label" data-rozie-s-d5dcab4c>${rozieDisplay(lc.label)}</span>
</label>`)}
</div>
</details>` : nothing}</div>
${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
${this.groupBar !== undefined ? this.groupBar({grouping: this.groupingKeys(), groupableColumns: this.groupableColumns(), applyGrouping: this.applyGrouping, clearGrouping: this.clearGrouping}) : html`<slot name="groupBar" data-rozie-params=${(() => { try { return JSON.stringify({grouping: this.groupingKeys(), groupableColumns: this.groupableColumns()}); } catch { return '{}'; } })()} @rozie-group-bar-apply-grouping=${($event: CustomEvent) => ((this.applyGrouping) as (...args: any[]) => any)($event.detail)} @rozie-group-bar-clear-grouping=${($event: CustomEvent) => ((this.clearGrouping) as (...args: any[]) => any)($event.detail)}>
${repeat<any>(this.groupingKeys(), (gk, _idx) => gk, (gk, _idx) => html`<span class="rdt-group-token" data-group-token="" key=${rozieAttr(gk)} data-rozie-s-d5dcab4c>${rozieDisplay(gk)}</span>`)}
</slot>`}
</div>` : nothing}${this.virtual ? html`<div class="rdt-scroll" style=${rozieStyle(this.maxHeight ? 'max-height:' + this.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + this.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c>
<table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${this._rows.value.length} @keydown=${($event: Event) => { this.onGridKeyDown($event); }} @focusin=${($event: Event) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event) => { this.onGridFocusOut($event); }} @mousedown=${($event: Event) => { this.onGridMouseDown($event); }} data-rozie-s-d5dcab4c>
<thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" key=${rozieAttr(hg.id)} data-rozie-s-d5dcab4c>
${repeat<any>(hg.headers, (header, _idx) => header.id, (header, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(header.column.id), 'rdt-th-resizing': this.columnIsResizing(header.column.id) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" key=${rozieAttr(header.id)} data-col=${rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level=${rozieAttr(hgLevel)} colspan=${rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index=${rozieAttr(this.headerColIndexOf(hg, header))} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(header.column.id))} style=${rozieStyle(this.thStyle(header.column.id))} data-rozie-s-d5dcab4c>
${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.selectAll !== undefined ? this.selectAll({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected(), toggle: this.onToggleAllRows}) : html`<slot name="selectAll" data-rozie-params=${(() => { try { return JSON.stringify({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected()}); } catch { return '{}'; } })()} @rozie-select-all-toggle=${($event: CustomEvent) => ((this.onToggleAllRows) as (...args: any[]) => any)($event.detail)}>
${this.selectionMode === 'multiple' ? html`<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" ?checked=${this.isAllRowsSelected()} @change=${($event: Event) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
</span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
${header.column.getCanSort && header.column.getCanSort() ? html`<button class="rdt-sort-btn" type="button" @click=${($event: Event) => { this.onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c>
<span class="rdt-header-label" data-rozie-s-d5dcab4c>
${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
</span>
<span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>${rozieDisplay(this.sortIndicator(header.column.id))}</span>
</button>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
<span class="rdt-header-label" data-rozie-s-d5dcab4c>
${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
</span>
</span>`}${this.columnIsFilterable(header.column.id) ? html`<input class="rdt-col-filter" type="text" aria-label=${rozieAttr('Filter ' + this.headerLabel(header.column.id))} .value=${this.columnFilterValue(header.column.id)} @input=${($event: Event) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: Event) => { this.stopEvent($event); }} data-rozie-s-d5dcab4c />` : nothing}${this.columnIsFilterable(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.filter !== undefined ? this.filter({columnId: header.column.id, uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id), setFilter: this.setColumnFilter}) : html`<slot name="filter" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id)}); } catch { return '{}'; } })()} @rozie-filter-set-filter=${($event: CustomEvent) => ((this.setColumnFilter) as (...args: any[]) => any)($event.detail)}></slot>`}
</span>` : nothing}<span class="rdt-pin-controls" role="group" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c>
<button class="rdt-pin-btn rdt-pin-left" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to left')} aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button>
<button class="rdt-pin-btn rdt-pin-none" type="button" aria-label=${rozieAttr('Unpin ' + this.headerLabel(header.column.id))} aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: Event) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button>
<button class="rdt-pin-btn rdt-pin-right" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to right')} aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button>
</span>
<button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: Event) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: Event) => { this.onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button>
</span>`}</th>`)}
</tr>`)}
</thead>
<tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>
<tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c>
<td colspan=${rozieAttr(this.visibleColCount())} style=${rozieStyle('height:' + this.padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
</tr>
${repeat<any>(this.windowedRows(), (wr, _idx) => wr.row.id, (wr, _idx) => html`
<tr class="${Object.entries({ "rdt-tr": true, 'rdt-group-header': this.rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="row" data-row=${rozieAttr(wr.vi.index)} aria-rowindex=${rozieAttr(wr.vi.index + 1)} data-index=${rozieAttr(wr.vi.index)} data-pinned=${rozieAttr(wr.pinned ? 'true' : null)} data-depth=${rozieAttr(wr.row.depth)} data-group-header=${rozieAttr(this.rowIsGrouped(wr.row) ? wr.row.id : null)} data-group-leaf=${rozieAttr(this.groupingActive() && !this.rowIsGrouped(wr.row) ? wr.row.id : null)} aria-expanded=${rozieAttr(this.rowIsGrouped(wr.row) ? !!this.rowIsExpanded(wr.row) : null)} aria-level=${rozieAttr(this.groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>
${repeat<any>(this.visibleCellsFor(wr.row), (cellCtx, _idx) => cellCtx.id, (cellCtx, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cellCtx.column.id), 'rdt-in-range': this.inRange(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} key=${rozieAttr(cellCtx.id)} data-col=${rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row=${rozieAttr(wr.vi.index)} data-col-index=${rozieAttr(this.colIndexOf(wr.row, cellCtx))} tabindex=${rozieAttr(this.cellTabindex(String(wr.vi.index), this.colIndexOf(wr.row, cellCtx)))} style=${rozieStyle(this.bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(wr.vi.index, this.colIndexOf(wr.row, cellCtx)))} data-in-range=${rozieAttr(this.inRange(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>
${this.isExpanderColumn(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.rowCanExpand(wr.row) ? html`<button class="rdt-expander" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(wr.row)} aria-label=${rozieAttr(this.rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} @click=${($event: Event) => { this.onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(wr.row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.selectCell !== undefined ? this.selectCell({row: wr.row.original, checked: this.rowIsSelected(wr.row), toggle: e => this.onToggleRow(wr.row, e)}) : html`<slot name="selectCell" data-rozie-params=${(() => { try { return JSON.stringify({row: wr.row.original, checked: this.rowIsSelected(wr.row)}); } catch { return '{}'; } })()} @rozie-select-cell-toggle=${($event: CustomEvent) => ((e => this.onToggleRow(wr.row, e)) as (...args: any[]) => any)($event.detail)}>
<input class="rdt-select-row" type="checkbox" aria-label="Select row" ?checked=${this.rowIsSelected(wr.row)} @change=${($event: Event) => { this.onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c />
</slot>`}
</span>` : this.cellIsGrouped(cellCtx) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
<button class="rdt-expander rdt-group-toggle" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(wr.row)} aria-label=${rozieAttr(this.rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} @click=${($event: Event) => { this.onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(wr.row) ? '▾' : '▸')}</button>
<span class="rdt-group-value" data-rozie-s-d5dcab4c>
${this.cell !== undefined ? this.cell({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.getValue())}</slot>`}
</span>
<span class="rdt-group-count" data-rozie-s-d5dcab4c>${rozieDisplay('(' + this.groupSubRowCount(wr.row) + ')')}</span>
</span>` : this.isEditing(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.hasEditorSlot(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.editor !== undefined ? this.editor({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}); } catch { return '{}'; } })()}></slot>`}
</span>` : this.editorTypeOf(cellCtx.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cellCtx.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
${repeat<any>(this.editorOptionsOf(cellCtx.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option key=${rozieAttr(opt.value)} value=${rozieAttr(opt.value)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
</select>` : this.editorTypeOf(cellCtx.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorCheckbox(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
${this.cell !== undefined ? this.cell({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.getValue())}</slot>`}
</span>`}${this.isFillHandleCell(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: Event) => { this.onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>` : nothing}</td>`)}
</tr>
${this.rowShowsDetail(wr.row) ? html`<tr class="rdt-detail-row" role="row" data-detail-row=${rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c>
<td class="rdt-detail-cell" colspan=${rozieAttr(this.visibleColCount())} data-rozie-s-d5dcab4c>
${this.detail !== undefined ? this.detail({row: wr.row.original}) : html`<slot name="detail" data-rozie-params=${(() => { try { return JSON.stringify({row: wr.row.original}); } catch { return '{}'; } })()}></slot>`}
</td>
</tr>` : nothing}`)}
<tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c>
<td colspan=${rozieAttr(this.visibleColCount())} style=${rozieStyle('height:' + this.padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
</tr>
</tbody>
</table>
</div>` : html`<table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.totalRowCount())} @keydown=${($event: Event) => { this.onGridKeyDown($event); }} @focusin=${($event: Event) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event) => { this.onGridFocusOut($event); }} @mousedown=${($event: Event) => { this.onGridMouseDown($event); }} data-rozie-s-d5dcab4c>
<thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" key=${rozieAttr(hg.id)} data-rozie-s-d5dcab4c>
${repeat<any>(hg.headers, (header, _idx) => header.id, (header, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(header.column.id), 'rdt-th-resizing': this.columnIsResizing(header.column.id) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" key=${rozieAttr(header.id)} data-col=${rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level=${rozieAttr(hgLevel)} colspan=${rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index=${rozieAttr(this.headerColIndexOf(hg, header))} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(header.column.id))} style=${rozieStyle(this.thStyle(header.column.id))} data-rozie-s-d5dcab4c>
${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.selectAll !== undefined ? this.selectAll({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected(), toggle: this.onToggleAllRows}) : html`<slot name="selectAll" data-rozie-params=${(() => { try { return JSON.stringify({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected()}); } catch { return '{}'; } })()} @rozie-select-all-toggle=${($event: CustomEvent) => ((this.onToggleAllRows) as (...args: any[]) => any)($event.detail)}>
${this.selectionMode === 'multiple' ? html`<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" ?checked=${this.isAllRowsSelected()} @change=${($event: Event) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
</span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
${header.column.getCanSort && header.column.getCanSort() ? html`<button class="rdt-sort-btn" type="button" @click=${($event: Event) => { this.onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c>
<span class="rdt-header-label" data-rozie-s-d5dcab4c>
${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
</span>
<span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>${rozieDisplay(this.sortIndicator(header.column.id))}</span>
</button>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
<span class="rdt-header-label" data-rozie-s-d5dcab4c>
${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
</span>
</span>`}${this.columnIsFilterable(header.column.id) ? html`<input class="rdt-col-filter" type="text" aria-label=${rozieAttr('Filter ' + this.headerLabel(header.column.id))} .value=${this.columnFilterValue(header.column.id)} @input=${($event: Event) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: Event) => { this.stopEvent($event); }} data-rozie-s-d5dcab4c />` : nothing}${this.columnIsFilterable(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.filter !== undefined ? this.filter({columnId: header.column.id, uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id), setFilter: this.setColumnFilter}) : html`<slot name="filter" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id)}); } catch { return '{}'; } })()} @rozie-filter-set-filter=${($event: CustomEvent) => ((this.setColumnFilter) as (...args: any[]) => any)($event.detail)}></slot>`}
</span>` : nothing}<span class="rdt-pin-controls" role="group" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c>
<button class="rdt-pin-btn rdt-pin-left" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to left')} aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button>
<button class="rdt-pin-btn rdt-pin-none" type="button" aria-label=${rozieAttr('Unpin ' + this.headerLabel(header.column.id))} aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: Event) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button>
<button class="rdt-pin-btn rdt-pin-right" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to right')} aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button>
</span>
<button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: Event) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: Event) => { this.onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button>
</span>`}</th>`)}
</tr>`)}
</thead>
<tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>
${repeat<any>(this._rows.value, (row, _idx) => row.id, (row, _idx) => html`
<tr class="${Object.entries({ "rdt-tr": true, 'rdt-group-header': this.rowIsGrouped(row) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="row" data-depth=${rozieAttr(row.depth)} aria-rowindex=${rozieAttr(this.isGrid() ? this.absRowIndexOf(row) + 1 : null)} data-group-header=${rozieAttr(this.rowIsGrouped(row) ? row.id : null)} data-group-leaf=${rozieAttr(this.groupingActive() && !this.rowIsGrouped(row) ? row.id : null)} aria-expanded=${rozieAttr(this.rowIsGrouped(row) ? !!this.rowIsExpanded(row) : null)} aria-level=${rozieAttr(this.groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c>
${repeat<any>(this.visibleCellsFor(row), (cellCtx, _idx) => cellCtx.id, (cellCtx, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cellCtx.column.id), 'rdt-in-range': this.inRange(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} key=${rozieAttr(cellCtx.id)} data-col=${rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row=${rozieAttr(this.rowIndexOf(row))} data-col-index=${rozieAttr(this.colIndexOf(row, cellCtx))} tabindex=${rozieAttr(this.cellTabindex(String(this.rowIndexOf(row)), this.colIndexOf(row, cellCtx)))} style=${rozieStyle(this.bodyCellStyle(row, cellCtx.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)))} data-in-range=${rozieAttr(this.inRange(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>
${this.isExpanderColumn(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.rowCanExpand(row) ? html`<button class="rdt-expander" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(row)} aria-label=${rozieAttr(this.rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} @click=${($event: Event) => { this.onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.selectCell !== undefined ? this.selectCell({row: row.original, checked: this.rowIsSelected(row), toggle: e => this.onToggleRow(row, e)}) : html`<slot name="selectCell" data-rozie-params=${(() => { try { return JSON.stringify({row: row.original, checked: this.rowIsSelected(row)}); } catch { return '{}'; } })()} @rozie-select-cell-toggle=${($event: CustomEvent) => ((e => this.onToggleRow(row, e)) as (...args: any[]) => any)($event.detail)}>
<input class="rdt-select-row" type="checkbox" aria-label="Select row" ?checked=${this.rowIsSelected(row)} @change=${($event: Event) => { this.onToggleRow(row, $event); }} data-rozie-s-d5dcab4c />
</slot>`}
</span>` : this.cellIsGrouped(cellCtx) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
<button class="rdt-expander rdt-group-toggle" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(row)} aria-label=${rozieAttr(this.rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} @click=${($event: Event) => { this.onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(row) ? '▾' : '▸')}</button>
<span class="rdt-group-value" data-rozie-s-d5dcab4c>
${this.cell !== undefined ? this.cell({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.getValue())}</slot>`}
</span>
<span class="rdt-group-count" data-rozie-s-d5dcab4c>${rozieDisplay('(' + this.groupSubRowCount(row) + ')')}</span>
</span>` : this.isEditing(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.hasEditorSlot(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
${this.editor !== undefined ? this.editor({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}); } catch { return '{}'; } })()}></slot>`}
</span>` : this.editorTypeOf(cellCtx.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cellCtx.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
${repeat<any>(this.editorOptionsOf(cellCtx.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option key=${rozieAttr(opt.value)} value=${rozieAttr(opt.value)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
</select>` : this.editorTypeOf(cellCtx.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorCheckbox(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
${this.cell !== undefined ? this.cell({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.getValue())}</slot>`}
</span>`}${this.isFillHandleCell(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: Event) => { this.onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>` : nothing}</td>`)}
</tr>
${this.rowShowsDetail(row) ? html`<tr class="rdt-detail-row" role="row" data-detail-row=${rozieAttr(row.id)} data-rozie-s-d5dcab4c>
<td class="rdt-detail-cell" colspan=${rozieAttr(this.visibleColCount())} data-rozie-s-d5dcab4c>
${this.detail !== undefined ? this.detail({row: row.original}) : html`<slot name="detail" data-rozie-params=${(() => { try { return JSON.stringify({row: row.original}); } catch { return '{}'; } })()}></slot>`}
</td>
</tr>` : nothing}`)}
</tbody>
</table>`}${!this.virtual ? html`<div class="rdt-pagination" role="group" aria-label="Pagination" data-rozie-s-d5dcab4c>
<button class="rdt-page-btn rdt-page-prev" type="button" ?disabled=${!this.canPrevPage()} @click=${($event: Event) => { this.onPrevPage(); }} data-rozie-s-d5dcab4c>Prev</button>
<span class="rdt-page-status" aria-live="polite" data-rozie-s-d5dcab4c>
${rozieDisplay('Page ' + (this.pageIndex() + 1) + ' of ' + this.pageCount())}
</span>
<button class="rdt-page-btn rdt-page-next" type="button" ?disabled=${!this.canNextPage()} @click=${($event: Event) => { this.onNextPage(); }} data-rozie-s-d5dcab4c>Next</button>
<select class="rdt-page-size" aria-label="Rows per page" .value=${this.pageSize()} @change=${($event: Event) => { this.onPageSizeChange($event); }} data-rozie-s-d5dcab4c>
<option value=${10} data-rozie-s-d5dcab4c>10</option>
<option value=${25} data-rozie-s-d5dcab4c>25</option>
<option value=${50} data-rozie-s-d5dcab4c>50</option>
<option value=${100} data-rozie-s-d5dcab4c>100</option>
</select>
</div>` : nothing}</div>
`;
}
table: any = null;
virtualizer: any = null;
virtualizerCleanup: any = null;
gridScrollEl: any = null;
remeasurePending = false;
GRID_PAGE_STEP = 10;
gridRoot: any = null;
programmatic = 0;
expandedTouched = false;
groupingActiveDefault = () => ((this.grouping != null ? this.grouping : this._groupingDefault.value) || []).length > 0;
currentState = (): any => ({
sorting: this.sorting != null ? this.sorting : this._sortingDefault.value,
globalFilter: this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value,
columnFilters: this.columnFilters != null ? this.columnFilters : this._columnFiltersDefault.value,
pagination: this.pagination != null ? this.pagination : this._paginationDefault.value,
rowSelection: this.rowSelection != null ? this.rowSelection : this._rowSelectionDefault.value,
// expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
// literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
// guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
// GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
// `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
// literal so the grouped subtree is visible by default; the first toggle latches
// expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
// unchanged → byte-identical-off (the table + the expandable-rows feature both keep
// $data.expandedDefault).
expanded: this.expanded != null ? this.expanded : this.groupingActiveDefault() && !this.expandedTouched ? true : this._expandedDefault.value,
// grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
// to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
// is inert when this is empty (byte-identical-off, req-10).
grouping: this.grouping != null ? this.grouping : this._groupingDefault.value,
columnVisibility: this.columnVisibility != null ? this.columnVisibility : this._columnVisibilityDefault.value,
columnSizing: this.columnSizing != null ? this.columnSizing : this._columnSizingDefault.value,
columnOrder: this.columnOrder != null ? this.columnOrder : this._columnOrderDefault.value,
columnPinning: this.columnPinning != null ? this.columnPinning : this._columnPinningDefault.value,
// columnSizingInfo: table-core's transient resize-gesture state. We pass an
// EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
// `column.getIsResizing()` / `getResizeHandler()` read
// `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
// absent. Seed the default shape (matches table-core's
// getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
// every render. Not a two-way model slice (transient gesture state, not consumer
// state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
columnSizingInfo: this._columnSizingInfo.value
});
currentData = (): any => this.data != null ? this.data : this._dataDefault.value;
isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
wrapAggregationFn = (fn: any) => {
if (typeof fn === 'string') return fn;
if (typeof fn !== 'function') return undefined;
return (columnId: any, leafRows: any, childRows: any) => {
try {
return fn(columnId, leafRows, childRows);
} catch (err: any) {
return undefined;
}
};
};
buildConfigDef = (c: any) => {
if (!c) return null;
// Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
// getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
// header spans its leaf children (B12). The group id falls back to its header text so it
// stays addressable (no accessor; group columns carry no data).
if (Array.isArray(c.columns)) {
const gid = c.id != null ? c.id : c.header;
if (gid == null) return null;
const id = String(gid);
if (!this.isSafeKey(id)) return null;
const kids = [];
for (const child of c.columns as any) {
const cd = this.buildConfigDef(child);
if (cd) kids.push(cd);
}
if (!kids.length) return null;
return {
id,
header: c.header != null ? c.header : id,
columns: kids
};
}
const rawId = c.id != null ? c.id : c.field;
if (rawId == null) return null;
const id = String(rawId);
if (!this.isSafeKey(id)) return null;
return {
id,
accessorKey: c.field != null ? c.field : id,
header: c.header != null ? c.header : id,
enableSorting: c.sortable === true,
// per-column filter opt-in (req-5). table-core gates the filter input + value
// funnel on enableColumnFilter; a column with filterable !== true cannot be
// filtered (and renders no per-column filter input in the chrome below).
enableColumnFilter: c.filterable === true,
filterable: c.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: c.expandable === true,
// Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
// so every data column is offered to the headless #groupBar by default; the per-column
// aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
// a custom fn defensively wrapped (T-50-04).
groupable: c.groupable !== false,
aggregationFn: this.wrapAggregationFn(c.aggregationFn),
pinned: c.pinned != null ? c.pinned : '',
width: c.width != null ? c.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
// metadata carrier the display↔editor branch + runValidator read. Off by default.
meta: {
editable: c.editable === true,
editor: c.editor != null ? c.editor : 'text',
editorOptions: c.editorOptions != null ? c.editorOptions : [],
validate: typeof c.validate === 'function' ? c.validate : null
}
};
};
columnDefs = () => {
const byId = Object.create(null);
const order = [];
const cfg = this.columns || [];
for (const c of cfg as any) {
const def = this.buildConfigDef(c);
if (!def) continue;
const id = def.id;
if (!(id in byId)) order.push(id);
byId[id] = def;
}
const reg = this._colReg.value || {};
for (const id in reg) {
if (!this.isSafeKey(id)) continue;
const spec = reg[id];
if (!spec) continue;
if (!(id in byId)) order.push(id);
byId[id] = {
id,
accessorKey: spec.field != null ? spec.field : id,
header: spec.header != null ? spec.header : id,
enableSorting: spec.sortable === true,
enableColumnFilter: spec.filterable === true,
filterable: spec.filterable === true,
// Expandable-rows reserved per-column metadata (phase 50, D-04).
expandable: spec.expandable === true,
// Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
groupable: spec.groupable !== false,
aggregationFn: this.wrapAggregationFn(spec.aggregationFn),
pinned: spec.pinned != null ? spec.pinned : '',
width: spec.width != null ? spec.width : '',
// Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
meta: {
editable: spec.editable === true,
editor: spec.editor != null ? spec.editor : 'text',
editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
validate: typeof spec.validate === 'function' ? spec.validate : null
}
};
}
const out = [];
for (const id of order as any) if (byId[id]) out.push(byId[id]);
return out;
};
SELECT_COL_ID = '__rdt_select';
EXPANDER_COL_ID = '__rdt_expander';
selectionEnabled = () => this.selectionMode === 'single' || this.selectionMode === 'multiple';
tableColumns = () => {
const cols = this.columnDefs();
// Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
// isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
// value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
// → byte-identical-off (req-10).
let withExpander = cols;
if (this.expandable === true) {
const expanderCol = {
id: this.EXPANDER_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isExpanderColumn: true,
pinned: '',
width: ''
};
withExpander = [expanderCol].concat(cols);
}
if (this.selectionEnabled()) {
const selectCol = {
id: this.SELECT_COL_ID,
enableSorting: false,
enableColumnFilter: false,
filterable: false,
isSelectColumn: true,
pinned: '',
width: ''
};
return [selectCol].concat(withExpander);
}
return withExpander;
};
writeSorting = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._sortingDefault.value = next; // fresh array only (never in-place)
this._sortingControllable.write(next); // two-way emit if bound (no-op-diff if not)
this.dispatchEvent(new CustomEvent("sort-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
writeExpanded = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
// Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
// the user now owns the expanded state, so currentState() stops defaulting grouped rows to
// the `true` expand-all literal and honors $data.expandedDefault from here on.
this.expandedTouched = true;
this._expandedDefault.value = next; // fresh value only (never in-place)
this._expandedControllable.write(next); // two-way emit if bound (no-op-diff if not)
// Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
// prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
// interface, and an `expanded-change` event would camelCase to the SAME identifier
// → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
// sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
// sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
this.dispatchEvent(new CustomEvent("expand-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeGrouping = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._groupingDefault.value = next; // fresh ordered array only (never in-place push)
this._groupingControllable.write(next); // two-way emit if bound (no-op-diff if not)
this.dispatchEvent(new CustomEvent("group-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeGlobalFilter = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._globalFilterDefault.value = next;
this._globalFilterControllable.write(next);
this.dispatchEvent(new CustomEvent("filter-change", {
detail: {
globalFilter: next
},
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeColumnFilters = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._columnFiltersDefault.value = next;
this._columnFiltersControllable.write(next);
this.dispatchEvent(new CustomEvent("filter-change", {
detail: {
columnFilters: next
},
bubbles: true,
composed: true
}));
this.programmatic--;
};
writePagination = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._paginationDefault.value = next;
this._paginationControllable.write(next);
this.dispatchEvent(new CustomEvent("page-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeRowSelection = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._rowSelectionDefault.value = next;
this._rowSelectionControllable.write(next);
this.dispatchEvent(new CustomEvent("selection-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeColumnVisibility = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._columnVisibilityDefault.value = next;
this._columnVisibilityControllable.write(next);
this.dispatchEvent(new CustomEvent("visibility-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeColumnSizing = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._columnSizingDefault.value = next;
this._columnSizingControllable.write(next);
this.dispatchEvent(new CustomEvent("resize-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeColumnOrder = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._columnOrderDefault.value = next;
this._columnOrderControllable.write(next);
this.dispatchEvent(new CustomEvent("reorder-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeColumnPinning = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._columnPinningDefault.value = next;
this._columnPinningControllable.write(next);
this.dispatchEvent(new CustomEvent("pin-change", {
detail: next,
bubbles: true,
composed: true
}));
this.programmatic--;
};
writeData = (next: any) => {
if (this.programmatic) return;
this.programmatic++;
this._dataDefault.value = next; // fresh array only (never in-place)
this._dataControllable.write(next); // two-way emit if bound (no-op-diff if not)
this.programmatic--;
};
columnFilterValue = (colId: any) => {
const cf = this.currentState().columnFilters || [];
for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
return '';
};
setColumnFilter = (colId: any, value: any) => {
const prev = this.currentState().columnFilters || [];
const next = [];
for (const f of prev as any) if (f && f.id !== colId) next.push(f);
if (value != null && value !== '') next.push({
id: colId,
value
});
this.writeColumnFilters(next);
};
refreshRowModel: any = null;
onSortingChangeCb = (updater: any) => {
this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
};
onExpandedChangeCb = (updater: any) => {
this.writeExpanded(this.applyUpdater(updater, this.currentState().expanded));
};
onGroupingChangeCb = (updater: any) => {
this.writeGrouping(this.applyUpdater(updater, this.currentState().grouping));
};
onGlobalFilterChangeCb = (updater: any) => {
this.writeGlobalFilter(this.applyUpdater(updater, this.currentState().globalFilter));
};
onColumnFiltersChangeCb = (updater: any) => {
this.writeColumnFilters(this.applyUpdater(updater, this.currentState().columnFilters));
};
onPaginationChangeCb = (updater: any) => {
this.writePagination(this.applyUpdater(updater, this.currentState().pagination));
};
onRowSelectionChangeCb = (updater: any) => {
this.writeRowSelection(this.applyUpdater(updater, this.currentState().rowSelection));
};
onColumnVisibilityChangeCb = (updater: any) => {
this.writeColumnVisibility(this.applyUpdater(updater, this.currentState().columnVisibility));
};
onColumnSizingChangeCb = (updater: any) => {
this.writeColumnSizing(this.applyUpdater(updater, this.currentState().columnSizing));
};
onColumnOrderChangeCb = (updater: any) => {
this.writeColumnOrder(this.applyUpdater(updater, this.currentState().columnOrder));
};
onColumnPinningChangeCb = (updater: any) => {
this.writeColumnPinning(this.applyUpdater(updater, this.currentState().columnPinning));
};
onColumnSizingInfoChangeCb = (updater: any) => {
const next = this.applyUpdater(updater, this._columnSizingInfo.value);
this._columnSizingInfo.value = next != null ? next : this._columnSizingInfo.value;
};
windowSource = () => {
if (!this.table) return [];
if (this.virtual) return this.table.getPrePaginationRowModel().rows;
return this.table.getRowModel().rows;
};
scheduleRemeasure = () => {
if (this.remeasurePending) return;
this.remeasurePending = true;
let ranMicro = false;
const microPass = () => {
this.remeasureWindow();
};
const rafPass = () => {
this.remeasurePending = false;
this.remeasureWindow();
};
if (typeof queueMicrotask !== 'undefined') {
ranMicro = true;
queueMicrotask(microPass);
}
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
};
pinnedEditIndex = () => {
if (this._editingRow.value >= 0) return this._editingRow.value;
if (this._editingRowIndex.value != null) return this._editingRowIndex.value;
return -1;
};
pinnedMeasurement = (pin: any) => {
if (!this.virtualizer || pin < 0) return null;
const ms = this.virtualizer.getMeasurements();
return ms && ms[pin] ? ms[pin] : null;
};
remeasureWindow = () => {
if (!this.virtualizer || !this.gridRoot) return;
// Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
// exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
// null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
// scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
// resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
// regression); the next settled onChange re-measures the stable window. Manual-scroll
// recycling (the CR-01 case) has scrollState === null, so it measures normally.
if (this.virtualizer.scrollState) return;
const trs = this.gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
for (const tr of trs as any) this.virtualizer.measureElement(tr);
};
virtualItemKey = (i: any) => {
const src = this.windowSource();
return src && src[i] ? src[i].id : undefined;
};
virtualizerOptions = (): any => ({
count: this.windowSource().length,
getScrollElement: () => this.gridScrollEl,
estimateSize: () => this.estimateRowHeight,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
measureElement,
overscan: 8,
getItemKey: this.virtualItemKey,
onChange: () => {
this._windowVer.value = this._windowVer.value + 1;
// CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
// virtual-core only observe()s a node you explicitly hand to measureElement (it does
// NOT auto-discover rendered rows — measureElement is the SOLE caller of
// observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
// into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
// estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
// so the new <tr> set is in the DOM before we measure. Safe from an infinite
// measure→onChange→measure loop: measureElement is idempotent on an already-observed
// node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
// measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
// re-measure is a no-op.
this.scheduleRemeasure();
}
});
pinMeasurement = (pin: number): {
start: number;
size: number;
index: number;
end: number;
} | null => this.pinnedMeasurement(pin);
windowedRows = () => {
// SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
// early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
// which happens at initial render while `virtualizer` is still null (it is built in $onMount,
// after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
// BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
// signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
// blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
// placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
// first re-run that picks up the now-non-null virtualizer.
// ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
// pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
void this._windowVer.value;
void this._editVer.value;
if (!this.virtualizer) {
// Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
// but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
// the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
// rows appear on the first onChange after _didMount.
if (!this.virtual) {
const rowList = this._rows.value || [];
return rowList.map((r: any) => ({
vi: null,
row: r
}));
}
return [];
}
const items = this.virtualizer.getVirtualItems();
const rowList = this._rows.value || [];
// WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
// shrink window where the virtualizer count is stale relative to $data.rows on the async
// onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
// throw "Cannot read properties of undefined"; filter it here so the template never sees it.
const out = items.map((vi: any) => ({
vi,
row: rowList[vi.index]
})).filter((wr: any) => wr.row);
// ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
// window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
// into another full-model row), LEADING the slice when it sits above the window and TRAILING
// it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
// padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
// into the real windowing.
const pin = this.pinnedEditIndex();
if (pin >= 0 && rowList[pin]) {
let inWindow = false;
for (let i = 0; i < items.length; i++) {
if (items[i].index === pin) {
inWindow = true;
break;
}
}
if (!inWindow) {
const pm = this.pinMeasurement(pin);
const firstStart = items.length ? items[0].start : 0;
const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
const pinnedEntry = {
vi: pm != null ? pm : {
index: pin
},
row: rowList[pin],
pinned: true
};
if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
}
}
return out;
};
padTop = () => {
// SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
// spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
// and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
void this._windowVer.value;
void this._editVer.value;
if (!this.virtual || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
let pad = items.length ? items[0].start : 0;
// D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
// in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
// that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
}
return pad < 0 ? 0 : pad;
};
padBottom = () => {
// subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
// return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
// on pin/unpin.
void this._windowVer.value;
void this._editVer.value;
if (!this.virtual || !this.virtualizer) return 0;
const items = this.virtualizer.getVirtualItems();
if (!items.length) return 0;
let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
// D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
// in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
const pin = this.pinnedEditIndex();
if (pin >= 0) {
const pm = this.pinMeasurement(pin);
const inWindow = this.pmIndexInWindow(items, pin);
// WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
// measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
// index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
// The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
// the offset comparison only if the measurement lacks an index (defensive).
const lastItemIdx = items[items.length - 1].index;
const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
if (pm && !inWindow && below) {
// below the window → it trailed the slice; subtract its height from the trailing spacer.
if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
}
}
return pad < 0 ? 0 : pad;
};
pmIndexInWindow = (items: any, idx: any) => {
for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
return false;
};
rowIsOutsideWindow = (r: any) => {
if (!this.virtual || !this.virtualizer) return false;
const items = this.virtualizer.getVirtualItems();
for (const it of items as any) if (it.index === r) return false;
return true;
};
reFeed = () => {
if (!this.table) return;
this.table.setOptions((prev: any) => ({
...prev,
data: this.currentData(),
columns: this.tableColumns(),
state: this.currentState(),
enableRowSelection: this.selectionMode !== 'none',
enableMultiRowSelection: this.selectionMode === 'multiple',
// Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
// setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
// onExpandedChange callback must re-capture fresh currentState each cycle, F6).
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (this.getSubRows || undefined) as any,
getRowCanExpand: this.expandable === true && this.getSubRows == null ? () => true : undefined,
onExpandedChange: this.onExpandedChangeCb,
// Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
// POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
// onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
// writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
// default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
// Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
// until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
// filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
// are unaffected (they fire regardless of this flag).
autoResetExpanded: false,
// Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
// omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
// fresh currentState each cycle, F6).
getGroupedRowModel: getGroupedRowModel(),
onGroupingChange: this.onGroupingChangeCb,
// Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
// drop the model on re-feed; on React the faceted closures must re-capture so exposed
// unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: makeFacetedUniqueValues(),
getFacetedMinMaxValues: makeFacetedMinMaxValues(),
// Re-pass the per-slice callbacks so React captures fresh currentState each cycle
// (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
onSortingChange: this.onSortingChangeCb,
onGlobalFilterChange: this.onGlobalFilterChangeCb,
onColumnFiltersChange: this.onColumnFiltersChangeCb,
onPaginationChange: this.onPaginationChangeCb,
onRowSelectionChange: this.onRowSelectionChangeCb,
onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
onColumnSizingChange: this.onColumnSizingChangeCb,
onColumnOrderChange: this.onColumnOrderChangeCb,
onColumnPinningChange: this.onColumnPinningChangeCb,
onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb
}));
if (this.refreshRowModel) this.refreshRowModel();
};
lastData: any = null;
lastDataLen = -1;
onHeaderSort = (colId: any, evt: any) => {
if (!this.table) return;
const col = this.table.getColumn(colId);
if (!col || !col.getCanSort()) return;
const multi = !!(evt && evt.shiftKey);
// toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
col.toggleSorting(undefined, multi);
};
tick = () => this._rowModelVer.value;
ariaSortFor = (colId: any) => {
if (this.tick() < 0 || !this.table) return 'none';
const col = this.table.getColumn(colId);
if (!col) return 'none';
const dir = col.getIsSorted();
if (dir === 'asc') return 'ascending';
if (dir === 'desc') return 'descending';
return 'none';
};
sortIndicator = (colId: any) => {
if (this.tick() < 0 || !this.table) return '';
const col = this.table.getColumn(colId);
if (!col) return '';
const dir = col.getIsSorted();
if (dir === 'asc') return '▲';
if (dir === 'desc') return '▼';
return '';
};
defFor = (colId: any) => {
const defs = this.columnDefs();
for (const d of defs as any) if (d.id === colId) return d;
return null;
};
visibleCellsFor = (row: any) => this._rowModelVer.value >= 0 ? row.getVisibleCells() : [];
editMetaOf = (colId: any) => {
const d = this.defFor(colId);
return d && d.meta ? d.meta : null;
};
columnEditable = (colId: any) => {
const m = this.editMetaOf(colId);
return !!(m && m.editable === true);
};
editorTypeOf = (colId: any) => {
const m = this.editMetaOf(colId);
return m && m.editor != null ? m.editor : 'text';
};
editorOptionsOf = (colId: any) => {
const m = this.editMetaOf(colId);
return m && m.editorOptions != null ? m.editorOptions : [];
};
hasEditorSlot = (colId: any) => this.editorTypeOf(colId) === 'custom' && !!(this._hasSlotEditor || this.editor !== undefined);
columnIsFilterable = (colId: any) => {
const d = this.defFor(colId);
return !!(d && d.filterable);
};
headerLabel = (colId: any) => {
const d = this.defFor(colId);
return d ? d.header : colId;
};
headerWidth = (colId: any) => {
if (this.tick() < 0 || !this.table) return null;
const col = this.table.getColumn(colId);
if (!col) return null;
const w = col.getSize();
return w != null && w > 0 ? w + 'px' : null;
};
onResizeStart = (colId: any, evt: any) => {
// stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!this.table) return;
const header = this.findHeader(colId);
if (!header || !header.getResizeHandler) return;
const handler = header.getResizeHandler();
if (handler) handler(evt);
};
findHeader = (colId: any) => {
const groups = this._headerGroups.value || [];
for (const hg of groups as any) {
const hs = hg.headers || [];
for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
}
return null;
};
columnIsResizing = (colId: any) => {
if (this.tick() < 0 || !this.table) return false;
const header = this.findHeader(colId);
return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};
columnIsVisible = (colId: any) => {
if (this.tick() < 0 || !this.table) return true;
const col = this.table.getColumn(colId);
return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};
onToggleVisibility = (colId: any) => {
if (!this.table) return;
const col = this.table.getColumn(colId);
if (col && col.toggleVisibility) col.toggleVisibility();
};
allLeafColumns = () => {
if (this.tick() < 0 || !this.table) return [];
const cols = this.table.getAllLeafColumns ? this.table.getAllLeafColumns() : [];
const out = [];
for (const c of cols as any) {
if (!c || c.id === this.SELECT_COL_ID) continue;
out.push({
id: c.id,
label: this.headerLabel(c.id),
visible: !!(c.getIsVisible && c.getIsVisible())
});
}
return out;
};
columnPinSide = (colId: any) => {
if (this.tick() < 0 || !this.table) return false;
const col = this.table.getColumn(colId);
if (!col || !col.getIsPinned) return false;
return col.getIsPinned();
};
onPinColumn = (colId: any, side: any, evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
if (!this.table) return;
const col = this.table.getColumn(colId);
if (col && col.pin) col.pin(side);
};
pinStyle = (colId: any) => {
if (this.tick() < 0 || !this.table) return '';
const col = this.table.getColumn(colId);
if (!col || !col.getIsPinned) return '';
const side = col.getIsPinned();
if (side === 'left') {
const left = col.getStart ? col.getStart('left') : 0;
return 'position:sticky;left:' + left + 'px;z-index:1;';
}
if (side === 'right') {
const right = col.getAfter ? col.getAfter('right') : 0;
return 'position:sticky;right:' + right + 'px;z-index:1;';
}
return '';
};
thStyle = (colId: any) => {
let s = '';
const w = this.headerWidth(colId);
if (w) s += 'width:' + w + ';';
s += this.pinStyle(colId);
return s;
};
onGlobalFilterInput = (evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
if (this.table) {
this.table.setGlobalFilter(value);
return;
}
this.writeGlobalFilter(value);
};
onColumnFilterInput = (colId: any, evt: any) => {
const value = evt && evt.target ? evt.target.value : '';
this.setColumnFilter(colId, value);
};
globalFilterValue = () => {
const v = this.currentState().globalFilter;
return v != null ? v : '';
};
pageIndex = () => {
if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageIndex;
const p = this.currentState().pagination;
return p && p.pageIndex != null ? p.pageIndex : 0;
};
pageSize = () => {
if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageSize;
const p = this.currentState().pagination;
return p && p.pageSize != null ? p.pageSize : 10;
};
pageCount = () => {
if (this.tick() < 0 || !this.table) return 1;
const c = this.table.getPageCount();
return c != null && c > 0 ? c : 1;
};
canPrevPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanPreviousPage());
canNextPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanNextPage());
onPrevPage = () => {
if (this.table) this.table.previousPage();
};
onNextPage = () => {
if (this.table) this.table.nextPage();
};
onPageSizeChange = (evt: any) => {
if (!this.table) return;
const v = evt && evt.target ? evt.target.value : '';
const n = parseInt(v, 10);
this.table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};
isSelectColumn = (colId: any) => colId === this.SELECT_COL_ID;
isExpanderColumn = (colId: any) => colId === this.EXPANDER_COL_ID;
rowCanExpand = (row: any) => !!(this.tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
rowShowsDetail = (row: any) => this.getSubRows == null && this.rowIsExpanded(row);
onToggleExpand = (row: any, evt: any) => {
if (!row || !row.toggleExpanded) return;
// Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
// expanded-state re-render. On Solid the expander <td>/<button> is RECREATED on that
// re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
// pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
// breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
// Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
// the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
// reactive flush so the fresh node exists. Harmless on the targets that keep the node
// (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
row.toggleExpanded();
if (ownerRow && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
const btn = ownerRow.querySelector('[data-expander]');
if (btn) btn.focus();
});
}
};
bodyCellStyle = (row: any, colId: any) => {
const base = this.pinStyle(colId);
if (this.isExpanderColumn(colId) && row && row.depth) {
const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
return base ? base + ';' + pad : pad;
}
return base;
};
rowIsGrouped = (row: any) => !!(this.tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
groupingActive = () => this.tick() >= 0 && (this.currentState().grouping || []).length > 0;
cellIsGrouped = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
cellIsAggregated = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
groupSubRowCount = (row: any) => row && row.subRows ? row.subRows.length : 0;
groupingKeys = () => this.currentState().grouping || [];
groupableColumns = () => {
const out = [];
const defs = this.columnDefs();
for (const d of defs as any) {
if (!d || d.groupable === false) continue;
out.push({
id: d.id,
label: d.header != null ? d.header : d.id
});
}
return out;
};
stopEvent = (evt: any) => {
if (evt && evt.stopPropagation) evt.stopPropagation();
};
isAllRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsAllRowsSelected());
isSomeRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsSomeRowsSelected());
onToggleAllRows = (evt: any) => {
if (!this.table) return;
this.table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};
rowIsSelected = (row: any) => {
if (!row) return false;
const id = row.id;
const sel = this.currentState().rowSelection || {};
if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
return !!(row.getIsSelected && row.getIsSelected());
};
onToggleRow = (row: any, evt: any) => {
if (!row || !row.toggleSelected) return;
row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};
selectAllBox: any = null;
syncIndeterminate = () => {
if (!this._ref__rozieRoot || !this._ref__rozieRoot.querySelector) return;
this.selectAllBox = this._ref__rozieRoot.querySelector('.rdt-select-all');
if (this.selectAllBox) this.selectAllBox.indeterminate = this.isSomeRowsSelected() && !this.isAllRowsSelected();
};
sortColumn = (colId: any, desc: any) => {
if (this.table) this.table.getColumn(colId) && this.table.getColumn(colId).toggleSorting(desc, false);
};
clearSorting = () => {
if (this.table) this.table.resetSorting(true);
};
getColumnDefs = () => this.columnDefs();
toggleAllRows = (value: any) => {
if (this.table) this.table.toggleAllRowsSelected(value);
};
clearSelection = () => {
if (this.table) this.table.resetRowSelection(true);
};
getSelectedRows = () => this.table ? this.table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
setPage = (idx: any) => {
if (this.table) this.table.setPageIndex(idx);
};
setRowsPerPage = (size: any) => {
if (this.table) this.table.setPageSize(size);
};
toggleColumnVisibility = (colId: any) => {
if (this.table) {
const c = this.table.getColumn(colId);
if (c && c.toggleVisibility) c.toggleVisibility();
}
};
applyColumnOrder = (order: any) => {
if (this.table) this.table.setColumnOrder(order);
};
resetColumnSizing = () => {
if (this.table) this.table.resetColumnSizing(true);
};
pinColumn = (colId: any, side: any) => {
if (this.table) {
const c = this.table.getColumn(colId);
if (c && c.pin) c.pin(side);
}
};
getRowIndexRelativeToPage = (absRow: any) => {
const abs = absRow == null ? this.toAbsRow(this._activeRow.value) : Math.trunc(Number(absRow)) || 0;
if (this.virtual) return abs;
return abs - this.pageRowOffset();
};
cut = () => this.cutRange();
isGrid = () => this.interactionMode === 'grid';
tableRole = () => this.isGrid() ? 'grid' : 'table';
cellRole = () => this.isGrid() ? 'gridcell' : 'cell';
rowIndexOf = (row: any) => this.tick() >= 0 ? (this._rows.value || []).indexOf(row) : -1;
colIndexOf = (row: any, cellCtx: any) => this.tick() >= 0 ? this.visibleCellsFor(row).indexOf(cellCtx) : -1;
headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
pageRowOffset = () => {
if (!this.isGrid() || this.virtual) return 0;
return this.pageIndex() * this.pageSize();
};
toAbsRow = (localRow: any) => localRow + this.pageRowOffset();
absRowIndexOf = (row: any) => this.rowIndexOf(row) + this.pageRowOffset();
prePaginationRowCount = () => {
if (!this.table || this.virtual) return this.bodyRowCount();
const pm = this.table.getPrePaginationRowModel();
return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
};
cellTabindex = (rowKey: any, colIndex: any, level = null) => {
if (!this.isGrid()) return null;
// B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
// the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
// tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
if (this.bodyRowCount() === 0) {
return rowKey === '__header' && colIndex === 0 && level === this.headerLeafLevel() ? 0 : -1;
}
// B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
// grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
// lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
// tab-stops (the roving invariant broke under grouped headers).
if (this._activeIsHeader.value) {
if (rowKey !== '__header') return -1;
return colIndex === this._activeColIndex.value && level === this._activeHeaderLevel.value ? 0 : -1;
}
const isActive = rowKey === String(this._activeRow.value) && colIndex === this._activeColIndex.value;
return isActive ? 0 : -1;
};
resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
if (!this.gridRoot) return null;
// B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
// same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
// integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
// match (the parent) when the leaf is meant. level is an integer (NO consumer string is
// interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
// byte-unchanged.
let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
return this.gridRoot.querySelector(sel);
};
focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
if (!this.isGrid() || !this.gridRoot) return;
const r = nextRow == null ? this._activeRow.value : nextRow;
const c = nextCol == null ? this._activeColIndex.value : nextCol;
// B12: thread the FRESH post-write header level (the grouped-header analog of the
// nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
// level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
const lvl = nextLevel == null ? this._activeHeaderLevel.value : nextLevel;
// Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
// crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
// Angular's signal write are async within one handler — re-reading $data.activeIsHeader
// here returns the PRE-write value, resolving focus to the BODY cell instead of the
// header. Callers pass the fresh isHeader local; falls back to $data when omitted.
const header = nextIsHeader == null ? this._activeIsHeader.value : nextIsHeader;
// ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
// rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
// double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
// in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
// The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
// while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
if (this.virtual && this.virtualizer && !header && this.rowIsOutsideWindow(r)) {
this.virtualizer.scrollToIndex(r, {
align: 'center'
});
// Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
// bump → the framework commits the scrolled-in row. On React that commit is async (setState →
// reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
// fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
// React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
// fast-committing targets resolve on the first attempt (behavior unchanged), React retries
// across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
// cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
let focusAttempts = 0;
const focusWhenReady = () => {
const el = this.resolveCellEl(String(r), c);
if (el) {
el.focus();
return;
}
focusAttempts = focusAttempts + 1;
if (focusAttempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
return;
}
const rowKey = header ? '__header' : String(r);
const el = this.resolveCellEl(rowKey, c, header ? lvl : null);
if (el) el.focus();
};
totalRowCount = () => {
if (!this.table) return (this._rows.value || []).length;
const fm = this.table.getFilteredRowModel();
return fm && fm.rows ? fm.rows.length : (this._rows.value || []).length;
};
visibleColCount = () => {
// NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
// state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
// self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
const rowList = this._rows.value || [];
if (rowList.length) return rowList[0].getVisibleCells().length;
const hg = this._headerGroups.value || [];
return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};
bodyRowCount = () => (this._rows.value || []).length;
clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
headerLeafLevel = () => {
const hg = this._headerGroups.value || [];
return hg.length ? hg.length - 1 : 0;
};
headerAt = (level: any, colIndex: any) => {
const hg = this._headerGroups.value || [];
const grp = hg[level];
if (!grp || !grp.headers) return null;
return grp.headers[colIndex] || null;
};
parentHeaderColIndex = (level: any, colIndex: any) => {
if (level <= 0) return -1;
const h = this.headerAt(level, colIndex);
if (!h || !h.column || !h.column.parent) return -1;
const parentId = h.column.parent.id;
const hg = this._headerGroups.value || [];
const pg = hg[level - 1];
if (!pg || !pg.headers) return -1;
for (let i = 0; i < pg.headers.length; i++) {
const ph = pg.headers[i];
if (ph && ph.column && ph.column.id === parentId) return i;
}
return -1;
};
firstChildHeaderColIndex = (level: any, colIndex: any) => {
const h = this.headerAt(level, colIndex);
if (!h || !h.column) return -1;
const kids = h.column.columns || [];
if (!kids.length) return -1;
const childId = kids[0].id;
const hg = this._headerGroups.value || [];
const cg = hg[level + 1];
if (!cg || !cg.headers) return -1;
for (let i = 0; i < cg.headers.length; i++) {
const ch = cg.headers[i];
if (ch && ch.column && ch.column.id === childId) return i;
}
return -1;
};
moveCol = (delta: any) => {
const max = this.visibleColCount() - 1;
const nextCol = this.clamp(this._activeColIndex.value + delta, 0, max < 0 ? 0 : max);
this._activeColIndex.value = nextCol;
return nextCol;
};
moveRow = (delta: any) => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const leafLevel = this.headerLeafLevel();
if (this._activeIsHeader.value) {
if (delta > 0) {
// B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
// level down); from the LEAF header level, drop into the body (row 0). A header-level
// move re-targets activeColIndex (parent↔child column indices differ), so the fresh
// col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
if (this._activeHeaderLevel.value < leafLevel) {
const childCol = this.firstChildHeaderColIndex(this._activeHeaderLevel.value, this._activeColIndex.value);
if (childCol >= 0) {
const nextLevel = this._activeHeaderLevel.value + 1;
this._activeHeaderLevel.value = nextLevel;
this._activeColIndex.value = childCol;
return {
row: this._activeRow.value,
col: childCol,
isHeader: true,
level: nextLevel
};
}
}
// At the leaf header: an empty grid has no body to drop into → stay put.
if (this.bodyRowCount() === 0) return {
row: this._activeRow.value,
col: this._activeColIndex.value,
isHeader: true,
level: this._activeHeaderLevel.value
};
// B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
// (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
// page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
// (delta-1 = 0); clamped to the page-last body row.
const landRow = this.clamp(delta - 1, 0, maxRow);
this._activeIsHeader.value = false;
this._activeRow.value = landRow;
return {
row: landRow,
col: this._activeColIndex.value,
isHeader: false,
level: 0
};
}
// B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
// spans the active column; at the top level (or no real parent) stay put. The parent col
// index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
const parentCol = this.parentHeaderColIndex(this._activeHeaderLevel.value, this._activeColIndex.value);
if (parentCol >= 0) {
const nextLevel = this._activeHeaderLevel.value - 1;
this._activeHeaderLevel.value = nextLevel;
this._activeColIndex.value = parentCol;
return {
row: this._activeRow.value,
col: parentCol,
isHeader: true,
level: nextLevel
};
}
return {
row: this._activeRow.value,
col: this._activeColIndex.value,
isHeader: true,
level: this._activeHeaderLevel.value
};
}
// In the body: an upward move from row 0 crosses into the LEAF header level (the header row
// adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
// activeColIndex carries over unchanged.
if (delta < 0 && this._activeRow.value === 0) {
this._activeIsHeader.value = true;
this._activeHeaderLevel.value = leafLevel;
return {
row: this._activeRow.value,
col: this._activeColIndex.value,
isHeader: true,
level: leafLevel
};
}
const nextRow = this.clamp(this._activeRow.value + delta, 0, maxRow);
this._activeRow.value = nextRow;
this._activeIsHeader.value = false;
return {
row: nextRow,
col: this._activeColIndex.value,
isHeader: false,
level: 0
};
};
gotoColEdge = (toEnd: any) => {
const max = this.visibleColCount() - 1;
const nextCol = toEnd ? max < 0 ? 0 : max : 0;
this._activeColIndex.value = nextCol;
return nextCol;
};
gotoStart = () => {
this._activeIsHeader.value = false;
this._activeRow.value = 0;
this._activeColIndex.value = 0;
return {
row: 0,
col: 0
};
};
gotoEnd = () => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const max = this.visibleColCount() - 1;
const maxCol = max < 0 ? 0 : max;
this._activeIsHeader.value = false;
this._activeRow.value = maxRow;
this._activeColIndex.value = maxCol;
return {
row: maxRow,
col: maxCol
};
};
currentCellEl = () => {
const rowKey = this._activeIsHeader.value ? '__header' : String(this._activeRow.value);
return this.resolveCellEl(rowKey, this._activeColIndex.value, this._activeIsHeader.value ? this._activeHeaderLevel.value : null);
};
focusables = (cellEl: any) => {
if (!cellEl || !cellEl.querySelectorAll) return [];
const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
return list.filter((n: any) => !n.disabled);
};
enterControl = () => {
const cellEl = this.currentCellEl();
const list = this.focusables(cellEl);
if (!list.length) return;
this._activeInControl.value = true;
list[0].focus();
};
cycleWithinCell = (cellEl: any, forward: any) => {
const list = this.focusables(cellEl);
if (!list.length) return;
const active = this.gridRoot ? this.gridRoot.getRootNode().activeElement : null;
const cur = list.indexOf(active);
let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
if (i >= list.length) i = 0;
if (i < 0) i = list.length - 1;
list[i].focus();
};
onGridKeyDown = (e: any) => {
if (!this.isGrid() || !e) return;
const key = e.key;
// Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
// via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
// early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
// modes (editing / in-control / navigation) stay mutually exclusive and ordered.
if (this._editingRow.value >= 0) return;
// Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
// editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
// keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
if (this._editingRowIndex.value != null) return;
// Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
if (this._activeInControl.value) {
if (key === 'Escape') {
e.preventDefault();
this._activeInControl.value = false;
// Return focus to the OWNING cell (no move happened) — pass the current indices
// explicitly (the React-emitted seam types both params as required; a zero-arg call
// is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
this.focusActiveCell(this._activeRow.value, this._activeColIndex.value);
} else if (key === 'Tab') {
e.preventDefault();
this.cycleWithinCell(this.currentCellEl(), !e.shiftKey);
}
return;
}
// WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
// grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
// user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
// NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
// focused node; if it is not itself a [data-grid-cell], let the event pass through.
const tgt = e.target;
if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
// Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
// nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
// async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
// plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
// in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
// fires ONLY on a real move (a clamped no-op edge move leaves them identical).
const prevRow = this._activeRow.value;
const prevCol = this._activeColIndex.value;
const prevIsHeader = this._activeIsHeader.value;
const prevLevel = this._activeHeaderLevel.value;
let nextRow = prevRow;
let nextCol = prevCol;
let nextIsHeader = prevIsHeader;
// B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
// threaded into the focus seam so a leaf↔parent header move lands focus at the correct
// level. moveRow returns it; the non-vertical branches keep the pre-move level.
let nextLevel = prevLevel;
// ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
// the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
// fall through to a plain navigation move). Body cells only (no range from a header). The
// extendRange call owns focus + the range-change emit, so return immediately. ──────────
if (key === 'ArrowRight' && e.shiftKey && !this._activeIsHeader.value) {
e.preventDefault();
this.extendRange(0, 1);
return;
} else if (key === 'ArrowLeft' && e.shiftKey && !this._activeIsHeader.value) {
e.preventDefault();
this.extendRange(0, -1);
return;
} else if (key === 'ArrowDown' && e.shiftKey && !this._activeIsHeader.value) {
e.preventDefault();
this.extendRange(1, 0);
return;
} else if (key === 'ArrowUp' && e.shiftKey && !this._activeIsHeader.value) {
e.preventDefault();
this.extendRange(-1, 0);
return;
} else if (key === 'ArrowRight') {
e.preventDefault();
this.clearRange();
nextCol = this.moveCol(1);
} else if (key === 'ArrowLeft') {
e.preventDefault();
this.clearRange();
nextCol = this.moveCol(-1);
} else if (key === 'ArrowDown') {
e.preventDefault();
this.clearRange();
const m = this.moveRow(1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'ArrowUp') {
e.preventDefault();
this.clearRange();
const m = this.moveRow(-1);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageDown') {
e.preventDefault();
const m = this.moveRow(this.GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'PageUp') {
e.preventDefault();
const m = this.moveRow(-this.GRID_PAGE_STEP);
nextRow = m.row;
nextCol = m.col;
nextIsHeader = m.isHeader;
nextLevel = m.level;
} else if (key === 'Home') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const s = this.gotoStart();
nextRow = s.row;
nextCol = s.col;
nextIsHeader = false;
} else {
nextCol = this.gotoColEdge(false);
}
} else if (key === 'End') {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const en = this.gotoEnd();
nextRow = en.row;
nextCol = en.col;
nextIsHeader = false;
} else {
nextCol = this.gotoColEdge(true);
}
}
// ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
// pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
// edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
// type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
// gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
// falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
// (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
e.preventDefault();
this.copyRange();
return;
} else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
e.preventDefault();
this.pasteRange();
return;
}
// ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
// source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
// Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
// and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
// shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
e.preventDefault();
this.cutRange();
return;
}
// ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
// EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
// (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
// collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
// at least the active editable column); a non-editable active cell falls through unchanged.
else if (key === 'F2' && e.shiftKey && this.isActiveCellEditable()) {
e.preventDefault();
this.beginRowEdit((this._rows.value || [])[this._activeRow.value]);
return;
}
// ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
// Gated by isActiveCellEditable(): a non-editable active cell falls through to
// enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
// (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
else if ((key === 'Enter' || key === 'F2') && this.isActiveCellEditable()) {
e.preventDefault();
this.beginEdit(this._activeRow.value, this._activeColIndex.value, null);
return;
} else if (this.isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
// checkbox/select/date editor must NOT take the typed char as its value (it would
// force-check the checkbox, seed a garbage select option, or corrupt the date) — open
// those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
e.preventDefault();
const editType = this.editorTypeOf(this.activeCellColumnId());
const seed = editType === 'text' || editType === 'number' ? key : null;
this.beginEdit(this._activeRow.value, this._activeColIndex.value, seed);
return;
}
// ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
// expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
// verified invariant) so it never hits the edit branches above and would otherwise fall to
// enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
// Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
// model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
// ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
// full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
else if (key === 'Enter' && !this._activeIsHeader.value && this.rowIsGrouped((this._rows.value || [])[this._activeRow.value])) {
e.preventDefault();
// C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
// active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
// the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
// row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
// fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
// DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
// while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
// settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
// onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
// SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
// until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
// already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
const grpRow = this._activeRow.value;
const grpCol = this._activeColIndex.value;
this.onToggleExpand((this._rows.value || [])[this._activeRow.value], e);
this.recoverGridFocus(String(grpRow), grpCol, null);
return;
} else if (key === 'Enter' || key === 'F2') {
e.preventDefault();
this.enterControl();
return;
} else return;
// THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
// focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
this.focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
// WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
// changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
// row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
// B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
// C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
// keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
// toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
// The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
this.dispatchEvent(new CustomEvent("activecell-change", {
detail: {
rowIndex: this.toAbsRow(nextRow),
colIndex: nextCol
},
bubbles: true,
composed: true
}));
}
};
syncActiveFromEvent = (e: any) => {
if (!this.isGrid() || !e) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null) return;
const col = parseInt(colAttr, 10);
if (!Number.isFinite(col)) return;
const isHeader = rowAttr === '__header';
this._activeIsHeader.value = isHeader;
if (isHeader) {
// B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
// roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
// one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
const lvlAttr = cellEl.getAttribute('data-header-level');
const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : this.headerLeafLevel();
this._activeHeaderLevel.value = Number.isFinite(lvl) ? lvl : this.headerLeafLevel();
} else {
const row = parseInt(rowAttr, 10);
if (Number.isFinite(row)) this._activeRow.value = row;
}
this._activeColIndex.value = col;
// A plain focus collapses any range back to the single active cell — EXCEPT (a) the
// programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
// ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
// focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
// BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
// path owns the shift case and flags it here so the collapse is skipped.
if (this.rangeTransition) {
this.rangeTransition = false;
} else if (this.rangeClickPending) {
this.rangeClickPending = false;
} else {
this.clearRange();
}
// The cell box (not an inner control) receiving focus = navigation mode.
if (tgt === cellEl) this._activeInControl.value = false;
};
onGridMouseDown = (e: any) => {
if (!this.isGrid() || !e || !e.shiftKey) return;
const tgt = e.target;
if (!tgt || !tgt.closest) return;
const cellEl = tgt.closest('[data-grid-cell]');
if (!cellEl) return;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
const row = parseInt(rowAttr, 10);
const col = parseInt(colAttr, 10);
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
this.setRangeFocus(row, col);
this._activeIsHeader.value = false;
this._activeRow.value = row;
this._activeColIndex.value = col;
this.rangeClickPending = true;
};
onGridFocusOut = (e: any) => {
if (!this.isGrid() || !this._activeInControl.value) return;
const next = e ? e.relatedTarget : null;
const cellEl = this.currentCellEl();
if (!cellEl || !next || !cellEl.contains(next)) this._activeInControl.value = false;
};
recoverGridFocus = (rowKey: any, col: any, level: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = this.resolveCellEl(rowKey, col, level);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
clampActiveCell = (rowCount: any, colCount: any) => {
if (!this.isGrid()) return;
// B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
// just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
// async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
// and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
// out of bounds on React only). Falls back to the live helpers when called without bounds.
const colN = colCount != null ? colCount : this.visibleColCount();
const rowN = rowCount != null ? rowCount : this.bodyRowCount();
// B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
// shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
// framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
// — true on all six, incl React's async setState), so the doomed cell + its focus are still
// observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
// programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
// Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
// doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
// The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
// NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
// shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
// DOM coords are always fresh.
let recoverFocus = false;
let doomedRow = -1;
let doomedCol = 0;
if (this.gridRoot) {
const rootNode = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
const focusedEl = rootNode ? rootNode.activeElement : null;
const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
if (focusedCell && this.gridRoot.contains(focusedCell)) {
const fRowAttr = focusedCell.getAttribute('data-row');
const fColAttr = focusedCell.getAttribute('data-col-index');
if (fRowAttr != null && fRowAttr !== '__header') {
const fr = parseInt(fRowAttr, 10);
const fc = parseInt(fColAttr, 10);
if (Number.isFinite(fr) && fr > rowN - 1) {
recoverFocus = true;
doomedRow = fr;
doomedCol = Number.isFinite(fc) ? fc : 0;
}
}
}
}
const maxCol = colN - 1;
const col = this.clamp(this._activeColIndex.value, 0, maxCol < 0 ? 0 : maxCol);
if (col !== this._activeColIndex.value) this._activeColIndex.value = col;
// B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
// cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
// an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
// re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
if (rowN <= 0) {
this._activeIsHeader.value = true;
this._activeHeaderLevel.value = this.headerLeafLevel();
this._activeColIndex.value = 0;
// B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
// reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
// async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
// class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
// recovery branch below actually runs on React too.
this.gridEmptyFallback = true;
this.clampRange(rowN - 1, colN - 1);
// B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
// reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
// Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
// (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
// the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
return;
}
// B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
// re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
// cell. A user-driven header position (not the empty fallback) is left untouched.
if (this.gridEmptyFallback) {
this.gridEmptyFallback = false;
this._activeIsHeader.value = false;
this._activeRow.value = 0;
}
if (!this._activeIsHeader.value) {
const lastRow = rowN - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const row = this.clamp(this._activeRow.value, 0, maxRow);
if (row !== this._activeRow.value) this._activeRow.value = row;
}
// B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
// shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
// out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
this.clampRange(rowN - 1, colN - 1);
// B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
// when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
// into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
if (recoverFocus) {
const recRow = this.clamp(doomedRow, 0, rowN - 1);
const recCol = this.clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
this.recoverGridFocus(String(recRow), recCol, null);
}
};
gridEmptyFallback = false;
rangeTransition = false;
rangeClickPending = false;
rangeActive = false;
inRange = (rIdx: any, cIdx: any) => {
const a = this._rangeAnchor.value;
const f = this._rangeFocus.value;
if (!a || !f) return false;
const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};
getSelectedRange = () => {
// B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
// payload) never reports a corner past a shrunken model — React-stale-safe (the eager
// refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
const a = this._rangeAnchor.value;
const f = this._rangeFocus.value;
if (!a && !f) return {
anchor: null,
focus: null
};
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
anchor: null,
focus: null
};
const clampCorner = (c: any) => c == null ? null : {
rowIndex: this.clamp(c.rowIndex, 0, maxRow),
colIndex: this.clamp(c.colIndex, 0, maxCol)
};
return {
anchor: clampCorner(a),
focus: clampCorner(f)
};
};
isFillHandleCell = (rIdx: any, cIdx: any) => {
const a = this._rangeAnchor.value;
const f = this._rangeFocus.value;
if (!a || !f) return false;
const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
return rIdx === r1 && cIdx === c1;
};
emitRangeChange = (anchor: any, focus: any) => {
this.dispatchEvent(new CustomEvent("range-change", {
detail: {
anchor,
focus
},
bubbles: true,
composed: true
}));
};
extendRange = (dRow: any, dCol: any) => {
if (this._activeIsHeader.value) return;
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
// Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
let anchor = this._rangeAnchor.value;
let focus = this._rangeFocus.value;
const hadRange = !!(anchor && focus);
if (!anchor || !focus) {
anchor = {
rowIndex: this._activeRow.value,
colIndex: this._activeColIndex.value
};
focus = {
rowIndex: this._activeRow.value,
colIndex: this._activeColIndex.value
};
}
const nextRow = this.clamp(focus.rowIndex + dRow, 0, maxRow);
const nextCol = this.clamp(focus.colIndex + dCol, 0, maxCol);
const nextFocus = {
rowIndex: nextRow,
colIndex: nextCol
};
this._rangeAnchor.value = anchor;
this._rangeFocus.value = nextFocus;
this.rangeActive = true;
// Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
// from the range's leading edge, the spreadsheet convention).
this._activeRow.value = nextRow;
this._activeColIndex.value = nextCol;
// Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
// settle on the new focus corner is part of THIS range extension, not a fresh navigation.
this.rangeTransition = true;
this.focusActiveCell(nextRow, nextCol, false);
// B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
// and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
// change → no emit. Seeding a brand-new range (no prior range) is always a change (the
// rectangle came into existence) even if its first corner is a degenerate 1×1.
if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
this.emitRangeChange(anchor, nextFocus);
}
};
setRangeFocus = (rIdx: any, cIdx: any) => {
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return;
let anchor = this._rangeAnchor.value;
if (!anchor) anchor = {
rowIndex: this._activeRow.value,
colIndex: this._activeColIndex.value
};
const r = this.clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
const c = this.clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
const nextFocus = {
rowIndex: r,
colIndex: c
};
this._rangeAnchor.value = anchor;
this._rangeFocus.value = nextFocus;
this.rangeActive = true;
this.emitRangeChange(anchor, nextFocus);
};
clearRange = () => {
// B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
// in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
// move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
// would let the SECOND call through and emit a duplicate range-change. rangeActive flips
// synchronously → the second call returns here.
if (!this.rangeActive) return;
this.rangeActive = false;
this._rangeAnchor.value = null;
this._rangeFocus.value = null;
this.emitRangeChange(null, null);
};
clampRange = (maxRowArg: any, maxColArg: any) => {
const a = this._rangeAnchor.value;
const f = this._rangeFocus.value;
if (!a && !f) return;
// Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
// shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
const maxRow = maxRowArg != null ? maxRowArg : this.bodyRowCount() - 1;
const maxCol = maxColArg != null ? maxColArg : this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) {
this._rangeAnchor.value = null;
this._rangeFocus.value = null;
this.rangeActive = false;
return;
}
if (a) {
const ar = this.clamp(a.rowIndex, 0, maxRow);
const ac = this.clamp(a.colIndex, 0, maxCol);
if (ar !== a.rowIndex || ac !== a.colIndex) this._rangeAnchor.value = {
rowIndex: ar,
colIndex: ac
};
}
if (f) {
const fr = this.clamp(f.rowIndex, 0, maxRow);
const fc = this.clamp(f.colIndex, 0, maxCol);
if (fr !== f.rowIndex || fc !== f.colIndex) this._rangeFocus.value = {
rowIndex: fr,
colIndex: fc
};
}
};
announce = (msg: any) => {
this._pasteAnnounce.value = msg != null ? msg : '';
};
clipboardActiveAllowed = () => !this._activeIsHeader.value;
fieldOfColId = (colId: any) => {
const d = this.defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
normalizedRange = () => {
const a = this._rangeAnchor.value;
const f = this._rangeFocus.value;
if (!a || !f) return null;
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return null;
const ar = this.clamp(a.rowIndex, 0, maxRow);
const ac = this.clamp(a.colIndex, 0, maxCol);
const fr = this.clamp(f.rowIndex, 0, maxRow);
const fc = this.clamp(f.colIndex, 0, maxCol);
return {
r0: ar < fr ? ar : fr,
r1: ar > fr ? ar : fr,
c0: ac < fc ? ac : fc,
c1: ac > fc ? ac : fc
};
};
escapeTsvField = (s: any) => {
if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
rangeToTsv = () => {
const box = this.normalizedRange();
const r0 = box ? box.r0 : this._activeRow.value;
const r1 = box ? box.r1 : this._activeRow.value;
const c0 = box ? box.c0 : this._activeColIndex.value;
const c1 = box ? box.c1 : this._activeColIndex.value;
const lines = [];
for (let r = r0; r <= r1; r++) {
const cells = [];
for (let c = c0; c <= c1; c++) {
const v = this.cellValueAt(r, c);
cells.push(this.escapeTsvField(v == null ? '' : String(v)));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
};
parseTsv = (text: any) => {
const str = text != null ? String(text) : '';
// CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
// large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
// single-pass scan allocates a cell-per-character grid.
if (str === '' || str.length > 2000000) return [];
// B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
// cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
// newlines, and doubled quotes ("") inside it are literal content until the closing quote;
// an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
let i = 0;
const n = str.length;
while (i < n) {
const ch = str[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < n && str[i + 1] === '"') {
field = field + '"';
i = i + 2;
continue;
}
inQuotes = false;
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
continue;
}
if (ch === '"' && field === '') {
inQuotes = true;
i = i + 1;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
i = i + 1;
continue;
}
if (ch === '\r') {
if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
if (ch === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i = i + 1;
continue;
}
field = field + ch;
i = i + 1;
}
// Flush the trailing field + row.
row.push(field);
rows.push(row);
// Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
if (rows.length > 1) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
};
copyRange = () => {
// B11: never copy from a header-active state (the reusable clipboard guard).
if (!this.clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
try {
const p = navigator.clipboard.writeText(this.rangeToTsv());
if (p && p.catch) p.catch(() => {});
} catch (err: any) {/* best-effort copy */}
};
applyGridToRange = (grid: any, originRow: any, originCol: any) => {
const maxRow = this.bodyRowCount() - 1;
const maxCol = this.visibleColCount() - 1;
if (maxRow < 0 || maxCol < 0) return {
wrote: 0,
total: 0
};
let total = 0;
let wrote = 0;
const committed = [];
// Build the fresh data array incrementally so the whole paste is ONE writeData.
let next = this.currentData();
for (let gr = 0; gr < grid.length; gr++) {
const r = originRow + gr;
if (r > maxRow) break;
const cols = grid[gr] || [];
for (let gc = 0; gc < cols.length; gc++) {
const c = originCol + gc;
if (c > maxCol) break;
total = total + 1;
const colId = this.columnIdAt(r, c);
if (colId == null || !this.columnEditable(colId)) continue;
const rowObj = this.rowOriginalAt(r);
// B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
// single-cell commit coercion) — a numeric column commits a real Number, an empty cell
// commits null; every other editor type passes through verbatim. No mixed/garbage types
// ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
const value = this.coerceCellValue(colId, cols[gc]);
// T-51-01: validate the pasted value as plain DATA before any write.
if (this.runValidator(colId, value, rowObj) !== true) continue;
const field = this.fieldOfColId(colId);
const srcIndex = this.sourceIndexOfRow(r);
const oldValue = rowObj ? rowObj[field] : null;
next = this.replaceRowValue(next, srcIndex, field, value);
committed.push({
rowId: this.rowIdAt(r),
columnId: colId,
oldValue,
newValue: value
});
wrote = wrote + 1;
}
}
if (wrote > 0) {
this.editTransition = true;
this.writeData(next);
this.editTransition = false;
// One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
for (let i = 0; i < committed.length; i++) this.dispatchEvent(new CustomEvent("cell-edit-commit", {
detail: committed[i],
bubbles: true,
composed: true
}));
}
// WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
// targeted real cells but every one was skipped (validation-failed / non-editable), announce a
// distinct validation-failed message instead of a misleading "0 of M cells pasted".
if (wrote > 0) this.announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) this.announce('No cells pasted — ' + total + ' cells were invalid or read-only');
return {
wrote,
total
};
};
rowOriginalAt = (rowIndex: any) => {
const rowList = this._rows.value || [];
const row = rowList[rowIndex];
return row ? row.original : null;
};
rowIdAt = (rowIndex: any) => {
const rowList = this._rows.value || [];
const row = rowList[rowIndex];
return row ? row.id : null;
};
tileGridToBox = (grid: any, box: any) => {
const srcRows = grid.length;
const srcCols = srcRows > 0 ? grid[0].length : 0;
if (srcRows <= 0 || srcCols <= 0) return grid;
const boxRows = box.r1 - box.r0 + 1;
const boxCols = box.c1 - box.c0 + 1;
const rows = boxRows > srcRows ? boxRows : srcRows;
const cols = boxCols > srcCols ? boxCols : srcCols;
const out = [];
for (let r = 0; r < rows; r++) {
const srcLine = grid[r % srcRows] || [];
const line = [];
for (let c = 0; c < cols; c++) {
const v = srcLine[c % srcCols];
line.push(v != null ? v : '');
}
out.push(line);
}
return out;
};
pasteRange = () => {
// B11: never paste into a header-active state (the reusable clipboard guard) — a header
// anchor would silently write body row 0 at the header's column.
if (!this.clipboardActiveAllowed()) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
// CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
// C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
// single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
// on React; re-reading them inside the async .then() returns the mount-render stale value, so a
// selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
// the box + anchor now and pass them into tileGridToBox / applyGridToRange.
const box = this.normalizedRange();
const anchorRow = box ? box.r0 : this._activeRow.value;
const anchorCol = box ? box.c0 : this._activeColIndex.value;
const destBox = box || {
r0: anchorRow,
r1: anchorRow,
c0: anchorCol,
c1: anchorCol
};
let p: any = null;
try {
p = navigator.clipboard.readText();
} catch (err: any) {
return;
}
if (!p || !p.then) return;
p.then((text: any) => {
const grid = this.parseTsv(text);
if (!grid.length) return;
// C3: tile the clipboard block to fill the destination range (single→range fill,
// smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
const tiled = this.tileGridToBox(grid, destBox);
this.applyGridToRange(tiled, anchorRow, anchorCol);
}).catch(() => {});
};
cutRange = () => {
if (!this.clipboardActiveAllowed()) return;
// Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
const box = this.normalizedRange();
const r0 = box ? box.r0 : this._activeRow.value;
const r1 = box ? box.r1 : this._activeRow.value;
const c0 = box ? box.c0 : this._activeColIndex.value;
const c1 = box ? box.c1 : this._activeColIndex.value;
// Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
const cp = navigator.clipboard.writeText(this.rangeToTsv());
if (cp && cp.catch) cp.catch(() => {});
} catch (err: any) {/* best-effort copy */}
}
// Clear the source: a grid of empty strings sized to the range, applied at the top-left.
const grid = [];
for (let r = r0; r <= r1; r++) {
const cols = [];
for (let c = c0; c <= c1; c++) cols.push('');
grid.push(cols);
}
this.applyGridToRange(grid, r0, c0);
};
tileIndex = (i: any, lo: any, hi: any) => {
const span = hi - lo + 1;
if (span <= 1) return lo;
let k = (i - lo) % span;
if (k < 0) k = k + span;
return lo + k;
};
fillRange = (sourceBox: any, endCell: any) => {
// B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
// the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
// React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
// grows), so deriving the box from the threaded endpoints is what makes the fill cover the
// dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
let box;
if (sourceBox && sourceBox.r0 != null && endCell) {
let r0 = sourceBox.r0;
let r1 = sourceBox.r1;
let c0 = sourceBox.c0;
let c1 = sourceBox.c1;
if (endCell.r < r0) r0 = endCell.r;
if (endCell.r > r1) r1 = endCell.r;
if (endCell.c < c0) c0 = endCell.c;
if (endCell.c > c1) c1 = endCell.c;
box = {
r0,
r1,
c0,
c1
};
} else {
box = this.normalizedRange();
}
if (!box) return;
const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
r0: box.r0,
r1: box.r0,
c0: box.c0,
c1: box.c0
};
const grid = [];
for (let r = box.r0; r <= box.r1; r++) {
const cols = [];
for (let c = box.c0; c <= box.c1; c++) {
const sr = this.tileIndex(r, src.r0, src.r1);
const sc = this.tileIndex(c, src.c0, src.c1);
const v = this.cellValueAt(sr, sc);
cols.push(v == null ? '' : String(v));
}
grid.push(cols);
}
this.applyGridToRange(grid, box.r0, box.c0);
};
fillDragging = false;
fillDragMove: any = null;
fillDragUp: any = null;
teardownFillDrag = () => {
if (typeof document !== 'undefined') {
if (this.fillDragMove) document.removeEventListener('pointermove', this.fillDragMove);
if (this.fillDragUp) document.removeEventListener('pointerup', this.fillDragUp);
}
this.fillDragMove = null;
this.fillDragUp = null;
this.fillDragging = false;
};
cellIndexFromPoint = (clientX: any, clientY: any) => {
if (typeof document === 'undefined' || !document.elementFromPoint) return null;
let el = document.elementFromPoint(clientX, clientY);
// Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
// a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
// and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
// deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
if (!inner || inner === el) break;
el = inner;
}
if (!el || !el.closest) return null;
const cellEl = el.closest('[data-grid-cell]');
if (!cellEl) return null;
const rowAttr = cellEl.getAttribute('data-row');
const colAttr = cellEl.getAttribute('data-col-index');
if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
const r = parseInt(rowAttr, 10);
const c = parseInt(colAttr, 10);
if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
return {
r,
c
};
};
onFillHandlePointerDown = (e: any) => {
if (!e) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
this.fillDragging = true;
// B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
// range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
// up/left drag copies from the real origin (not the post-drag corner that would flip to a
// target cell). Captured per-gesture in the closure (no module-let needed).
const sourceBox = this.normalizedRange();
// B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
// the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
let lastCell = sourceBox ? {
r: sourceBox.r1,
c: sourceBox.c1
} : null;
const move = (ev: any) => {
if (!this.fillDragging) return;
const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
// B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
// pointermove (the pointer fires many per cell) spams the event with identical payloads.
// Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
// seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
// or re-enters the same cell is suppressed (the range is unchanged).
if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
lastCell = cell;
this.setRangeFocus(cell.r, cell.c);
}
};
const up = () => {
// teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
this.teardownFillDrag();
this.fillRange(sourceBox, lastCell);
};
// Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
this.fillDragMove = move;
this.fillDragUp = up;
if (typeof document !== 'undefined') {
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
}
};
activeCellColumnId = () => {
if (this._activeIsHeader.value) return null;
const rowList = this._rows.value || [];
const row = rowList[this._activeRow.value];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[this._activeColIndex.value];
return cell && cell.column ? cell.column.id : null;
};
isActiveCellEditable = () => {
const colId = this.activeCellColumnId();
return colId != null && this.columnEditable(colId);
};
isEditing = (rowIndex: any, colIndex: any) => {
if (this._editVer.value < 0) return false;
if (this._editingRowIndex.value != null && this._editingRowIndex.value === rowIndex) {
const colId = this.columnIdAt(rowIndex, colIndex);
return colId != null && this.columnEditable(colId);
}
return this._editingRow.value === rowIndex && this._editingCol.value === colIndex;
};
cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => this.isEditing(rowIndex, colIndex) && !!this._invalidMsg.value ? 'true' : null;
runValidator = (colId: any, value: any, row: any) => {
const m = this.editMetaOf(colId);
const v = m ? m.validate : null;
if (typeof v !== 'function') return true;
let r: any = null;
try {
r = v(value, row);
} catch (err: any) {
return 'Invalid value';
}
if (r === true) return true;
if (typeof r === 'string') return r;
return 'Invalid value';
};
setInvalid = (msg: any) => {
this._invalidMsg.value = msg != null ? msg : '';
};
replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
const src = rows || [];
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
// and would copy inherited enumerable props of typed/class-instance row objects.
out.push({
...(src[i] || {}),
[field]: value
});
} else {
out.push(src[i]);
}
}
return out;
};
sourceIndexOfRow = (visibleRowIndex: any) => {
const rowList = this._rows.value || [];
const row = rowList[visibleRowIndex];
if (!row) return visibleRowIndex;
const orig = row.original;
const data = this.currentData() || [];
const idx = data.indexOf(orig);
return idx >= 0 ? idx : visibleRowIndex;
};
editingColumnId = () => {
const rowList = this._rows.value || [];
const row = rowList[this._editingRow.value];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[this._editingCol.value];
return cell && cell.column ? cell.column.id : null;
};
editingColumnField = () => {
const colId = this.editingColumnId();
if (colId == null) return null;
const d = this.defFor(colId);
return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
editingCellValue = () => {
const rowList = this._rows.value || [];
const row = rowList[this._editingRow.value];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[this._editingCol.value];
return cell ? cell.getValue() : null;
};
editingRowOriginal = () => {
const rowList = this._rows.value || [];
const row = rowList[this._editingRow.value];
return row ? row.original : null;
};
editingRowId = () => {
const rowList = this._rows.value || [];
const row = rowList[this._editingRow.value];
return row ? row.id : null;
};
focusEditorWhenReady = (selectAll = true) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = this.gridRoot ? this.gridRoot.querySelector('[data-editing-cell]') : null;
if (el) {
el.focus();
if (selectAll && el.select) {
try {
el.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
columnIdAt = (rowIndex: any, colIndex: any) => {
const rowList = this._rows.value || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[colIndex];
return cell && cell.column ? cell.column.id : null;
};
cellValueAt = (rowIndex: any, colIndex: any) => {
const rowList = this._rows.value || [];
const row = rowList[rowIndex];
if (!row) return null;
const cells = this.visibleCellsFor(row);
const cell = cells[colIndex];
return cell ? cell.getValue() : null;
};
beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
const colId = this.columnIdAt(rowIndex, colIndex);
if (colId == null || !this.columnEditable(colId)) return;
this.setInvalid('');
// Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
// editor clears any row-edit state so isEditing never resolves both modes for one cell.
this._editingRowIndex.value = null;
this._rowDraft.value = {};
this._editingRow.value = rowIndex;
this._editingCol.value = colIndex;
this._draftValue.value = seed != null ? seed : this.cellValueAt(rowIndex, colIndex);
this._activeInControl.value = true;
this._editVer.value = this._editVer.value + 1;
// B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
// seeded char so subsequent typing appends instead of replacing it.
this.focusEditorWhenReady(seed == null);
};
focusCellWhenReady = (row: any, col: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const el = this.resolveCellEl(String(row), col);
if (el) {
el.focus();
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
const list = rows || [];
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (!r) continue;
if (rowId != null && r.id === rowId) return i;
if (rowOriginal != null && r.original === rowOriginal) return i;
}
return -1;
};
endEdit = () => {
this._editingRow.value = -1;
this._editingCol.value = -1;
this._draftValue.value = null;
this._invalidMsg.value = '';
this._activeInControl.value = false;
this._editVer.value = this._editVer.value + 1;
};
endRowEdit = () => {
this._editingRowIndex.value = null;
this._rowDraft.value = {};
this._invalidMsg.value = '';
this._activeInControl.value = false;
this._editVer.value = this._editVer.value + 1;
};
coerceCellValue = (colId: any, raw: any) => {
if (this.editorTypeOf(colId) !== 'number') return raw;
if (raw == null) return null;
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
const s = String(raw).trim();
if (s === '') return null;
const n = Number(s);
return Number.isNaN(n) ? null : n;
};
commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
if (this._editingRow.value < 0) return false;
const colId = this.editingColumnId();
if (colId == null) {
this.endEdit();
return false;
}
const field = this.editingColumnField();
const oldValue = this.editingCellValue();
const rowOriginal = this.editingRowOriginal();
const rowId = this.editingRowId();
// B3: coerce by the column's editor type BEFORE validation + write so the validator
// and the model both see the typed value (number/null), not the raw draft string.
const rawValue = overrideValue !== undefined ? overrideValue : this._draftValue.value;
const newValue = this.coerceCellValue(colId, rawValue);
const err = this.runValidator(colId, newValue, rowOriginal);
if (err !== true) {
// D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
this.setInvalid(err);
this.focusEditorWhenReady();
return false;
}
this.setInvalid('');
const srcIndex = this.sourceIndexOfRow(this._editingRow.value);
const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
// Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
const focusRow = this._editingRow.value;
const focusCol = this._editingCol.value;
// Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
// must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
this.editTransition = true;
this.writeData(next);
// Exactly one emit per commit, from this single call site (writeData does NOT emit).
this.dispatchEvent(new CustomEvent("cell-edit-commit", {
detail: {
rowId,
columnId: colId,
oldValue,
newValue
},
bubbles: true,
composed: true
}));
this.endEdit();
this.editTransition = false;
// Defer the focus return so the display↔editor re-render commits first (async on
// React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
// editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
// Tab-advance (the caller immediately opens the next editor and focuses THAT).
// B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
// RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
// index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
// consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
// fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
// active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
// index → byte-behaviorally identical to before.
if (skipFocusReturn !== true) this.pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
cancelEdit = () => {
if (this._editingRow.value < 0) return;
// CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
// Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
// an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
// instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
const focusRow = this._editingRow.value;
const focusCol = this._editingCol.value;
this.editTransition = true;
this.endEdit();
this.editTransition = false;
this.focusCellWhenReady(focusRow, focusCol);
};
editableColumnsForRow = (rowIndex: any) => {
const rowList = this._rows.value || [];
const row = rowList[rowIndex];
if (!row) return [];
const cells = this.visibleCellsFor(row);
const out = [];
for (let c = 0; c < cells.length; c++) {
const cell = cells[c];
const colId = cell && cell.column ? cell.column.id : null;
if (colId == null || !this.columnEditable(colId)) continue;
const d = this.defFor(colId);
const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
// colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
// Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
// can address a SPECIFIC editor by column, not just the first [data-editing-cell].
out.push({
colId,
field,
colIndex: c
});
}
return out;
};
focusRowEditorAt = (rowIndex: any, colIndex: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const tryFocus = () => {
const cellEl = this.resolveCellEl(String(rowIndex), colIndex);
const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
if (ed) {
ed.focus();
if (ed.select) {
try {
ed.select();
} catch (e: any) {}
}
return;
}
attempts = attempts + 1;
if (attempts >= 30) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
beginRowEdit = (row: any) => {
const rowIndex = this.rowIndexOf(row);
if (rowIndex < 0) return;
const editable = this.editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
// Clear any single-cell editor first (mutual exclusivity).
this._editingRow.value = -1;
this._editingCol.value = -1;
this._draftValue.value = null;
this.setInvalid('');
// Seed each editable cell's draft from its current value.
const draft = {};
const rowList = this._rows.value || [];
const r = rowList[rowIndex];
const orig = r ? r.original : null;
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
draft[ec.colId] = orig ? orig[ec.field] : null;
}
this._rowDraft.value = draft;
this._editingRowIndex.value = rowIndex;
this._activeInControl.value = true;
this._editVer.value = this._editVer.value + 1;
this.focusEditorWhenReady();
};
commitRow = () => {
if (this._editingRowIndex.value == null) return false;
const rowIndex = this._editingRowIndex.value;
const editable = this.editableColumnsForRow(rowIndex);
if (editable.length === 0) {
this.endRowEdit();
return false;
}
const rowList = this._rows.value || [];
const r = rowList[rowIndex];
const rowOriginal = r ? r.original : null;
const rowId = r ? r.id : null;
const draft = this._rowDraft.value || {};
// Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
// B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
// 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
// commitEdit already coerces via coerceCellValue; the row path silently committed strings →
// a number column ended up holding '99'). Coerce once here so the validator and the model both
// see the typed value, identical to the single-cell funnel.
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
const err = this.runValidator(ec.colId, this.coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
if (err !== true) {
this.setInvalid(err);
// B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
// unconditionally the first editor (focusEditorWhenReady resolves the first
// [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
this.focusRowEditorAt(rowIndex, ec.colIndex);
return false;
}
}
this.setInvalid('');
// Build the changes payload (only the columns whose value actually changed) + the field→
// value map for the single row-object replace.
const changes = [];
const fieldValues = {};
for (let i = 0; i < editable.length; i++) {
const ec = editable[i];
// B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
// string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
const newValue = this.coerceCellValue(ec.colId, draft[ec.colId]);
const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
fieldValues[ec.field] = newValue;
if (oldValue !== newValue) changes.push({
columnId: ec.colId,
oldValue,
newValue
});
}
// ONE fresh-array replace of the SINGLE row object with all field values applied at once.
const srcIndex = this.sourceIndexOfRow(rowIndex);
const next = this.replaceRowValues(this.currentData(), srcIndex, fieldValues);
// Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
// active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
const focusCol = this._activeColIndex.value;
this.editTransition = true;
this.writeData(next);
// EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
this.dispatchEvent(new CustomEvent("row-edit-commit", {
detail: {
rowId,
changes
},
bubbles: true,
composed: true
}));
this.endRowEdit();
this.editTransition = false;
// WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
// like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
// col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
// $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
// DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
// refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
// display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
// the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
// poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
this.pendingEditFollow = {
rowOriginal,
rowId,
col: focusCol
};
return true;
};
cancelRow = () => {
if (this._editingRowIndex.value == null) return;
const focusRow = this._activeRow.value;
const focusCol = this._activeColIndex.value;
this.editTransition = true;
this.endRowEdit();
this.editTransition = false;
this.focusCellWhenReady(focusRow, focusCol);
};
replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
const src = rows || [];
const fv = fieldValues || {};
const out = [];
for (let i = 0; i < src.length; i++) {
if (i === rowIndex) {
// WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
// prototype-walking copy. Spread copies own enumerable props only.
out.push({
...(src[i] || {}),
...fv
});
} else {
out.push(src[i]);
}
}
return out;
};
nextEditableCell = (fromRow: any, fromCol: any) => {
const rowList = this._rows.value || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol + 1;
while (r < rowCount) {
const row = rowList[r];
const cells = row ? this.visibleCellsFor(row) : [];
while (c < cells.length) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && this.columnEditable(cid)) return {
row: r,
col: c
};
c = c + 1;
}
r = r + 1;
c = 0;
}
return null;
};
prevEditableCell = (fromRow: any, fromCol: any) => {
const rowList = this._rows.value || [];
const rowCount = rowList.length;
if (rowCount === 0) return null;
let r = fromRow;
let c = fromCol - 1;
while (r >= 0) {
const row = rowList[r];
const cells = row ? this.visibleCellsFor(row) : [];
while (c >= 0) {
const cell = cells[c];
const cid = cell && cell.column ? cell.column.id : null;
if (cid != null && this.columnEditable(cid)) return {
row: r,
col: c
};
c = c - 1;
}
r = r - 1;
if (r >= 0) {
const prow = rowList[r];
const pcells = prow ? this.visibleCellsFor(prow) : [];
c = pcells.length - 1;
}
}
return null;
};
editTransition = false;
pendingEditFollow: any = null;
inRowEdit = () => this._editingRowIndex.value != null;
editorValueFor = (colId: any) => this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value;
editorCheckedFor = (colId: any) => !!(this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value);
editorCommitFor = (colId: any) => (value: any) => {
if (this.inRowEdit()) {
this.setRowDraft(colId, value);
return;
}
this.commitEdit(value);
};
editorCancelFor = () => () => {
if (this.inRowEdit()) {
this.cancelRow();
return;
}
this.cancelEdit();
};
onCellEditorInput = (colId: any, evt: any) => {
const v = evt && evt.target ? evt.target.value : '';
if (this.inRowEdit()) {
this.setRowDraft(colId, v);
return;
}
this._draftValue.value = v;
};
onCellEditorCheckbox = (colId: any, evt: any) => {
const v = !!(evt && evt.target && evt.target.checked);
if (this.inRowEdit()) {
this.setRowDraft(colId, v);
return;
}
this._draftValue.value = v;
};
setRowDraft = (colId: any, value: any) => {
const src = this._rowDraft.value || {};
const next = {};
for (const k in src) next[k] = src[k];
next[colId] = value;
this._rowDraft.value = next;
};
rowEditTab = (target: any, backward: any) => {
const rowIndex = this._editingRowIndex.value;
if (rowIndex == null) return;
const editable = this.editableColumnsForRow(rowIndex);
if (editable.length === 0) return;
const cols = editable.map((ec: any) => ec.colIndex);
const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
const curAttr = cell ? cell.getAttribute('data-col-index') : null;
const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
let pos = cols.indexOf(cur);
if (pos < 0) pos = 0;
const len = cols.length;
const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
this.focusRowEditorAt(rowIndex, cols[nextPos]);
};
onEditorKeyDown = (e: any) => {
if (!e) return;
const key = e.key;
// Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
// model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
// row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
// preventDefault it here.
if (this.inRowEdit()) {
if (key === 'Enter') {
e.preventDefault();
this.commitRow();
} else if (key === 'Escape') {
e.preventDefault();
this.cancelRow();
}
// B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
// editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
// Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
// first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
// tab order across a Lit shadow boundary).
else if (key === 'Tab') {
e.preventDefault();
this.rowEditTab(e.target, e.shiftKey);
}
return;
}
if (key === 'Enter') {
e.preventDefault();
this.commitEdit(undefined);
} else if (key === 'Tab') {
e.preventDefault();
// Resolve the advance target from the EDITING pair (the cell that is open), not the
// active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
// moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
// the editing pair BEFORE commit (commitEdit resets it to -1).
const fromRow = this._editingRow.value;
const fromCol = this._editingCol.value;
const target = e.shiftKey ? this.prevEditableCell(fromRow, fromCol) : this.nextEditableCell(fromRow, fromCol);
// skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
// straight into the next editable cell's editor below. Use the RETURN value (not a
// re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
// failure returns false and keeps the editor open (the user must fix the value first).
const committed = this.commitEdit(undefined, true);
if (committed && target) {
this._activeRow.value = target.row;
this._activeColIndex.value = target.col;
this.beginEdit(target.row, target.col, null);
} else if (committed) {
// B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
// grid by returning it to the just-committed cell instead of letting it drop to <body>.
this.focusCellWhenReady(fromRow, fromCol);
}
} else if (key === 'Escape') {
e.preventDefault();
this.cancelEdit();
}
};
onEditorBlur = (e: any) => {
// Full-row mode (req-6): blur NEVER commits — the row commits as a UNIT only on an
// explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
// into N writes + N events, violating the one-write/one-event contract). Tabbing between
// the row's own editors is a normal focus move, not a commit.
if (this.inRowEdit()) return;
if (this._editingRow.value < 0 || this.editTransition) return;
const next = e ? e.relatedTarget : null;
// A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
// keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
// a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
// conservative null=skip behavior.
if (next == null) return;
// Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
// the editor open on an invalid value).
if (!(this.gridRoot && this.gridRoot.contains && this.gridRoot.contains(next))) {
this.commitEdit(undefined);
return;
}
// Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
// keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
// (which must commit + close so the grid is not wedged with an open editor).
const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
// Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
// controlled move; skip. Also skip when either cell can't be resolved (an unmounting
// editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
if (!nextCell || !fromCell || nextCell === fromCell) return;
// A Tab-advance already committed the old editor and opened the next one, so the live
// editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
// editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
// double commit, WR-04).
const fromRow = fromCell.getAttribute('data-row');
const fromCol = fromCell.getAttribute('data-col-index');
if (fromRow !== String(this._editingRow.value) || fromCol !== String(this._editingCol.value)) return;
// Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
// commit does NOT bounce focus back to the just-committed editing cell (which would fight
// the click destination). The commit's writeData re-renders the table and can DROP DOM
// focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
// DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
// (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
// late, which would steal focus back from a subsequent navigation.
const destRow = nextCell.getAttribute('data-row');
const destCol = nextCell.getAttribute('data-col-index');
this.commitEdit(undefined, true);
const reseatDestFocus = () => {
if (!this.gridRoot || destRow == null || destCol == null || destRow === '__header') return;
const root = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
const act = root && root.activeElement ? root.activeElement : null;
// Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
if (act && this.gridRoot.contains && this.gridRoot.contains(act)) return;
const el = this.resolveCellEl(destRow, parseInt(destCol, 10));
if (el) el.focus();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};
editCell = (rowIndex: any, colIndex: any) => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const maxCol = this.visibleColCount() - 1;
const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
this._activeIsHeader.value = false;
this._activeRow.value = r;
this._activeColIndex.value = c;
this.beginEdit(r, c, null);
};
commitEditing = () => {
if (this._editingRow.value >= 0) this.commitEdit(undefined);
};
editRow = (rowIndex: any) => {
const lastRow = this.bodyRowCount() - 1;
const maxRow = lastRow < 0 ? 0 : lastRow;
const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
const rowList = this._rows.value || [];
const row = rowList[r];
if (!row) return;
this._activeIsHeader.value = false;
this._activeRow.value = r;
this.beginRowEdit(row);
};
focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
if (!this.gridRoot) return;
let attempts = 0;
const want = String(absRow + 1);
const tryFocus = () => {
const el = this.resolveCellEl(String(localRow), col);
if (el) {
const rowEl = el.closest ? el.closest('[role="row"]') : null;
const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
if (ari === want) {
el.focus();
return;
}
}
attempts = attempts + 1;
if (attempts >= 60) return;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
focusCell = (rowIndex: any, colIndex: any) => {
// B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
// is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
// isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
// instance does not leak a spurious activecell-change.
if (!this.isGrid()) return;
const maxCol = this.visibleColCount() - 1;
const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
// C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
const absLast = this.prePaginationRowCount() - 1;
const absRow = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
// B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
// real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
// active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
const prevAbs = this.toAbsRow(this._activeRow.value);
const prevIsHeader = this._activeIsHeader.value;
if (this.virtual) {
// Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
// the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
// focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
this._activeIsHeader.value = false;
this._activeInControl.value = false;
this._activeRow.value = absRow;
this._activeColIndex.value = c;
this.focusActiveCell(absRow, c, false);
} else {
// Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
// in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
// body's data-row markers (and the roving tabindex) address.
const size = this.pageSize();
const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
const localRow = absRow - targetPage * size;
const switched = targetPage !== this.pageIndex();
if (switched) this.setPage(targetPage);
this._activeIsHeader.value = false;
this._activeInControl.value = false;
this._activeRow.value = localRow;
this._activeColIndex.value = c;
if (switched) {
// The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
// TARGET page's absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
// same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
this.focusAbsCellWhenReady(absRow, localRow, c);
} else {
// Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
// Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
// async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
this.focusActiveCell(localRow, c, false);
}
}
if (absRow !== prevAbs || prevIsHeader) {
this.dispatchEvent(new CustomEvent("activecell-change", {
detail: {
rowIndex: absRow,
colIndex: c
},
bubbles: true,
composed: true
}));
}
};
getActiveCell = () => this._activeIsHeader.value ? {
rowIndex: null,
colIndex: this._activeColIndex.value,
isHeader: true
} : {
rowIndex: this.toAbsRow(this._activeRow.value),
colIndex: this._activeColIndex.value,
isHeader: false
};
clearActiveCell = () => {
if (!this.isGrid()) return;
this._activeIsHeader.value = false;
this._activeInControl.value = false;
this._activeRow.value = 0;
this._activeColIndex.value = 0;
};
toggleRowExpanded = (rowId: any) => {
if (!this.table) return;
const target = String(rowId);
const flat = this.table.getCoreRowModel().flatRows;
for (const r of flat as any) {
if (r.id === target || r.original && String(r.original.id) === target) {
r.toggleExpanded();
return;
}
}
};
expandAll = () => {
if (!this.table) return;
this.table.toggleAllRowsExpanded(true);
};
collapseAll = () => {
if (!this.table) return;
this.table.resetExpanded(true);
};
getExpandedRows = () => {
if (!this.table) return [];
const out = [];
const flat = this.table.getCoreRowModel().flatRows;
for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
return out;
};
applyGrouping = (cols: any) => {
if (this.table) this.table.setGrouping(cols);
};
clearGrouping = () => {
if (this.table) this.table.setGrouping([]);
};
getFacetedUniqueValues = (colId: any) => {
if (this.tick() < 0 || !this.table) return [];
const col = this.table.getColumn(colId);
if (!col || !col.getFacetedUniqueValues) return [];
const map = col.getFacetedUniqueValues(); // Map<any, number>
return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};
getFacetedMinMaxValues = (colId: any) => {
if (this.tick() < 0 || !this.table) return null;
const col = this.table.getColumn(colId);
if (!col || !col.getFacetedMinMaxValues) return null;
return col.getFacetedMinMaxValues() || null; // [number, number] | null
};
get data(): any[] { return this._dataControllable.read(); }
set data(v: any[]) { this._dataControllable.notifyPropertyWrite(v); }
get sorting(): any[] { return this._sortingControllable.read(); }
set sorting(v: any[]) { this._sortingControllable.notifyPropertyWrite(v); }
get globalFilter(): string { return this._globalFilterControllable.read(); }
set globalFilter(v: string) { this._globalFilterControllable.notifyPropertyWrite(v); }
get columnFilters(): any[] { return this._columnFiltersControllable.read(); }
set columnFilters(v: any[]) { this._columnFiltersControllable.notifyPropertyWrite(v); }
get pagination(): any { return this._paginationControllable.read(); }
set pagination(v: any) { this._paginationControllable.notifyPropertyWrite(v); }
get expanded(): any | boolean { return this._expandedControllable.read(); }
set expanded(v: any | boolean) { this._expandedControllable.notifyPropertyWrite(v); }
get grouping(): any[] { return this._groupingControllable.read(); }
set grouping(v: any[]) { this._groupingControllable.notifyPropertyWrite(v); }
get rowSelection(): any { return this._rowSelectionControllable.read(); }
set rowSelection(v: any) { this._rowSelectionControllable.notifyPropertyWrite(v); }
get columnVisibility(): any { return this._columnVisibilityControllable.read(); }
set columnVisibility(v: any) { this._columnVisibilityControllable.notifyPropertyWrite(v); }
get columnSizing(): any { return this._columnSizingControllable.read(); }
set columnSizing(v: any) { this._columnSizingControllable.notifyPropertyWrite(v); }
get columnOrder(): any[] { return this._columnOrderControllable.read(); }
set columnOrder(v: any[]) { this._columnOrderControllable.notifyPropertyWrite(v); }
get columnPinning(): any { return this._columnPinningControllable.read(); }
set columnPinning(v: any) { this._columnPinningControllable.notifyPropertyWrite(v); }
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same twelve two-way slices, same fourteen change events, same <Column> API, same scoped slots, same imperative handle — all from the one source above, built on @tanstack/table-core with no per-framework adapter behind it.
See also
- DataTable — overview & install — the package install table and the section index linking quick start, the
<Column>API, theming, and the full API reference. - Data table comparison — how
@rozie-ui/data-tablestacks up against TanStack Table, AG Grid, PrimeVue, Material, and the per-framework grids.