@octane-xplat/table 0.0.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +427 -3
  3. package/dist/native/ColumnDrag.tsrx.js +237 -0
  4. package/dist/native/DataGrid.tsrx.js +957 -0
  5. package/dist/native/GridControls.tsrx.js +424 -0
  6. package/dist/native/grid-accessibility.js +12 -0
  7. package/dist/native/grid-layout.js +43 -0
  8. package/dist/native/index.js +4 -0
  9. package/dist/native/octane-client-build.json +8 -0
  10. package/dist/native/table.js +56 -0
  11. package/dist/native/useTable.tsrx.js +24 -0
  12. package/dist/web/ColumnDrag.tsrx.js +85 -0
  13. package/dist/web/DataGrid.tsrx.js +242 -0
  14. package/dist/web/GridControls.tsrx.js +114 -0
  15. package/dist/web/grid-accessibility.js +12 -0
  16. package/dist/web/grid-layout.js +43 -0
  17. package/dist/web/index.js +4 -0
  18. package/dist/web/octane-client-build.json +8 -0
  19. package/dist/web/table.js +56 -0
  20. package/dist/web/useTable.tsrx.js +26 -0
  21. package/package.json +75 -3
  22. package/src/ColumnDrag.tsrx +123 -0
  23. package/src/DataGrid.mobile.test.tsrx +55 -0
  24. package/src/DataGrid.tsrx +350 -0
  25. package/src/DataGrid.web.test.tsrx +277 -0
  26. package/src/GridControls.tsrx +157 -0
  27. package/src/grid-accessibility.ts +14 -0
  28. package/src/grid-layout.test.ts +32 -0
  29. package/src/grid-layout.ts +52 -0
  30. package/src/index.macos.ts +8 -0
  31. package/src/index.ts +7 -0
  32. package/src/index.web.ts +8 -0
  33. package/src/props.ts +122 -0
  34. package/src/table.test.ts +114 -0
  35. package/src/table.ts +91 -0
  36. package/src/useTable.tsrx +34 -0
  37. package/types/index.d.ts +20 -0
  38. package/types/index.macos.d.ts +17 -0
  39. package/types/props.d.ts +96 -0
  40. package/types/table.d.ts +83 -0
@@ -0,0 +1,123 @@
1
+ import { useRef } from 'octane'
2
+ import { Pressable, HStack as Row, Text, View } from '@octane-xplat/ui'
3
+ import { dragTarget, moveColumn, resizedWidth } from './grid-layout'
4
+
5
+ /** Pan handlers use shared gesture payloads; no browser resize listeners. */
6
+ export function ColumnDrag(props: {
7
+ table: any
8
+ column: any
9
+ resize?: boolean
10
+ reorder?: boolean
11
+ direction?: 'ltr' | 'rtl'
12
+ id?: string
13
+ state: any
14
+ }) {
15
+ void props.state
16
+ const drag = useRef<{
17
+ size: number
18
+ sizing: Record<string, number>
19
+ order: string[]
20
+ columns: any[]
21
+ } | null>(null)
22
+
23
+ const begin = () => {
24
+ drag.current = {
25
+ size: props.column.getSize(),
26
+ sizing: { ...props.table.store.get().columnSizing },
27
+ order: props.table.getAllLeafColumns().map((column: any) => column.id),
28
+ columns: props.table.getVisibleLeafColumns().map((column: any) => {
29
+ const size = column.getSize()
30
+ return { id: column.id, getSize: () => size }
31
+ }),
32
+ }
33
+ }
34
+
35
+ const onPan = (event: any, resize: boolean) => {
36
+ if (event.state === 'began') {
37
+ begin()
38
+ return
39
+ }
40
+
41
+ const start = drag.current
42
+ if (!start) return
43
+ if (event.state === 'cancelled') {
44
+ if (resize)
45
+ props.table.setColumnSizing((old: Record<string, number>) => {
46
+ const next = { ...old }
47
+ if (start.sizing[props.column.id] == null) delete next[props.column.id]
48
+ else next[props.column.id] = start.sizing[props.column.id]
49
+
50
+ return next
51
+ })
52
+
53
+ drag.current = null
54
+ return
55
+ }
56
+
57
+ const dx = event.dx * (props.direction === 'rtl' ? -1 : 1)
58
+ if (resize && Number.isFinite(dx)) {
59
+ props.table.setColumnSizing((old: any) => ({
60
+ ...old,
61
+ [props.column.id]: resizedWidth(props.column, start.size + dx),
62
+ }))
63
+ }
64
+
65
+ if (event.state === 'ended') {
66
+ if (!resize) {
67
+ const target = dragTarget(start.columns, props.column.id, dx)
68
+ props.table.setColumnOrder(moveColumn(start.order, props.column.id, target))
69
+ }
70
+
71
+ drag.current = null
72
+ }
73
+ }
74
+
75
+ const adjust = (delta: number) =>
76
+ props.table.setColumnSizing((old: any) => ({
77
+ ...old,
78
+ [props.column.id]: resizedWidth(props.column, props.column.getSize() + delta),
79
+ }))
80
+
81
+ return (
82
+ <Row className="vx-datagrid-handles">
83
+ {props.reorder ? (
84
+ <View
85
+ id={props.id ? `${props.id}-drag` : undefined}
86
+ className="vx-datagrid-handle"
87
+ onPan={(event) => onPan(event, false)}
88
+ accessibilityLabel={`Drag ${props.column.id} to reorder`}
89
+ >
90
+ <Text>↔</Text>
91
+ </View>
92
+ ) : null}
93
+ {props.resize ? (
94
+ <>
95
+ <Pressable
96
+ id={props.id ? `${props.id}-narrow` : undefined}
97
+ className="vx-datagrid-handle"
98
+ onPress={() => adjust(-10)}
99
+ accessibilityLabel={`Narrow ${props.column.id}`}
100
+ >
101
+ <Text>−</Text>
102
+ </Pressable>
103
+ <View
104
+ id={props.id ? `${props.id}-resize` : undefined}
105
+ className="vx-datagrid-handle"
106
+ onPan={(event) => onPan(event, true)}
107
+ accessibilityLabel={`Drag to resize ${props.column.id}`}
108
+ >
109
+ <Text>│</Text>
110
+ </View>
111
+ <Pressable
112
+ id={props.id ? `${props.id}-widen` : undefined}
113
+ className="vx-datagrid-handle"
114
+ onPress={() => adjust(10)}
115
+ accessibilityLabel={`Widen ${props.column.id}`}
116
+ >
117
+ <Text>+</Text>
118
+ </Pressable>
119
+ </>
120
+ ) : null}
121
+ </Row>
122
+ )
123
+ }
@@ -0,0 +1,55 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { createObjectContainer, createObjectDriver, createUniversalRoot, flushUniversalSync } from 'octane/universal/native'
4
+ import { useState } from 'octane'
5
+
6
+ vi.mock('@nativescript/core', () => ({ isIOS: false, isAndroid: true, Application: {} }))
7
+ vi.mock('../../ui/src/pan.tsrx', () => ({ usePan: () => undefined }))
8
+ vi.mock('@octane-xplat/ui', () => import('../tests/native-controls.mobile.tsrx'))
9
+ import { DataGrid } from './DataGrid.tsrx'
10
+
11
+ const data = [{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }, { id: 'c', name: 'Charlie' }]
12
+ const columns = [{ accessorKey: 'name', header: 'Name' }]
13
+
14
+ function find(nodes: any[], id: string): any {
15
+ for (const node of nodes) {
16
+ if (node.props?.id === id) return node
17
+ const child = find(node.children ?? [], id)
18
+ if (child) return child
19
+ }
20
+ }
21
+
22
+ function Controlled() {
23
+ const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
24
+ return <DataGrid id="grid" data={data} columns={columns} getRowId={(row) => row.id}
25
+ getRowLabel={(row) => row.original.name} showRowSelection showPagination
26
+ state={{ rowSelection }} options={{ onRowSelectionChange: setRowSelection }}
27
+ initialState={{ pagination: { pageIndex: 0, pageSize: 2 } }} />
28
+ }
29
+
30
+ describe('native DataGrid selection integration', () => {
31
+ it('updates retained controls for caller state, mixed selection and off-page ordinals', () => {
32
+ const container = createObjectContainer('nativescript')
33
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
34
+ root.render(Controlled as any, {})
35
+ const get = (id: string) => find(container.children, id)
36
+ const press = (id: string) => flushUniversalSync(() => container.dispatchEvent(get(id), 'tap', {}))
37
+ const page = get('grid-select-page')
38
+ expect(get('grid-select-a').props.accessibilityLabel).toBe('Select Alice')
39
+ expect(get('grid-select-a').props.accessibilityHint).toBe('Row 1 of 3')
40
+ press('grid-select-a')
41
+ expect(get('grid-select-page')).toBe(page)
42
+ expect(page.props.accessibilityValue).toBe('Partially selected')
43
+ expect(page.props.accessibilityState).toBeUndefined()
44
+ press('grid-select-page')
45
+ expect(page.props.accessibilityValue).toBeUndefined()
46
+ expect(page.props.accessibilityState).toBe('checked')
47
+ press('grid-next')
48
+ expect(get('grid-select-c').props.accessibilityHint).toBe('Row 3 of 3')
49
+ expect(page.props.accessibilityState).toBe('unchecked')
50
+ press('grid-clear-selection')
51
+ press('grid-previous')
52
+ expect(get('grid-select-a').props.accessibilityState).toBe('unchecked')
53
+ root.unmount()
54
+ })
55
+ })
@@ -0,0 +1,350 @@
1
+ import type { Cell, Header, Row, RowData } from '@tanstack/table-core'
2
+ import {
3
+ CheckboxInput as Checkbox,
4
+ Pressable,
5
+ HStack as GridRow,
6
+ Text,
7
+ TextInput,
8
+ View,
9
+ VirtualList,
10
+ } from '@octane-xplat/ui'
11
+
12
+ import { useTable } from './useTable.tsrx'
13
+ import type { DataGridProps, UseTable } from './props'
14
+ import type { DataGridFeatures } from './table'
15
+ import { columnStyle } from './grid-layout'
16
+ import { gridRowPosition } from './grid-accessibility'
17
+ import { ColumnDrag } from './ColumnDrag.tsrx'
18
+ import { GridControls, GridPagination } from './GridControls.tsrx'
19
+
20
+ /** Evaluates a column `cell`/`header` template (string | fn → node) — the
21
+ * DOM-free half of table-core's `flexRender`. */
22
+ function renderTemplate(template: any, ctx: any) {
23
+ if (template == null) return null
24
+ return typeof template === 'function' ? template(ctx) : template
25
+ }
26
+
27
+ /** Primitive template results draw as Text; nodes pass through. */
28
+ function TemplateContent(props: { content: any }) {
29
+ const content = props.content
30
+ if (content == null || content === false) return null
31
+ if (typeof content === 'string' || typeof content === 'number' || typeof content === 'bigint') {
32
+ return <Text>{String(content)}</Text>
33
+ }
34
+
35
+ return content
36
+ }
37
+
38
+ function sortIndicator(column: any) {
39
+ const dir = column.getIsSorted?.()
40
+ return dir === 'asc' ? ' ↑' : dir === 'desc' ? ' ↓' : ''
41
+ }
42
+
43
+ function DataGridHeaderCell<TData extends RowData>(props: {
44
+ header: Header<DataGridFeatures, TData, any>
45
+ render?: DataGridProps<TData>['renderHeader']
46
+ id?: string
47
+ /** Flat TableState — prop identity forces re-eval of derived reads
48
+ * (`getIsSorted`) on native, where unchanged-prop children are retained. */
49
+ state: any
50
+ table: any
51
+ filters?: boolean
52
+ resize?: boolean
53
+ reorder?: boolean
54
+ direction?: 'ltr' | 'rtl'
55
+ }) {
56
+ const { header } = props
57
+ void props.state
58
+ const column = header.column as any
59
+ const canSort = !header.isPlaceholder && column.getCanSort?.() === true
60
+ const content = props.render
61
+ ? props.render(header)
62
+ : (renderTemplate(column.columnDef.header, header.getContext()) ?? column.id)
63
+
64
+ const isPrimitive =
65
+ typeof content === 'string' || typeof content === 'number' || typeof content === 'bigint'
66
+
67
+ // Native Labels can't host span/element children — primitives join the
68
+ // indicator text inside one Text; nodes become siblings of it.
69
+ const inner = isPrimitive ? (
70
+ <Text>
71
+ {String(content)}
72
+ {canSort ? sortIndicator(column) : ''}
73
+ </Text>
74
+ ) : (
75
+ <>
76
+ <TemplateContent content={content} />
77
+ {canSort ? <Text>{sortIndicator(column)}</Text> : null}
78
+ </>
79
+ )
80
+
81
+ const cls = 'vx-table-cell vx-table-th'
82
+ return (
83
+ <View
84
+ className={cls}
85
+ web={{ role: 'columnheader' }}
86
+ style={columnStyle(column, Boolean(props.resize || props.reorder), props.state.columnSizing)}
87
+ >
88
+ {canSort ? (
89
+ <Pressable
90
+ id={props.id}
91
+ className="vx-table-th--sortable"
92
+ onPress={() => column.toggleSorting()}
93
+ accessibilityLabel={`Sort ${column.id}, ${column.getIsSorted() || 'unsorted'}`}
94
+ >
95
+ <>{inner}</>
96
+ </Pressable>
97
+ ) : (
98
+ <View id={props.id}>{inner}</View>
99
+ )}
100
+ {!header.isPlaceholder && props.filters && column.getCanFilter() ? (
101
+ <TextInput
102
+ id={props.id ? `${props.id}-filter` : undefined}
103
+ value={String(column.getFilterValue() ?? '')}
104
+ placeholder={`Filter ${column.id}`}
105
+ accessibilityLabel={`Filter ${column.id}`}
106
+ onChange={(value) => {
107
+ column.setFilterValue(value)
108
+ props.table.setPageIndex(0)
109
+ }}
110
+ />
111
+ ) : null}
112
+ {!header.isPlaceholder && column.columns.length === 0 && (props.resize || props.reorder) ? (
113
+ <ColumnDrag
114
+ id={props.id}
115
+ table={props.table}
116
+ column={column}
117
+ resize={props.resize}
118
+ reorder={props.reorder}
119
+ direction={props.direction}
120
+ state={props.state}
121
+ />
122
+ ) : null}
123
+ </View>
124
+ )
125
+ }
126
+
127
+ function DataGridBodyRow<TData extends RowData>(props: {
128
+ row: Row<DataGridFeatures, TData>
129
+ renderCell?: DataGridProps<TData>['renderCell']
130
+ onRowPress?: DataGridProps<TData>['onRowPress']
131
+ idPrefix?: string
132
+ /** See DataGridHeaderCell.state — forces re-eval of `getIsSelected`. */
133
+ state: any
134
+ position: { ariaIndex: number; label: string }
135
+ selection?: boolean
136
+ fixed?: boolean
137
+ getRowLabel?: (row: any) => string
138
+ }) {
139
+ const { row } = props
140
+ void props.state
141
+ const cells = row.getVisibleCells()
142
+ const inner = (
143
+ <>
144
+ {cells.map((cell: Cell<DataGridFeatures, TData, any>) => (
145
+ <View
146
+ key={cell.id}
147
+ className="vx-table-cell"
148
+ web={{ role: 'cell' }}
149
+ style={columnStyle(cell.column, Boolean(props.fixed), props.state.columnSizing)}
150
+ >
151
+ <TemplateContent
152
+ content={
153
+ props.renderCell
154
+ ? props.renderCell(cell)
155
+ : (renderTemplate(cell.column.columnDef.cell, cell.getContext()) ??
156
+ cell.renderValue?.())
157
+ }
158
+ />
159
+ </View>
160
+ ))}
161
+ </>
162
+ )
163
+
164
+ const cls =
165
+ 'vx-table-row' +
166
+ (props.onRowPress ? ' vx-table-row--pressable' : '') +
167
+ (row.getIsSelected?.() ? ' vx-table-row--selected' : '')
168
+
169
+ const id = props.idPrefix ? `${props.idPrefix}-row-${row.id}` : undefined
170
+ const content = props.onRowPress ? (
171
+ <Pressable
172
+ id={props.selection ? undefined : id}
173
+ className="vx-datagrid-row-action"
174
+ onPress={() => props.onRowPress!(row)}
175
+ accessibilityLabel={props.getRowLabel?.(row) ?? row.id}
176
+ accessibilityHint={props.position.label}
177
+ accessibilityState={{ selected: row.getIsSelected?.() || undefined }}
178
+ >
179
+ {inner}
180
+ </Pressable>
181
+ ) : (
182
+ inner
183
+ )
184
+
185
+ return (
186
+ <GridRow
187
+ id={props.selection || !props.onRowPress ? id : undefined}
188
+ className={cls}
189
+ web={{ role: 'row', 'aria-rowindex': props.position.ariaIndex }}
190
+ accessibilityHint={props.position.label}
191
+ accessibilityState={{ selected: row.getIsSelected?.() || undefined }}
192
+ >
193
+ {props.selection ? (
194
+ <View className="vx-datagrid-selection" web={{ role: 'cell' }}>
195
+ <Checkbox
196
+ id={props.idPrefix ? `${props.idPrefix}-select-${row.id}` : undefined}
197
+ checked={row.getIsSelected()}
198
+ isDisabled={!row.getCanSelect()}
199
+ accessibilityLabel={`Select ${props.getRowLabel?.(row) ?? row.id}`}
200
+ accessibilityHint={props.position.label}
201
+ onCheckedChange={(checked) => row.toggleSelected(checked)}
202
+ />
203
+ </View>
204
+ ) : null}
205
+ {content}
206
+ </GridRow>
207
+ )
208
+ }
209
+
210
+ /** DataGrid — headless `@tanstack/table-core` row model (sorting, filtering,
211
+ * pagination, selection, grouping, expanding) over shared `VirtualList`
212
+ * windowing. One structure on every target; the columns/state contract is
213
+ * the library's `ColumnDef`s plus `TableState` slices. Large-column or
214
+ * platform-recycler presentations belong to platform subpaths. */
215
+ export function DataGrid<TData extends RowData = any>(props: DataGridProps<TData>) {
216
+ const { table, state }: UseTable<DataGridFeatures, TData> = useTable<TData, DataGridFeatures>({
217
+ ...props.options,
218
+ defaultColumn: props.showColumnFilters
219
+ ? { filterFn: 'includesString', ...props.options?.defaultColumn }
220
+ : props.options?.defaultColumn,
221
+ data: props.data,
222
+ columns: props.columns as any,
223
+ features: props.features as any,
224
+ initialState: props.initialState as any,
225
+ state: props.state as any,
226
+ getRowId: props.getRowId,
227
+ })
228
+
229
+ const headerGroups = table.getHeaderGroups()
230
+ const rows = table.getRowModel().rows
231
+ // getRowCount uses the filtered pre-pagination model for client data.
232
+ // An unknown manual total must not masquerade as the loaded page length.
233
+ const rowCount = props.options?.manualPagination && props.options.rowCount == null
234
+ ? -1
235
+ : table.getRowCount()
236
+
237
+ const ariaRowCount = rowCount < 0 ? -1 : headerGroups.length + rowCount
238
+ return (
239
+ <View
240
+ id={props.id}
241
+ className={['vx-table', 'vx-datagrid', props.className]}
242
+ style={props.style}
243
+ flexGrow={props.flexGrow ?? 1}
244
+ flexShrink={props.flexShrink}
245
+ alignSelf={props.alignSelf}
246
+ row={props.row}
247
+ col={props.col}
248
+ rowSpan={props.rowSpan}
249
+ colSpan={props.colSpan}
250
+ ios={props.ios}
251
+ android={props.android}
252
+ web={props.web}
253
+ accessible={props.accessible}
254
+ accessibilityHint={props.accessibilityHint}
255
+ accessibilityState={props.accessibilityState}
256
+ accessibilityRole={props.accessibilityRole as any}
257
+ accessibilityLabel={props.accessibilityLabel}
258
+ >
259
+ {props.showGlobalFilter || props.showColumnSettings ? (
260
+ <GridControls
261
+ id={props.id}
262
+ table={table}
263
+ state={state}
264
+ search={props.showGlobalFilter}
265
+ settings={props.showColumnSettings}
266
+ />
267
+ ) : null}
268
+ {props.showRowSelection ? (
269
+ <GridRow className="vx-datagrid-controls">
270
+ <Text id={props.id ? `${props.id}-selected-count` : undefined}>
271
+ {Object.values(state.rowSelection).filter(Boolean).length} selected
272
+ </Text>
273
+ <Pressable
274
+ id={props.id ? `${props.id}-clear-selection` : undefined}
275
+ className="vx-datagrid-action"
276
+ onPress={() => table.resetRowSelection(true)}
277
+ accessibilityLabel="Clear selection"
278
+ >
279
+ <Text>Clear selection</Text>
280
+ </Pressable>
281
+ {props.renderSelectionActions?.(table)}
282
+ </GridRow>
283
+ ) : null}
284
+ <View
285
+ id={props.id ? `${props.id}-table` : undefined}
286
+ flexGrow={1}
287
+ web={{ role: 'table', 'aria-label': props.accessibilityLabel, 'aria-rowcount': ariaRowCount, 'aria-colcount': table.getVisibleLeafColumns().length + (props.showRowSelection ? 1 : 0) }}
288
+ >
289
+ <View id={props.id ? `${props.id}-header` : undefined} flexShrink={0}>
290
+ {headerGroups.map((group) => (
291
+ <GridRow key={group.id} className="vx-table-head" web={{ role: 'row', 'aria-rowindex': group.depth + 1 }}>
292
+ {props.showRowSelection ? (
293
+ <View className="vx-datagrid-selection" web={{ role: 'columnheader' }}>
294
+ {group.depth === 0 ? (
295
+ <Checkbox
296
+ id={props.id ? `${props.id}-select-page` : undefined}
297
+ checked={table.getIsAllPageRowsSelected()}
298
+ indeterminate={!table.getIsAllPageRowsSelected() && table.getIsSomePageRowsSelected()}
299
+ isDisabled={!rows.some((row) => row.getCanSelect())}
300
+ accessibilityLabel="Select current page"
301
+ onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
302
+ />
303
+ ) : null}
304
+ </View>
305
+ ) : null}
306
+ {group.headers.map((header: Header<DataGridFeatures, TData, any>) => (
307
+ <DataGridHeaderCell
308
+ key={header.id}
309
+ header={header}
310
+ render={props.renderHeader}
311
+ id={props.id ? `${props.id}-th-${header.column.id}` : undefined}
312
+ state={state}
313
+ table={table}
314
+ filters={props.showColumnFilters}
315
+ resize={props.resizableColumns}
316
+ reorder={props.reorderableColumns}
317
+ direction={props.direction}
318
+ />
319
+ ))}
320
+ </GridRow>
321
+ ))}
322
+ </View>
323
+ <VirtualList
324
+ flexGrow={1}
325
+ className="vx-datagrid-body"
326
+ id={props.id ? `${props.id}-body` : undefined}
327
+ items={rows}
328
+ keyExtractor={(row: Row<DataGridFeatures, TData>) => row.id}
329
+ renderItem={(row: Row<DataGridFeatures, TData>, index: number) => (
330
+ <DataGridBodyRow
331
+ row={row}
332
+ position={gridRowPosition(index, state.pagination, headerGroups.length, rowCount)}
333
+ renderCell={props.renderCell}
334
+ onRowPress={props.onRowPress}
335
+ idPrefix={props.id}
336
+ state={state}
337
+ selection={props.showRowSelection}
338
+ getRowLabel={props.getRowLabel}
339
+ fixed={Boolean(props.resizableColumns || props.reorderableColumns)}
340
+ />
341
+ )}
342
+ renderEmpty={props.renderEmpty}
343
+ />
344
+ </View>
345
+ {props.showPagination ? (
346
+ <GridPagination id={props.id} table={table} state={state} sizes={props.pageSizeOptions} />
347
+ ) : null}
348
+ </View>
349
+ )
350
+ }