@pyreon/table 0.0.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,388 @@
1
+ import { h } from '@pyreon/core'
2
+ import { signal, computed, type Computed } from '@pyreon/reactivity'
3
+ import { mount } from '@pyreon/runtime-dom'
4
+ import {
5
+ useTable,
6
+ getCoreRowModel,
7
+ getSortedRowModel,
8
+ getFilteredRowModel,
9
+ getPaginationRowModel,
10
+ createColumnHelper,
11
+ flexRender,
12
+ } from '../index'
13
+ import type { ColumnDef } from '../index'
14
+
15
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
16
+
17
+ interface Person {
18
+ name: string
19
+ age: number
20
+ }
21
+
22
+ const defaultData: Person[] = [
23
+ { name: 'Alice', age: 30 },
24
+ { name: 'Bob', age: 25 },
25
+ { name: 'Charlie', age: 35 },
26
+ ]
27
+
28
+ const defaultColumns: ColumnDef<Person, unknown>[] = [
29
+ { accessorKey: 'name', header: 'Name' },
30
+ { accessorKey: 'age', header: 'Age' },
31
+ ]
32
+
33
+ function mountWithTable<T>(fn: () => T): { result: T; unmount: () => void } {
34
+ let result: T | undefined
35
+ const el = document.createElement('div')
36
+ document.body.appendChild(el)
37
+ const unmount = mount(
38
+ h(() => {
39
+ result = fn()
40
+ return null
41
+ }, null),
42
+ el,
43
+ )
44
+ return {
45
+ result: result!,
46
+ unmount: () => {
47
+ unmount()
48
+ el.remove()
49
+ },
50
+ }
51
+ }
52
+
53
+ // ─── useTable ─────────────────────────────────────────────────────────────────
54
+
55
+ describe('useTable', () => {
56
+ it('creates a table with core row model', () => {
57
+ const { result: table, unmount } = mountWithTable(() =>
58
+ useTable(() => ({
59
+ data: defaultData,
60
+ columns: defaultColumns,
61
+ getCoreRowModel: getCoreRowModel(),
62
+ })),
63
+ )
64
+
65
+ const rows = table().getRowModel().rows
66
+ expect(rows).toHaveLength(3)
67
+ expect(rows[0]!.original.name).toBe('Alice')
68
+ expect(rows[1]!.original.name).toBe('Bob')
69
+ expect(rows[2]!.original.name).toBe('Charlie')
70
+ unmount()
71
+ })
72
+
73
+ it('returns correct header groups', () => {
74
+ const { result: table, unmount } = mountWithTable(() =>
75
+ useTable(() => ({
76
+ data: defaultData,
77
+ columns: defaultColumns,
78
+ getCoreRowModel: getCoreRowModel(),
79
+ })),
80
+ )
81
+
82
+ const headerGroups = table().getHeaderGroups()
83
+ expect(headerGroups).toHaveLength(1)
84
+ expect(headerGroups[0]!.headers).toHaveLength(2)
85
+ unmount()
86
+ })
87
+
88
+ it('reactive data — table updates when data signal changes', () => {
89
+ const data = signal<Person[]>(defaultData)
90
+ const { result: table, unmount } = mountWithTable(() =>
91
+ useTable(() => ({
92
+ data: data(),
93
+ columns: defaultColumns,
94
+ getCoreRowModel: getCoreRowModel(),
95
+ })),
96
+ )
97
+
98
+ expect(table().getRowModel().rows).toHaveLength(3)
99
+
100
+ data.set([...defaultData, { name: 'Diana', age: 28 }])
101
+ expect(table().getRowModel().rows).toHaveLength(4)
102
+ expect(table().getRowModel().rows[3]!.original.name).toBe('Diana')
103
+ unmount()
104
+ })
105
+
106
+ it('reactive subscribers — computed derived from table re-evaluates on data change', () => {
107
+ const data = signal<Person[]>(defaultData)
108
+ let rowCount: Computed<number> | undefined
109
+
110
+ const { unmount } = mountWithTable(() => {
111
+ const table = useTable(() => ({
112
+ data: data(),
113
+ columns: defaultColumns,
114
+ getCoreRowModel: getCoreRowModel(),
115
+ }))
116
+ // A computed that depends on the table signal — should re-evaluate
117
+ // when data changes, proving the signal actually notifies subscribers.
118
+ rowCount = computed(() => table().getRowModel().rows.length)
119
+ return table
120
+ })
121
+
122
+ expect(rowCount!()).toBe(3)
123
+
124
+ data.set([...defaultData, { name: 'Diana', age: 28 }])
125
+ expect(rowCount!()).toBe(4)
126
+
127
+ data.set([defaultData[0]!])
128
+ expect(rowCount!()).toBe(1)
129
+ unmount()
130
+ })
131
+
132
+ it('reactive columns — table updates when columns signal changes', () => {
133
+ const cols = signal<ColumnDef<Person, unknown>[]>(defaultColumns)
134
+ const { result: table, unmount } = mountWithTable(() =>
135
+ useTable(() => ({
136
+ data: defaultData,
137
+ columns: cols(),
138
+ getCoreRowModel: getCoreRowModel(),
139
+ })),
140
+ )
141
+
142
+ expect(table().getAllColumns()).toHaveLength(2)
143
+
144
+ cols.set([{ accessorKey: 'name', header: 'Name' }])
145
+ expect(table().getAllColumns()).toHaveLength(1)
146
+ unmount()
147
+ })
148
+
149
+ it('sorting — toggleSorting updates row order', () => {
150
+ const { result: table, unmount } = mountWithTable(() =>
151
+ useTable(() => ({
152
+ data: defaultData,
153
+ columns: defaultColumns,
154
+ getCoreRowModel: getCoreRowModel(),
155
+ getSortedRowModel: getSortedRowModel(),
156
+ })),
157
+ )
158
+
159
+ // Sort by age ascending
160
+ table().getColumn('age')!.toggleSorting(false)
161
+ const rows = table().getRowModel().rows
162
+ expect(rows[0]!.original.age).toBe(25)
163
+ expect(rows[1]!.original.age).toBe(30)
164
+ expect(rows[2]!.original.age).toBe(35)
165
+
166
+ // Sort by age descending
167
+ table().getColumn('age')!.toggleSorting(true)
168
+ const desc = table().getRowModel().rows
169
+ expect(desc[0]!.original.age).toBe(35)
170
+ expect(desc[2]!.original.age).toBe(25)
171
+ unmount()
172
+ })
173
+
174
+ it('filtering — setFilterValue filters rows', () => {
175
+ const { result: table, unmount } = mountWithTable(() =>
176
+ useTable(() => ({
177
+ data: defaultData,
178
+ columns: defaultColumns,
179
+ getCoreRowModel: getCoreRowModel(),
180
+ getFilteredRowModel: getFilteredRowModel(),
181
+ })),
182
+ )
183
+
184
+ table().getColumn('name')!.setFilterValue('Ali')
185
+ const filtered = table().getRowModel().rows
186
+ expect(filtered).toHaveLength(1)
187
+ expect(filtered[0]!.original.name).toBe('Alice')
188
+ unmount()
189
+ })
190
+
191
+ it('pagination — page size and navigation', () => {
192
+ const bigData: Person[] = Array.from({ length: 25 }, (_, i) => ({
193
+ name: `Person ${i}`,
194
+ age: 20 + i,
195
+ }))
196
+
197
+ const { result: table, unmount } = mountWithTable(() =>
198
+ useTable(() => ({
199
+ data: bigData,
200
+ columns: defaultColumns,
201
+ getCoreRowModel: getCoreRowModel(),
202
+ getPaginationRowModel: getPaginationRowModel(),
203
+ })),
204
+ )
205
+
206
+ // Default page size is 10
207
+ expect(table().getRowModel().rows).toHaveLength(10)
208
+ expect(table().getCanNextPage()).toBe(true)
209
+ expect(table().getCanPreviousPage()).toBe(false)
210
+
211
+ table().nextPage()
212
+ expect(table().getRowModel().rows).toHaveLength(10)
213
+ expect(table().getRowModel().rows[0]!.original.name).toBe('Person 10')
214
+
215
+ table().nextPage()
216
+ expect(table().getRowModel().rows).toHaveLength(5)
217
+ expect(table().getCanNextPage()).toBe(false)
218
+ unmount()
219
+ })
220
+
221
+ it('row selection — toggleRowSelected updates selection state', () => {
222
+ const { result: table, unmount } = mountWithTable(() =>
223
+ useTable(() => ({
224
+ data: defaultData,
225
+ columns: defaultColumns,
226
+ getCoreRowModel: getCoreRowModel(),
227
+ enableRowSelection: true,
228
+ })),
229
+ )
230
+
231
+ expect(table().getSelectedRowModel().rows).toHaveLength(0)
232
+
233
+ table().getRowModel().rows[0]!.toggleSelected(true)
234
+ expect(table().getSelectedRowModel().rows).toHaveLength(1)
235
+ expect(table().getSelectedRowModel().rows[0]!.original.name).toBe('Alice')
236
+
237
+ table().getRowModel().rows[0]!.toggleSelected(false)
238
+ expect(table().getSelectedRowModel().rows).toHaveLength(0)
239
+ unmount()
240
+ })
241
+
242
+ it('column visibility — toggleVisibility hides columns', () => {
243
+ const { result: table, unmount } = mountWithTable(() =>
244
+ useTable(() => ({
245
+ data: defaultData,
246
+ columns: defaultColumns,
247
+ getCoreRowModel: getCoreRowModel(),
248
+ })),
249
+ )
250
+
251
+ expect(table().getVisibleFlatColumns()).toHaveLength(2)
252
+
253
+ table().getColumn('age')!.toggleVisibility(false)
254
+ expect(table().getVisibleFlatColumns()).toHaveLength(1)
255
+ expect(table().getVisibleFlatColumns()[0]!.id).toBe('name')
256
+
257
+ table().getColumn('age')!.toggleVisibility(true)
258
+ expect(table().getVisibleFlatColumns()).toHaveLength(2)
259
+ unmount()
260
+ })
261
+
262
+ it('getState returns merged state', () => {
263
+ const { result: table, unmount } = mountWithTable(() =>
264
+ useTable(() => ({
265
+ data: defaultData,
266
+ columns: defaultColumns,
267
+ getCoreRowModel: getCoreRowModel(),
268
+ getSortedRowModel: getSortedRowModel(),
269
+ })),
270
+ )
271
+
272
+ expect(table().getState().sorting).toEqual([])
273
+ table().getColumn('name')!.toggleSorting(false)
274
+ expect(table().getState().sorting).toEqual([{ id: 'name', desc: false }])
275
+ unmount()
276
+ })
277
+
278
+ it('createColumnHelper works with useTable', () => {
279
+ const columnHelper = createColumnHelper<Person>()
280
+ const columns = [
281
+ columnHelper.accessor('name', { header: 'Full Name' }),
282
+ columnHelper.accessor('age', { header: 'Years' }),
283
+ ]
284
+
285
+ const { result: table, unmount } = mountWithTable(() =>
286
+ useTable(() => ({
287
+ data: defaultData,
288
+ columns,
289
+ getCoreRowModel: getCoreRowModel(),
290
+ })),
291
+ )
292
+
293
+ const headers = table().getHeaderGroups()[0]!.headers
294
+ expect(headers).toHaveLength(2)
295
+ unmount()
296
+ })
297
+ })
298
+
299
+ // ─── flexRender ──────────────────────────────────────────────────────────────
300
+
301
+ describe('flexRender', () => {
302
+ it('renders a string directly', () => {
303
+ expect(flexRender('Hello', {})).toBe('Hello')
304
+ })
305
+
306
+ it('renders a number directly', () => {
307
+ expect(flexRender(42, {})).toBe(42)
308
+ })
309
+
310
+ it('renders null for undefined/null', () => {
311
+ expect(flexRender(undefined, {})).toBeNull()
312
+ expect(flexRender(null, {})).toBeNull()
313
+ })
314
+
315
+ it('calls a function with props', () => {
316
+ const fn = (props: { value: string }) => `Value: ${props.value}`
317
+ expect(flexRender(fn, { value: 'test' })).toBe('Value: test')
318
+ })
319
+
320
+ it('passes through VNodes as-is', () => {
321
+ const vnode = h('span', null, 'cell content')
322
+ const result = flexRender(vnode as unknown, {})
323
+ expect(result).toBe(vnode)
324
+ })
325
+
326
+ it('returns null for unsupported types', () => {
327
+ expect(flexRender(true as unknown, {})).toBeNull()
328
+ expect(flexRender({} as unknown, {})).toBeNull()
329
+ })
330
+ })
331
+
332
+ // ─── onStateChange with non-function updater ─────────────────────────────────
333
+
334
+ describe('useTable — onStateChange with direct state object', () => {
335
+ it('handles a non-function updater (plain state object) passed to onStateChange', () => {
336
+ const { result: table, unmount } = mountWithTable(() =>
337
+ useTable(() => ({
338
+ data: defaultData,
339
+ columns: defaultColumns,
340
+ getCoreRowModel: getCoreRowModel(),
341
+ getSortedRowModel: getSortedRowModel(),
342
+ })),
343
+ )
344
+
345
+ // Grab the current full state, then call onStateChange with a direct
346
+ // state object (not an updater function) to exercise the else-branch.
347
+ const currentState = table().getState()
348
+ const newState = {
349
+ ...currentState,
350
+ sorting: [{ id: 'name', desc: true }],
351
+ }
352
+
353
+ // Access the resolved onStateChange and invoke it with a plain object
354
+ table().options.onStateChange(newState as any)
355
+
356
+ // The table should now reflect the new sorting state
357
+ expect(table().getState().sorting).toEqual([{ id: 'name', desc: true }])
358
+ unmount()
359
+ })
360
+
361
+ it('propagates non-function updater to user-provided onStateChange callback', () => {
362
+ const stateChanges: unknown[] = []
363
+
364
+ const { result: table, unmount } = mountWithTable(() =>
365
+ useTable(() => ({
366
+ data: defaultData,
367
+ columns: defaultColumns,
368
+ getCoreRowModel: getCoreRowModel(),
369
+ onStateChange: (updater) => {
370
+ stateChanges.push(updater)
371
+ },
372
+ })),
373
+ )
374
+
375
+ const currentState = table().getState()
376
+ const newState = { ...currentState, columnOrder: ['age', 'name'] }
377
+
378
+ table().options.onStateChange(newState as any)
379
+
380
+ // The user callback should have received the plain state object
381
+ expect(stateChanges.length).toBeGreaterThanOrEqual(1)
382
+ const lastChange = stateChanges[stateChanges.length - 1]
383
+ expect(lastChange).toEqual(
384
+ expect.objectContaining({ columnOrder: ['age', 'name'] }),
385
+ )
386
+ unmount()
387
+ })
388
+ })
@@ -0,0 +1,105 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import { signal, computed, effect, batch } from '@pyreon/reactivity'
3
+ import type { Computed } from '@pyreon/reactivity'
4
+ import {
5
+ createTable,
6
+ type RowData,
7
+ type TableOptions,
8
+ type TableOptionsResolved,
9
+ type TableState,
10
+ type Table,
11
+ type Updater,
12
+ } from '@tanstack/table-core'
13
+
14
+ export type UseTableOptions<TData extends RowData> = () => TableOptions<TData>
15
+
16
+ /**
17
+ * Create a reactive TanStack Table instance. Returns a read-only signal
18
+ * that holds the Table instance — read it in effects or templates to
19
+ * track state changes.
20
+ *
21
+ * Options are passed as a function so reactive signals (e.g. data, columns)
22
+ * can be read inside, and the table updates automatically when they change.
23
+ *
24
+ * @example
25
+ * const data = signal([{ name: "Alice" }, { name: "Bob" }])
26
+ * const table = useTable(() => ({
27
+ * data: data(),
28
+ * columns: [{ accessorKey: "name", header: "Name" }],
29
+ * getCoreRowModel: getCoreRowModel(),
30
+ * }))
31
+ * // In template: () => table().getRowModel().rows
32
+ */
33
+ export function useTable<TData extends RowData>(
34
+ options: UseTableOptions<TData>,
35
+ ): Computed<Table<TData>> {
36
+ // Internal state managed by the adapter — merged with user-provided state.
37
+ const tableState = signal<TableState>({} as TableState)
38
+
39
+ // Version counter — Pyreon signals use Object.is for equality, so
40
+ // setting the same table reference is a no-op. We bump a version
41
+ // counter to force the computed to re-evaluate and notify consumers.
42
+ const version = signal(0)
43
+
44
+ // Resolve user options with adapter-required defaults.
45
+ const resolvedOptions: TableOptionsResolved<TData> = {
46
+ state: {},
47
+ onStateChange() {
48
+ /* default noop */
49
+ },
50
+ renderFallbackValue: null,
51
+ ...options(),
52
+ }
53
+
54
+ // Create the table instance once.
55
+ const table = createTable(resolvedOptions)
56
+
57
+ // Initialize internal state from the table's initial state.
58
+ tableState.set(table.initialState)
59
+
60
+ // The signal that consumers read — depends on `version` so it
61
+ // re-notifies whenever we bump the version after a state/option change.
62
+ const tableSig = computed(() => {
63
+ version()
64
+ return table
65
+ })
66
+
67
+ // Sync options reactively: when signals inside options() change, or when
68
+ // internal state changes, update the table and notify consumers.
69
+ const cleanup = effect(() => {
70
+ const userOpts = options()
71
+ const currentState = tableState()
72
+ let stateChanged = false
73
+
74
+ table.setOptions((prev) => ({
75
+ ...prev,
76
+ ...userOpts,
77
+ state: {
78
+ ...currentState,
79
+ ...userOpts.state,
80
+ },
81
+ onStateChange: (updater: Updater<TableState>) => {
82
+ const newState =
83
+ typeof updater === 'function' ? updater(tableState.peek()) : updater
84
+
85
+ stateChanged = true
86
+ batch(() => {
87
+ tableState.set(newState)
88
+ version.update((n) => n + 1)
89
+ })
90
+
91
+ userOpts.onStateChange?.(updater)
92
+ },
93
+ }))
94
+
95
+ // Only bump if setOptions didn't already trigger a state change
96
+ if (!stateChanged) {
97
+ version.update((n) => n + 1)
98
+ }
99
+ })
100
+
101
+ // Clean up the effect when the component unmounts.
102
+ onUnmount(() => cleanup.dispose())
103
+
104
+ return tableSig
105
+ }