@rozie-ui/data-table-solid 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ // activeFollow.ts — the active cell follows its record (design doc 2026-11-06, Part 3).
2
+ // Sigil-free, self-contained: no $props/$data/$emit/$computed/$onMount/$refs/$el/$expose/
3
+ // $watch, and no cross-partial host-symbol calls. Pure decisions over row-id lists; the
4
+ // DataTable wiring (refreshRowModel → followActiveCell) applies them.
5
+
6
+ export type FollowResult =
7
+ | { kind: 'stay' } // same index, nothing to do
8
+ | { kind: 'move'; index: number } // follow to the new index
9
+ | { kind: 'park' } // row gone after a view change / table write
10
+ | { kind: 'clamp' } // row gone after navigation / reset → existing D-05 + B25
11
+
12
+ // resolveFollow: where does the active row (index `activeRow` in the PREVIOUS visible model,
13
+ // whose row ids are `prevIds`) live in the NEXT model (`nextIds`)?
14
+ // • found at the same index → stay; found elsewhere → move there.
15
+ // • not found → park on the header when the rows changed because of a view change (sort /
16
+ // filter / refreshView) or one of the table's own writes — the next keystroke would
17
+ // otherwise edit whichever record now sits at the old index — else clamp (paging, page
18
+ // size, expand/collapse, an external data swap: navigation or reset).
19
+ // • no previous model, or an index outside it → clamp (nothing to follow).
20
+ export function resolveFollow(
21
+ prevIds: readonly string[] | null,
22
+ activeRow: number,
23
+ nextIds: readonly string[],
24
+ viewChangeOrWrite: boolean,
25
+ ): FollowResult {
26
+ if (!prevIds || !Number.isInteger(activeRow) || activeRow < 0 || activeRow >= prevIds.length) {
27
+ return { kind: 'clamp' }
28
+ }
29
+ const id = prevIds[activeRow]
30
+ if (nextIds[activeRow] === id) return { kind: 'stay' }
31
+ const index = nextIds.indexOf(id)
32
+ if (index >= 0) return { kind: 'move', index }
33
+ return viewChangeOrWrite ? { kind: 'park' } : { kind: 'clamp' }
34
+ }
35
+
36
+ // sameIdSequence: are both models the same rows in the same order? (A range can only survive
37
+ // a re-derive that keeps row membership AND order.)
38
+ export function sameIdSequence(a: readonly string[] | null, b: readonly string[]): boolean {
39
+ if (!a || a.length !== b.length) return false
40
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
41
+ return true
42
+ }
@@ -0,0 +1,105 @@
1
+ // columnRuleUtils.ts — per-column filterFn / sortingFn rule resolution. Sigil-free,
2
+ // self-contained: no $props/$data/$emit/$computed/$onMount/$refs/$el/$expose/$watch, no
3
+ // host-symbol calls, and no @tanstack/table-core import (the name lists below mirror
4
+ // table-core's `filterFns` / `sortingFns` keys; a test pins them to the real maps).
5
+ //
6
+ // A rule is a built-in NAME, 'auto', or a consumer FUNCTION. `columnRuleKeys` returns an
7
+ // object with NO key for a rule that resolves to undefined: table-core builds columns as
8
+ // `{...defaultColumn, ...columnDef}`, so an explicit `filterFn: undefined` would override
9
+ // the default 'auto' (silently dropping filters; `sortingFn: undefined` throws in
10
+ // getSortedRowModel).
11
+
12
+ export const FILTER_FN_NAMES: readonly string[] = [
13
+ 'includesString',
14
+ 'includesStringSensitive',
15
+ 'equalsString',
16
+ 'arrIncludes',
17
+ 'arrIncludesAll',
18
+ 'arrIncludesSome',
19
+ 'equals',
20
+ 'weakEquals',
21
+ 'inNumberRange',
22
+ ]
23
+
24
+ export const SORTING_FN_NAMES: readonly string[] = [
25
+ 'alphanumeric',
26
+ 'alphanumericCaseSensitive',
27
+ 'text',
28
+ 'textCaseSensitive',
29
+ 'datetime',
30
+ 'basic',
31
+ ]
32
+
33
+ // Module-level dedupe: warn once per (columnId, name) pair.
34
+ const warned = new Set<string>()
35
+
36
+ const resolveName = (
37
+ value: string,
38
+ names: readonly string[],
39
+ kind: string,
40
+ columnId: string,
41
+ warn: (msg: string) => void,
42
+ ): string => {
43
+ if (value === 'auto' || names.includes(value)) return value
44
+ const key = kind + '\u0000' + columnId + '\u0000' + value
45
+ if (!warned.has(key)) {
46
+ warned.add(key)
47
+ warn(`[data-table] column "${columnId}": unknown ${kind} "${value}"; falling back to "auto".`)
48
+ }
49
+ return 'auto'
50
+ }
51
+
52
+ export const resolveFilterFn = (
53
+ value: unknown,
54
+ columnId: string,
55
+ warn: (msg: string) => void = console.warn,
56
+ ): string | Function | undefined => {
57
+ if (value == null) return undefined
58
+ if (typeof value === 'string') return resolveName(value, FILTER_FN_NAMES, 'filterFn', columnId, warn)
59
+ if (typeof value !== 'function') return undefined
60
+ const fn: any = value
61
+ // A consumer fn runs per row, so a throw is coerced to "row passes" and can never
62
+ // crash getFilteredRowModel.
63
+ const adapter: any = (row: any, colId: string, filterValue: any) => {
64
+ try {
65
+ return fn(row.getValue(colId), filterValue, row.original)
66
+ } catch (err) {
67
+ return true
68
+ }
69
+ }
70
+ if (fn.autoRemove !== undefined) adapter.autoRemove = fn.autoRemove
71
+ if (fn.resolveFilterValue !== undefined) adapter.resolveFilterValue = fn.resolveFilterValue
72
+ return adapter
73
+ }
74
+
75
+ export const resolveSortingFn = (
76
+ value: unknown,
77
+ columnId: string,
78
+ warn: (msg: string) => void = console.warn,
79
+ ): string | Function | undefined => {
80
+ if (value == null) return undefined
81
+ if (typeof value === 'string') return resolveName(value, SORTING_FN_NAMES, 'sortingFn', columnId, warn)
82
+ if (typeof value !== 'function') return undefined
83
+ const fn: any = value
84
+ // A throw is coerced to 0 ("equal") so getSortedRowModel can never crash.
85
+ return (rowA: any, rowB: any, colId: string) => {
86
+ try {
87
+ return fn(rowA.getValue(colId), rowB.getValue(colId), rowA.original, rowB.original)
88
+ } catch (err) {
89
+ return 0
90
+ }
91
+ }
92
+ }
93
+
94
+ export const columnRuleKeys = (
95
+ spec: { filterFn?: unknown; sortingFn?: unknown },
96
+ columnId: string,
97
+ warn?: (msg: string) => void,
98
+ ): { filterFn?: string | Function; sortingFn?: string | Function } => {
99
+ const out: { filterFn?: string | Function; sortingFn?: string | Function } = {}
100
+ const f = resolveFilterFn(spec?.filterFn, columnId, warn)
101
+ if (f !== undefined) out.filterFn = f
102
+ const s = resolveSortingFn(spec?.sortingFn, columnId, warn)
103
+ if (s !== undefined) out.sortingFn = s
104
+ return out
105
+ }
@@ -0,0 +1,185 @@
1
+ // heldView.ts — the hold controller behind `holdView` (held filter membership + sort
2
+ // order through the table's own writes). Sigil-free, self-contained: no $props/$data/
3
+ // $emit/$computed/$onMount/$refs/$el/$expose/$watch, no host-symbol calls, and no
4
+ // @tanstack/table-core import (the tests drive it against the real table-core).
5
+ //
6
+ // Seam: the hold lives INSIDE table-core's row pipeline. `wrapFiltered` / `wrapSorted`
7
+ // wrap the built-in row-model factories (shape `(table) => () => RowModel`). While not
8
+ // holding they return the base model unchanged (same identity). While holding:
9
+ // - filtered stage: the current core rows whose id is in the held membership, in core
10
+ // order (fresh rows, so new values render in place). The base (live) filtered id set
11
+ // is kept for counting.
12
+ // - sorted stage: the base output is the live sort of the held set (its upstream is the
13
+ // held filtered model); its id order is kept for counting, and the rows are returned
14
+ // reordered to the held order. Mapped by id — getSortedRowModel shallow-copies rows.
15
+ // Each wrapper memoizes on [base result identity, controller version], so downstream
16
+ // table-core memos (keyed on upstream model identity) see a stable model.
17
+ //
18
+ // Limits worth knowing:
19
+ // - The held filtered stage returns the CORE rows (not the rows table-core's filtered stage
20
+ // produced), so they do not carry table-core's per-row `columnFilters` / `columnFiltersMeta`
21
+ // bookkeeping. Nothing in DataTable reads those; a future reader would have to take them
22
+ // from the base (live) model instead.
23
+ // - One controller per table: it holds a single membership / order / written-row set, so
24
+ // never share one controller (or its wrapped factories) between two table instances.
25
+
26
+ export interface ViewState {
27
+ held: boolean
28
+ filterMismatches: number
29
+ sortMismatches: number
30
+ }
31
+
32
+ export const CLEAN_VIEW_STATE: ViewState = Object.freeze({
33
+ held: false,
34
+ filterMismatches: 0,
35
+ sortMismatches: 0,
36
+ }) as ViewState
37
+
38
+ export const viewStatesEqual = (a: ViewState, b: ViewState): boolean =>
39
+ a.held === b.held && a.filterMismatches === b.filterMismatches && a.sortMismatches === b.sortMismatches
40
+
41
+ // Index-aligned reference comparison. Differing lengths → every index of the longer
42
+ // (the shape changed, so every position is suspect).
43
+ export const changedIndices = (prev: readonly unknown[], next: readonly unknown[]): number[] => {
44
+ const out: number[] = []
45
+ if (prev.length !== next.length) {
46
+ const n = Math.max(prev.length, next.length)
47
+ for (let i = 0; i < n; i++) out.push(i)
48
+ return out
49
+ }
50
+ for (let i = 0; i < next.length; i++) if (prev[i] !== next[i]) out.push(i)
51
+ return out
52
+ }
53
+
54
+ export interface HoldController {
55
+ setActive(active: boolean): void
56
+ isActive(): boolean
57
+ isHolding(): boolean
58
+ capture(table: any): void
59
+ release(): void
60
+ noteWrite(prev: readonly unknown[], next: readonly unknown[]): void
61
+ wrapFiltered(base: (table: any) => () => any): (table: any) => () => any
62
+ wrapSorted(base: (table: any) => () => any): (table: any) => () => any
63
+ viewState(table: any): ViewState
64
+ }
65
+
66
+ const rowIds = (model: any): string[] => (model?.rows ?? []).map((r: any) => r.id)
67
+
68
+ export const createHoldController = (): HoldController => {
69
+ let active = false
70
+ // Snapshot — null when not holding.
71
+ let heldMembership: Set<string> | null = null
72
+ let heldOrder: string[] | null = null
73
+ const written = new Set<number>()
74
+ let version = 0
75
+ // Live (unheld) results observed by the wrappers while holding, for counting.
76
+ let liveFilteredIds: Set<string> = new Set()
77
+ let liveSortedIds: string[] = []
78
+
79
+ const release = () => {
80
+ heldMembership = null
81
+ heldOrder = null
82
+ written.clear()
83
+ version++
84
+ }
85
+
86
+ const wrapFiltered = (base: (table: any) => () => any) => (table: any) => {
87
+ const getBase = base(table)
88
+ let lastBase: any = undefined
89
+ let lastVersion = -1
90
+ let lastOut: any = undefined
91
+ return () => {
92
+ const baseModel = getBase()
93
+ if (!heldMembership) return baseModel
94
+ if (baseModel === lastBase && version === lastVersion) return lastOut
95
+ liveFilteredIds = new Set(rowIds(baseModel))
96
+ const held = heldMembership
97
+ const coreRows: any[] = table.getCoreRowModel().rows
98
+ const rows = coreRows.filter((r: any) => held.has(r.id))
99
+ const rowsById: Record<string, any> = {}
100
+ for (const r of rows) rowsById[r.id] = r
101
+ lastBase = baseModel
102
+ lastVersion = version
103
+ lastOut = { rows, flatRows: rows, rowsById }
104
+ return lastOut
105
+ }
106
+ }
107
+
108
+ const wrapSorted = (base: (table: any) => () => any) => (table: any) => {
109
+ const getBase = base(table)
110
+ let lastBase: any = undefined
111
+ let lastVersion = -1
112
+ let lastOut: any = undefined
113
+ return () => {
114
+ const baseModel = getBase()
115
+ if (!heldOrder) return baseModel
116
+ if (baseModel === lastBase && version === lastVersion) return lastOut
117
+ const baseRows: any[] = baseModel?.rows ?? []
118
+ liveSortedIds = baseRows.map((r: any) => r.id)
119
+ const byId = new Map<string, any>()
120
+ for (const r of baseRows) byId.set(r.id, r)
121
+ const rows: any[] = []
122
+ for (const id of heldOrder) {
123
+ const r = byId.get(id)
124
+ if (r) {
125
+ rows.push(r)
126
+ byId.delete(id)
127
+ }
128
+ }
129
+ // Defensive: anything the held order does not know keeps its live position at the end.
130
+ for (const r of baseRows) if (byId.has(r.id)) rows.push(r)
131
+ lastBase = baseModel
132
+ lastVersion = version
133
+ lastOut = { rows, flatRows: rows, rowsById: baseModel?.rowsById ?? {} }
134
+ return lastOut
135
+ }
136
+ }
137
+
138
+ return {
139
+ setActive(next: boolean) {
140
+ const was = active
141
+ active = !!next
142
+ if (!active && was) release()
143
+ },
144
+ isActive: () => active,
145
+ isHolding: () => heldMembership !== null,
146
+ capture(table: any) {
147
+ if (!active || heldMembership) return
148
+ const filtered = table.getFilteredRowModel()
149
+ const sorted = typeof table.getSortedRowModel === 'function' ? table.getSortedRowModel() : filtered
150
+ heldMembership = new Set(rowIds(filtered))
151
+ heldOrder = rowIds(sorted)
152
+ written.clear()
153
+ version++
154
+ },
155
+ release,
156
+ noteWrite(prev: readonly unknown[], next: readonly unknown[]) {
157
+ if (!heldMembership) return
158
+ for (const i of changedIndices(prev, next)) written.add(i)
159
+ },
160
+ wrapFiltered,
161
+ wrapSorted,
162
+ viewState(table: any): ViewState {
163
+ if (!active) return CLEAN_VIEW_STATE
164
+ if (!heldMembership || !heldOrder) return { held: true, filterMismatches: 0, sortMismatches: 0 }
165
+ // Pull the models so the wrappers have observed the current live results.
166
+ table.getSortedRowModel()
167
+ const held = heldMembership
168
+ let filterMismatches = 0
169
+ for (const id of held) if (!liveFilteredIds.has(id)) filterMismatches++
170
+ const coreRows: any[] = table.getCoreRowModel().rows
171
+ for (const r of coreRows) if (!held.has(r.id) && liveFilteredIds.has(r.id)) filterMismatches++
172
+ const heldPos = new Map<string, number>()
173
+ heldOrder.forEach((id, i) => heldPos.set(id, i))
174
+ const livePos = new Map<string, number>()
175
+ liveSortedIds.forEach((id, i) => livePos.set(id, i))
176
+ let sortMismatches = 0
177
+ for (const i of written) {
178
+ const id = coreRows[i]?.id
179
+ if (id === undefined || !heldPos.has(id)) continue
180
+ if (heldPos.get(id) !== livePos.get(id)) sortMismatches++
181
+ }
182
+ return { held: true, filterMismatches, sortMismatches }
183
+ },
184
+ }
185
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,6 @@ export { default as FilterSelect } from './FilterSelect';
12
12
  export { default as GroupBar } from './GroupBar';
13
13
  export { default as DetailPanel } from './DetailPanel';
14
14
 
15
- /** The `$expose` imperative handle received via `ref` — { sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, scrollToRow, getScrollElement, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory }. */
15
+ /** The `$expose` imperative handle received via `ref` — { sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, scrollToRow, getScrollElement, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory, refreshView, getViewState }. */
16
16
  export type { DataTableHandle } from './DataTable';
17
17
  export type * from './DataTable';