@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.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -1
- package/dist/index.cjs +501 -16
- package/dist/index.d.cts +23 -2
- package/dist/index.d.mts +23 -2
- package/dist/index.mjs +501 -16
- package/dist/source/index.jsx +500 -15
- package/package.json +2 -2
- package/src/Column.tsx +36 -3
- package/src/DataTable.tsx +484 -32
- package/src/helpers/activeFollow.ts +42 -0
- package/src/helpers/columnRuleUtils.ts +105 -0
- package/src/helpers/heldView.ts +185 -0
- package/src/index.ts +1 -1
|
@@ -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';
|