@multicomma/arkcn 0.1.2 → 0.1.3
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/package.json +1 -1
- package/registry/index.json +1 -1
- package/registry/items/accordion.json +302 -0
- package/registry/items/activity-feed.json +408 -0
- package/registry/items/alert-dialog.json +676 -0
- package/registry/items/alert.json +113 -0
- package/registry/items/angle-slider.json +378 -0
- package/registry/items/app-shell.json +2654 -1
- package/registry/items/aspect-ratio.json +36 -0
- package/registry/items/attachment.json +299 -1
- package/registry/items/avatar.json +213 -0
- package/registry/items/badge.json +44 -0
- package/registry/items/breadcrumb.json +191 -0
- package/registry/items/bubble.json +146 -0
- package/registry/items/button-group.json +110 -0
- package/registry/items/button.json +52 -0
- package/registry/items/calendar.json +499 -1
- package/registry/items/canvas.json +446 -1
- package/registry/items/card.json +192 -0
- package/registry/items/carousel.json +208 -1
- package/registry/items/cascader.json +797 -1
- package/registry/items/chart.json +1764 -1
- package/registry/items/chat.json +993 -0
- package/registry/items/checkbox.json +503 -0
- package/registry/items/clipboard.json +308 -0
- package/registry/items/collapsible.json +226 -0
- package/registry/items/color-picker.json +1261 -0
- package/registry/items/combobox.json +1123 -1
- package/registry/items/command.json +837 -1
- package/registry/items/comment-thread.json +952 -1
- package/registry/items/context-menu.json +1344 -1
- package/registry/items/data-grid.json +449 -1
- package/registry/items/data-table.json +1310 -1
- package/registry/items/date-input.json +476 -0
- package/registry/items/dialog.json +592 -0
- package/registry/items/direction.json +29 -1
- package/registry/items/drawer.json +797 -0
- package/registry/items/dropdown-menu.json +1311 -1
- package/registry/items/editable.json +503 -0
- package/registry/items/empty.json +166 -0
- package/registry/items/field.json +286 -1
- package/registry/items/file-upload.json +638 -0
- package/registry/items/floating-panel.json +720 -0
- package/registry/items/floating-toolbar.json +43 -0
- package/registry/items/format.json +109 -0
- package/registry/items/gantt.json +773 -1
- package/registry/items/hooks/use-mobile.json +1 -0
- package/registry/items/hotkeys.json +99 -1
- package/registry/items/hover-card.json +357 -0
- package/registry/items/image-cropper.json +431 -0
- package/registry/items/input-group.json +189 -0
- package/registry/items/input-otp.json +181 -0
- package/registry/items/input.json +28 -0
- package/registry/items/item.json +308 -0
- package/registry/items/json-tree-view.json +336 -0
- package/registry/items/kanban.json +624 -1
- package/registry/items/kbd.json +54 -0
- package/registry/items/label.json +28 -0
- package/registry/items/lib/controllable.json +1 -0
- package/registry/items/lib/data-table-adapter.json +1 -0
- package/registry/items/lib/drag-preview.json +1 -0
- package/registry/items/lib/form.json +1 -0
- package/registry/items/lib/history.json +1 -0
- package/registry/items/lib/row-layout.json +1 -0
- package/registry/items/lib/table.json +1 -0
- package/registry/items/lib/time.json +1 -0
- package/registry/items/lib/utils.json +1 -0
- package/registry/items/live-region.json +43 -0
- package/registry/items/marker.json +96 -0
- package/registry/items/marquee.json +300 -0
- package/registry/items/menubar.json +1282 -1
- package/registry/items/message.json +166 -0
- package/registry/items/navigation-menu.json +561 -1
- package/registry/items/node-graph.json +1037 -1
- package/registry/items/number-input.json +519 -0
- package/registry/items/pagination.json +244 -1
- package/registry/items/password-input.json +311 -0
- package/registry/items/popconfirm.json +566 -0
- package/registry/items/popover.json +615 -0
- package/registry/items/progress.json +516 -0
- package/registry/items/qr-code.json +249 -0
- package/registry/items/query-builder.json +1368 -1
- package/registry/items/radio-group.json +362 -0
- package/registry/items/rating-group.json +304 -0
- package/registry/items/resizable.json +242 -1
- package/registry/items/rich-text-editor.json +934 -1
- package/registry/items/scheduler.json +1013 -1
- package/registry/items/scroll-area.json +227 -0
- package/registry/items/segment-group.json +363 -0
- package/registry/items/select.json +956 -0
- package/registry/items/separator.json +45 -1
- package/registry/items/sheet.json +592 -0
- package/registry/items/sidebar.json +785 -1
- package/registry/items/signature-pad.json +330 -0
- package/registry/items/skeleton.json +28 -0
- package/registry/items/slider.json +799 -0
- package/registry/items/sonner.json +135 -0
- package/registry/items/spinner.json +28 -0
- package/registry/items/steps.json +483 -0
- package/registry/items/swap.json +107 -0
- package/registry/items/switch.json +362 -0
- package/registry/items/table.json +210 -0
- package/registry/items/tabs.json +326 -0
- package/registry/items/tags-input.json +640 -0
- package/registry/items/textarea.json +28 -0
- package/registry/items/timer.json +289 -0
- package/registry/items/toc.json +372 -0
- package/registry/items/toggle-group.json +214 -1
- package/registry/items/toggle.json +124 -0
- package/registry/items/tooltip.json +417 -0
- package/registry/items/tour.json +498 -0
- package/registry/items/transfer-list.json +609 -1
- package/registry/items/tree-select.json +742 -1
- package/registry/items/tree-view.json +794 -0
- package/registry/items/virtual-list.json +214 -1
- package/src/components/ui/app-shell.tsx +11 -0
- package/src/components/ui/attachment.tsx +1 -0
- package/src/components/ui/calendar.tsx +1 -0
- package/src/components/ui/canvas.tsx +8 -0
- package/src/components/ui/carousel.tsx +3 -0
- package/src/components/ui/cascader.tsx +1 -0
- package/src/components/ui/chart.tsx +10 -0
- package/src/components/ui/combobox.tsx +3 -0
- package/src/components/ui/command.tsx +1 -0
- package/src/components/ui/comment-thread.tsx +11 -0
- package/src/components/ui/context-menu.tsx +6 -0
- package/src/components/ui/data-grid.tsx +8 -0
- package/src/components/ui/data-table.tsx +6 -0
- package/src/components/ui/direction.tsx +2 -0
- package/src/components/ui/dropdown-menu.tsx +6 -0
- package/src/components/ui/field.tsx +1 -0
- package/src/components/ui/gantt.tsx +15 -0
- package/src/components/ui/hotkeys.tsx +3 -0
- package/src/components/ui/kanban.tsx +3 -0
- package/src/components/ui/menubar.tsx +6 -0
- package/src/components/ui/navigation-menu.tsx +1 -0
- package/src/components/ui/node-graph.tsx +23 -0
- package/src/components/ui/pagination.tsx +1 -0
- package/src/components/ui/query-builder.tsx +7 -0
- package/src/components/ui/resizable.tsx +1 -0
- package/src/components/ui/rich-text-editor.tsx +54 -0
- package/src/components/ui/scheduler.tsx +26 -0
- package/src/components/ui/separator.tsx +1 -0
- package/src/components/ui/sidebar.tsx +5 -0
- package/src/components/ui/toggle-group.tsx +3 -0
- package/src/components/ui/transfer-list.tsx +11 -0
- package/src/components/ui/tree-select.tsx +5 -0
- package/src/components/ui/virtual-list.tsx +7 -0
|
@@ -19,6 +19,454 @@
|
|
|
19
19
|
"useDataGrid",
|
|
20
20
|
"useDataGridRow"
|
|
21
21
|
],
|
|
22
|
+
"parts": [
|
|
23
|
+
{
|
|
24
|
+
"name": "DataGrid",
|
|
25
|
+
"props": [
|
|
26
|
+
{
|
|
27
|
+
"name": "table",
|
|
28
|
+
"type": "DataTableInstance<TData>",
|
|
29
|
+
"required": true,
|
|
30
|
+
"description": "The table instance, when not read from context.",
|
|
31
|
+
"from": "arkcn"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "children",
|
|
35
|
+
"type": "ReactNode",
|
|
36
|
+
"required": false,
|
|
37
|
+
"description": "Content rendered inside the part.",
|
|
38
|
+
"from": "arkcn"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "columns",
|
|
42
|
+
"type": "DataGridColumnConfig[]",
|
|
43
|
+
"required": false,
|
|
44
|
+
"default": "[]",
|
|
45
|
+
"description": "Per-column grid behaviour keyed by column id: editor type, width, pinning, options, format and parse.",
|
|
46
|
+
"from": "arkcn"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "columnSizing",
|
|
50
|
+
"type": "Record<string, number>",
|
|
51
|
+
"required": false,
|
|
52
|
+
"description": "Controlled column widths by column id.",
|
|
53
|
+
"from": "arkcn"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "defaultColumnSizing",
|
|
57
|
+
"type": "Record<string, number>",
|
|
58
|
+
"required": false,
|
|
59
|
+
"description": "Initial column widths when uncontrolled.",
|
|
60
|
+
"from": "arkcn"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "onCellChange",
|
|
64
|
+
"type": "((change: DataGridCellChange<TData>) => void)",
|
|
65
|
+
"required": false,
|
|
66
|
+
"description": "Called with `{ row, columnId, value, previous }` when a cell edit commits; the consumer applies it.",
|
|
67
|
+
"from": "arkcn"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "onColumnSizingChange",
|
|
71
|
+
"type": "((sizing: Record<string, number>) => void)",
|
|
72
|
+
"required": false,
|
|
73
|
+
"description": "Called when a column is resized.",
|
|
74
|
+
"from": "arkcn"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "overscan",
|
|
78
|
+
"type": "number",
|
|
79
|
+
"required": false,
|
|
80
|
+
"default": "6",
|
|
81
|
+
"description": "Rows rendered beyond the visible window on each side.",
|
|
82
|
+
"from": "arkcn"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "rowHeight",
|
|
86
|
+
"type": "number",
|
|
87
|
+
"required": false,
|
|
88
|
+
"default": "36",
|
|
89
|
+
"description": "Fixed height of every row in px.",
|
|
90
|
+
"from": "arkcn"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "className",
|
|
94
|
+
"type": "string",
|
|
95
|
+
"required": false,
|
|
96
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
97
|
+
"from": "@types/react"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "style",
|
|
101
|
+
"type": "CSSProperties",
|
|
102
|
+
"required": false,
|
|
103
|
+
"description": "Inline styles applied to the rendered element.",
|
|
104
|
+
"from": "@types/react"
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "DataGridContainer",
|
|
110
|
+
"props": [
|
|
111
|
+
{
|
|
112
|
+
"name": "children",
|
|
113
|
+
"type": "ReactNode",
|
|
114
|
+
"required": false,
|
|
115
|
+
"description": "Content rendered inside the part.",
|
|
116
|
+
"from": "@types/react"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "className",
|
|
120
|
+
"type": "string",
|
|
121
|
+
"required": false,
|
|
122
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
123
|
+
"from": "@types/react"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "style",
|
|
127
|
+
"type": "CSSProperties",
|
|
128
|
+
"required": false,
|
|
129
|
+
"description": "Inline styles applied to the rendered element.",
|
|
130
|
+
"from": "@types/react"
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "DataGridHeader",
|
|
136
|
+
"props": [
|
|
137
|
+
{
|
|
138
|
+
"name": "children",
|
|
139
|
+
"type": "ReactNode",
|
|
140
|
+
"required": false,
|
|
141
|
+
"description": "Content rendered inside the part.",
|
|
142
|
+
"from": "@types/react"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "className",
|
|
146
|
+
"type": "string",
|
|
147
|
+
"required": false,
|
|
148
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
149
|
+
"from": "@types/react"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"name": "style",
|
|
153
|
+
"type": "CSSProperties",
|
|
154
|
+
"required": false,
|
|
155
|
+
"description": "Inline styles applied to the rendered element.",
|
|
156
|
+
"from": "@types/react"
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"name": "DataGridHeaderRow",
|
|
162
|
+
"props": [
|
|
163
|
+
{
|
|
164
|
+
"name": "children",
|
|
165
|
+
"type": "ReactNode",
|
|
166
|
+
"required": false,
|
|
167
|
+
"description": "Content rendered inside the part.",
|
|
168
|
+
"from": "@types/react"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"name": "className",
|
|
172
|
+
"type": "string",
|
|
173
|
+
"required": false,
|
|
174
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
175
|
+
"from": "@types/react"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"name": "style",
|
|
179
|
+
"type": "CSSProperties",
|
|
180
|
+
"required": false,
|
|
181
|
+
"description": "Inline styles applied to the rendered element.",
|
|
182
|
+
"from": "@types/react"
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"name": "DataGridHead",
|
|
188
|
+
"props": [
|
|
189
|
+
{
|
|
190
|
+
"name": "column",
|
|
191
|
+
"type": "string",
|
|
192
|
+
"required": true,
|
|
193
|
+
"description": "Id of the column this part belongs to.",
|
|
194
|
+
"from": "arkcn"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"name": "children",
|
|
198
|
+
"type": "ReactNode",
|
|
199
|
+
"required": false,
|
|
200
|
+
"description": "Content rendered inside the part.",
|
|
201
|
+
"from": "@types/react"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"name": "className",
|
|
205
|
+
"type": "string",
|
|
206
|
+
"required": false,
|
|
207
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
208
|
+
"from": "@types/react"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"name": "style",
|
|
212
|
+
"type": "CSSProperties",
|
|
213
|
+
"required": false,
|
|
214
|
+
"description": "Inline styles applied to the rendered element.",
|
|
215
|
+
"from": "@types/react"
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"name": "DataGridHeadWithResize",
|
|
221
|
+
"props": [
|
|
222
|
+
{
|
|
223
|
+
"name": "column",
|
|
224
|
+
"type": "string",
|
|
225
|
+
"required": true,
|
|
226
|
+
"description": "Id of the column this part belongs to.",
|
|
227
|
+
"from": "arkcn"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"name": "children",
|
|
231
|
+
"type": "ReactNode",
|
|
232
|
+
"required": false,
|
|
233
|
+
"description": "Content rendered inside the part.",
|
|
234
|
+
"from": "@types/react"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"name": "className",
|
|
238
|
+
"type": "string",
|
|
239
|
+
"required": false,
|
|
240
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
241
|
+
"from": "@types/react"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"name": "style",
|
|
245
|
+
"type": "CSSProperties",
|
|
246
|
+
"required": false,
|
|
247
|
+
"description": "Inline styles applied to the rendered element.",
|
|
248
|
+
"from": "@types/react"
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"name": "DataGridResizeHandle",
|
|
254
|
+
"props": [
|
|
255
|
+
{
|
|
256
|
+
"name": "children",
|
|
257
|
+
"type": "ReactNode",
|
|
258
|
+
"required": false,
|
|
259
|
+
"description": "Content rendered inside the part.",
|
|
260
|
+
"from": "@types/react"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"name": "className",
|
|
264
|
+
"type": "string",
|
|
265
|
+
"required": false,
|
|
266
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
267
|
+
"from": "@types/react"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"name": "style",
|
|
271
|
+
"type": "CSSProperties",
|
|
272
|
+
"required": false,
|
|
273
|
+
"description": "Inline styles applied to the rendered element.",
|
|
274
|
+
"from": "@types/react"
|
|
275
|
+
}
|
|
276
|
+
]
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"name": "DataGridBody",
|
|
280
|
+
"props": [
|
|
281
|
+
{
|
|
282
|
+
"name": "children",
|
|
283
|
+
"type": "(row: DataTableRow<TData>, index: number) => ReactNode",
|
|
284
|
+
"required": true,
|
|
285
|
+
"description": "Content rendered inside the part.",
|
|
286
|
+
"from": "arkcn"
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"name": "className",
|
|
290
|
+
"type": "string",
|
|
291
|
+
"required": false,
|
|
292
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
293
|
+
"from": "@types/react"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"name": "style",
|
|
297
|
+
"type": "CSSProperties",
|
|
298
|
+
"required": false,
|
|
299
|
+
"description": "Inline styles applied to the rendered element.",
|
|
300
|
+
"from": "@types/react"
|
|
301
|
+
}
|
|
302
|
+
]
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"name": "DataGridRow",
|
|
306
|
+
"props": [
|
|
307
|
+
{
|
|
308
|
+
"name": "index",
|
|
309
|
+
"type": "number",
|
|
310
|
+
"required": true,
|
|
311
|
+
"description": "Position of the item in its list.",
|
|
312
|
+
"from": "arkcn"
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"name": "row",
|
|
316
|
+
"type": "DataTableRow<TData>",
|
|
317
|
+
"required": true,
|
|
318
|
+
"description": "The row this part belongs to.",
|
|
319
|
+
"from": "arkcn"
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"name": "children",
|
|
323
|
+
"type": "ReactNode",
|
|
324
|
+
"required": false,
|
|
325
|
+
"description": "Content rendered inside the part.",
|
|
326
|
+
"from": "@types/react"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"name": "className",
|
|
330
|
+
"type": "string",
|
|
331
|
+
"required": false,
|
|
332
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
333
|
+
"from": "@types/react"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"name": "style",
|
|
337
|
+
"type": "CSSProperties",
|
|
338
|
+
"required": false,
|
|
339
|
+
"description": "Inline styles applied to the rendered element.",
|
|
340
|
+
"from": "@types/react"
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"name": "DataGridCell",
|
|
346
|
+
"props": [
|
|
347
|
+
{
|
|
348
|
+
"name": "column",
|
|
349
|
+
"type": "string",
|
|
350
|
+
"required": true,
|
|
351
|
+
"description": "Id of the column this part belongs to.",
|
|
352
|
+
"from": "arkcn"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
"name": "children",
|
|
356
|
+
"type": "ReactNode",
|
|
357
|
+
"required": false,
|
|
358
|
+
"description": "Content rendered inside the part.",
|
|
359
|
+
"from": "arkcn"
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"name": "editable",
|
|
363
|
+
"type": "boolean",
|
|
364
|
+
"required": false,
|
|
365
|
+
"description": "Override the column config for this cell.",
|
|
366
|
+
"from": "arkcn"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"name": "editor",
|
|
370
|
+
"type": "((props: EditorRenderProps) => ReactNode)",
|
|
371
|
+
"required": false,
|
|
372
|
+
"description": "Custom editor; return `undefined` to use the default for the column type.",
|
|
373
|
+
"from": "arkcn"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"name": "className",
|
|
377
|
+
"type": "string",
|
|
378
|
+
"required": false,
|
|
379
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
380
|
+
"from": "@types/react"
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
"name": "style",
|
|
384
|
+
"type": "CSSProperties",
|
|
385
|
+
"required": false,
|
|
386
|
+
"description": "Inline styles applied to the rendered element.",
|
|
387
|
+
"from": "@types/react"
|
|
388
|
+
}
|
|
389
|
+
]
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
"name": "DataGridSelectAll",
|
|
393
|
+
"props": [
|
|
394
|
+
{
|
|
395
|
+
"name": "children",
|
|
396
|
+
"type": "ReactNode",
|
|
397
|
+
"required": false,
|
|
398
|
+
"description": "Content rendered inside the part.",
|
|
399
|
+
"from": "@types/react"
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
"name": "className",
|
|
403
|
+
"type": "string",
|
|
404
|
+
"required": false,
|
|
405
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
406
|
+
"from": "@types/react"
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
"name": "style",
|
|
410
|
+
"type": "CSSProperties",
|
|
411
|
+
"required": false,
|
|
412
|
+
"description": "Inline styles applied to the rendered element.",
|
|
413
|
+
"from": "@types/react"
|
|
414
|
+
}
|
|
415
|
+
]
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
"name": "DataGridSelectRow",
|
|
419
|
+
"props": [
|
|
420
|
+
{
|
|
421
|
+
"name": "children",
|
|
422
|
+
"type": "ReactNode",
|
|
423
|
+
"required": false,
|
|
424
|
+
"description": "Content rendered inside the part.",
|
|
425
|
+
"from": "@types/react"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
"name": "className",
|
|
429
|
+
"type": "string",
|
|
430
|
+
"required": false,
|
|
431
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
432
|
+
"from": "@types/react"
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"name": "style",
|
|
436
|
+
"type": "CSSProperties",
|
|
437
|
+
"required": false,
|
|
438
|
+
"description": "Inline styles applied to the rendered element.",
|
|
439
|
+
"from": "@types/react"
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
"name": "DataGridEmpty",
|
|
445
|
+
"props": [
|
|
446
|
+
{
|
|
447
|
+
"name": "children",
|
|
448
|
+
"type": "ReactNode",
|
|
449
|
+
"required": false,
|
|
450
|
+
"description": "Content rendered inside the part.",
|
|
451
|
+
"from": "@types/react"
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
"name": "className",
|
|
455
|
+
"type": "string",
|
|
456
|
+
"required": false,
|
|
457
|
+
"description": "Extra classes merged into the part's own classes with `cn`.",
|
|
458
|
+
"from": "@types/react"
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
"name": "style",
|
|
462
|
+
"type": "CSSProperties",
|
|
463
|
+
"required": false,
|
|
464
|
+
"description": "Inline styles applied to the rendered element.",
|
|
465
|
+
"from": "@types/react"
|
|
466
|
+
}
|
|
467
|
+
]
|
|
468
|
+
}
|
|
469
|
+
],
|
|
22
470
|
"dependencies": {
|
|
23
471
|
"@ark-ui/react": "^5.39.1",
|
|
24
472
|
"lucide-react": "^1.41.0"
|
|
@@ -34,7 +482,7 @@
|
|
|
34
482
|
"files": [
|
|
35
483
|
{
|
|
36
484
|
"path": "components/ui/data-grid.tsx",
|
|
37
|
-
"content": "import * as React from \"react\"\nimport { ark } from \"@ark-ui/react\"\nimport { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from \"lucide-react\"\nimport { createListCollection } from \"@ark-ui/react/collection\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n Select,\n SelectContent,\n SelectControl,\n SelectItem,\n SelectItemIndicator,\n SelectItemText,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\"\nimport type { DataTableInstance, DataTableRow } from \"@/lib/data-table-adapter\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllable } from \"@/lib/controllable\"\nimport { LiveRegion, useLiveRegion } from \"@/components/ui/live-region\"\n\n/**\n * DataGrid — an editable, keyboard-driven grid on the same source-agnostic\n * `DataTableInstance` contract as the data table. The instance supplies rows,\n * columns, sorting, filtering and selection; the grid adds cell focus, range\n * selection, inline editing, copy/paste, row virtualization, column resizing\n * and pinning. Edits are intents: `onCellChange({ row, columnId, value })`\n * and the consumer owns the data.\n *\n * Column behaviour is data (`columns` prop, keyed by column id):\n * `{ id, type, editable, options, width, minWidth, pinned, format, parse }`.\n *\n * Anatomy:\n * DataGrid { table, columns, onCellChange }\n * DataGridContainer scroll viewport, role=\"grid\"\n * DataGridHeader > DataGridHeaderRow > DataGridHead column=… (+ DataGridResizeHandle)\n * DataGridBody children={(row) => cells} virtualized rows\n * DataGridRow > DataGridCell column=… focusable, editable\n * DataGridEmpty\n * DataGridSelectAll / DataGridSelectRow checkbox cells\n *\n * Keyboard: arrows move, Shift+arrows extend the range, Home/End and\n * Ctrl+Home/End jump, PageUp/PageDown scroll, Enter or F2 or typing edits,\n * Enter commits and moves down, Tab commits and moves right, Escape cancels,\n * Space toggles booleans, Delete clears the range, ⌘C/⌘V copy and paste TSV.\n */\n\ntype DataGridColumnType = \"text\" | \"number\" | \"boolean\" | \"select\" | \"date\"\ntype DataGridOption = { value: string; label: string }\ntype DataGridColumnConfig = {\n id: string\n type?: DataGridColumnType\n editable?: boolean\n options?: DataGridOption[]\n width?: number\n minWidth?: number\n pinned?: \"left\" | \"right\"\n /** Value → text for display and for seeding the editor. */\n format?: (value: unknown) => string\n /** Editor text → value on commit. */\n parse?: (input: string, previous: unknown) => unknown\n}\n\ntype CellAddress = { rowIndex: number; columnId: string }\ntype CellRange = { anchor: CellAddress; focus: CellAddress }\ntype DataGridCellChange<TData> = {\n row: DataTableRow<TData>\n columnId: string\n value: unknown\n previous: unknown\n}\n\ntype EditorRenderProps = {\n value: string\n onChange: (value: string) => void\n commit: (value?: string) => void\n cancel: () => void\n column: DataGridColumnConfig\n row: DataTableRow<unknown>\n inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }\n}\n\nconst DEFAULT_WIDTH = 160\nconst MIN_WIDTH = 60\n\n/* ---------------------------------------------------------------------------\n * Context\n * ------------------------------------------------------------------------- */\n\ntype ColumnLayout = {\n id: string\n width: number\n pinned?: \"left\" | \"right\"\n /** Sticky offset for pinned columns. */\n offset: number\n config: DataGridColumnConfig\n}\n\ntype DataGridContextValue<TData = unknown> = {\n table: DataTableInstance<TData>\n rows: DataTableRow<TData>[]\n layout: ColumnLayout[]\n layoutById: Map<string, ColumnLayout>\n templateColumns: string\n totalWidth: number\n rowHeight: number\n setWidth: (columnId: string, width: number) => void\n focus: CellAddress | null\n range: CellRange | null\n editing: (CellAddress & { seed?: string }) | null\n focusCell: (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => void\n startEditing: (address: CellAddress, seed?: string) => void\n stopEditing: (options?: { refocus?: boolean }) => void\n commit: (address: CellAddress, value: unknown) => void\n clearRange: () => void\n copyRange: () => Promise<void>\n paste: () => Promise<void>\n isInRange: (address: CellAddress) => boolean\n containerRef: React.RefObject<HTMLDivElement | null>\n /** Bumped whenever a cell should take DOM focus; cells act on it in a layout effect. */\n focusRequest: number\n /** Editors register so the grid can close them when the viewport scrolls. */\n registerEditor: (handle: { close: () => void }) => () => void\n closeEditor: () => void\n window: { start: number; end: number }\n setScrollTop: (top: number) => void\n setViewportHeight: (height: number) => void\n announce: (message: string) => void\n}\n\nconst DataGridContext = React.createContext<DataGridContextValue | null>(null)\nconst RowContext = React.createContext<{ row: DataTableRow<unknown>; index: number } | null>(null)\n\nfunction useDataGrid<TData = unknown>() {\n const ctx = React.useContext(DataGridContext)\n if (!ctx) throw new Error(\"DataGrid parts must be used within <DataGrid>\")\n return ctx as DataGridContextValue<TData>\n}\nfunction useDataGridRow() {\n const ctx = React.useContext(RowContext)\n if (!ctx) throw new Error(\"DataGridCell must be used within <DataGridRow>\")\n return ctx\n}\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))\nconst defaultFormat = (value: unknown) => {\n if (value === null || value === undefined) return \"\"\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? \"\" : value.toISOString().slice(0, 10)\n return String(value)\n}\n\n/* ---------------------------------------------------------------------------\n * Root\n * ------------------------------------------------------------------------- */\n\nfunction DataGrid<TData>({\n table,\n columns = [],\n rowHeight = 36,\n overscan = 6,\n onCellChange,\n columnSizing: sizingProp,\n defaultColumnSizing,\n onColumnSizingChange,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n table: DataTableInstance<TData>\n columns?: DataGridColumnConfig[]\n rowHeight?: number\n overscan?: number\n onCellChange?: (change: DataGridCellChange<TData>) => void\n columnSizing?: Record<string, number>\n defaultColumnSizing?: Record<string, number>\n onColumnSizingChange?: (sizing: Record<string, number>) => void\n children?: React.ReactNode\n}) {\n const containerRef = React.useRef<HTMLDivElement>(null)\n const rows = table.getRowModel().rows\n const visibleColumns = table.getAllColumns().filter((c) => c.getIsVisible())\n\n /* Column sizing (controllable) */\n const [initialSizing] = React.useState<Record<string, number>>(() => defaultColumnSizing ?? {})\n const [sizing, setSizing] = useControllable(sizingProp, initialSizing, onColumnSizingChange)\n const setWidth = React.useCallback(\n (columnId: string, width: number) => setSizing((prev) => ({ ...prev, [columnId]: width })),\n [setSizing]\n )\n\n const configById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns])\n\n /* Layout: order = visible engine columns; pinned left first, right last */\n const { layout, layoutById, templateColumns, totalWidth } = React.useMemo(() => {\n const base = visibleColumns.map((c) => {\n const config = configById.get(c.id) ?? { id: c.id }\n const width = Math.max(config.minWidth ?? MIN_WIDTH, sizing[c.id] ?? config.width ?? DEFAULT_WIDTH)\n return { id: c.id, width, pinned: config.pinned, offset: 0, config }\n })\n const left = base.filter((c) => c.pinned === \"left\")\n const middle = base.filter((c) => !c.pinned)\n const right = base.filter((c) => c.pinned === \"right\")\n let offset = 0\n for (const c of left) {\n c.offset = offset\n offset += c.width\n }\n offset = 0\n for (const c of [...right].reverse()) {\n c.offset = offset\n offset += c.width\n }\n const ordered: ColumnLayout[] = [...left, ...middle, ...right]\n return {\n layout: ordered,\n layoutById: new Map(ordered.map((c) => [c.id, c])),\n templateColumns: ordered.map((c) => `${c.width}px`).join(\" \"),\n totalWidth: ordered.reduce((sum, c) => sum + c.width, 0),\n }\n // visibleColumns is derived from table state; its identity changes with it.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visibleColumns.map((c) => c.id).join(\"|\"), configById, sizing])\n\n /* Virtual window */\n const [scrollTop, setScrollTopState] = React.useState(0)\n const [viewportHeight, setViewportHeight] = React.useState(0)\n const setScrollTop = React.useCallback((top: number) => setScrollTopState(top), [])\n const window = React.useMemo(() => {\n const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)\n const end = Math.min(rows.length, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan)\n return { start, end }\n }, [scrollTop, viewportHeight, rowHeight, overscan, rows.length])\n\n /* Focus, range, editing */\n const [focus, setFocus] = React.useState<CellAddress | null>(null)\n const [range, setRange] = React.useState<CellRange | null>(null)\n const [editing, setEditing] = React.useState<(CellAddress & { seed?: string }) | null>(null)\n const { message: announcement, announce } = useLiveRegion()\n const [focusRequest, setFocusRequest] = React.useState(0)\n const activeEditor = React.useRef<{ close: () => void } | null>(null)\n const registerEditor = React.useCallback((handle: { close: () => void }) => {\n activeEditor.current = handle\n return () => {\n if (activeEditor.current === handle) activeEditor.current = null\n }\n }, [])\n const closeEditor = React.useCallback(() => activeEditor.current?.close(), [])\n\n const latest = React.useRef({ rows, layout, focus, range, editing, onCellChange, rowHeight })\n React.useEffect(() => {\n latest.current = { rows, layout, focus, range, editing, onCellChange, rowHeight }\n })\n\n const scrollRowIntoView = React.useCallback(\n (rowIndex: number) => {\n const el = containerRef.current\n if (!el) return\n const headerHeight = el.querySelector<HTMLElement>(\"[data-slot=data-grid-header]\")?.offsetHeight ?? 0\n const top = rowIndex * rowHeight\n const bottom = top + rowHeight\n const viewTop = el.scrollTop\n const viewBottom = el.scrollTop + el.clientHeight - headerHeight\n if (top < viewTop) el.scrollTop = top\n else if (bottom > viewBottom) el.scrollTop = bottom - (el.clientHeight - headerHeight)\n },\n [rowHeight]\n )\n\n const scrollColumnIntoView = React.useCallback((columnId: string) => {\n const el = containerRef.current\n const col = latest.current.layout.find((c) => c.id === columnId)\n if (!el || !col || col.pinned) return\n const left = latest.current.layout.slice(0, latest.current.layout.indexOf(col)).reduce((s, c) => s + c.width, 0)\n const pinnedLeft = latest.current.layout.filter((c) => c.pinned === \"left\").reduce((s, c) => s + c.width, 0)\n const pinnedRight = latest.current.layout.filter((c) => c.pinned === \"right\").reduce((s, c) => s + c.width, 0)\n if (left < el.scrollLeft + pinnedLeft) el.scrollLeft = left - pinnedLeft\n else if (left + col.width > el.scrollLeft + el.clientWidth - pinnedRight)\n el.scrollLeft = left + col.width - el.clientWidth + pinnedRight\n }, [])\n\n const focusCell = React.useCallback(\n (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => {\n const { rows, layout } = latest.current\n if (!rows.length || !layout.length) return\n const rowIndex = clamp(address.rowIndex, 0, rows.length - 1)\n const columnId = layout.some((c) => c.id === address.columnId) ? address.columnId : layout[0].id\n const next = { rowIndex, columnId }\n setFocus(next)\n setRange((prev) =>\n options?.extend && (prev?.anchor ?? latest.current.focus)\n ? { anchor: prev?.anchor ?? latest.current.focus!, focus: next }\n : null\n )\n setFocusRequest((n) => n + 1)\n if (options?.scroll !== false) {\n scrollRowIntoView(rowIndex)\n scrollColumnIntoView(columnId)\n }\n },\n [scrollRowIntoView, scrollColumnIntoView]\n )\n\n const startEditing = React.useCallback((address: CellAddress, seed?: string) => {\n const config = latest.current.layout.find((c) => c.id === address.columnId)?.config\n if (!config?.editable) return\n setEditing({ ...address, seed })\n }, [])\n\n const stopEditing = React.useCallback((options?: { refocus?: boolean }) => {\n setEditing(null)\n if (options?.refocus !== false) setFocusRequest((n) => n + 1)\n }, [])\n\n const commit = React.useCallback((address: CellAddress, value: unknown) => {\n const row = latest.current.rows[address.rowIndex]\n if (!row) return\n const previous = row.getValue(address.columnId)\n if (Object.is(previous, value)) return\n latest.current.onCellChange?.({ row, columnId: address.columnId, value, previous })\n }, [])\n\n const rangeBounds = React.useCallback(\n () => boundsOf(latest.current.range, latest.current.focus, latest.current.layout),\n []\n )\n\n const isInRange = React.useCallback(\n (address: CellAddress) => {\n if (!range) return false\n const b = boundsOf(range, focus, layout)\n const ci = layout.findIndex((c) => c.id === address.columnId)\n return !!b && address.rowIndex >= b.r0 && address.rowIndex <= b.r1 && ci >= b.c0 && ci <= b.c1\n },\n [range, focus, layout]\n )\n\n const clearRange = React.useCallback(() => {\n const b = rangeBounds()\n if (!b) return\n const { rows, layout } = latest.current\n let count = 0\n for (let r = b.r0; r <= b.r1; r++) {\n for (let c = b.c0; c <= b.c1; c++) {\n const col = layout[c]\n if (!col?.config.editable) continue\n const row = rows[r]\n const empty = col.config.type === \"boolean\" ? false : col.config.type === \"number\" ? null : \"\"\n if (row && !Object.is(row.getValue(col.id), empty)) {\n latest.current.onCellChange?.({ row, columnId: col.id, value: empty, previous: row.getValue(col.id) })\n count++\n }\n }\n }\n if (count) announce(`Cleared ${count} cell${count === 1 ? \"\" : \"s\"}.`)\n }, [rangeBounds, announce])\n\n const copyRange = React.useCallback(async () => {\n const b = rangeBounds()\n if (!b) return\n const { rows, layout } = latest.current\n const lines: string[] = []\n for (let r = b.r0; r <= b.r1; r++) {\n const cells: string[] = []\n for (let c = b.c0; c <= b.c1; c++) {\n const col = layout[c]\n const value = rows[r]?.getValue(col.id)\n cells.push((col.config.format ?? defaultFormat)(value))\n }\n lines.push(cells.join(\"\\t\"))\n }\n try {\n await navigator.clipboard.writeText(lines.join(\"\\n\"))\n const n = (b.r1 - b.r0 + 1) * (b.c1 - b.c0 + 1)\n announce(`Copied ${n} cell${n === 1 ? \"\" : \"s\"}.`)\n } catch {\n announce(\"Could not copy to the clipboard.\")\n }\n }, [rangeBounds, announce])\n\n const paste = React.useCallback(async () => {\n const { focus, rows, layout } = latest.current\n if (!focus) return\n let text = \"\"\n try {\n text = await navigator.clipboard.readText()\n } catch {\n announce(\"Could not read the clipboard.\")\n return\n }\n const lines = text\n .replace(/\\r/g, \"\")\n .split(\"\\n\")\n .filter((l, i, arr) => l !== \"\" || i < arr.length - 1)\n const startCol = layout.findIndex((c) => c.id === focus.columnId)\n let count = 0\n lines.forEach((line, dr) => {\n const row = rows[focus.rowIndex + dr]\n if (!row) return\n line.split(\"\\t\").forEach((cell, dc) => {\n const col = layout[startCol + dc]\n if (!col?.config.editable) return\n const previous = row.getValue(col.id)\n const value = parseInput(col.config, cell, previous)\n if (!Object.is(previous, value)) {\n latest.current.onCellChange?.({ row, columnId: col.id, value, previous })\n count++\n }\n })\n })\n announce(`Pasted into ${count} cell${count === 1 ? \"\" : \"s\"}.`)\n }, [announce])\n\n const ctx = React.useMemo<DataGridContextValue<TData>>(\n () => ({\n table,\n rows,\n layout,\n layoutById,\n templateColumns,\n totalWidth,\n rowHeight,\n setWidth,\n focus,\n range,\n editing,\n focusCell,\n startEditing,\n stopEditing,\n commit,\n clearRange,\n copyRange,\n paste,\n isInRange,\n containerRef,\n focusRequest,\n registerEditor,\n closeEditor,\n window,\n setScrollTop,\n setViewportHeight,\n announce,\n }),\n [\n table,\n rows,\n layout,\n layoutById,\n templateColumns,\n totalWidth,\n rowHeight,\n setWidth,\n focus,\n range,\n editing,\n focusCell,\n startEditing,\n stopEditing,\n commit,\n clearRange,\n copyRange,\n paste,\n isInRange,\n focusRequest,\n registerEditor,\n closeEditor,\n window,\n setScrollTop,\n announce,\n ]\n )\n\n return (\n <DataGridContext.Provider value={ctx as DataGridContextValue}>\n <div\n data-slot=\"data-grid\"\n data-editing={editing ? \"\" : undefined}\n className={cn(\"flex min-h-0 flex-1 flex-col\", className)}\n {...props}\n >\n {children}\n <LiveRegion data-slot=\"data-grid-live-region\" message={announcement} />\n </div>\n </DataGridContext.Provider>\n )\n}\n\nfunction boundsOf(range: CellRange | null, focus: CellAddress | null, layout: ColumnLayout[]) {\n const a = range?.anchor ?? focus\n const b = range?.focus ?? focus\n if (!a || !b) return null\n const ca = layout.findIndex((c) => c.id === a.columnId)\n const cb = layout.findIndex((c) => c.id === b.columnId)\n return {\n r0: Math.min(a.rowIndex, b.rowIndex),\n r1: Math.max(a.rowIndex, b.rowIndex),\n c0: Math.min(ca, cb),\n c1: Math.max(ca, cb),\n }\n}\n\nfunction parseInput(config: DataGridColumnConfig, input: string, previous: unknown): unknown {\n if (config.parse) return config.parse(input, previous)\n switch (config.type) {\n case \"number\": {\n if (input.trim() === \"\") return null\n const n = Number(input)\n return Number.isNaN(n) ? previous : n\n }\n case \"boolean\":\n return /^(true|yes|1|on|x)$/i.test(input.trim())\n case \"date\": {\n if (!input) return null\n const d = new Date(input)\n return Number.isNaN(d.getTime()) ? previous : previous instanceof Date ? d : input\n }\n default:\n return input\n }\n}\n\n/* ---------------------------------------------------------------------------\n * Container, header, body\n * ------------------------------------------------------------------------- */\n\nfunction DataGridContainer({ className, onScroll, ...props }: React.ComponentProps<\"div\">) {\n const { containerRef, setScrollTop, setViewportHeight, rows, closeEditor } = useDataGrid()\n React.useLayoutEffect(() => {\n const el = containerRef.current\n if (!el) return\n const update = () => setViewportHeight(el.clientHeight)\n update()\n const observer = new ResizeObserver(update)\n observer.observe(el)\n return () => observer.disconnect()\n }, [containerRef, setViewportHeight])\n return (\n <div\n ref={containerRef}\n data-slot=\"data-grid-container\"\n role=\"grid\"\n aria-rowcount={rows.length}\n className={cn(\"relative min-h-0 flex-1 overflow-auto rounded-lg border bg-background text-sm\", className)}\n onScroll={(event) => {\n onScroll?.(event)\n // An open editor (and its portaled popover) must not drift away from its cell.\n closeEditor()\n setScrollTop(event.currentTarget.scrollTop)\n }}\n {...props}\n />\n )\n}\n\nfunction DataGridHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"data-grid-header\"\n role=\"rowgroup\"\n className={cn(\"sticky top-0 z-30 w-fit min-w-full bg-muted\", className)}\n {...props}\n />\n )\n}\n\nfunction DataGridHeaderRow({ className, style, ...props }: React.ComponentProps<\"div\">) {\n const { templateColumns, totalWidth } = useDataGrid()\n return (\n <div\n data-slot=\"data-grid-header-row\"\n role=\"row\"\n className={cn(\"grid border-b bg-muted\", className)}\n style={{ gridTemplateColumns: templateColumns, minWidth: totalWidth, ...style }}\n {...props}\n />\n )\n}\n\nconst pinnedStyle = (col: ColumnLayout | undefined): React.CSSProperties | undefined =>\n col?.pinned === \"left\" ? { left: col.offset } : col?.pinned === \"right\" ? { right: col.offset } : undefined\n\n/** A header cell. Sortable columns get a sort button with `children` as the title. */\nfunction DataGridHead({\n column: columnId,\n className,\n children,\n style,\n ...props\n}: React.ComponentProps<\"div\"> & { column: string }) {\n const { table, layoutById } = useDataGrid()\n const column = table.getColumn(columnId)\n const layout = layoutById.get(columnId)\n if (!column || !layout) return null\n const sorted = column.getIsSorted()\n const sortable = column.getCanSort()\n return (\n <div\n data-slot=\"data-grid-head\"\n data-column={columnId}\n data-pinned={layout.pinned}\n role=\"columnheader\"\n aria-sort={sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined}\n className={cn(\n \"group/head relative flex h-9 items-center gap-1 overflow-hidden px-2 font-medium whitespace-nowrap text-muted-foreground\",\n layout.pinned && \"sticky z-10 bg-muted\",\n layout.pinned === \"left\" && \"border-e\",\n layout.pinned === \"right\" && \"border-s\",\n className\n )}\n style={{ ...pinnedStyle(layout), ...style }}\n {...props}\n >\n {sortable ? (\n <ark.button\n type=\"button\"\n data-slot=\"data-grid-sort-trigger\"\n className=\"-ms-1 flex h-7 min-w-0 items-center gap-1 rounded-md px-1 outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n onClick={() => column.toggleSorting()}\n >\n <span className=\"truncate\">{children}</span>\n {sorted === \"desc\" ? (\n <ArrowDownIcon className=\"size-3.5 shrink-0\" />\n ) : sorted === \"asc\" ? (\n <ArrowUpIcon className=\"size-3.5 shrink-0\" />\n ) : (\n <ChevronsUpDownIcon className=\"size-3.5 shrink-0 opacity-50\" />\n )}\n </ark.button>\n ) : (\n <span className=\"truncate\">{children}</span>\n )}\n </div>\n )\n}\n\n/** Pointer-driven column resizer; double-click resets to the configured width. */\nfunction DataGridResizeHandle({ className, onPointerDown, onDoubleClick, ...props }: React.ComponentProps<\"div\">) {\n const { setWidth, layoutById } = useDataGrid()\n const head = React.useContext(HeadColumnContext)\n return (\n <div\n data-slot=\"data-grid-resize-handle\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n className={cn(\n \"absolute inset-y-0 inset-e-0 z-10 w-2 cursor-col-resize touch-none select-none after:absolute after:inset-y-2 after:inset-e-0.5 after:w-px after:bg-border group-hover/head:after:bg-foreground/40 hover:after:bg-primary\",\n className\n )}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0 || !head) return\n event.preventDefault()\n event.stopPropagation()\n const start = event.clientX\n const startWidth = layoutById.get(head)?.width ?? DEFAULT_WIDTH\n const min = layoutById.get(head)?.config.minWidth ?? MIN_WIDTH\n const move = (e: PointerEvent) => setWidth(head, Math.max(min, startWidth + e.clientX - start))\n const up = () => {\n window.removeEventListener(\"pointermove\", move)\n window.removeEventListener(\"pointerup\", up)\n }\n window.addEventListener(\"pointermove\", move)\n window.addEventListener(\"pointerup\", up)\n }}\n onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented && head) {\n const config = layoutById.get(head)?.config\n setWidth(head, config?.width ?? DEFAULT_WIDTH)\n }\n }}\n {...props}\n />\n )\n}\n\nconst HeadColumnContext = React.createContext<string | null>(null)\n\n/** Wraps `DataGridHead` so `DataGridResizeHandle` knows its column. */\nfunction DataGridHeadWithResize({ column, children, ...props }: React.ComponentProps<typeof DataGridHead>) {\n return (\n <HeadColumnContext.Provider value={column}>\n <DataGridHead column={column} {...props}>\n {children}\n <DataGridResizeHandle />\n </DataGridHead>\n </HeadColumnContext.Provider>\n )\n}\n\n/** Virtualized rows. `children` renders the cells for a row. */\nfunction DataGridBody<TData>({\n className,\n children,\n style,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n children: (row: DataTableRow<TData>, index: number) => React.ReactNode\n}) {\n const { rows, window: win, rowHeight, totalWidth } = useDataGrid<TData>()\n const visible = rows.slice(win.start, win.end)\n return (\n <div\n data-slot=\"data-grid-body\"\n role=\"rowgroup\"\n className={cn(\"relative\", className)}\n style={{ height: rows.length * rowHeight, minWidth: totalWidth, ...style }}\n {...props}\n >\n <div style={{ transform: `translateY(${win.start * rowHeight}px)` }}>\n {visible.map((row, i) => (\n <DataGridRow key={row.id} row={row} index={win.start + i}>\n {children(row, win.start + i)}\n </DataGridRow>\n ))}\n </div>\n </div>\n )\n}\n\nfunction DataGridRow<TData>({\n row,\n index,\n className,\n style,\n ...props\n}: React.ComponentProps<\"div\"> & { row: DataTableRow<TData>; index: number }) {\n const { templateColumns, rowHeight, focus } = useDataGrid()\n const ctx = React.useMemo(() => ({ row: row as DataTableRow<unknown>, index }), [row, index])\n const selected = row.getIsSelected()\n return (\n <RowContext.Provider value={ctx}>\n <div\n data-slot=\"data-grid-row\"\n data-index={index}\n data-state={selected ? \"selected\" : undefined}\n data-focused={focus?.rowIndex === index ? \"\" : undefined}\n role=\"row\"\n aria-rowindex={index + 1}\n aria-selected={selected || undefined}\n className={cn(\n \"group/row grid border-b transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted/60\",\n className\n )}\n style={{ gridTemplateColumns: templateColumns, height: rowHeight, ...style }}\n {...props}\n />\n </RowContext.Provider>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Cells\n * ------------------------------------------------------------------------- */\n\nconst isPrintable = (event: React.KeyboardEvent) =>\n event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey\n\nfunction DataGridCell({\n column: columnId,\n editable: editableProp,\n editor,\n className,\n children,\n style,\n onClick,\n onDoubleClick,\n onKeyDown,\n onPointerDown,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n column: string\n /** Override the column config for this cell. */\n editable?: boolean\n /** Custom editor; return `undefined` to use the default for the column type. */\n editor?: (props: EditorRenderProps) => React.ReactNode | undefined\n children?: React.ReactNode\n}) {\n const grid = useDataGrid()\n const { row, index } = useDataGridRow()\n const layout = grid.layoutById.get(columnId)\n const address = React.useMemo(() => ({ rowIndex: index, columnId }), [index, columnId])\n const ref = React.useRef<HTMLDivElement>(null)\n const focused = grid.focus?.rowIndex === index && grid.focus.columnId === columnId\n const isEditing = grid.editing?.rowIndex === index && grid.editing.columnId === columnId\n // Take DOM focus when this cell is the focused one and a focus request is pending\n // (after navigation, after an editor closes, or once a virtualized row mounts).\n React.useLayoutEffect(() => {\n const el = ref.current\n if (!focused || isEditing || !el) return\n if (document.activeElement !== el && !el.contains(document.activeElement)) el.focus({ preventScroll: true })\n }, [focused, isEditing, grid.focusRequest])\n if (!layout) return null\n const config = layout.config\n const editable = editableProp ?? !!config.editable\n const inRange = grid.isInRange(address)\n const value = row.getValue(columnId)\n const format = config.format ?? defaultFormat\n const columnIndex = grid.layout.indexOf(layout)\n\n const move = (dr: number, dc: number, extend = false) => {\n // Navigate from the focused address in state; DOM focus can lag a frame after an edit.\n const base = grid.focus ?? address\n const baseCol = Math.max(\n 0,\n grid.layout.findIndex((c) => c.id === base.columnId)\n )\n const nextCol = grid.layout[clamp(baseCol + dc, 0, grid.layout.length - 1)]\n grid.focusCell({ rowIndex: base.rowIndex + dr, columnId: nextCol.id }, { extend })\n }\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event)\n if (event.defaultPrevented || isEditing) return\n const meta = event.metaKey || event.ctrlKey\n const pageRows = Math.max(1, Math.floor((grid.containerRef.current?.clientHeight ?? 0) / grid.rowHeight) - 1)\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault()\n move(1, 0, event.shiftKey)\n return\n case \"ArrowUp\":\n event.preventDefault()\n move(-1, 0, event.shiftKey)\n return\n case \"ArrowRight\":\n event.preventDefault()\n move(0, 1, event.shiftKey)\n return\n case \"ArrowLeft\":\n event.preventDefault()\n move(0, -1, event.shiftKey)\n return\n case \"Home\":\n event.preventDefault()\n grid.focusCell({ rowIndex: meta ? 0 : index, columnId: grid.layout[0].id }, { extend: event.shiftKey })\n return\n case \"End\":\n event.preventDefault()\n grid.focusCell(\n { rowIndex: meta ? grid.rows.length - 1 : index, columnId: grid.layout[grid.layout.length - 1].id },\n { extend: event.shiftKey }\n )\n return\n case \"PageDown\":\n event.preventDefault()\n move(pageRows, 0, event.shiftKey)\n return\n case \"PageUp\":\n event.preventDefault()\n move(-pageRows, 0, event.shiftKey)\n return\n case \"Tab\":\n event.preventDefault()\n move(0, event.shiftKey ? -1 : 1)\n return\n case \"Enter\":\n case \"F2\":\n event.preventDefault()\n if (editable) grid.startEditing(address)\n else if (event.key === \"Enter\") move(1, 0)\n return\n case \" \":\n if (editable && config.type === \"boolean\") {\n event.preventDefault()\n grid.commit(address, !value)\n }\n return\n case \"Escape\":\n grid.focusCell(address)\n return\n case \"Delete\":\n case \"Backspace\":\n event.preventDefault()\n grid.clearRange()\n return\n case \"c\":\n case \"C\":\n if (meta) {\n event.preventDefault()\n void grid.copyRange()\n }\n return\n case \"v\":\n case \"V\":\n if (meta) {\n event.preventDefault()\n void grid.paste()\n }\n return\n }\n if (editable && isPrintable(event) && config.type !== \"boolean\") {\n event.preventDefault()\n grid.startEditing(address, event.key)\n }\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"data-grid-cell\"\n data-column={columnId}\n data-row-index={index}\n data-type={config.type}\n data-pinned={layout.pinned}\n data-editable={editable ? \"\" : undefined}\n data-focused={focused ? \"\" : undefined}\n data-editing={isEditing ? \"\" : undefined}\n data-selected={inRange ? \"\" : undefined}\n role=\"gridcell\"\n aria-colindex={columnIndex + 1}\n aria-readonly={editable ? undefined : true}\n tabIndex={focused ? 0 : -1}\n className={cn(\n \"relative flex min-w-0 items-center overflow-hidden px-2 whitespace-nowrap outline-none\",\n \"data-selected:bg-primary/10\",\n \"data-focused:z-10 data-focused:ring-2 data-focused:ring-primary data-focused:ring-inset\",\n \"data-editing:bg-background data-editing:p-0\",\n layout.pinned && \"sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted\",\n layout.pinned === \"left\" && \"border-e\",\n layout.pinned === \"right\" && \"border-s\",\n config.type === \"number\" && \"justify-end tabular-nums\",\n className\n )}\n style={{ ...pinnedStyle(layout), ...style }}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0 || isEditing) return\n if ((event.target as HTMLElement).closest(\"button, a, input, select, textarea, [role=checkbox]\")) return\n grid.focusCell(address, { extend: event.shiftKey, scroll: false })\n }}\n onClick={onClick}\n onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented && editable && config.type !== \"boolean\") grid.startEditing(address)\n }}\n onKeyDown={handleKeyDown}\n {...props}\n >\n {isEditing ? (\n <CellEditor\n address={address}\n config={config}\n row={row}\n value={value}\n format={format}\n editor={editor}\n display={children}\n />\n ) : children !== undefined ? (\n children\n ) : config.type === \"boolean\" ? (\n <Checkbox\n checked={!!value}\n disabled={!editable}\n onCheckedChange={({ checked }) => grid.commit(address, checked === true)}\n aria-label={columnId}\n />\n ) : (\n <span className=\"truncate\">{format(value)}</span>\n )}\n </div>\n )\n}\n\nfunction CellEditor({\n address,\n config,\n row,\n value,\n format,\n editor,\n display,\n}: {\n address: CellAddress\n config: DataGridColumnConfig\n row: DataTableRow<unknown>\n value: unknown\n format: (value: unknown) => string\n editor?: (props: EditorRenderProps) => React.ReactNode | undefined\n display?: React.ReactNode\n}) {\n const grid = useDataGrid()\n const seed = grid.editing?.seed\n const [text, setText] = React.useState(() => seed ?? format(value))\n const inputRef = React.useRef<HTMLInputElement>(null)\n const done = React.useRef(false)\n React.useLayoutEffect(() => {\n const el = inputRef.current\n if (!el) return\n el.focus()\n if (seed === undefined && \"select\" in el && typeof el.select === \"function\" && el.type !== \"date\") el.select()\n }, [seed])\n\n const commit = (next?: string, then?: () => void) => {\n if (done.current) return\n done.current = true\n grid.commit(address, parseInput(config, next ?? text, value))\n grid.stopEditing({ refocus: !then })\n then?.()\n }\n const cancel = () => {\n if (done.current) return\n done.current = true\n grid.stopEditing()\n }\n const isSelect = config.type === \"select\" && !!config.options\n const commitRef = React.useRef(commit)\n const cancelRef = React.useRef(cancel)\n React.useEffect(() => {\n commitRef.current = commit\n cancelRef.current = cancel\n })\n const { registerEditor } = grid\n // Scrolling the grid closes the editor (text commits, an unpicked select cancels) before its\n // row can leave the virtual window, so the editor never unmounts mid-edit.\n React.useEffect(\n () => registerEditor({ close: () => (isSelect ? cancelRef.current() : commitRef.current()) }),\n [registerEditor, isSelect]\n )\n const moveAfter = (dr: number, dc: number) => () => {\n const col = grid.layout.findIndex((c) => c.id === address.columnId)\n const next = grid.layout[clamp(col + dc, 0, grid.layout.length - 1)]\n grid.focusCell({ rowIndex: address.rowIndex + dr, columnId: next.id })\n }\n const onKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\") {\n event.preventDefault()\n commit(undefined, moveAfter(1, 0))\n } else if (event.key === \"Tab\") {\n event.preventDefault()\n commit(undefined, moveAfter(0, event.shiftKey ? -1 : 1))\n } else if (event.key === \"Escape\") {\n event.preventDefault()\n cancel()\n }\n event.stopPropagation()\n }\n const inputProps: EditorRenderProps[\"inputProps\"] = {\n ref: inputRef,\n value: text,\n onChange: (e) => setText(e.target.value),\n onKeyDown,\n onBlur: () => commit(),\n className: \"size-full bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground\",\n \"aria-label\": \"Edit value\",\n }\n const custom = editor?.({ value: text, onChange: setText, commit, cancel, column: config, row, inputProps })\n if (custom !== undefined) return <>{custom}</>\n\n if (config.type === \"select\" && config.options) {\n return (\n <SelectEditor\n options={config.options}\n value={text}\n display={display}\n onCommit={(v) => commit(v, moveAfter(1, 0))}\n onCancel={cancel}\n />\n )\n }\n const type = config.type === \"number\" ? \"number\" : config.type === \"date\" ? \"date\" : \"text\"\n return <input data-slot=\"data-grid-editor\" type={type} {...inputProps} />\n}\n\n/** Opens the toolkit Select immediately; picking commits, closing without a pick cancels. */\nfunction SelectEditor({\n options,\n value,\n display,\n onCommit,\n onCancel,\n}: {\n options: DataGridOption[]\n value: string\n /** What the cell normally shows, so the open editor looks like the cell. */\n display?: React.ReactNode\n onCommit: (value: string) => void\n onCancel: () => void\n}) {\n const collection = React.useMemo(\n () => createListCollection({ items: options, itemToValue: (o) => o.value, itemToString: (o) => o.label }),\n [options]\n )\n const picked = React.useRef(false)\n const contentId = React.useId()\n // Ark handles list navigation on the open content, so focus it (it is portaled) once mounted.\n React.useLayoutEffect(() => {\n const focusContent = () => document.getElementById(contentId)?.focus({ preventScroll: true })\n focusContent()\n const timer = setTimeout(focusContent, 0)\n return () => clearTimeout(timer)\n }, [contentId])\n const label = options.find((o) => o.value === value)?.label ?? value\n return (\n <Select\n collection={collection}\n value={value ? [value] : []}\n defaultOpen\n ids={{ content: contentId }}\n positioning={{ sameWidth: true, gutter: 2 }}\n onValueChange={({ value: next }) => {\n if (next[0] === undefined) return\n picked.current = true\n onCommit(next[0])\n }}\n onOpenChange={({ open }) => {\n if (!open && !picked.current) onCancel()\n }}\n onPointerDown={(event) => event.stopPropagation()}\n className=\"size-full\"\n >\n <SelectControl className=\"size-full\">\n <SelectTrigger\n variant=\"unstyled\"\n data-slot=\"data-grid-select-editor\"\n className=\"size-full px-2 [&_svg]:text-muted-foreground\"\n aria-label=\"Edit value\"\n >\n <SelectValue placeholder=\"Choose…\">{display ?? label}</SelectValue>\n </SelectTrigger>\n </SelectControl>\n <SelectContent>\n {options.map((o) => (\n <SelectItem key={o.value} item={o}>\n <SelectItemText>{o.label}</SelectItemText>\n <SelectItemIndicator />\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Selection cells, empty\n * ------------------------------------------------------------------------- */\n\nfunction DataGridSelectAll({ className, ...props }: React.ComponentProps<\"div\">) {\n const { table, layoutById } = useDataGrid()\n const layout = layoutById.get(\"select\")\n const t = table as DataTableInstance<unknown> & {\n getIsAllPageRowsSelected?: () => boolean\n getIsSomePageRowsSelected?: () => boolean\n toggleAllPageRowsSelected?: (v?: boolean) => void\n }\n const all = t.getIsAllPageRowsSelected?.() ?? false\n const some = t.getIsSomePageRowsSelected?.() ?? false\n return (\n <div\n data-slot=\"data-grid-select-all\"\n role=\"columnheader\"\n className={cn(\n \"flex items-center justify-center\",\n layout?.pinned && \"sticky z-10 bg-muted\",\n layout?.pinned === \"left\" && \"border-e\",\n className\n )}\n style={pinnedStyle(layout)}\n {...props}\n >\n <Checkbox\n checked={all ? true : some ? \"indeterminate\" : false}\n onCheckedChange={({ checked }) => t.toggleAllPageRowsSelected?.(checked === true)}\n aria-label=\"Select all\"\n />\n </div>\n )\n}\n\nfunction DataGridSelectRow({ className, ...props }: React.ComponentProps<\"div\">) {\n const { layoutById } = useDataGrid()\n const { row } = useDataGridRow()\n const layout = layoutById.get(\"select\")\n return (\n <div\n data-slot=\"data-grid-select-row\"\n role=\"gridcell\"\n className={cn(\n \"flex items-center justify-center\",\n layout?.pinned && \"sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted\",\n layout?.pinned === \"left\" && \"border-e\",\n className\n )}\n style={pinnedStyle(layout)}\n {...props}\n >\n <Checkbox\n checked={row.getIsSelected()}\n onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}\n aria-label=\"Select row\"\n />\n </div>\n )\n}\n\nfunction DataGridEmpty({ className, ...props }: React.ComponentProps<\"div\">) {\n const { rows } = useDataGrid()\n if (rows.length) return null\n return (\n <div\n data-slot=\"data-grid-empty\"\n className={cn(\"flex h-24 items-center justify-center text-sm text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nexport {\n DataGrid,\n DataGridContainer,\n DataGridHeader,\n DataGridHeaderRow,\n DataGridHead,\n DataGridHeadWithResize,\n DataGridResizeHandle,\n DataGridBody,\n DataGridRow,\n DataGridCell,\n DataGridSelectAll,\n DataGridSelectRow,\n DataGridEmpty,\n useDataGrid,\n useDataGridRow,\n type DataGridColumnConfig,\n type DataGridColumnType,\n type DataGridCellChange,\n type DataGridOption,\n}\n"
|
|
485
|
+
"content": "import * as React from \"react\"\nimport { ark } from \"@ark-ui/react\"\nimport { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from \"lucide-react\"\nimport { createListCollection } from \"@ark-ui/react/collection\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n Select,\n SelectContent,\n SelectControl,\n SelectItem,\n SelectItemIndicator,\n SelectItemText,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\"\nimport type { DataTableInstance, DataTableRow } from \"@/lib/data-table-adapter\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllable } from \"@/lib/controllable\"\nimport { LiveRegion, useLiveRegion } from \"@/components/ui/live-region\"\n\n/**\n * DataGrid — an editable, keyboard-driven grid on the same source-agnostic\n * `DataTableInstance` contract as the data table. The instance supplies rows,\n * columns, sorting, filtering and selection; the grid adds cell focus, range\n * selection, inline editing, copy/paste, row virtualization, column resizing\n * and pinning. Edits are intents: `onCellChange({ row, columnId, value })`\n * and the consumer owns the data.\n *\n * Column behaviour is data (`columns` prop, keyed by column id):\n * `{ id, type, editable, options, width, minWidth, pinned, format, parse }`.\n *\n * Anatomy:\n * DataGrid { table, columns, onCellChange }\n * DataGridContainer scroll viewport, role=\"grid\"\n * DataGridHeader > DataGridHeaderRow > DataGridHead column=… (+ DataGridResizeHandle)\n * DataGridBody children={(row) => cells} virtualized rows\n * DataGridRow > DataGridCell column=… focusable, editable\n * DataGridEmpty\n * DataGridSelectAll / DataGridSelectRow checkbox cells\n *\n * Keyboard: arrows move, Shift+arrows extend the range, Home/End and\n * Ctrl+Home/End jump, PageUp/PageDown scroll, Enter or F2 or typing edits,\n * Enter commits and moves down, Tab commits and moves right, Escape cancels,\n * Space toggles booleans, Delete clears the range, ⌘C/⌘V copy and paste TSV.\n */\n\ntype DataGridColumnType = \"text\" | \"number\" | \"boolean\" | \"select\" | \"date\"\ntype DataGridOption = { value: string; label: string }\ntype DataGridColumnConfig = {\n id: string\n type?: DataGridColumnType\n editable?: boolean\n options?: DataGridOption[]\n width?: number\n minWidth?: number\n pinned?: \"left\" | \"right\"\n /** Value → text for display and for seeding the editor. */\n format?: (value: unknown) => string\n /** Editor text → value on commit. */\n parse?: (input: string, previous: unknown) => unknown\n}\n\ntype CellAddress = { rowIndex: number; columnId: string }\ntype CellRange = { anchor: CellAddress; focus: CellAddress }\ntype DataGridCellChange<TData> = {\n row: DataTableRow<TData>\n columnId: string\n value: unknown\n previous: unknown\n}\n\ntype EditorRenderProps = {\n value: string\n onChange: (value: string) => void\n commit: (value?: string) => void\n cancel: () => void\n column: DataGridColumnConfig\n row: DataTableRow<unknown>\n inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }\n}\n\nconst DEFAULT_WIDTH = 160\nconst MIN_WIDTH = 60\n\n/* ---------------------------------------------------------------------------\n * Context\n * ------------------------------------------------------------------------- */\n\ntype ColumnLayout = {\n id: string\n width: number\n pinned?: \"left\" | \"right\"\n /** Sticky offset for pinned columns. */\n offset: number\n config: DataGridColumnConfig\n}\n\ntype DataGridContextValue<TData = unknown> = {\n table: DataTableInstance<TData>\n rows: DataTableRow<TData>[]\n layout: ColumnLayout[]\n layoutById: Map<string, ColumnLayout>\n templateColumns: string\n totalWidth: number\n /** Fixed height of every row in px. */\n rowHeight: number\n setWidth: (columnId: string, width: number) => void\n focus: CellAddress | null\n range: CellRange | null\n editing: (CellAddress & { seed?: string }) | null\n focusCell: (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => void\n startEditing: (address: CellAddress, seed?: string) => void\n stopEditing: (options?: { refocus?: boolean }) => void\n commit: (address: CellAddress, value: unknown) => void\n clearRange: () => void\n copyRange: () => Promise<void>\n paste: () => Promise<void>\n isInRange: (address: CellAddress) => boolean\n containerRef: React.RefObject<HTMLDivElement | null>\n /** Bumped whenever a cell should take DOM focus; cells act on it in a layout effect. */\n focusRequest: number\n /** Editors register so the grid can close them when the viewport scrolls. */\n registerEditor: (handle: { close: () => void }) => () => void\n closeEditor: () => void\n window: { start: number; end: number }\n setScrollTop: (top: number) => void\n setViewportHeight: (height: number) => void\n announce: (message: string) => void\n}\n\nconst DataGridContext = React.createContext<DataGridContextValue | null>(null)\nconst RowContext = React.createContext<{ row: DataTableRow<unknown>; index: number } | null>(null)\n\nfunction useDataGrid<TData = unknown>() {\n const ctx = React.useContext(DataGridContext)\n if (!ctx) throw new Error(\"DataGrid parts must be used within <DataGrid>\")\n return ctx as DataGridContextValue<TData>\n}\nfunction useDataGridRow() {\n const ctx = React.useContext(RowContext)\n if (!ctx) throw new Error(\"DataGridCell must be used within <DataGridRow>\")\n return ctx\n}\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))\nconst defaultFormat = (value: unknown) => {\n if (value === null || value === undefined) return \"\"\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? \"\" : value.toISOString().slice(0, 10)\n return String(value)\n}\n\n/* ---------------------------------------------------------------------------\n * Root\n * ------------------------------------------------------------------------- */\n\nfunction DataGrid<TData>({\n table,\n columns = [],\n rowHeight = 36,\n overscan = 6,\n onCellChange,\n columnSizing: sizingProp,\n defaultColumnSizing,\n onColumnSizingChange,\n className,\n children,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n table: DataTableInstance<TData>\n /** Per-column grid behaviour keyed by column id: editor type, width, pinning, options, format and parse. */\n columns?: DataGridColumnConfig[]\n /** Fixed height of every row in px. */\n rowHeight?: number\n /** Rows rendered beyond the visible window on each side. */\n overscan?: number\n /** Called with `{ row, columnId, value, previous }` when a cell edit commits; the consumer applies it. */\n onCellChange?: (change: DataGridCellChange<TData>) => void\n /** Controlled column widths by column id. */\n columnSizing?: Record<string, number>\n /** Initial column widths when uncontrolled. */\n defaultColumnSizing?: Record<string, number>\n /** Called when a column is resized. */\n onColumnSizingChange?: (sizing: Record<string, number>) => void\n children?: React.ReactNode\n}) {\n const containerRef = React.useRef<HTMLDivElement>(null)\n const rows = table.getRowModel().rows\n const visibleColumns = table.getAllColumns().filter((c) => c.getIsVisible())\n\n /* Column sizing (controllable) */\n const [initialSizing] = React.useState<Record<string, number>>(() => defaultColumnSizing ?? {})\n const [sizing, setSizing] = useControllable(sizingProp, initialSizing, onColumnSizingChange)\n const setWidth = React.useCallback(\n (columnId: string, width: number) => setSizing((prev) => ({ ...prev, [columnId]: width })),\n [setSizing]\n )\n\n const configById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns])\n\n /* Layout: order = visible engine columns; pinned left first, right last */\n const { layout, layoutById, templateColumns, totalWidth } = React.useMemo(() => {\n const base = visibleColumns.map((c) => {\n const config = configById.get(c.id) ?? { id: c.id }\n const width = Math.max(config.minWidth ?? MIN_WIDTH, sizing[c.id] ?? config.width ?? DEFAULT_WIDTH)\n return { id: c.id, width, pinned: config.pinned, offset: 0, config }\n })\n const left = base.filter((c) => c.pinned === \"left\")\n const middle = base.filter((c) => !c.pinned)\n const right = base.filter((c) => c.pinned === \"right\")\n let offset = 0\n for (const c of left) {\n c.offset = offset\n offset += c.width\n }\n offset = 0\n for (const c of [...right].reverse()) {\n c.offset = offset\n offset += c.width\n }\n const ordered: ColumnLayout[] = [...left, ...middle, ...right]\n return {\n layout: ordered,\n layoutById: new Map(ordered.map((c) => [c.id, c])),\n templateColumns: ordered.map((c) => `${c.width}px`).join(\" \"),\n totalWidth: ordered.reduce((sum, c) => sum + c.width, 0),\n }\n // visibleColumns is derived from table state; its identity changes with it.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [visibleColumns.map((c) => c.id).join(\"|\"), configById, sizing])\n\n /* Virtual window */\n const [scrollTop, setScrollTopState] = React.useState(0)\n const [viewportHeight, setViewportHeight] = React.useState(0)\n const setScrollTop = React.useCallback((top: number) => setScrollTopState(top), [])\n const window = React.useMemo(() => {\n const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)\n const end = Math.min(rows.length, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan)\n return { start, end }\n }, [scrollTop, viewportHeight, rowHeight, overscan, rows.length])\n\n /* Focus, range, editing */\n const [focus, setFocus] = React.useState<CellAddress | null>(null)\n const [range, setRange] = React.useState<CellRange | null>(null)\n const [editing, setEditing] = React.useState<(CellAddress & { seed?: string }) | null>(null)\n const { message: announcement, announce } = useLiveRegion()\n const [focusRequest, setFocusRequest] = React.useState(0)\n const activeEditor = React.useRef<{ close: () => void } | null>(null)\n const registerEditor = React.useCallback((handle: { close: () => void }) => {\n activeEditor.current = handle\n return () => {\n if (activeEditor.current === handle) activeEditor.current = null\n }\n }, [])\n const closeEditor = React.useCallback(() => activeEditor.current?.close(), [])\n\n const latest = React.useRef({ rows, layout, focus, range, editing, onCellChange, rowHeight })\n React.useEffect(() => {\n latest.current = { rows, layout, focus, range, editing, onCellChange, rowHeight }\n })\n\n const scrollRowIntoView = React.useCallback(\n (rowIndex: number) => {\n const el = containerRef.current\n if (!el) return\n const headerHeight = el.querySelector<HTMLElement>(\"[data-slot=data-grid-header]\")?.offsetHeight ?? 0\n const top = rowIndex * rowHeight\n const bottom = top + rowHeight\n const viewTop = el.scrollTop\n const viewBottom = el.scrollTop + el.clientHeight - headerHeight\n if (top < viewTop) el.scrollTop = top\n else if (bottom > viewBottom) el.scrollTop = bottom - (el.clientHeight - headerHeight)\n },\n [rowHeight]\n )\n\n const scrollColumnIntoView = React.useCallback((columnId: string) => {\n const el = containerRef.current\n const col = latest.current.layout.find((c) => c.id === columnId)\n if (!el || !col || col.pinned) return\n const left = latest.current.layout.slice(0, latest.current.layout.indexOf(col)).reduce((s, c) => s + c.width, 0)\n const pinnedLeft = latest.current.layout.filter((c) => c.pinned === \"left\").reduce((s, c) => s + c.width, 0)\n const pinnedRight = latest.current.layout.filter((c) => c.pinned === \"right\").reduce((s, c) => s + c.width, 0)\n if (left < el.scrollLeft + pinnedLeft) el.scrollLeft = left - pinnedLeft\n else if (left + col.width > el.scrollLeft + el.clientWidth - pinnedRight)\n el.scrollLeft = left + col.width - el.clientWidth + pinnedRight\n }, [])\n\n const focusCell = React.useCallback(\n (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => {\n const { rows, layout } = latest.current\n if (!rows.length || !layout.length) return\n const rowIndex = clamp(address.rowIndex, 0, rows.length - 1)\n const columnId = layout.some((c) => c.id === address.columnId) ? address.columnId : layout[0].id\n const next = { rowIndex, columnId }\n setFocus(next)\n setRange((prev) =>\n options?.extend && (prev?.anchor ?? latest.current.focus)\n ? { anchor: prev?.anchor ?? latest.current.focus!, focus: next }\n : null\n )\n setFocusRequest((n) => n + 1)\n if (options?.scroll !== false) {\n scrollRowIntoView(rowIndex)\n scrollColumnIntoView(columnId)\n }\n },\n [scrollRowIntoView, scrollColumnIntoView]\n )\n\n const startEditing = React.useCallback((address: CellAddress, seed?: string) => {\n const config = latest.current.layout.find((c) => c.id === address.columnId)?.config\n if (!config?.editable) return\n setEditing({ ...address, seed })\n }, [])\n\n const stopEditing = React.useCallback((options?: { refocus?: boolean }) => {\n setEditing(null)\n if (options?.refocus !== false) setFocusRequest((n) => n + 1)\n }, [])\n\n const commit = React.useCallback((address: CellAddress, value: unknown) => {\n const row = latest.current.rows[address.rowIndex]\n if (!row) return\n const previous = row.getValue(address.columnId)\n if (Object.is(previous, value)) return\n latest.current.onCellChange?.({ row, columnId: address.columnId, value, previous })\n }, [])\n\n const rangeBounds = React.useCallback(\n () => boundsOf(latest.current.range, latest.current.focus, latest.current.layout),\n []\n )\n\n const isInRange = React.useCallback(\n (address: CellAddress) => {\n if (!range) return false\n const b = boundsOf(range, focus, layout)\n const ci = layout.findIndex((c) => c.id === address.columnId)\n return !!b && address.rowIndex >= b.r0 && address.rowIndex <= b.r1 && ci >= b.c0 && ci <= b.c1\n },\n [range, focus, layout]\n )\n\n const clearRange = React.useCallback(() => {\n const b = rangeBounds()\n if (!b) return\n const { rows, layout } = latest.current\n let count = 0\n for (let r = b.r0; r <= b.r1; r++) {\n for (let c = b.c0; c <= b.c1; c++) {\n const col = layout[c]\n if (!col?.config.editable) continue\n const row = rows[r]\n const empty = col.config.type === \"boolean\" ? false : col.config.type === \"number\" ? null : \"\"\n if (row && !Object.is(row.getValue(col.id), empty)) {\n latest.current.onCellChange?.({ row, columnId: col.id, value: empty, previous: row.getValue(col.id) })\n count++\n }\n }\n }\n if (count) announce(`Cleared ${count} cell${count === 1 ? \"\" : \"s\"}.`)\n }, [rangeBounds, announce])\n\n const copyRange = React.useCallback(async () => {\n const b = rangeBounds()\n if (!b) return\n const { rows, layout } = latest.current\n const lines: string[] = []\n for (let r = b.r0; r <= b.r1; r++) {\n const cells: string[] = []\n for (let c = b.c0; c <= b.c1; c++) {\n const col = layout[c]\n const value = rows[r]?.getValue(col.id)\n cells.push((col.config.format ?? defaultFormat)(value))\n }\n lines.push(cells.join(\"\\t\"))\n }\n try {\n await navigator.clipboard.writeText(lines.join(\"\\n\"))\n const n = (b.r1 - b.r0 + 1) * (b.c1 - b.c0 + 1)\n announce(`Copied ${n} cell${n === 1 ? \"\" : \"s\"}.`)\n } catch {\n announce(\"Could not copy to the clipboard.\")\n }\n }, [rangeBounds, announce])\n\n const paste = React.useCallback(async () => {\n const { focus, rows, layout } = latest.current\n if (!focus) return\n let text = \"\"\n try {\n text = await navigator.clipboard.readText()\n } catch {\n announce(\"Could not read the clipboard.\")\n return\n }\n const lines = text\n .replace(/\\r/g, \"\")\n .split(\"\\n\")\n .filter((l, i, arr) => l !== \"\" || i < arr.length - 1)\n const startCol = layout.findIndex((c) => c.id === focus.columnId)\n let count = 0\n lines.forEach((line, dr) => {\n const row = rows[focus.rowIndex + dr]\n if (!row) return\n line.split(\"\\t\").forEach((cell, dc) => {\n const col = layout[startCol + dc]\n if (!col?.config.editable) return\n const previous = row.getValue(col.id)\n const value = parseInput(col.config, cell, previous)\n if (!Object.is(previous, value)) {\n latest.current.onCellChange?.({ row, columnId: col.id, value, previous })\n count++\n }\n })\n })\n announce(`Pasted into ${count} cell${count === 1 ? \"\" : \"s\"}.`)\n }, [announce])\n\n const ctx = React.useMemo<DataGridContextValue<TData>>(\n () => ({\n table,\n rows,\n layout,\n layoutById,\n templateColumns,\n totalWidth,\n rowHeight,\n setWidth,\n focus,\n range,\n editing,\n focusCell,\n startEditing,\n stopEditing,\n commit,\n clearRange,\n copyRange,\n paste,\n isInRange,\n containerRef,\n focusRequest,\n registerEditor,\n closeEditor,\n window,\n setScrollTop,\n setViewportHeight,\n announce,\n }),\n [\n table,\n rows,\n layout,\n layoutById,\n templateColumns,\n totalWidth,\n rowHeight,\n setWidth,\n focus,\n range,\n editing,\n focusCell,\n startEditing,\n stopEditing,\n commit,\n clearRange,\n copyRange,\n paste,\n isInRange,\n focusRequest,\n registerEditor,\n closeEditor,\n window,\n setScrollTop,\n announce,\n ]\n )\n\n return (\n <DataGridContext.Provider value={ctx as DataGridContextValue}>\n <div\n data-slot=\"data-grid\"\n data-editing={editing ? \"\" : undefined}\n className={cn(\"flex min-h-0 flex-1 flex-col\", className)}\n {...props}\n >\n {children}\n <LiveRegion data-slot=\"data-grid-live-region\" message={announcement} />\n </div>\n </DataGridContext.Provider>\n )\n}\n\nfunction boundsOf(range: CellRange | null, focus: CellAddress | null, layout: ColumnLayout[]) {\n const a = range?.anchor ?? focus\n const b = range?.focus ?? focus\n if (!a || !b) return null\n const ca = layout.findIndex((c) => c.id === a.columnId)\n const cb = layout.findIndex((c) => c.id === b.columnId)\n return {\n r0: Math.min(a.rowIndex, b.rowIndex),\n r1: Math.max(a.rowIndex, b.rowIndex),\n c0: Math.min(ca, cb),\n c1: Math.max(ca, cb),\n }\n}\n\nfunction parseInput(config: DataGridColumnConfig, input: string, previous: unknown): unknown {\n if (config.parse) return config.parse(input, previous)\n switch (config.type) {\n case \"number\": {\n if (input.trim() === \"\") return null\n const n = Number(input)\n return Number.isNaN(n) ? previous : n\n }\n case \"boolean\":\n return /^(true|yes|1|on|x)$/i.test(input.trim())\n case \"date\": {\n if (!input) return null\n const d = new Date(input)\n return Number.isNaN(d.getTime()) ? previous : previous instanceof Date ? d : input\n }\n default:\n return input\n }\n}\n\n/* ---------------------------------------------------------------------------\n * Container, header, body\n * ------------------------------------------------------------------------- */\n\nfunction DataGridContainer({ className, onScroll, ...props }: React.ComponentProps<\"div\">) {\n const { containerRef, setScrollTop, setViewportHeight, rows, closeEditor } = useDataGrid()\n React.useLayoutEffect(() => {\n const el = containerRef.current\n if (!el) return\n const update = () => setViewportHeight(el.clientHeight)\n update()\n const observer = new ResizeObserver(update)\n observer.observe(el)\n return () => observer.disconnect()\n }, [containerRef, setViewportHeight])\n return (\n <div\n ref={containerRef}\n data-slot=\"data-grid-container\"\n role=\"grid\"\n aria-rowcount={rows.length}\n className={cn(\"relative min-h-0 flex-1 overflow-auto rounded-lg border bg-background text-sm\", className)}\n onScroll={(event) => {\n onScroll?.(event)\n // An open editor (and its portaled popover) must not drift away from its cell.\n closeEditor()\n setScrollTop(event.currentTarget.scrollTop)\n }}\n {...props}\n />\n )\n}\n\nfunction DataGridHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n <div\n data-slot=\"data-grid-header\"\n role=\"rowgroup\"\n className={cn(\"sticky top-0 z-30 w-fit min-w-full bg-muted\", className)}\n {...props}\n />\n )\n}\n\nfunction DataGridHeaderRow({ className, style, ...props }: React.ComponentProps<\"div\">) {\n const { templateColumns, totalWidth } = useDataGrid()\n return (\n <div\n data-slot=\"data-grid-header-row\"\n role=\"row\"\n className={cn(\"grid border-b bg-muted\", className)}\n style={{ gridTemplateColumns: templateColumns, minWidth: totalWidth, ...style }}\n {...props}\n />\n )\n}\n\nconst pinnedStyle = (col: ColumnLayout | undefined): React.CSSProperties | undefined =>\n col?.pinned === \"left\" ? { left: col.offset } : col?.pinned === \"right\" ? { right: col.offset } : undefined\n\n/** A header cell. Sortable columns get a sort button with `children` as the title. */\nfunction DataGridHead({\n column: columnId,\n className,\n children,\n style,\n ...props\n}: React.ComponentProps<\"div\"> & { column: string }) {\n const { table, layoutById } = useDataGrid()\n const column = table.getColumn(columnId)\n const layout = layoutById.get(columnId)\n if (!column || !layout) return null\n const sorted = column.getIsSorted()\n const sortable = column.getCanSort()\n return (\n <div\n data-slot=\"data-grid-head\"\n data-column={columnId}\n data-pinned={layout.pinned}\n role=\"columnheader\"\n aria-sort={sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined}\n className={cn(\n \"group/head relative flex h-9 items-center gap-1 overflow-hidden px-2 font-medium whitespace-nowrap text-muted-foreground\",\n layout.pinned && \"sticky z-10 bg-muted\",\n layout.pinned === \"left\" && \"border-e\",\n layout.pinned === \"right\" && \"border-s\",\n className\n )}\n style={{ ...pinnedStyle(layout), ...style }}\n {...props}\n >\n {sortable ? (\n <ark.button\n type=\"button\"\n data-slot=\"data-grid-sort-trigger\"\n className=\"-ms-1 flex h-7 min-w-0 items-center gap-1 rounded-md px-1 outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n onClick={() => column.toggleSorting()}\n >\n <span className=\"truncate\">{children}</span>\n {sorted === \"desc\" ? (\n <ArrowDownIcon className=\"size-3.5 shrink-0\" />\n ) : sorted === \"asc\" ? (\n <ArrowUpIcon className=\"size-3.5 shrink-0\" />\n ) : (\n <ChevronsUpDownIcon className=\"size-3.5 shrink-0 opacity-50\" />\n )}\n </ark.button>\n ) : (\n <span className=\"truncate\">{children}</span>\n )}\n </div>\n )\n}\n\n/** Pointer-driven column resizer; double-click resets to the configured width. */\nfunction DataGridResizeHandle({ className, onPointerDown, onDoubleClick, ...props }: React.ComponentProps<\"div\">) {\n const { setWidth, layoutById } = useDataGrid()\n const head = React.useContext(HeadColumnContext)\n return (\n <div\n data-slot=\"data-grid-resize-handle\"\n role=\"separator\"\n aria-orientation=\"vertical\"\n className={cn(\n \"absolute inset-y-0 inset-e-0 z-10 w-2 cursor-col-resize touch-none select-none after:absolute after:inset-y-2 after:inset-e-0.5 after:w-px after:bg-border group-hover/head:after:bg-foreground/40 hover:after:bg-primary\",\n className\n )}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0 || !head) return\n event.preventDefault()\n event.stopPropagation()\n const start = event.clientX\n const startWidth = layoutById.get(head)?.width ?? DEFAULT_WIDTH\n const min = layoutById.get(head)?.config.minWidth ?? MIN_WIDTH\n const move = (e: PointerEvent) => setWidth(head, Math.max(min, startWidth + e.clientX - start))\n const up = () => {\n window.removeEventListener(\"pointermove\", move)\n window.removeEventListener(\"pointerup\", up)\n }\n window.addEventListener(\"pointermove\", move)\n window.addEventListener(\"pointerup\", up)\n }}\n onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented && head) {\n const config = layoutById.get(head)?.config\n setWidth(head, config?.width ?? DEFAULT_WIDTH)\n }\n }}\n {...props}\n />\n )\n}\n\nconst HeadColumnContext = React.createContext<string | null>(null)\n\n/** Wraps `DataGridHead` so `DataGridResizeHandle` knows its column. */\nfunction DataGridHeadWithResize({ column, children, ...props }: React.ComponentProps<typeof DataGridHead>) {\n return (\n <HeadColumnContext.Provider value={column}>\n <DataGridHead column={column} {...props}>\n {children}\n <DataGridResizeHandle />\n </DataGridHead>\n </HeadColumnContext.Provider>\n )\n}\n\n/** Virtualized rows. `children` renders the cells for a row. */\nfunction DataGridBody<TData>({\n className,\n children,\n style,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n children: (row: DataTableRow<TData>, index: number) => React.ReactNode\n}) {\n const { rows, window: win, rowHeight, totalWidth } = useDataGrid<TData>()\n const visible = rows.slice(win.start, win.end)\n return (\n <div\n data-slot=\"data-grid-body\"\n role=\"rowgroup\"\n className={cn(\"relative\", className)}\n style={{ height: rows.length * rowHeight, minWidth: totalWidth, ...style }}\n {...props}\n >\n <div style={{ transform: `translateY(${win.start * rowHeight}px)` }}>\n {visible.map((row, i) => (\n <DataGridRow key={row.id} row={row} index={win.start + i}>\n {children(row, win.start + i)}\n </DataGridRow>\n ))}\n </div>\n </div>\n )\n}\n\nfunction DataGridRow<TData>({\n row,\n index,\n className,\n style,\n ...props\n}: React.ComponentProps<\"div\"> & { row: DataTableRow<TData>; index: number }) {\n const { templateColumns, rowHeight, focus } = useDataGrid()\n const ctx = React.useMemo(() => ({ row: row as DataTableRow<unknown>, index }), [row, index])\n const selected = row.getIsSelected()\n return (\n <RowContext.Provider value={ctx}>\n <div\n data-slot=\"data-grid-row\"\n data-index={index}\n data-state={selected ? \"selected\" : undefined}\n data-focused={focus?.rowIndex === index ? \"\" : undefined}\n role=\"row\"\n aria-rowindex={index + 1}\n aria-selected={selected || undefined}\n className={cn(\n \"group/row grid border-b transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted/60\",\n className\n )}\n style={{ gridTemplateColumns: templateColumns, height: rowHeight, ...style }}\n {...props}\n />\n </RowContext.Provider>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Cells\n * ------------------------------------------------------------------------- */\n\nconst isPrintable = (event: React.KeyboardEvent) =>\n event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey\n\nfunction DataGridCell({\n column: columnId,\n editable: editableProp,\n editor,\n className,\n children,\n style,\n onClick,\n onDoubleClick,\n onKeyDown,\n onPointerDown,\n ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n column: string\n /** Override the column config for this cell. */\n editable?: boolean\n /** Custom editor; return `undefined` to use the default for the column type. */\n editor?: (props: EditorRenderProps) => React.ReactNode | undefined\n children?: React.ReactNode\n}) {\n const grid = useDataGrid()\n const { row, index } = useDataGridRow()\n const layout = grid.layoutById.get(columnId)\n const address = React.useMemo(() => ({ rowIndex: index, columnId }), [index, columnId])\n const ref = React.useRef<HTMLDivElement>(null)\n const focused = grid.focus?.rowIndex === index && grid.focus.columnId === columnId\n const isEditing = grid.editing?.rowIndex === index && grid.editing.columnId === columnId\n // Take DOM focus when this cell is the focused one and a focus request is pending\n // (after navigation, after an editor closes, or once a virtualized row mounts).\n React.useLayoutEffect(() => {\n const el = ref.current\n if (!focused || isEditing || !el) return\n if (document.activeElement !== el && !el.contains(document.activeElement)) el.focus({ preventScroll: true })\n }, [focused, isEditing, grid.focusRequest])\n if (!layout) return null\n const config = layout.config\n const editable = editableProp ?? !!config.editable\n const inRange = grid.isInRange(address)\n const value = row.getValue(columnId)\n const format = config.format ?? defaultFormat\n const columnIndex = grid.layout.indexOf(layout)\n\n const move = (dr: number, dc: number, extend = false) => {\n // Navigate from the focused address in state; DOM focus can lag a frame after an edit.\n const base = grid.focus ?? address\n const baseCol = Math.max(\n 0,\n grid.layout.findIndex((c) => c.id === base.columnId)\n )\n const nextCol = grid.layout[clamp(baseCol + dc, 0, grid.layout.length - 1)]\n grid.focusCell({ rowIndex: base.rowIndex + dr, columnId: nextCol.id }, { extend })\n }\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(event)\n if (event.defaultPrevented || isEditing) return\n const meta = event.metaKey || event.ctrlKey\n const pageRows = Math.max(1, Math.floor((grid.containerRef.current?.clientHeight ?? 0) / grid.rowHeight) - 1)\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault()\n move(1, 0, event.shiftKey)\n return\n case \"ArrowUp\":\n event.preventDefault()\n move(-1, 0, event.shiftKey)\n return\n case \"ArrowRight\":\n event.preventDefault()\n move(0, 1, event.shiftKey)\n return\n case \"ArrowLeft\":\n event.preventDefault()\n move(0, -1, event.shiftKey)\n return\n case \"Home\":\n event.preventDefault()\n grid.focusCell({ rowIndex: meta ? 0 : index, columnId: grid.layout[0].id }, { extend: event.shiftKey })\n return\n case \"End\":\n event.preventDefault()\n grid.focusCell(\n { rowIndex: meta ? grid.rows.length - 1 : index, columnId: grid.layout[grid.layout.length - 1].id },\n { extend: event.shiftKey }\n )\n return\n case \"PageDown\":\n event.preventDefault()\n move(pageRows, 0, event.shiftKey)\n return\n case \"PageUp\":\n event.preventDefault()\n move(-pageRows, 0, event.shiftKey)\n return\n case \"Tab\":\n event.preventDefault()\n move(0, event.shiftKey ? -1 : 1)\n return\n case \"Enter\":\n case \"F2\":\n event.preventDefault()\n if (editable) grid.startEditing(address)\n else if (event.key === \"Enter\") move(1, 0)\n return\n case \" \":\n if (editable && config.type === \"boolean\") {\n event.preventDefault()\n grid.commit(address, !value)\n }\n return\n case \"Escape\":\n grid.focusCell(address)\n return\n case \"Delete\":\n case \"Backspace\":\n event.preventDefault()\n grid.clearRange()\n return\n case \"c\":\n case \"C\":\n if (meta) {\n event.preventDefault()\n void grid.copyRange()\n }\n return\n case \"v\":\n case \"V\":\n if (meta) {\n event.preventDefault()\n void grid.paste()\n }\n return\n }\n if (editable && isPrintable(event) && config.type !== \"boolean\") {\n event.preventDefault()\n grid.startEditing(address, event.key)\n }\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"data-grid-cell\"\n data-column={columnId}\n data-row-index={index}\n data-type={config.type}\n data-pinned={layout.pinned}\n data-editable={editable ? \"\" : undefined}\n data-focused={focused ? \"\" : undefined}\n data-editing={isEditing ? \"\" : undefined}\n data-selected={inRange ? \"\" : undefined}\n role=\"gridcell\"\n aria-colindex={columnIndex + 1}\n aria-readonly={editable ? undefined : true}\n tabIndex={focused ? 0 : -1}\n className={cn(\n \"relative flex min-w-0 items-center overflow-hidden px-2 whitespace-nowrap outline-none\",\n \"data-selected:bg-primary/10\",\n \"data-focused:z-10 data-focused:ring-2 data-focused:ring-primary data-focused:ring-inset\",\n \"data-editing:bg-background data-editing:p-0\",\n layout.pinned && \"sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted\",\n layout.pinned === \"left\" && \"border-e\",\n layout.pinned === \"right\" && \"border-s\",\n config.type === \"number\" && \"justify-end tabular-nums\",\n className\n )}\n style={{ ...pinnedStyle(layout), ...style }}\n onPointerDown={(event) => {\n onPointerDown?.(event)\n if (event.defaultPrevented || event.button !== 0 || isEditing) return\n if ((event.target as HTMLElement).closest(\"button, a, input, select, textarea, [role=checkbox]\")) return\n grid.focusCell(address, { extend: event.shiftKey, scroll: false })\n }}\n onClick={onClick}\n onDoubleClick={(event) => {\n onDoubleClick?.(event)\n if (!event.defaultPrevented && editable && config.type !== \"boolean\") grid.startEditing(address)\n }}\n onKeyDown={handleKeyDown}\n {...props}\n >\n {isEditing ? (\n <CellEditor\n address={address}\n config={config}\n row={row}\n value={value}\n format={format}\n editor={editor}\n display={children}\n />\n ) : children !== undefined ? (\n children\n ) : config.type === \"boolean\" ? (\n <Checkbox\n checked={!!value}\n disabled={!editable}\n onCheckedChange={({ checked }) => grid.commit(address, checked === true)}\n aria-label={columnId}\n />\n ) : (\n <span className=\"truncate\">{format(value)}</span>\n )}\n </div>\n )\n}\n\nfunction CellEditor({\n address,\n config,\n row,\n value,\n format,\n editor,\n display,\n}: {\n address: CellAddress\n config: DataGridColumnConfig\n row: DataTableRow<unknown>\n value: unknown\n format: (value: unknown) => string\n editor?: (props: EditorRenderProps) => React.ReactNode | undefined\n display?: React.ReactNode\n}) {\n const grid = useDataGrid()\n const seed = grid.editing?.seed\n const [text, setText] = React.useState(() => seed ?? format(value))\n const inputRef = React.useRef<HTMLInputElement>(null)\n const done = React.useRef(false)\n React.useLayoutEffect(() => {\n const el = inputRef.current\n if (!el) return\n el.focus()\n if (seed === undefined && \"select\" in el && typeof el.select === \"function\" && el.type !== \"date\") el.select()\n }, [seed])\n\n const commit = (next?: string, then?: () => void) => {\n if (done.current) return\n done.current = true\n grid.commit(address, parseInput(config, next ?? text, value))\n grid.stopEditing({ refocus: !then })\n then?.()\n }\n const cancel = () => {\n if (done.current) return\n done.current = true\n grid.stopEditing()\n }\n const isSelect = config.type === \"select\" && !!config.options\n const commitRef = React.useRef(commit)\n const cancelRef = React.useRef(cancel)\n React.useEffect(() => {\n commitRef.current = commit\n cancelRef.current = cancel\n })\n const { registerEditor } = grid\n // Scrolling the grid closes the editor (text commits, an unpicked select cancels) before its\n // row can leave the virtual window, so the editor never unmounts mid-edit.\n React.useEffect(\n () => registerEditor({ close: () => (isSelect ? cancelRef.current() : commitRef.current()) }),\n [registerEditor, isSelect]\n )\n const moveAfter = (dr: number, dc: number) => () => {\n const col = grid.layout.findIndex((c) => c.id === address.columnId)\n const next = grid.layout[clamp(col + dc, 0, grid.layout.length - 1)]\n grid.focusCell({ rowIndex: address.rowIndex + dr, columnId: next.id })\n }\n const onKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\") {\n event.preventDefault()\n commit(undefined, moveAfter(1, 0))\n } else if (event.key === \"Tab\") {\n event.preventDefault()\n commit(undefined, moveAfter(0, event.shiftKey ? -1 : 1))\n } else if (event.key === \"Escape\") {\n event.preventDefault()\n cancel()\n }\n event.stopPropagation()\n }\n const inputProps: EditorRenderProps[\"inputProps\"] = {\n ref: inputRef,\n value: text,\n onChange: (e) => setText(e.target.value),\n onKeyDown,\n onBlur: () => commit(),\n className: \"size-full bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground\",\n \"aria-label\": \"Edit value\",\n }\n const custom = editor?.({ value: text, onChange: setText, commit, cancel, column: config, row, inputProps })\n if (custom !== undefined) return <>{custom}</>\n\n if (config.type === \"select\" && config.options) {\n return (\n <SelectEditor\n options={config.options}\n value={text}\n display={display}\n onCommit={(v) => commit(v, moveAfter(1, 0))}\n onCancel={cancel}\n />\n )\n }\n const type = config.type === \"number\" ? \"number\" : config.type === \"date\" ? \"date\" : \"text\"\n return <input data-slot=\"data-grid-editor\" type={type} {...inputProps} />\n}\n\n/** Opens the toolkit Select immediately; picking commits, closing without a pick cancels. */\nfunction SelectEditor({\n options,\n value,\n display,\n onCommit,\n onCancel,\n}: {\n options: DataGridOption[]\n value: string\n /** What the cell normally shows, so the open editor looks like the cell. */\n display?: React.ReactNode\n onCommit: (value: string) => void\n onCancel: () => void\n}) {\n const collection = React.useMemo(\n () => createListCollection({ items: options, itemToValue: (o) => o.value, itemToString: (o) => o.label }),\n [options]\n )\n const picked = React.useRef(false)\n const contentId = React.useId()\n // Ark handles list navigation on the open content, so focus it (it is portaled) once mounted.\n React.useLayoutEffect(() => {\n const focusContent = () => document.getElementById(contentId)?.focus({ preventScroll: true })\n focusContent()\n const timer = setTimeout(focusContent, 0)\n return () => clearTimeout(timer)\n }, [contentId])\n const label = options.find((o) => o.value === value)?.label ?? value\n return (\n <Select\n collection={collection}\n value={value ? [value] : []}\n defaultOpen\n ids={{ content: contentId }}\n positioning={{ sameWidth: true, gutter: 2 }}\n onValueChange={({ value: next }) => {\n if (next[0] === undefined) return\n picked.current = true\n onCommit(next[0])\n }}\n onOpenChange={({ open }) => {\n if (!open && !picked.current) onCancel()\n }}\n onPointerDown={(event) => event.stopPropagation()}\n className=\"size-full\"\n >\n <SelectControl className=\"size-full\">\n <SelectTrigger\n variant=\"unstyled\"\n data-slot=\"data-grid-select-editor\"\n className=\"size-full px-2 [&_svg]:text-muted-foreground\"\n aria-label=\"Edit value\"\n >\n <SelectValue placeholder=\"Choose…\">{display ?? label}</SelectValue>\n </SelectTrigger>\n </SelectControl>\n <SelectContent>\n {options.map((o) => (\n <SelectItem key={o.value} item={o}>\n <SelectItemText>{o.label}</SelectItemText>\n <SelectItemIndicator />\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )\n}\n\n/* ---------------------------------------------------------------------------\n * Selection cells, empty\n * ------------------------------------------------------------------------- */\n\nfunction DataGridSelectAll({ className, ...props }: React.ComponentProps<\"div\">) {\n const { table, layoutById } = useDataGrid()\n const layout = layoutById.get(\"select\")\n const t = table as DataTableInstance<unknown> & {\n getIsAllPageRowsSelected?: () => boolean\n getIsSomePageRowsSelected?: () => boolean\n toggleAllPageRowsSelected?: (v?: boolean) => void\n }\n const all = t.getIsAllPageRowsSelected?.() ?? false\n const some = t.getIsSomePageRowsSelected?.() ?? false\n return (\n <div\n data-slot=\"data-grid-select-all\"\n role=\"columnheader\"\n className={cn(\n \"flex items-center justify-center\",\n layout?.pinned && \"sticky z-10 bg-muted\",\n layout?.pinned === \"left\" && \"border-e\",\n className\n )}\n style={pinnedStyle(layout)}\n {...props}\n >\n <Checkbox\n checked={all ? true : some ? \"indeterminate\" : false}\n onCheckedChange={({ checked }) => t.toggleAllPageRowsSelected?.(checked === true)}\n aria-label=\"Select all\"\n />\n </div>\n )\n}\n\nfunction DataGridSelectRow({ className, ...props }: React.ComponentProps<\"div\">) {\n const { layoutById } = useDataGrid()\n const { row } = useDataGridRow()\n const layout = layoutById.get(\"select\")\n return (\n <div\n data-slot=\"data-grid-select-row\"\n role=\"gridcell\"\n className={cn(\n \"flex items-center justify-center\",\n layout?.pinned && \"sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted\",\n layout?.pinned === \"left\" && \"border-e\",\n className\n )}\n style={pinnedStyle(layout)}\n {...props}\n >\n <Checkbox\n checked={row.getIsSelected()}\n onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}\n aria-label=\"Select row\"\n />\n </div>\n )\n}\n\nfunction DataGridEmpty({ className, ...props }: React.ComponentProps<\"div\">) {\n const { rows } = useDataGrid()\n if (rows.length) return null\n return (\n <div\n data-slot=\"data-grid-empty\"\n className={cn(\"flex h-24 items-center justify-center text-sm text-muted-foreground\", className)}\n {...props}\n />\n )\n}\n\nexport {\n DataGrid,\n DataGridContainer,\n DataGridHeader,\n DataGridHeaderRow,\n DataGridHead,\n DataGridHeadWithResize,\n DataGridResizeHandle,\n DataGridBody,\n DataGridRow,\n DataGridCell,\n DataGridSelectAll,\n DataGridSelectRow,\n DataGridEmpty,\n useDataGrid,\n useDataGridRow,\n type DataGridColumnConfig,\n type DataGridColumnType,\n type DataGridCellChange,\n type DataGridOption,\n}\n"
|
|
38
486
|
}
|
|
39
487
|
],
|
|
40
488
|
"docs": "## Data grid\n`src/components/ui/data-grid.tsx` is an editable, keyboard-driven grid on the same source-agnostic\n`DataTableInstance` contract as the data table (`useDataTable` with a huge `pageSize` feeds it every\nrow; `DataTable` toolbar parts can wrap it). Column behaviour is a separate data-only `columns` prop\nkeyed by column id: `{ id, type: text|number|boolean|select|date, editable, options, width, minWidth,\npinned: left|right, format, parse }`. Edits are intents: `onCellChange({ row, columnId, value,\nprevious })`; the consumer owns the data. Parts (div-based, `role=\"grid\"`): `DataGrid` (context:\nlayout, focus, range, editing, virtual window; `columnSizing` controllable) > `DataGridContainer`\n(scroll viewport) > `DataGridHeader` > `DataGridHeaderRow` > `DataGridHead column=` (sort button\nwhen sortable) or `DataGridHeadWithResize` (adds `DataGridResizeHandle`; double-click resets),\n`DataGridBody` (`children: (row, index) => cells`, renders only the visible window plus\n`overscan`, fixed `rowHeight`) > `DataGridRow` > `DataGridCell column=` (roving tabindex,\n`data-focused/selected/editing/pinned/editable`; `editable` and `editor` render-prop overrides;\ndefault editors by type, native `<select>` for select columns), `DataGridSelectAll` /\n`DataGridSelectRow` (Checkbox cells for the `select` column), `DataGridEmpty`. Editors never use\nnative form controls: select columns open the toolkit `Select` (with `SelectTrigger\nvariant=\"unstyled\"`, focus moved to the listbox content via the root `ids`), booleans render a\n`Checkbox`, text/number/date use a borderless input; the cell owns the single focus ring. Pinned columns\nare sticky with computed offsets. Keyboard: arrows move, Shift+arrows extend the range,\nHome/End and Ctrl+Home/End jump, PageUp/Down, Enter/F2/typing edits, Enter commits + moves down,\nTab commits + moves right, Escape cancels, Space toggles booleans, Delete clears the range, ⌘C/⌘V\ncopy/paste TSV; a live region announces clears/copies. `src/demo/data-grid/` is the reference\n(`/contacts`, \"Contacts\"): 500 users, pinned select + username, per-type editors, and tracked\nchanges with Undo / Discard / Save in a `FloatingToolbar`."
|