@octane-xplat/table 0.0.1 → 0.11.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.
- package/LICENSE +21 -0
- package/README.md +427 -3
- package/dist/native/ColumnDrag.tsrx.js +237 -0
- package/dist/native/DataGrid.tsrx.js +957 -0
- package/dist/native/GridControls.tsrx.js +424 -0
- package/dist/native/grid-accessibility.js +12 -0
- package/dist/native/grid-layout.js +43 -0
- package/dist/native/index.js +4 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/table.js +56 -0
- package/dist/native/useTable.tsrx.js +24 -0
- package/dist/web/ColumnDrag.tsrx.js +85 -0
- package/dist/web/DataGrid.tsrx.js +242 -0
- package/dist/web/GridControls.tsrx.js +114 -0
- package/dist/web/grid-accessibility.js +12 -0
- package/dist/web/grid-layout.js +43 -0
- package/dist/web/index.js +4 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/table.js +56 -0
- package/dist/web/useTable.tsrx.js +26 -0
- package/package.json +75 -3
- package/src/ColumnDrag.tsrx +123 -0
- package/src/DataGrid.mobile.test.tsrx +55 -0
- package/src/DataGrid.tsrx +350 -0
- package/src/DataGrid.web.test.tsrx +277 -0
- package/src/GridControls.tsrx +157 -0
- package/src/grid-accessibility.ts +14 -0
- package/src/grid-layout.test.ts +32 -0
- package/src/grid-layout.ts +52 -0
- package/src/index.macos.ts +8 -0
- package/src/index.ts +7 -0
- package/src/index.web.ts +8 -0
- package/src/props.ts +122 -0
- package/src/table.test.ts +114 -0
- package/src/table.ts +91 -0
- package/src/useTable.tsrx +34 -0
- package/types/index.d.ts +20 -0
- package/types/index.macos.d.ts +17 -0
- package/types/props.d.ts +96 -0
- package/types/table.d.ts +83 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { act, createRoot, useState } from 'octane'
|
|
4
|
+
import { DataGrid } from './DataGrid.tsrx'
|
|
5
|
+
|
|
6
|
+
// Windowing has its own integration suite; keep these control regressions
|
|
7
|
+
// independent of jsdom's absent layout and ResizeObserver implementation.
|
|
8
|
+
vi.mock('@octane-xplat/ui', async (original) => {
|
|
9
|
+
const ui = await original<any>()
|
|
10
|
+
return {
|
|
11
|
+
...ui,
|
|
12
|
+
VirtualList: (props: any) => (
|
|
13
|
+
<ui.View>
|
|
14
|
+
{props.items.length
|
|
15
|
+
? props.items.map((item: any, index: number) => (
|
|
16
|
+
<ui.View key={props.keyExtractor(item, index)}>
|
|
17
|
+
{props.renderItem(item, index)}
|
|
18
|
+
</ui.View>
|
|
19
|
+
))
|
|
20
|
+
: props.renderEmpty?.()}
|
|
21
|
+
</ui.View>
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
27
|
+
matches: false,
|
|
28
|
+
addEventListener() {},
|
|
29
|
+
removeEventListener() {},
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const roots: any[] = []
|
|
33
|
+
const data = [
|
|
34
|
+
{ id: 'a', name: 'Alice', count: 1 },
|
|
35
|
+
{ id: 'b', name: 'Bob', count: 2 },
|
|
36
|
+
{ id: 'c', name: 'Charlie', count: 3 },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const columns = [
|
|
40
|
+
{
|
|
41
|
+
accessorKey: 'name',
|
|
42
|
+
header: 'Name',
|
|
43
|
+
size: 140,
|
|
44
|
+
minSize: 100,
|
|
45
|
+
maxSize: 180,
|
|
46
|
+
enableHiding: false,
|
|
47
|
+
},
|
|
48
|
+
{ accessorKey: 'count', header: 'Count', size: 100 },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
function mount(extra: any = {}) {
|
|
52
|
+
const el = document.createElement('div')
|
|
53
|
+
document.body.append(el)
|
|
54
|
+
const root = createRoot(el)
|
|
55
|
+
roots.push(root)
|
|
56
|
+
act(() =>
|
|
57
|
+
root.render(
|
|
58
|
+
<DataGrid id="grid" data={data} columns={columns} getRowId={(row) => row.id} {...extra} />,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return el
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const click = (id: string) =>
|
|
66
|
+
act(() => document.getElementById(id)!.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
|
67
|
+
|
|
68
|
+
const input = (id: string, text: string) =>
|
|
69
|
+
act(() => {
|
|
70
|
+
const el = document.getElementById(id) as HTMLInputElement
|
|
71
|
+
el.value = text
|
|
72
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const pan = (id: string, type: string, x: number) =>
|
|
76
|
+
act(() => {
|
|
77
|
+
const event = new Event(type, { bubbles: true })
|
|
78
|
+
Object.assign(event, { clientX: x, clientY: 0, pointerId: 1 })
|
|
79
|
+
document.getElementById(id)!.dispatchEvent(event)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const names = () => [...document.querySelectorAll('[id^="grid-row-"]')].map((el) => el.textContent)
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
for (const root of roots.splice(0)) act(() => root.unmount())
|
|
86
|
+
document.body.innerHTML = ''
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('DataGrid controls', () => {
|
|
90
|
+
it('filters and resets to the first page, pages results and changes page size', () => {
|
|
91
|
+
mount({
|
|
92
|
+
showGlobalFilter: true,
|
|
93
|
+
showColumnFilters: true,
|
|
94
|
+
showPagination: true,
|
|
95
|
+
initialState: { pagination: { pageIndex: 1, pageSize: 1 } },
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(names()).toEqual(['Bob2'])
|
|
99
|
+
input('grid-search', 'ali')
|
|
100
|
+
expect(names()).toEqual(['Alice1'])
|
|
101
|
+
expect(document.getElementById('grid-page')!.textContent).toBe('Page 1 of 1')
|
|
102
|
+
input('grid-search', '')
|
|
103
|
+
click('grid-next')
|
|
104
|
+
expect(names()).toEqual(['Bob2'])
|
|
105
|
+
input('grid-th-name-filter', 'char')
|
|
106
|
+
expect(names()).toEqual(['Charlie3'])
|
|
107
|
+
input('grid-th-name-filter', '')
|
|
108
|
+
click('grid-size-10')
|
|
109
|
+
expect(names()).toHaveLength(3)
|
|
110
|
+
input('grid-th-count-filter', '2')
|
|
111
|
+
expect(names()).toEqual(['Bob2'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('selects only selectable page rows, preserves off-page IDs, and does not activate a row', () => {
|
|
115
|
+
const press = vi.fn()
|
|
116
|
+
mount({
|
|
117
|
+
showRowSelection: true,
|
|
118
|
+
showPagination: true,
|
|
119
|
+
onRowPress: press,
|
|
120
|
+
options: { enableRowSelection: (row: any) => row.id !== 'b' },
|
|
121
|
+
initialState: { pagination: { pageIndex: 0, pageSize: 2 } },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
click('grid-select-a')
|
|
125
|
+
expect(document.getElementById('grid-select-page')!.getAttribute('aria-checked')).toBe('true')
|
|
126
|
+
expect(press).not.toHaveBeenCalled()
|
|
127
|
+
expect(document.getElementById('grid-select-b')!.getAttribute('aria-disabled')).toBe('true')
|
|
128
|
+
click('grid-next')
|
|
129
|
+
click('grid-select-page')
|
|
130
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('2 selected')
|
|
131
|
+
click('grid-clear-selection')
|
|
132
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('0 selected')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('protects non-hideable columns, moves columns and resets settings', () => {
|
|
136
|
+
mount({ showColumnSettings: true })
|
|
137
|
+
click('grid-settings')
|
|
138
|
+
expect(document.getElementById('grid-visible-name')!.getAttribute('aria-disabled')).toBe('true')
|
|
139
|
+
click('grid-visible-count')
|
|
140
|
+
expect(document.getElementById('grid-th-count')).toBeNull()
|
|
141
|
+
click('grid-visible-count')
|
|
142
|
+
click('grid-earlier-count')
|
|
143
|
+
expect(names()[0]).toBe('1Alice')
|
|
144
|
+
click('grid-reset-columns')
|
|
145
|
+
expect(names()[0]).toBe('Alice1')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('resizes within bounds with matching row widths and rolls back cancelled pans', () => {
|
|
149
|
+
mount({ resizableColumns: true })
|
|
150
|
+
const width = () =>
|
|
151
|
+
(document.getElementById('grid-th-name')!.parentElement as HTMLElement).style.width
|
|
152
|
+
|
|
153
|
+
click('grid-th-name-widen')
|
|
154
|
+
expect(width()).toBe('150px')
|
|
155
|
+
expect((document.querySelector('#grid-row-a .vx-table-cell') as HTMLElement).style.width).toBe(
|
|
156
|
+
'150px',
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
pan('grid-th-name-resize', 'pointerdown', 0)
|
|
160
|
+
pan('grid-th-name-resize', 'pointermove', 200)
|
|
161
|
+
expect(width()).toBe('180px')
|
|
162
|
+
pan('grid-th-name-resize', 'pointercancel', 200)
|
|
163
|
+
expect(width()).toBe('150px')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('reorders on release and preserves order on cancellation', () => {
|
|
167
|
+
mount({ reorderableColumns: true })
|
|
168
|
+
pan('grid-th-name-drag', 'pointerdown', 0)
|
|
169
|
+
pan('grid-th-name-drag', 'pointermove', 200)
|
|
170
|
+
pan('grid-th-name-drag', 'pointercancel', 200)
|
|
171
|
+
expect(names()[0]).toBe('Alice1')
|
|
172
|
+
pan('grid-th-name-drag', 'pointerdown', 0)
|
|
173
|
+
pan('grid-th-name-drag', 'pointerup', 200)
|
|
174
|
+
expect(names()[0]).toBe('1Alice')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('forwards controlled callbacks without changing the owner snapshot', () => {
|
|
178
|
+
const changed = vi.fn()
|
|
179
|
+
mount({ state: { sorting: [] }, options: { onSortingChange: changed } })
|
|
180
|
+
click('grid-th-name')
|
|
181
|
+
expect(changed).toHaveBeenCalledOnce()
|
|
182
|
+
expect(changed.mock.calls[0][0]([])).toEqual([{ id: 'name', desc: false }])
|
|
183
|
+
expect(names()[0]).toBe('Alice1')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
describe('DataGrid selection and row accessibility', () => {
|
|
189
|
+
it('announces mixed page selection, supports Space, and disables an empty filtered page', () => {
|
|
190
|
+
mount({ showRowSelection: true, showGlobalFilter: true, getRowLabel: (row: any) => row.original.name })
|
|
191
|
+
const page = () => document.getElementById('grid-select-page')!
|
|
192
|
+
const alice = document.getElementById('grid-select-a')!
|
|
193
|
+
expect(alice.getAttribute('aria-label')).toBe('Select Alice')
|
|
194
|
+
expect(alice.getAttribute('aria-description')).toBe('Row 1 of 3')
|
|
195
|
+
act(() => alice.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })))
|
|
196
|
+
expect(page().getAttribute('aria-checked')).toBe('mixed')
|
|
197
|
+
click('grid-select-page')
|
|
198
|
+
expect(page().getAttribute('aria-checked')).toBe('true')
|
|
199
|
+
click('grid-select-page')
|
|
200
|
+
expect(page().getAttribute('aria-checked')).toBe('false')
|
|
201
|
+
input('grid-search', 'missing')
|
|
202
|
+
expect(page().getAttribute('aria-disabled')).toBe('true')
|
|
203
|
+
click('grid-select-page')
|
|
204
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('0 selected')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('uses sorted filtered ordinals with a page offset and excludes controls from the table', () => {
|
|
208
|
+
mount({ showRowSelection: true, showPagination: true, showGlobalFilter: true,
|
|
209
|
+
initialState: { pagination: { pageIndex: 1, pageSize: 1 }, sorting: [{ id: 'name', desc: true }] } })
|
|
210
|
+
|
|
211
|
+
const table = document.getElementById('grid-table')!
|
|
212
|
+
expect(table.getAttribute('role')).toBe('table')
|
|
213
|
+
expect(table.getAttribute('aria-rowcount')).toBe('4')
|
|
214
|
+
expect(table.getAttribute('aria-colcount')).toBe('3')
|
|
215
|
+
expect(document.getElementById('grid-row-b')!.getAttribute('aria-rowindex')).toBe('3')
|
|
216
|
+
expect(table.querySelectorAll('[role="columnheader"]')).toHaveLength(3)
|
|
217
|
+
expect(table.querySelectorAll('[role="cell"]')).toHaveLength(3)
|
|
218
|
+
expect(table.contains(document.getElementById('grid-clear-selection'))).toBe(false)
|
|
219
|
+
input('grid-search', 'ali')
|
|
220
|
+
expect(document.getElementById('grid-row-a')!.getAttribute('aria-rowindex')).toBe('2')
|
|
221
|
+
expect(table.getAttribute('aria-rowcount')).toBe('2')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('preserves caller-owned IDs through filtering, paging and data replacement, then clears them', () => {
|
|
225
|
+
const changes = vi.fn()
|
|
226
|
+
const el = document.createElement('div')
|
|
227
|
+
document.body.append(el)
|
|
228
|
+
const root = createRoot(el)
|
|
229
|
+
roots.push(root)
|
|
230
|
+
function Owner() {
|
|
231
|
+
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({ absent: true })
|
|
232
|
+
const [rows, setRows] = useState(data)
|
|
233
|
+
return <>
|
|
234
|
+
<button id="replace" onClick={() => setRows([...data].reverse())}>Replace</button>
|
|
235
|
+
<DataGrid id="grid" data={rows} columns={columns} getRowId={(row) => row.id}
|
|
236
|
+
showRowSelection showGlobalFilter showPagination
|
|
237
|
+
initialState={{ pagination: { pageIndex: 0, pageSize: 2 } }}
|
|
238
|
+
state={{ rowSelection }} options={{ enableRowSelection: (row) => row.id !== 'b',
|
|
239
|
+
onRowSelectionChange: (updater) => { changes(updater); setRowSelection(updater) } }} />
|
|
240
|
+
</>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
act(() => root.render(<Owner />))
|
|
244
|
+
click('grid-select-page')
|
|
245
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('2 selected')
|
|
246
|
+
input('grid-search', 'char')
|
|
247
|
+
click('grid-select-page')
|
|
248
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('3 selected')
|
|
249
|
+
click('grid-select-page')
|
|
250
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('2 selected')
|
|
251
|
+
input('grid-search', '')
|
|
252
|
+
click('grid-next')
|
|
253
|
+
click('replace')
|
|
254
|
+
click('grid-previous')
|
|
255
|
+
expect(document.getElementById('grid-select-b')!.getAttribute('aria-checked')).toBe('false')
|
|
256
|
+
click('grid-clear-selection')
|
|
257
|
+
expect(document.getElementById('grid-selected-count')!.textContent).toBe('0 selected')
|
|
258
|
+
expect(changes).toHaveBeenCalledTimes(4)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('keeps a controlled snapshot read-only until its owner publishes the updater', () => {
|
|
262
|
+
const changed = vi.fn()
|
|
263
|
+
mount({ showRowSelection: true, state: { rowSelection: {} }, options: { onRowSelectionChange: changed } })
|
|
264
|
+
click('grid-select-a')
|
|
265
|
+
expect(changed.mock.calls[0][0]({})).toEqual({ a: true })
|
|
266
|
+
expect(document.getElementById('grid-select-a')!.getAttribute('aria-checked')).toBe('false')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it.each([[-1, undefined], [101, 100]])('describes manual server pages with total %s', (expected, rowCount) => {
|
|
270
|
+
mount({ showRowSelection: true, state: { pagination: { pageIndex: 3, pageSize: 10 } },
|
|
271
|
+
options: { manualPagination: true, rowCount, pageCount: -1 } })
|
|
272
|
+
|
|
273
|
+
expect(document.getElementById('grid-table')!.getAttribute('aria-rowcount')).toBe(String(expected))
|
|
274
|
+
expect(document.getElementById('grid-row-a')!.getAttribute('aria-rowindex')).toBe('32')
|
|
275
|
+
expect(document.getElementById('grid-select-a')!.getAttribute('aria-description')).toBe(rowCount == null ? 'Row 31' : 'Row 31 of 100')
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useState } from 'octane'
|
|
2
|
+
import {
|
|
3
|
+
CheckboxInput as Checkbox,
|
|
4
|
+
Pressable,
|
|
5
|
+
HStack as Row,
|
|
6
|
+
Text,
|
|
7
|
+
TextInput,
|
|
8
|
+
View,
|
|
9
|
+
} from '@octane-xplat/ui'
|
|
10
|
+
|
|
11
|
+
import { moveColumn } from './grid-layout'
|
|
12
|
+
|
|
13
|
+
function Action(props: { id?: string; label: string; disabled?: boolean; onPress: () => void }) {
|
|
14
|
+
return (
|
|
15
|
+
<Pressable
|
|
16
|
+
id={props.id}
|
|
17
|
+
className="vx-datagrid-action"
|
|
18
|
+
disabled={props.disabled}
|
|
19
|
+
onPress={props.onPress}
|
|
20
|
+
accessibilityLabel={props.label}
|
|
21
|
+
>
|
|
22
|
+
<Text>{props.label}</Text>
|
|
23
|
+
</Pressable>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Controls subscribe through their parent's state snapshot on native too. */
|
|
28
|
+
export function GridControls(props: {
|
|
29
|
+
table: any
|
|
30
|
+
state: any
|
|
31
|
+
id?: string
|
|
32
|
+
settings?: boolean
|
|
33
|
+
search?: boolean
|
|
34
|
+
}) {
|
|
35
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
36
|
+
void props.state
|
|
37
|
+
const table = props.table
|
|
38
|
+
const columns = table.getAllLeafColumns()
|
|
39
|
+
const order = columns.map((column: any) => column.id)
|
|
40
|
+
const move = (id: string, index: number) =>
|
|
41
|
+
table.setColumnOrder(moveColumn(order, id, order[index]))
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View>
|
|
45
|
+
<Row className="vx-datagrid-controls">
|
|
46
|
+
{props.search ? (
|
|
47
|
+
<TextInput
|
|
48
|
+
id={props.id ? `${props.id}-search` : undefined}
|
|
49
|
+
className="flex-1"
|
|
50
|
+
value={String(props.state.globalFilter ?? '')}
|
|
51
|
+
placeholder="Filter rows"
|
|
52
|
+
accessibilityLabel="Filter rows"
|
|
53
|
+
onChange={(value) => {
|
|
54
|
+
table.setGlobalFilter(value)
|
|
55
|
+
table.setPageIndex(0)
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
) : null}
|
|
59
|
+
{props.settings ? (
|
|
60
|
+
<Action
|
|
61
|
+
id={props.id ? `${props.id}-settings` : undefined}
|
|
62
|
+
label="Columns"
|
|
63
|
+
onPress={() => setSettingsOpen(!settingsOpen)}
|
|
64
|
+
/>
|
|
65
|
+
) : null}
|
|
66
|
+
</Row>
|
|
67
|
+
{props.settings && settingsOpen ? (
|
|
68
|
+
<View className="vx-datagrid-settings">
|
|
69
|
+
{columns.map((column: any, index: number) => (
|
|
70
|
+
<Row key={column.id} className="vx-datagrid-controls">
|
|
71
|
+
<Checkbox
|
|
72
|
+
id={props.id ? `${props.id}-visible-${column.id}` : undefined}
|
|
73
|
+
checked={column.getIsVisible()}
|
|
74
|
+
isDisabled={!column.getCanHide()}
|
|
75
|
+
label={
|
|
76
|
+
typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id
|
|
77
|
+
}
|
|
78
|
+
accessibilityLabel={`Show ${column.id}`}
|
|
79
|
+
onCheckedChange={(checked) => column.toggleVisibility(checked)}
|
|
80
|
+
/>
|
|
81
|
+
<Action
|
|
82
|
+
id={props.id ? `${props.id}-earlier-${column.id}` : undefined}
|
|
83
|
+
label={`Move ${column.id} earlier`}
|
|
84
|
+
disabled={index === 0}
|
|
85
|
+
onPress={() => move(column.id, index - 1)}
|
|
86
|
+
/>
|
|
87
|
+
<Action
|
|
88
|
+
id={props.id ? `${props.id}-later-${column.id}` : undefined}
|
|
89
|
+
label={`Move ${column.id} later`}
|
|
90
|
+
disabled={index === columns.length - 1}
|
|
91
|
+
onPress={() => move(column.id, index + 1)}
|
|
92
|
+
/>
|
|
93
|
+
</Row>
|
|
94
|
+
))}
|
|
95
|
+
<Action
|
|
96
|
+
id={props.id ? `${props.id}-reset-columns` : undefined}
|
|
97
|
+
label="Reset columns"
|
|
98
|
+
onPress={() => {
|
|
99
|
+
table.resetColumnVisibility()
|
|
100
|
+
table.resetColumnOrder()
|
|
101
|
+
table.resetColumnSizing()
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
</View>
|
|
105
|
+
) : null}
|
|
106
|
+
</View>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function GridPagination(props: {
|
|
111
|
+
table: any
|
|
112
|
+
state: any
|
|
113
|
+
id?: string
|
|
114
|
+
sizes?: readonly number[]
|
|
115
|
+
}) {
|
|
116
|
+
void props.state
|
|
117
|
+
const table = props.table
|
|
118
|
+
const { pageIndex, pageSize } = props.state.pagination
|
|
119
|
+
const count = table.getPageCount()
|
|
120
|
+
const sizes = [
|
|
121
|
+
...new Set((props.sizes ?? [10, 25, 50]).filter((size) => Number.isInteger(size) && size > 0)),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Row className="vx-datagrid-controls" accessibilityLabel="Pagination">
|
|
126
|
+
<Action
|
|
127
|
+
id={props.id ? `${props.id}-previous` : undefined}
|
|
128
|
+
label="Previous page"
|
|
129
|
+
disabled={!table.getCanPreviousPage()}
|
|
130
|
+
onPress={() => table.previousPage()}
|
|
131
|
+
/>
|
|
132
|
+
<Text id={props.id ? `${props.id}-page` : undefined}>
|
|
133
|
+
{count < 0
|
|
134
|
+
? `Page ${pageIndex + 1}`
|
|
135
|
+
: `Page ${count === 0 ? 0 : pageIndex + 1} of ${count}`}
|
|
136
|
+
</Text>
|
|
137
|
+
<Action
|
|
138
|
+
id={props.id ? `${props.id}-next` : undefined}
|
|
139
|
+
label="Next page"
|
|
140
|
+
disabled={!table.getCanNextPage()}
|
|
141
|
+
onPress={() => table.nextPage()}
|
|
142
|
+
/>
|
|
143
|
+
{sizes.map((size) => (
|
|
144
|
+
<Action
|
|
145
|
+
key={size}
|
|
146
|
+
id={props.id ? `${props.id}-size-${size}` : undefined}
|
|
147
|
+
label={`${size} per page`}
|
|
148
|
+
disabled={pageSize === size}
|
|
149
|
+
onPress={() => {
|
|
150
|
+
table.setPageSize(size)
|
|
151
|
+
table.setPageIndex(0)
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</Row>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Positions describe the final flat row model, before VirtualList windowing.
|
|
2
|
+
* Header rows count toward ARIA indices, but not the spoken data-row ordinal. */
|
|
3
|
+
export function gridRowPosition(
|
|
4
|
+
index: number,
|
|
5
|
+
pagination: { pageIndex: number; pageSize: number },
|
|
6
|
+
headerRows: number,
|
|
7
|
+
rowCount: number,
|
|
8
|
+
) {
|
|
9
|
+
const ordinal = pagination.pageIndex * pagination.pageSize + index + 1
|
|
10
|
+
return {
|
|
11
|
+
ariaIndex: headerRows + ordinal,
|
|
12
|
+
label: rowCount < 0 ? `Row ${ordinal}` : `Row ${ordinal} of ${rowCount}`,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { dragTarget, moveColumn, resizedWidth } from './grid-layout'
|
|
3
|
+
|
|
4
|
+
describe('column gesture geometry', () => {
|
|
5
|
+
it('moves across variable-width midpoints in either direction', () => {
|
|
6
|
+
const columns = [
|
|
7
|
+
{ id: 'a', getSize: () => 100 },
|
|
8
|
+
{ id: 'b', getSize: () => 200 },
|
|
9
|
+
{ id: 'c', getSize: () => 80 },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
expect(dragTarget(columns, 'a', 149)).toBe('a')
|
|
13
|
+
expect(dragTarget(columns, 'a', 150)).toBe('b')
|
|
14
|
+
expect(dragTarget(columns, 'a', 290)).toBe('c')
|
|
15
|
+
expect(dragTarget(columns, 'c', -140)).toBe('b')
|
|
16
|
+
expect(dragTarget(columns, 'c', -290)).toBe('a')
|
|
17
|
+
expect(dragTarget(columns, 'a', Number.NaN)).toBe('a')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps hidden IDs in the complete order and rejects missing targets', () => {
|
|
21
|
+
const order = ['a', 'hidden', 'b']
|
|
22
|
+
expect(moveColumn(order, 'a', 'b')).toEqual(['hidden', 'b', 'a'])
|
|
23
|
+
expect(order).toEqual(['a', 'hidden', 'b'])
|
|
24
|
+
expect(moveColumn(order, 'missing', 'b')).toBe(order)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('clamps to both configured size bounds', () => {
|
|
28
|
+
const column = { columnDef: { minSize: 80, maxSize: 180 } }
|
|
29
|
+
expect(resizedWidth(column, 10)).toBe(80)
|
|
30
|
+
expect(resizedWidth(column, 500)).toBe(180)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Resolve the displayed width without changing flexible legacy columns. */
|
|
2
|
+
export function columnStyle(column: any, fixed: boolean, sizing: Record<string, number>) {
|
|
3
|
+
const explicit = fixed || column.columnDef.size != null || sizing[column.id] != null
|
|
4
|
+
return explicit
|
|
5
|
+
? { width: column.getSize(), flexGrow: 0, flexShrink: 0 }
|
|
6
|
+
: { flexGrow: 1, flexShrink: 1 }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Move a column within the complete order, preserving hidden columns. */
|
|
10
|
+
export function moveColumn(order: string[], source: string, target: string) {
|
|
11
|
+
const from = order.indexOf(source)
|
|
12
|
+
const to = order.indexOf(target)
|
|
13
|
+
if (from < 0 || to < 0 || from === to) {
|
|
14
|
+
return order
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const next = [...order]
|
|
18
|
+
next.splice(from, 1)
|
|
19
|
+
next.splice(to, 0, source)
|
|
20
|
+
return next
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Find the visible column whose midpoint a horizontal drag has crossed. */
|
|
24
|
+
export function dragTarget(columns: { id: string; getSize(): number }[], id: string, dx: number) {
|
|
25
|
+
const from = columns.findIndex((column) => column.id === id)
|
|
26
|
+
if (from < 0 || !Number.isFinite(dx)) {
|
|
27
|
+
return id
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let distance = columns[from].getSize() / 2
|
|
31
|
+
let target = id
|
|
32
|
+
const direction = dx < 0 ? -1 : 1
|
|
33
|
+
for (let i = from + direction; i >= 0 && i < columns.length; i += direction) {
|
|
34
|
+
distance += columns[i].getSize() / 2
|
|
35
|
+
if (Math.abs(dx) < distance) {
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
target = columns[i].id
|
|
40
|
+
distance += columns[i].getSize() / 2
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return target
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Clamp interactive widths to the column's configured bounds. */
|
|
47
|
+
export function resizedWidth(column: any, width: number) {
|
|
48
|
+
return Math.max(
|
|
49
|
+
column.columnDef.minSize ?? 20,
|
|
50
|
+
Math.min(column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER, width),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DataGrid } from './DataGrid.tsrx'
|
|
2
|
+
export { useTable } from './useTable.tsrx'
|
|
3
|
+
export { createTable, dataGridFeatures } from './table'
|
|
4
|
+
export type { DataGridOptions, DataGridProps, UseTable } from './props'
|
|
5
|
+
|
|
6
|
+
export type { Cell, ColumnDef, Header, Row, Table, TableState } from '@tanstack/table-core'
|
|
7
|
+
|
|
8
|
+
export type { DataGridFeatures } from './table'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { DataGrid } from './DataGrid.tsrx'
|
|
2
|
+
export { useTable } from './useTable.tsrx'
|
|
3
|
+
export { createTable, dataGridFeatures } from './table'
|
|
4
|
+
export type { DataGridFeatures } from './table'
|
|
5
|
+
export type { DataGridOptions, DataGridProps, UseTable } from './props'
|
|
6
|
+
|
|
7
|
+
export type { Cell, ColumnDef, Header, Row, Table, TableState } from '@tanstack/table-core'
|
package/src/index.web.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DataGrid } from './DataGrid.tsrx'
|
|
2
|
+
export { useTable } from './useTable.tsrx'
|
|
3
|
+
export { createTable, dataGridFeatures } from './table'
|
|
4
|
+
export type { DataGridOptions, DataGridProps, UseTable } from './props'
|
|
5
|
+
|
|
6
|
+
export type { Cell, ColumnDef, Header, Row, Table, TableState } from '@tanstack/table-core'
|
|
7
|
+
|
|
8
|
+
export type { DataGridFeatures } from './table'
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Cell,
|
|
3
|
+
ColumnDef,
|
|
4
|
+
Header,
|
|
5
|
+
Row,
|
|
6
|
+
RowData,
|
|
7
|
+
Table,
|
|
8
|
+
TableFeatures,
|
|
9
|
+
TableOptions,
|
|
10
|
+
TableState,
|
|
11
|
+
} from '@tanstack/table-core'
|
|
12
|
+
|
|
13
|
+
import type { DataGridFeatures } from './table'
|
|
14
|
+
|
|
15
|
+
export interface LayoutChildProps {
|
|
16
|
+
row?: number
|
|
17
|
+
col?: number
|
|
18
|
+
rowSpan?: number
|
|
19
|
+
colSpan?: number
|
|
20
|
+
dock?: 'left' | 'top' | 'right' | 'bottom'
|
|
21
|
+
left?: number
|
|
22
|
+
top?: number
|
|
23
|
+
flexGrow?: number
|
|
24
|
+
flexShrink?: number
|
|
25
|
+
alignSelf?: string
|
|
26
|
+
order?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AccessibilityProps {
|
|
30
|
+
accessible?: boolean
|
|
31
|
+
accessibilityLabel?: string
|
|
32
|
+
accessibilityHint?: string
|
|
33
|
+
accessibilityRole?: string
|
|
34
|
+
accessibilityState?: Record<string, any>
|
|
35
|
+
testID?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options shared by `createTable`/`useTable`/`DataGrid`. Extends the
|
|
39
|
+
* table-core options for the enabled feature set minus its reactivity slot
|
|
40
|
+
* (owned by the adapter). */
|
|
41
|
+
export type DataGridOptions<TFeatures extends TableFeatures, TData extends RowData> = Omit<
|
|
42
|
+
TableOptions<TFeatures, TData>,
|
|
43
|
+
'features' | 'coreReactivityFeature'
|
|
44
|
+
> & {
|
|
45
|
+
/** Feature slots beyond the defaults — pass `tableFeatures({...})`
|
|
46
|
+
* output or merge onto `dataGridFeatures`. */
|
|
47
|
+
features?: Partial<TableFeatures>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `useTable` return — the live table instance plus the flat reactive state.
|
|
51
|
+
* `state` re-reads on every slice change; row models come from
|
|
52
|
+
* `table.getRowModel()` (memoized per state/option revision). */
|
|
53
|
+
export interface UseTable<TFeatures extends TableFeatures, TData extends RowData> {
|
|
54
|
+
table: Table<TFeatures, TData>
|
|
55
|
+
state: TableState<TFeatures>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DataGridProps<
|
|
59
|
+
TData extends RowData = any,
|
|
60
|
+
TFeatures extends TableFeatures = DataGridFeatures,
|
|
61
|
+
>
|
|
62
|
+
extends LayoutChildProps, AccessibilityProps {
|
|
63
|
+
id?: string
|
|
64
|
+
className?: any
|
|
65
|
+
style?: any
|
|
66
|
+
/** Row data — pass a new array reference when data changes. */
|
|
67
|
+
data: TData[]
|
|
68
|
+
columns: ColumnDef<TFeatures, TData, any>[]
|
|
69
|
+
/** Feature overrides merged onto the defaults (see `dataGridFeatures`). */
|
|
70
|
+
features?: Partial<TableFeatures>
|
|
71
|
+
/** Core options (atoms, change callbacks, manual row models, selection
|
|
72
|
+
* predicates). Data/columns and the explicit props below take precedence. */
|
|
73
|
+
options?: Omit<
|
|
74
|
+
DataGridOptions<TFeatures, TData>,
|
|
75
|
+
'data' | 'columns' | 'features' | 'state' | 'initialState' | 'getRowId'
|
|
76
|
+
>
|
|
77
|
+
/** Partial controlled state. Pair with options.atoms or per-slice
|
|
78
|
+
* options callbacks; a state snapshot alone is read-only. */
|
|
79
|
+
state?: Partial<TableState<TFeatures>>
|
|
80
|
+
initialState?: Partial<TableState<TFeatures>>
|
|
81
|
+
getRowId?: (row: TData, index: number) => string
|
|
82
|
+
/** Per-cell content override. Defaults to the column's `cell` template,
|
|
83
|
+
* then `cell.getValue()`. */
|
|
84
|
+
renderCell?: (cell: Cell<TFeatures, TData, any>) => any
|
|
85
|
+
/** Per-header content override. Defaults to the column's `header`
|
|
86
|
+
* template plus a sort affordance when the column can sort. */
|
|
87
|
+
renderHeader?: (header: Header<TFeatures, TData, any>) => any
|
|
88
|
+
/** Row tap — marks rows pressable. */
|
|
89
|
+
onRowPress?: (row: Row<TFeatures, TData>) => void
|
|
90
|
+
renderEmpty?: () => any
|
|
91
|
+
/** Show a text query across globally filterable columns. */
|
|
92
|
+
showGlobalFilter?: boolean
|
|
93
|
+
/** Show text filters beneath filterable leaf-column headings. */
|
|
94
|
+
showColumnFilters?: boolean
|
|
95
|
+
/** Show previous/next and page-size controls. Client-side by default;
|
|
96
|
+
* use options.manualPagination with pageCount/rowCount for server data. */
|
|
97
|
+
showPagination?: boolean
|
|
98
|
+
/** Positive integer page sizes offered by the pager. Defaults to [10, 25, 50]. */
|
|
99
|
+
pageSizeOptions?: readonly number[]
|
|
100
|
+
/** Show row checkboxes and a current-page select-all checkbox. Selection
|
|
101
|
+
* survives filtering/pagination by ID; provide getRowId for mutable data. */
|
|
102
|
+
showRowSelection?: boolean
|
|
103
|
+
/** Optional label for each selection checkbox. */
|
|
104
|
+
getRowLabel?: (row: Row<TFeatures, TData>) => string
|
|
105
|
+
/** Show a column visibility/order panel, reset to initial state, and
|
|
106
|
+
* keyboard/touch move buttons. Non-hideable columns stay visible. */
|
|
107
|
+
showColumnSettings?: boolean
|
|
108
|
+
/** Fixed-width columns with pan resize handles and accessible +/- actions.
|
|
109
|
+
* Widths use column minSize/maxSize; cancellation restores prior state. */
|
|
110
|
+
resizableColumns?: boolean
|
|
111
|
+
/** Separate pan handles reorder leaf columns on release. Cancel preserves
|
|
112
|
+
* order; settings move buttons provide a non-drag alternative. */
|
|
113
|
+
reorderableColumns?: boolean
|
|
114
|
+
/** Horizontal gesture direction. Defaults to ltr; use rtl for mirrored layouts. */
|
|
115
|
+
direction?: 'ltr' | 'rtl'
|
|
116
|
+
/** Caller-owned bulk actions; selected IDs include off-page selections. */
|
|
117
|
+
renderSelectionActions?: (table: Table<TFeatures, TData>) => any
|
|
118
|
+
/** Platform escape bags — applied after shared props on the root view. */
|
|
119
|
+
ios?: Record<string, any>
|
|
120
|
+
android?: Record<string, any>
|
|
121
|
+
web?: Record<string, any>
|
|
122
|
+
}
|