@multicomma/arkcn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +78 -0
  2. package/bin/arkcn.mjs +78 -0
  3. package/cli/commands.mjs +251 -0
  4. package/cli/project.mjs +168 -0
  5. package/cli/registry.mjs +50 -0
  6. package/cli/ui.mjs +46 -0
  7. package/mcp/server.mjs +149 -0
  8. package/package.json +201 -0
  9. package/registry/base.css +680 -0
  10. package/registry/index.json +1833 -0
  11. package/registry/items/accordion.json +18 -0
  12. package/registry/items/activity-feed.json +18 -0
  13. package/registry/items/alert-dialog.json +18 -0
  14. package/registry/items/alert.json +17 -0
  15. package/registry/items/angle-slider.json +17 -0
  16. package/registry/items/app-shell.json +28 -0
  17. package/registry/items/aspect-ratio.json +13 -0
  18. package/registry/items/attachment.json +19 -0
  19. package/registry/items/avatar.json +17 -0
  20. package/registry/items/badge.json +18 -0
  21. package/registry/items/breadcrumb.json +18 -0
  22. package/registry/items/bubble.json +18 -0
  23. package/registry/items/button-group.json +19 -0
  24. package/registry/items/button.json +18 -0
  25. package/registry/items/calendar.json +19 -0
  26. package/registry/items/canvas.json +24 -0
  27. package/registry/items/card.json +15 -0
  28. package/registry/items/carousel.json +19 -0
  29. package/registry/items/cascader.json +23 -0
  30. package/registry/items/chart.json +17 -0
  31. package/registry/items/chat.json +23 -0
  32. package/registry/items/checkbox.json +18 -0
  33. package/registry/items/clipboard.json +19 -0
  34. package/registry/items/collapsible.json +15 -0
  35. package/registry/items/color-picker.json +19 -0
  36. package/registry/items/combobox.json +20 -0
  37. package/registry/items/command.json +21 -0
  38. package/registry/items/comment-thread.json +25 -0
  39. package/registry/items/context-menu.json +18 -0
  40. package/registry/items/controllable.json +14 -0
  41. package/registry/items/data-grid.json +24 -0
  42. package/registry/items/data-table-adapter.json +14 -0
  43. package/registry/items/data-table.json +32 -0
  44. package/registry/items/date-input.json +17 -0
  45. package/registry/items/dialog.json +19 -0
  46. package/registry/items/direction.json +13 -0
  47. package/registry/items/drag-preview.json +16 -0
  48. package/registry/items/drawer.json +18 -0
  49. package/registry/items/dropdown-menu.json +18 -0
  50. package/registry/items/editable.json +17 -0
  51. package/registry/items/empty.json +17 -0
  52. package/registry/items/field.json +19 -0
  53. package/registry/items/file-upload.json +19 -0
  54. package/registry/items/floating-panel.json +19 -0
  55. package/registry/items/floating-toolbar.json +16 -0
  56. package/registry/items/form.json +14 -0
  57. package/registry/items/format.json +15 -0
  58. package/registry/items/gantt.json +22 -0
  59. package/registry/items/history.json +14 -0
  60. package/registry/items/hotkeys.json +18 -0
  61. package/registry/items/hover-card.json +17 -0
  62. package/registry/items/image-cropper.json +17 -0
  63. package/registry/items/input-group.json +20 -0
  64. package/registry/items/input-otp.json +18 -0
  65. package/registry/items/input.json +15 -0
  66. package/registry/items/item.json +19 -0
  67. package/registry/items/json-tree-view.json +17 -0
  68. package/registry/items/kanban.json +26 -0
  69. package/registry/items/kbd.json +15 -0
  70. package/registry/items/label.json +15 -0
  71. package/registry/items/live-region.json +16 -0
  72. package/registry/items/marker.json +18 -0
  73. package/registry/items/marquee.json +17 -0
  74. package/registry/items/menubar.json +19 -0
  75. package/registry/items/message.json +15 -0
  76. package/registry/items/navigation-menu.json +19 -0
  77. package/registry/items/node-graph.json +22 -0
  78. package/registry/items/number-input.json +18 -0
  79. package/registry/items/pagination.json +18 -0
  80. package/registry/items/password-input.json +18 -0
  81. package/registry/items/popconfirm.json +20 -0
  82. package/registry/items/popover.json +18 -0
  83. package/registry/items/progress.json +17 -0
  84. package/registry/items/qr-code.json +17 -0
  85. package/registry/items/query-builder.json +23 -0
  86. package/registry/items/radio-group.json +17 -0
  87. package/registry/items/rating-group.json +18 -0
  88. package/registry/items/resizable.json +17 -0
  89. package/registry/items/rich-text-editor.json +37 -0
  90. package/registry/items/row-layout.json +15 -0
  91. package/registry/items/scheduler.json +23 -0
  92. package/registry/items/scroll-area.json +17 -0
  93. package/registry/items/segment-group.json +17 -0
  94. package/registry/items/select.json +19 -0
  95. package/registry/items/separator.json +15 -0
  96. package/registry/items/sheet.json +19 -0
  97. package/registry/items/sidebar.json +27 -0
  98. package/registry/items/signature-pad.json +19 -0
  99. package/registry/items/skeleton.json +15 -0
  100. package/registry/items/slider.json +17 -0
  101. package/registry/items/sonner.json +17 -0
  102. package/registry/items/spinner.json +17 -0
  103. package/registry/items/steps.json +19 -0
  104. package/registry/items/swap.json +17 -0
  105. package/registry/items/switch.json +17 -0
  106. package/registry/items/table.json +16 -0
  107. package/registry/items/tabs.json +18 -0
  108. package/registry/items/tags-input.json +18 -0
  109. package/registry/items/textarea.json +15 -0
  110. package/registry/items/time.json +14 -0
  111. package/registry/items/timer.json +18 -0
  112. package/registry/items/toc.json +17 -0
  113. package/registry/items/toggle-group.json +19 -0
  114. package/registry/items/toggle.json +18 -0
  115. package/registry/items/tooltip.json +17 -0
  116. package/registry/items/tour.json +19 -0
  117. package/registry/items/transfer-list.json +24 -0
  118. package/registry/items/tree-select.json +24 -0
  119. package/registry/items/tree-view.json +18 -0
  120. package/registry/items/use-mobile.json +13 -0
  121. package/registry/items/utils.json +17 -0
  122. package/registry/items/virtual-list.json +16 -0
  123. package/src/components/ui/accordion.tsx +111 -0
  124. package/src/components/ui/activity-feed.tsx +228 -0
  125. package/src/components/ui/alert-dialog.tsx +204 -0
  126. package/src/components/ui/alert.tsx +55 -0
  127. package/src/components/ui/angle-slider.tsx +117 -0
  128. package/src/components/ui/app-shell.tsx +985 -0
  129. package/src/components/ui/aspect-ratio.tsx +35 -0
  130. package/src/components/ui/attachment.tsx +193 -0
  131. package/src/components/ui/avatar.tsx +94 -0
  132. package/src/components/ui/badge.tsx +45 -0
  133. package/src/components/ui/breadcrumb.tsx +93 -0
  134. package/src/components/ui/bubble.tsx +119 -0
  135. package/src/components/ui/button-group.tsx +79 -0
  136. package/src/components/ui/button.tsx +65 -0
  137. package/src/components/ui/calendar.tsx +172 -0
  138. package/src/components/ui/canvas.tsx +723 -0
  139. package/src/components/ui/card.tsx +69 -0
  140. package/src/components/ui/carousel.tsx +228 -0
  141. package/src/components/ui/cascader.tsx +754 -0
  142. package/src/components/ui/chart.tsx +303 -0
  143. package/src/components/ui/chat.tsx +544 -0
  144. package/src/components/ui/checkbox.tsx +94 -0
  145. package/src/components/ui/clipboard.tsx +94 -0
  146. package/src/components/ui/collapsible.tsx +32 -0
  147. package/src/components/ui/color-picker.tsx +441 -0
  148. package/src/components/ui/combobox.tsx +400 -0
  149. package/src/components/ui/command.tsx +292 -0
  150. package/src/components/ui/comment-thread.tsx +923 -0
  151. package/src/components/ui/context-menu.tsx +331 -0
  152. package/src/components/ui/data-grid.tsx +1227 -0
  153. package/src/components/ui/data-table.tsx +993 -0
  154. package/src/components/ui/date-input.tsx +103 -0
  155. package/src/components/ui/dialog.tsx +159 -0
  156. package/src/components/ui/direction.tsx +32 -0
  157. package/src/components/ui/drawer.tsx +200 -0
  158. package/src/components/ui/dropdown-menu.tsx +329 -0
  159. package/src/components/ui/editable.tsx +90 -0
  160. package/src/components/ui/empty.tsx +86 -0
  161. package/src/components/ui/field.tsx +218 -0
  162. package/src/components/ui/file-upload.tsx +204 -0
  163. package/src/components/ui/floating-panel.tsx +199 -0
  164. package/src/components/ui/floating-toolbar.tsx +79 -0
  165. package/src/components/ui/format.tsx +22 -0
  166. package/src/components/ui/gantt.tsx +950 -0
  167. package/src/components/ui/hotkeys.tsx +340 -0
  168. package/src/components/ui/hover-card.tsx +83 -0
  169. package/src/components/ui/image-cropper.tsx +100 -0
  170. package/src/components/ui/input-group.tsx +133 -0
  171. package/src/components/ui/input-otp.tsx +82 -0
  172. package/src/components/ui/input.tsx +18 -0
  173. package/src/components/ui/item.tsx +174 -0
  174. package/src/components/ui/json-tree-view.tsx +37 -0
  175. package/src/components/ui/kanban.tsx +884 -0
  176. package/src/components/ui/kbd.tsx +20 -0
  177. package/src/components/ui/label.tsx +30 -0
  178. package/src/components/ui/live-region.tsx +54 -0
  179. package/src/components/ui/marker.tsx +66 -0
  180. package/src/components/ui/marquee.tsx +61 -0
  181. package/src/components/ui/menubar.tsx +456 -0
  182. package/src/components/ui/message.tsx +74 -0
  183. package/src/components/ui/navigation-menu.tsx +202 -0
  184. package/src/components/ui/node-graph.tsx +1723 -0
  185. package/src/components/ui/number-input.tsx +148 -0
  186. package/src/components/ui/pagination.tsx +88 -0
  187. package/src/components/ui/password-input.tsx +104 -0
  188. package/src/components/ui/popconfirm.tsx +149 -0
  189. package/src/components/ui/popover.tsx +126 -0
  190. package/src/components/ui/progress.tsx +114 -0
  191. package/src/components/ui/qr-code.tsx +51 -0
  192. package/src/components/ui/query-builder.tsx +938 -0
  193. package/src/components/ui/radio-group.tsx +108 -0
  194. package/src/components/ui/rating-group.tsx +91 -0
  195. package/src/components/ui/resizable.tsx +41 -0
  196. package/src/components/ui/rich-text-editor.tsx +845 -0
  197. package/src/components/ui/scheduler.tsx +1164 -0
  198. package/src/components/ui/scroll-area.tsx +82 -0
  199. package/src/components/ui/segment-group.tsx +95 -0
  200. package/src/components/ui/select.tsx +354 -0
  201. package/src/components/ui/separator.tsx +32 -0
  202. package/src/components/ui/sheet.tsx +130 -0
  203. package/src/components/ui/sidebar.tsx +665 -0
  204. package/src/components/ui/signature-pad.tsx +112 -0
  205. package/src/components/ui/skeleton.tsx +7 -0
  206. package/src/components/ui/slider.tsx +183 -0
  207. package/src/components/ui/sonner.tsx +37 -0
  208. package/src/components/ui/spinner.tsx +16 -0
  209. package/src/components/ui/steps.tsx +152 -0
  210. package/src/components/ui/swap.tsx +33 -0
  211. package/src/components/ui/switch.tsx +83 -0
  212. package/src/components/ui/table.tsx +72 -0
  213. package/src/components/ui/tabs.tsx +100 -0
  214. package/src/components/ui/tags-input.tsx +153 -0
  215. package/src/components/ui/textarea.tsx +17 -0
  216. package/src/components/ui/timer.tsx +67 -0
  217. package/src/components/ui/toc.tsx +64 -0
  218. package/src/components/ui/toggle-group.tsx +89 -0
  219. package/src/components/ui/toggle.tsx +46 -0
  220. package/src/components/ui/tooltip.tsx +113 -0
  221. package/src/components/ui/tour.tsx +168 -0
  222. package/src/components/ui/transfer-list.tsx +700 -0
  223. package/src/components/ui/tree-select.tsx +638 -0
  224. package/src/components/ui/tree-view.tsx +217 -0
  225. package/src/components/ui/virtual-list.tsx +343 -0
  226. package/src/hooks/use-mobile.ts +19 -0
  227. package/src/lib/controllable.ts +31 -0
  228. package/src/lib/data-table-adapter.ts +67 -0
  229. package/src/lib/drag-preview.ts +39 -0
  230. package/src/lib/form.ts +39 -0
  231. package/src/lib/history.ts +55 -0
  232. package/src/lib/row-layout.ts +81 -0
  233. package/src/lib/table.ts +483 -0
  234. package/src/lib/time.ts +37 -0
  235. package/src/lib/utils.ts +6 -0
  236. package/src/styles/arkcn.css +764 -0
  237. package/src/styles/index.css +3 -0
@@ -0,0 +1,1227 @@
1
+ import * as React from "react"
2
+ import { ark } from "@ark-ui/react"
3
+ import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from "lucide-react"
4
+ import { createListCollection } from "@ark-ui/react/collection"
5
+ import { Checkbox } from "@/components/ui/checkbox"
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectControl,
10
+ SelectItem,
11
+ SelectItemIndicator,
12
+ SelectItemText,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from "@/components/ui/select"
16
+ import type { DataTableInstance, DataTableRow } from "@/lib/data-table-adapter"
17
+ import { cn } from "@/lib/utils"
18
+ import { useControllable } from "@/lib/controllable"
19
+ import { LiveRegion, useLiveRegion } from "@/components/ui/live-region"
20
+
21
+ /**
22
+ * DataGrid — an editable, keyboard-driven grid on the same source-agnostic
23
+ * `DataTableInstance` contract as the data table. The instance supplies rows,
24
+ * columns, sorting, filtering and selection; the grid adds cell focus, range
25
+ * selection, inline editing, copy/paste, row virtualization, column resizing
26
+ * and pinning. Edits are intents: `onCellChange({ row, columnId, value })`
27
+ * and the consumer owns the data.
28
+ *
29
+ * Column behaviour is data (`columns` prop, keyed by column id):
30
+ * `{ id, type, editable, options, width, minWidth, pinned, format, parse }`.
31
+ *
32
+ * Anatomy:
33
+ * DataGrid { table, columns, onCellChange }
34
+ * DataGridContainer scroll viewport, role="grid"
35
+ * DataGridHeader > DataGridHeaderRow > DataGridHead column=… (+ DataGridResizeHandle)
36
+ * DataGridBody children={(row) => cells} virtualized rows
37
+ * DataGridRow > DataGridCell column=… focusable, editable
38
+ * DataGridEmpty
39
+ * DataGridSelectAll / DataGridSelectRow checkbox cells
40
+ *
41
+ * Keyboard: arrows move, Shift+arrows extend the range, Home/End and
42
+ * Ctrl+Home/End jump, PageUp/PageDown scroll, Enter or F2 or typing edits,
43
+ * Enter commits and moves down, Tab commits and moves right, Escape cancels,
44
+ * Space toggles booleans, Delete clears the range, ⌘C/⌘V copy and paste TSV.
45
+ */
46
+
47
+ type DataGridColumnType = "text" | "number" | "boolean" | "select" | "date"
48
+ type DataGridOption = { value: string; label: string }
49
+ type DataGridColumnConfig = {
50
+ id: string
51
+ type?: DataGridColumnType
52
+ editable?: boolean
53
+ options?: DataGridOption[]
54
+ width?: number
55
+ minWidth?: number
56
+ pinned?: "left" | "right"
57
+ /** Value → text for display and for seeding the editor. */
58
+ format?: (value: unknown) => string
59
+ /** Editor text → value on commit. */
60
+ parse?: (input: string, previous: unknown) => unknown
61
+ }
62
+
63
+ type CellAddress = { rowIndex: number; columnId: string }
64
+ type CellRange = { anchor: CellAddress; focus: CellAddress }
65
+ type DataGridCellChange<TData> = {
66
+ row: DataTableRow<TData>
67
+ columnId: string
68
+ value: unknown
69
+ previous: unknown
70
+ }
71
+
72
+ type EditorRenderProps = {
73
+ value: string
74
+ onChange: (value: string) => void
75
+ commit: (value?: string) => void
76
+ cancel: () => void
77
+ column: DataGridColumnConfig
78
+ row: DataTableRow<unknown>
79
+ inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> }
80
+ }
81
+
82
+ const DEFAULT_WIDTH = 160
83
+ const MIN_WIDTH = 60
84
+
85
+ /* ---------------------------------------------------------------------------
86
+ * Context
87
+ * ------------------------------------------------------------------------- */
88
+
89
+ type ColumnLayout = {
90
+ id: string
91
+ width: number
92
+ pinned?: "left" | "right"
93
+ /** Sticky offset for pinned columns. */
94
+ offset: number
95
+ config: DataGridColumnConfig
96
+ }
97
+
98
+ type DataGridContextValue<TData = unknown> = {
99
+ table: DataTableInstance<TData>
100
+ rows: DataTableRow<TData>[]
101
+ layout: ColumnLayout[]
102
+ layoutById: Map<string, ColumnLayout>
103
+ templateColumns: string
104
+ totalWidth: number
105
+ rowHeight: number
106
+ setWidth: (columnId: string, width: number) => void
107
+ focus: CellAddress | null
108
+ range: CellRange | null
109
+ editing: (CellAddress & { seed?: string }) | null
110
+ focusCell: (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => void
111
+ startEditing: (address: CellAddress, seed?: string) => void
112
+ stopEditing: (options?: { refocus?: boolean }) => void
113
+ commit: (address: CellAddress, value: unknown) => void
114
+ clearRange: () => void
115
+ copyRange: () => Promise<void>
116
+ paste: () => Promise<void>
117
+ isInRange: (address: CellAddress) => boolean
118
+ containerRef: React.RefObject<HTMLDivElement | null>
119
+ /** Bumped whenever a cell should take DOM focus; cells act on it in a layout effect. */
120
+ focusRequest: number
121
+ /** Editors register so the grid can close them when the viewport scrolls. */
122
+ registerEditor: (handle: { close: () => void }) => () => void
123
+ closeEditor: () => void
124
+ window: { start: number; end: number }
125
+ setScrollTop: (top: number) => void
126
+ setViewportHeight: (height: number) => void
127
+ announce: (message: string) => void
128
+ }
129
+
130
+ const DataGridContext = React.createContext<DataGridContextValue | null>(null)
131
+ const RowContext = React.createContext<{ row: DataTableRow<unknown>; index: number } | null>(null)
132
+
133
+ function useDataGrid<TData = unknown>() {
134
+ const ctx = React.useContext(DataGridContext)
135
+ if (!ctx) throw new Error("DataGrid parts must be used within <DataGrid>")
136
+ return ctx as DataGridContextValue<TData>
137
+ }
138
+ function useDataGridRow() {
139
+ const ctx = React.useContext(RowContext)
140
+ if (!ctx) throw new Error("DataGridCell must be used within <DataGridRow>")
141
+ return ctx
142
+ }
143
+
144
+ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
145
+ const defaultFormat = (value: unknown) => {
146
+ if (value === null || value === undefined) return ""
147
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? "" : value.toISOString().slice(0, 10)
148
+ return String(value)
149
+ }
150
+
151
+ /* ---------------------------------------------------------------------------
152
+ * Root
153
+ * ------------------------------------------------------------------------- */
154
+
155
+ function DataGrid<TData>({
156
+ table,
157
+ columns = [],
158
+ rowHeight = 36,
159
+ overscan = 6,
160
+ onCellChange,
161
+ columnSizing: sizingProp,
162
+ defaultColumnSizing,
163
+ onColumnSizingChange,
164
+ className,
165
+ children,
166
+ ...props
167
+ }: Omit<React.ComponentProps<"div">, "children"> & {
168
+ table: DataTableInstance<TData>
169
+ columns?: DataGridColumnConfig[]
170
+ rowHeight?: number
171
+ overscan?: number
172
+ onCellChange?: (change: DataGridCellChange<TData>) => void
173
+ columnSizing?: Record<string, number>
174
+ defaultColumnSizing?: Record<string, number>
175
+ onColumnSizingChange?: (sizing: Record<string, number>) => void
176
+ children?: React.ReactNode
177
+ }) {
178
+ const containerRef = React.useRef<HTMLDivElement>(null)
179
+ const rows = table.getRowModel().rows
180
+ const visibleColumns = table.getAllColumns().filter((c) => c.getIsVisible())
181
+
182
+ /* Column sizing (controllable) */
183
+ const [initialSizing] = React.useState<Record<string, number>>(() => defaultColumnSizing ?? {})
184
+ const [sizing, setSizing] = useControllable(sizingProp, initialSizing, onColumnSizingChange)
185
+ const setWidth = React.useCallback(
186
+ (columnId: string, width: number) => setSizing((prev) => ({ ...prev, [columnId]: width })),
187
+ [setSizing]
188
+ )
189
+
190
+ const configById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns])
191
+
192
+ /* Layout: order = visible engine columns; pinned left first, right last */
193
+ const { layout, layoutById, templateColumns, totalWidth } = React.useMemo(() => {
194
+ const base = visibleColumns.map((c) => {
195
+ const config = configById.get(c.id) ?? { id: c.id }
196
+ const width = Math.max(config.minWidth ?? MIN_WIDTH, sizing[c.id] ?? config.width ?? DEFAULT_WIDTH)
197
+ return { id: c.id, width, pinned: config.pinned, offset: 0, config }
198
+ })
199
+ const left = base.filter((c) => c.pinned === "left")
200
+ const middle = base.filter((c) => !c.pinned)
201
+ const right = base.filter((c) => c.pinned === "right")
202
+ let offset = 0
203
+ for (const c of left) {
204
+ c.offset = offset
205
+ offset += c.width
206
+ }
207
+ offset = 0
208
+ for (const c of [...right].reverse()) {
209
+ c.offset = offset
210
+ offset += c.width
211
+ }
212
+ const ordered: ColumnLayout[] = [...left, ...middle, ...right]
213
+ return {
214
+ layout: ordered,
215
+ layoutById: new Map(ordered.map((c) => [c.id, c])),
216
+ templateColumns: ordered.map((c) => `${c.width}px`).join(" "),
217
+ totalWidth: ordered.reduce((sum, c) => sum + c.width, 0),
218
+ }
219
+ // visibleColumns is derived from table state; its identity changes with it.
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [visibleColumns.map((c) => c.id).join("|"), configById, sizing])
222
+
223
+ /* Virtual window */
224
+ const [scrollTop, setScrollTopState] = React.useState(0)
225
+ const [viewportHeight, setViewportHeight] = React.useState(0)
226
+ const setScrollTop = React.useCallback((top: number) => setScrollTopState(top), [])
227
+ const window = React.useMemo(() => {
228
+ const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
229
+ const end = Math.min(rows.length, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan)
230
+ return { start, end }
231
+ }, [scrollTop, viewportHeight, rowHeight, overscan, rows.length])
232
+
233
+ /* Focus, range, editing */
234
+ const [focus, setFocus] = React.useState<CellAddress | null>(null)
235
+ const [range, setRange] = React.useState<CellRange | null>(null)
236
+ const [editing, setEditing] = React.useState<(CellAddress & { seed?: string }) | null>(null)
237
+ const { message: announcement, announce } = useLiveRegion()
238
+ const [focusRequest, setFocusRequest] = React.useState(0)
239
+ const activeEditor = React.useRef<{ close: () => void } | null>(null)
240
+ const registerEditor = React.useCallback((handle: { close: () => void }) => {
241
+ activeEditor.current = handle
242
+ return () => {
243
+ if (activeEditor.current === handle) activeEditor.current = null
244
+ }
245
+ }, [])
246
+ const closeEditor = React.useCallback(() => activeEditor.current?.close(), [])
247
+
248
+ const latest = React.useRef({ rows, layout, focus, range, editing, onCellChange, rowHeight })
249
+ React.useEffect(() => {
250
+ latest.current = { rows, layout, focus, range, editing, onCellChange, rowHeight }
251
+ })
252
+
253
+ const scrollRowIntoView = React.useCallback(
254
+ (rowIndex: number) => {
255
+ const el = containerRef.current
256
+ if (!el) return
257
+ const headerHeight = el.querySelector<HTMLElement>("[data-slot=data-grid-header]")?.offsetHeight ?? 0
258
+ const top = rowIndex * rowHeight
259
+ const bottom = top + rowHeight
260
+ const viewTop = el.scrollTop
261
+ const viewBottom = el.scrollTop + el.clientHeight - headerHeight
262
+ if (top < viewTop) el.scrollTop = top
263
+ else if (bottom > viewBottom) el.scrollTop = bottom - (el.clientHeight - headerHeight)
264
+ },
265
+ [rowHeight]
266
+ )
267
+
268
+ const scrollColumnIntoView = React.useCallback((columnId: string) => {
269
+ const el = containerRef.current
270
+ const col = latest.current.layout.find((c) => c.id === columnId)
271
+ if (!el || !col || col.pinned) return
272
+ const left = latest.current.layout.slice(0, latest.current.layout.indexOf(col)).reduce((s, c) => s + c.width, 0)
273
+ const pinnedLeft = latest.current.layout.filter((c) => c.pinned === "left").reduce((s, c) => s + c.width, 0)
274
+ const pinnedRight = latest.current.layout.filter((c) => c.pinned === "right").reduce((s, c) => s + c.width, 0)
275
+ if (left < el.scrollLeft + pinnedLeft) el.scrollLeft = left - pinnedLeft
276
+ else if (left + col.width > el.scrollLeft + el.clientWidth - pinnedRight)
277
+ el.scrollLeft = left + col.width - el.clientWidth + pinnedRight
278
+ }, [])
279
+
280
+ const focusCell = React.useCallback(
281
+ (address: CellAddress, options?: { extend?: boolean; scroll?: boolean }) => {
282
+ const { rows, layout } = latest.current
283
+ if (!rows.length || !layout.length) return
284
+ const rowIndex = clamp(address.rowIndex, 0, rows.length - 1)
285
+ const columnId = layout.some((c) => c.id === address.columnId) ? address.columnId : layout[0].id
286
+ const next = { rowIndex, columnId }
287
+ setFocus(next)
288
+ setRange((prev) =>
289
+ options?.extend && (prev?.anchor ?? latest.current.focus)
290
+ ? { anchor: prev?.anchor ?? latest.current.focus!, focus: next }
291
+ : null
292
+ )
293
+ setFocusRequest((n) => n + 1)
294
+ if (options?.scroll !== false) {
295
+ scrollRowIntoView(rowIndex)
296
+ scrollColumnIntoView(columnId)
297
+ }
298
+ },
299
+ [scrollRowIntoView, scrollColumnIntoView]
300
+ )
301
+
302
+ const startEditing = React.useCallback((address: CellAddress, seed?: string) => {
303
+ const config = latest.current.layout.find((c) => c.id === address.columnId)?.config
304
+ if (!config?.editable) return
305
+ setEditing({ ...address, seed })
306
+ }, [])
307
+
308
+ const stopEditing = React.useCallback((options?: { refocus?: boolean }) => {
309
+ setEditing(null)
310
+ if (options?.refocus !== false) setFocusRequest((n) => n + 1)
311
+ }, [])
312
+
313
+ const commit = React.useCallback((address: CellAddress, value: unknown) => {
314
+ const row = latest.current.rows[address.rowIndex]
315
+ if (!row) return
316
+ const previous = row.getValue(address.columnId)
317
+ if (Object.is(previous, value)) return
318
+ latest.current.onCellChange?.({ row, columnId: address.columnId, value, previous })
319
+ }, [])
320
+
321
+ const rangeBounds = React.useCallback(
322
+ () => boundsOf(latest.current.range, latest.current.focus, latest.current.layout),
323
+ []
324
+ )
325
+
326
+ const isInRange = React.useCallback(
327
+ (address: CellAddress) => {
328
+ if (!range) return false
329
+ const b = boundsOf(range, focus, layout)
330
+ const ci = layout.findIndex((c) => c.id === address.columnId)
331
+ return !!b && address.rowIndex >= b.r0 && address.rowIndex <= b.r1 && ci >= b.c0 && ci <= b.c1
332
+ },
333
+ [range, focus, layout]
334
+ )
335
+
336
+ const clearRange = React.useCallback(() => {
337
+ const b = rangeBounds()
338
+ if (!b) return
339
+ const { rows, layout } = latest.current
340
+ let count = 0
341
+ for (let r = b.r0; r <= b.r1; r++) {
342
+ for (let c = b.c0; c <= b.c1; c++) {
343
+ const col = layout[c]
344
+ if (!col?.config.editable) continue
345
+ const row = rows[r]
346
+ const empty = col.config.type === "boolean" ? false : col.config.type === "number" ? null : ""
347
+ if (row && !Object.is(row.getValue(col.id), empty)) {
348
+ latest.current.onCellChange?.({ row, columnId: col.id, value: empty, previous: row.getValue(col.id) })
349
+ count++
350
+ }
351
+ }
352
+ }
353
+ if (count) announce(`Cleared ${count} cell${count === 1 ? "" : "s"}.`)
354
+ }, [rangeBounds, announce])
355
+
356
+ const copyRange = React.useCallback(async () => {
357
+ const b = rangeBounds()
358
+ if (!b) return
359
+ const { rows, layout } = latest.current
360
+ const lines: string[] = []
361
+ for (let r = b.r0; r <= b.r1; r++) {
362
+ const cells: string[] = []
363
+ for (let c = b.c0; c <= b.c1; c++) {
364
+ const col = layout[c]
365
+ const value = rows[r]?.getValue(col.id)
366
+ cells.push((col.config.format ?? defaultFormat)(value))
367
+ }
368
+ lines.push(cells.join("\t"))
369
+ }
370
+ try {
371
+ await navigator.clipboard.writeText(lines.join("\n"))
372
+ const n = (b.r1 - b.r0 + 1) * (b.c1 - b.c0 + 1)
373
+ announce(`Copied ${n} cell${n === 1 ? "" : "s"}.`)
374
+ } catch {
375
+ announce("Could not copy to the clipboard.")
376
+ }
377
+ }, [rangeBounds, announce])
378
+
379
+ const paste = React.useCallback(async () => {
380
+ const { focus, rows, layout } = latest.current
381
+ if (!focus) return
382
+ let text = ""
383
+ try {
384
+ text = await navigator.clipboard.readText()
385
+ } catch {
386
+ announce("Could not read the clipboard.")
387
+ return
388
+ }
389
+ const lines = text
390
+ .replace(/\r/g, "")
391
+ .split("\n")
392
+ .filter((l, i, arr) => l !== "" || i < arr.length - 1)
393
+ const startCol = layout.findIndex((c) => c.id === focus.columnId)
394
+ let count = 0
395
+ lines.forEach((line, dr) => {
396
+ const row = rows[focus.rowIndex + dr]
397
+ if (!row) return
398
+ line.split("\t").forEach((cell, dc) => {
399
+ const col = layout[startCol + dc]
400
+ if (!col?.config.editable) return
401
+ const previous = row.getValue(col.id)
402
+ const value = parseInput(col.config, cell, previous)
403
+ if (!Object.is(previous, value)) {
404
+ latest.current.onCellChange?.({ row, columnId: col.id, value, previous })
405
+ count++
406
+ }
407
+ })
408
+ })
409
+ announce(`Pasted into ${count} cell${count === 1 ? "" : "s"}.`)
410
+ }, [announce])
411
+
412
+ const ctx = React.useMemo<DataGridContextValue<TData>>(
413
+ () => ({
414
+ table,
415
+ rows,
416
+ layout,
417
+ layoutById,
418
+ templateColumns,
419
+ totalWidth,
420
+ rowHeight,
421
+ setWidth,
422
+ focus,
423
+ range,
424
+ editing,
425
+ focusCell,
426
+ startEditing,
427
+ stopEditing,
428
+ commit,
429
+ clearRange,
430
+ copyRange,
431
+ paste,
432
+ isInRange,
433
+ containerRef,
434
+ focusRequest,
435
+ registerEditor,
436
+ closeEditor,
437
+ window,
438
+ setScrollTop,
439
+ setViewportHeight,
440
+ announce,
441
+ }),
442
+ [
443
+ table,
444
+ rows,
445
+ layout,
446
+ layoutById,
447
+ templateColumns,
448
+ totalWidth,
449
+ rowHeight,
450
+ setWidth,
451
+ focus,
452
+ range,
453
+ editing,
454
+ focusCell,
455
+ startEditing,
456
+ stopEditing,
457
+ commit,
458
+ clearRange,
459
+ copyRange,
460
+ paste,
461
+ isInRange,
462
+ focusRequest,
463
+ registerEditor,
464
+ closeEditor,
465
+ window,
466
+ setScrollTop,
467
+ announce,
468
+ ]
469
+ )
470
+
471
+ return (
472
+ <DataGridContext.Provider value={ctx as DataGridContextValue}>
473
+ <div
474
+ data-slot="data-grid"
475
+ data-editing={editing ? "" : undefined}
476
+ className={cn("flex min-h-0 flex-1 flex-col", className)}
477
+ {...props}
478
+ >
479
+ {children}
480
+ <LiveRegion data-slot="data-grid-live-region" message={announcement} />
481
+ </div>
482
+ </DataGridContext.Provider>
483
+ )
484
+ }
485
+
486
+ function boundsOf(range: CellRange | null, focus: CellAddress | null, layout: ColumnLayout[]) {
487
+ const a = range?.anchor ?? focus
488
+ const b = range?.focus ?? focus
489
+ if (!a || !b) return null
490
+ const ca = layout.findIndex((c) => c.id === a.columnId)
491
+ const cb = layout.findIndex((c) => c.id === b.columnId)
492
+ return {
493
+ r0: Math.min(a.rowIndex, b.rowIndex),
494
+ r1: Math.max(a.rowIndex, b.rowIndex),
495
+ c0: Math.min(ca, cb),
496
+ c1: Math.max(ca, cb),
497
+ }
498
+ }
499
+
500
+ function parseInput(config: DataGridColumnConfig, input: string, previous: unknown): unknown {
501
+ if (config.parse) return config.parse(input, previous)
502
+ switch (config.type) {
503
+ case "number": {
504
+ if (input.trim() === "") return null
505
+ const n = Number(input)
506
+ return Number.isNaN(n) ? previous : n
507
+ }
508
+ case "boolean":
509
+ return /^(true|yes|1|on|x)$/i.test(input.trim())
510
+ case "date": {
511
+ if (!input) return null
512
+ const d = new Date(input)
513
+ return Number.isNaN(d.getTime()) ? previous : previous instanceof Date ? d : input
514
+ }
515
+ default:
516
+ return input
517
+ }
518
+ }
519
+
520
+ /* ---------------------------------------------------------------------------
521
+ * Container, header, body
522
+ * ------------------------------------------------------------------------- */
523
+
524
+ function DataGridContainer({ className, onScroll, ...props }: React.ComponentProps<"div">) {
525
+ const { containerRef, setScrollTop, setViewportHeight, rows, closeEditor } = useDataGrid()
526
+ React.useLayoutEffect(() => {
527
+ const el = containerRef.current
528
+ if (!el) return
529
+ const update = () => setViewportHeight(el.clientHeight)
530
+ update()
531
+ const observer = new ResizeObserver(update)
532
+ observer.observe(el)
533
+ return () => observer.disconnect()
534
+ }, [containerRef, setViewportHeight])
535
+ return (
536
+ <div
537
+ ref={containerRef}
538
+ data-slot="data-grid-container"
539
+ role="grid"
540
+ aria-rowcount={rows.length}
541
+ className={cn("relative min-h-0 flex-1 overflow-auto rounded-lg border bg-background text-sm", className)}
542
+ onScroll={(event) => {
543
+ onScroll?.(event)
544
+ // An open editor (and its portaled popover) must not drift away from its cell.
545
+ closeEditor()
546
+ setScrollTop(event.currentTarget.scrollTop)
547
+ }}
548
+ {...props}
549
+ />
550
+ )
551
+ }
552
+
553
+ function DataGridHeader({ className, ...props }: React.ComponentProps<"div">) {
554
+ return (
555
+ <div
556
+ data-slot="data-grid-header"
557
+ role="rowgroup"
558
+ className={cn("sticky top-0 z-30 w-fit min-w-full bg-muted", className)}
559
+ {...props}
560
+ />
561
+ )
562
+ }
563
+
564
+ function DataGridHeaderRow({ className, style, ...props }: React.ComponentProps<"div">) {
565
+ const { templateColumns, totalWidth } = useDataGrid()
566
+ return (
567
+ <div
568
+ data-slot="data-grid-header-row"
569
+ role="row"
570
+ className={cn("grid border-b bg-muted", className)}
571
+ style={{ gridTemplateColumns: templateColumns, minWidth: totalWidth, ...style }}
572
+ {...props}
573
+ />
574
+ )
575
+ }
576
+
577
+ const pinnedStyle = (col: ColumnLayout | undefined): React.CSSProperties | undefined =>
578
+ col?.pinned === "left" ? { left: col.offset } : col?.pinned === "right" ? { right: col.offset } : undefined
579
+
580
+ /** A header cell. Sortable columns get a sort button with `children` as the title. */
581
+ function DataGridHead({
582
+ column: columnId,
583
+ className,
584
+ children,
585
+ style,
586
+ ...props
587
+ }: React.ComponentProps<"div"> & { column: string }) {
588
+ const { table, layoutById } = useDataGrid()
589
+ const column = table.getColumn(columnId)
590
+ const layout = layoutById.get(columnId)
591
+ if (!column || !layout) return null
592
+ const sorted = column.getIsSorted()
593
+ const sortable = column.getCanSort()
594
+ return (
595
+ <div
596
+ data-slot="data-grid-head"
597
+ data-column={columnId}
598
+ data-pinned={layout.pinned}
599
+ role="columnheader"
600
+ aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
601
+ className={cn(
602
+ "group/head relative flex h-9 items-center gap-1 overflow-hidden px-2 font-medium whitespace-nowrap text-muted-foreground",
603
+ layout.pinned && "sticky z-10 bg-muted",
604
+ layout.pinned === "left" && "border-e",
605
+ layout.pinned === "right" && "border-s",
606
+ className
607
+ )}
608
+ style={{ ...pinnedStyle(layout), ...style }}
609
+ {...props}
610
+ >
611
+ {sortable ? (
612
+ <ark.button
613
+ type="button"
614
+ data-slot="data-grid-sort-trigger"
615
+ 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"
616
+ onClick={() => column.toggleSorting()}
617
+ >
618
+ <span className="truncate">{children}</span>
619
+ {sorted === "desc" ? (
620
+ <ArrowDownIcon className="size-3.5 shrink-0" />
621
+ ) : sorted === "asc" ? (
622
+ <ArrowUpIcon className="size-3.5 shrink-0" />
623
+ ) : (
624
+ <ChevronsUpDownIcon className="size-3.5 shrink-0 opacity-50" />
625
+ )}
626
+ </ark.button>
627
+ ) : (
628
+ <span className="truncate">{children}</span>
629
+ )}
630
+ </div>
631
+ )
632
+ }
633
+
634
+ /** Pointer-driven column resizer; double-click resets to the configured width. */
635
+ function DataGridResizeHandle({ className, onPointerDown, onDoubleClick, ...props }: React.ComponentProps<"div">) {
636
+ const { setWidth, layoutById } = useDataGrid()
637
+ const head = React.useContext(HeadColumnContext)
638
+ return (
639
+ <div
640
+ data-slot="data-grid-resize-handle"
641
+ role="separator"
642
+ aria-orientation="vertical"
643
+ className={cn(
644
+ "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",
645
+ className
646
+ )}
647
+ onPointerDown={(event) => {
648
+ onPointerDown?.(event)
649
+ if (event.defaultPrevented || event.button !== 0 || !head) return
650
+ event.preventDefault()
651
+ event.stopPropagation()
652
+ const start = event.clientX
653
+ const startWidth = layoutById.get(head)?.width ?? DEFAULT_WIDTH
654
+ const min = layoutById.get(head)?.config.minWidth ?? MIN_WIDTH
655
+ const move = (e: PointerEvent) => setWidth(head, Math.max(min, startWidth + e.clientX - start))
656
+ const up = () => {
657
+ window.removeEventListener("pointermove", move)
658
+ window.removeEventListener("pointerup", up)
659
+ }
660
+ window.addEventListener("pointermove", move)
661
+ window.addEventListener("pointerup", up)
662
+ }}
663
+ onDoubleClick={(event) => {
664
+ onDoubleClick?.(event)
665
+ if (!event.defaultPrevented && head) {
666
+ const config = layoutById.get(head)?.config
667
+ setWidth(head, config?.width ?? DEFAULT_WIDTH)
668
+ }
669
+ }}
670
+ {...props}
671
+ />
672
+ )
673
+ }
674
+
675
+ const HeadColumnContext = React.createContext<string | null>(null)
676
+
677
+ /** Wraps `DataGridHead` so `DataGridResizeHandle` knows its column. */
678
+ function DataGridHeadWithResize({ column, children, ...props }: React.ComponentProps<typeof DataGridHead>) {
679
+ return (
680
+ <HeadColumnContext.Provider value={column}>
681
+ <DataGridHead column={column} {...props}>
682
+ {children}
683
+ <DataGridResizeHandle />
684
+ </DataGridHead>
685
+ </HeadColumnContext.Provider>
686
+ )
687
+ }
688
+
689
+ /** Virtualized rows. `children` renders the cells for a row. */
690
+ function DataGridBody<TData>({
691
+ className,
692
+ children,
693
+ style,
694
+ ...props
695
+ }: Omit<React.ComponentProps<"div">, "children"> & {
696
+ children: (row: DataTableRow<TData>, index: number) => React.ReactNode
697
+ }) {
698
+ const { rows, window: win, rowHeight, totalWidth } = useDataGrid<TData>()
699
+ const visible = rows.slice(win.start, win.end)
700
+ return (
701
+ <div
702
+ data-slot="data-grid-body"
703
+ role="rowgroup"
704
+ className={cn("relative", className)}
705
+ style={{ height: rows.length * rowHeight, minWidth: totalWidth, ...style }}
706
+ {...props}
707
+ >
708
+ <div style={{ transform: `translateY(${win.start * rowHeight}px)` }}>
709
+ {visible.map((row, i) => (
710
+ <DataGridRow key={row.id} row={row} index={win.start + i}>
711
+ {children(row, win.start + i)}
712
+ </DataGridRow>
713
+ ))}
714
+ </div>
715
+ </div>
716
+ )
717
+ }
718
+
719
+ function DataGridRow<TData>({
720
+ row,
721
+ index,
722
+ className,
723
+ style,
724
+ ...props
725
+ }: React.ComponentProps<"div"> & { row: DataTableRow<TData>; index: number }) {
726
+ const { templateColumns, rowHeight, focus } = useDataGrid()
727
+ const ctx = React.useMemo(() => ({ row: row as DataTableRow<unknown>, index }), [row, index])
728
+ const selected = row.getIsSelected()
729
+ return (
730
+ <RowContext.Provider value={ctx}>
731
+ <div
732
+ data-slot="data-grid-row"
733
+ data-index={index}
734
+ data-state={selected ? "selected" : undefined}
735
+ data-focused={focus?.rowIndex === index ? "" : undefined}
736
+ role="row"
737
+ aria-rowindex={index + 1}
738
+ aria-selected={selected || undefined}
739
+ className={cn(
740
+ "group/row grid border-b transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted/60",
741
+ className
742
+ )}
743
+ style={{ gridTemplateColumns: templateColumns, height: rowHeight, ...style }}
744
+ {...props}
745
+ />
746
+ </RowContext.Provider>
747
+ )
748
+ }
749
+
750
+ /* ---------------------------------------------------------------------------
751
+ * Cells
752
+ * ------------------------------------------------------------------------- */
753
+
754
+ const isPrintable = (event: React.KeyboardEvent) =>
755
+ event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey
756
+
757
+ function DataGridCell({
758
+ column: columnId,
759
+ editable: editableProp,
760
+ editor,
761
+ className,
762
+ children,
763
+ style,
764
+ onClick,
765
+ onDoubleClick,
766
+ onKeyDown,
767
+ onPointerDown,
768
+ ...props
769
+ }: Omit<React.ComponentProps<"div">, "children"> & {
770
+ column: string
771
+ /** Override the column config for this cell. */
772
+ editable?: boolean
773
+ /** Custom editor; return `undefined` to use the default for the column type. */
774
+ editor?: (props: EditorRenderProps) => React.ReactNode | undefined
775
+ children?: React.ReactNode
776
+ }) {
777
+ const grid = useDataGrid()
778
+ const { row, index } = useDataGridRow()
779
+ const layout = grid.layoutById.get(columnId)
780
+ const address = React.useMemo(() => ({ rowIndex: index, columnId }), [index, columnId])
781
+ const ref = React.useRef<HTMLDivElement>(null)
782
+ const focused = grid.focus?.rowIndex === index && grid.focus.columnId === columnId
783
+ const isEditing = grid.editing?.rowIndex === index && grid.editing.columnId === columnId
784
+ // Take DOM focus when this cell is the focused one and a focus request is pending
785
+ // (after navigation, after an editor closes, or once a virtualized row mounts).
786
+ React.useLayoutEffect(() => {
787
+ const el = ref.current
788
+ if (!focused || isEditing || !el) return
789
+ if (document.activeElement !== el && !el.contains(document.activeElement)) el.focus({ preventScroll: true })
790
+ }, [focused, isEditing, grid.focusRequest])
791
+ if (!layout) return null
792
+ const config = layout.config
793
+ const editable = editableProp ?? !!config.editable
794
+ const inRange = grid.isInRange(address)
795
+ const value = row.getValue(columnId)
796
+ const format = config.format ?? defaultFormat
797
+ const columnIndex = grid.layout.indexOf(layout)
798
+
799
+ const move = (dr: number, dc: number, extend = false) => {
800
+ // Navigate from the focused address in state; DOM focus can lag a frame after an edit.
801
+ const base = grid.focus ?? address
802
+ const baseCol = Math.max(
803
+ 0,
804
+ grid.layout.findIndex((c) => c.id === base.columnId)
805
+ )
806
+ const nextCol = grid.layout[clamp(baseCol + dc, 0, grid.layout.length - 1)]
807
+ grid.focusCell({ rowIndex: base.rowIndex + dr, columnId: nextCol.id }, { extend })
808
+ }
809
+
810
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
811
+ onKeyDown?.(event)
812
+ if (event.defaultPrevented || isEditing) return
813
+ const meta = event.metaKey || event.ctrlKey
814
+ const pageRows = Math.max(1, Math.floor((grid.containerRef.current?.clientHeight ?? 0) / grid.rowHeight) - 1)
815
+ switch (event.key) {
816
+ case "ArrowDown":
817
+ event.preventDefault()
818
+ move(1, 0, event.shiftKey)
819
+ return
820
+ case "ArrowUp":
821
+ event.preventDefault()
822
+ move(-1, 0, event.shiftKey)
823
+ return
824
+ case "ArrowRight":
825
+ event.preventDefault()
826
+ move(0, 1, event.shiftKey)
827
+ return
828
+ case "ArrowLeft":
829
+ event.preventDefault()
830
+ move(0, -1, event.shiftKey)
831
+ return
832
+ case "Home":
833
+ event.preventDefault()
834
+ grid.focusCell({ rowIndex: meta ? 0 : index, columnId: grid.layout[0].id }, { extend: event.shiftKey })
835
+ return
836
+ case "End":
837
+ event.preventDefault()
838
+ grid.focusCell(
839
+ { rowIndex: meta ? grid.rows.length - 1 : index, columnId: grid.layout[grid.layout.length - 1].id },
840
+ { extend: event.shiftKey }
841
+ )
842
+ return
843
+ case "PageDown":
844
+ event.preventDefault()
845
+ move(pageRows, 0, event.shiftKey)
846
+ return
847
+ case "PageUp":
848
+ event.preventDefault()
849
+ move(-pageRows, 0, event.shiftKey)
850
+ return
851
+ case "Tab":
852
+ event.preventDefault()
853
+ move(0, event.shiftKey ? -1 : 1)
854
+ return
855
+ case "Enter":
856
+ case "F2":
857
+ event.preventDefault()
858
+ if (editable) grid.startEditing(address)
859
+ else if (event.key === "Enter") move(1, 0)
860
+ return
861
+ case " ":
862
+ if (editable && config.type === "boolean") {
863
+ event.preventDefault()
864
+ grid.commit(address, !value)
865
+ }
866
+ return
867
+ case "Escape":
868
+ grid.focusCell(address)
869
+ return
870
+ case "Delete":
871
+ case "Backspace":
872
+ event.preventDefault()
873
+ grid.clearRange()
874
+ return
875
+ case "c":
876
+ case "C":
877
+ if (meta) {
878
+ event.preventDefault()
879
+ void grid.copyRange()
880
+ }
881
+ return
882
+ case "v":
883
+ case "V":
884
+ if (meta) {
885
+ event.preventDefault()
886
+ void grid.paste()
887
+ }
888
+ return
889
+ }
890
+ if (editable && isPrintable(event) && config.type !== "boolean") {
891
+ event.preventDefault()
892
+ grid.startEditing(address, event.key)
893
+ }
894
+ }
895
+
896
+ return (
897
+ <div
898
+ ref={ref}
899
+ data-slot="data-grid-cell"
900
+ data-column={columnId}
901
+ data-row-index={index}
902
+ data-type={config.type}
903
+ data-pinned={layout.pinned}
904
+ data-editable={editable ? "" : undefined}
905
+ data-focused={focused ? "" : undefined}
906
+ data-editing={isEditing ? "" : undefined}
907
+ data-selected={inRange ? "" : undefined}
908
+ role="gridcell"
909
+ aria-colindex={columnIndex + 1}
910
+ aria-readonly={editable ? undefined : true}
911
+ tabIndex={focused ? 0 : -1}
912
+ className={cn(
913
+ "relative flex min-w-0 items-center overflow-hidden px-2 whitespace-nowrap outline-none",
914
+ "data-selected:bg-primary/10",
915
+ "data-focused:z-10 data-focused:ring-2 data-focused:ring-primary data-focused:ring-inset",
916
+ "data-editing:bg-background data-editing:p-0",
917
+ layout.pinned && "sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
918
+ layout.pinned === "left" && "border-e",
919
+ layout.pinned === "right" && "border-s",
920
+ config.type === "number" && "justify-end tabular-nums",
921
+ className
922
+ )}
923
+ style={{ ...pinnedStyle(layout), ...style }}
924
+ onPointerDown={(event) => {
925
+ onPointerDown?.(event)
926
+ if (event.defaultPrevented || event.button !== 0 || isEditing) return
927
+ if ((event.target as HTMLElement).closest("button, a, input, select, textarea, [role=checkbox]")) return
928
+ grid.focusCell(address, { extend: event.shiftKey, scroll: false })
929
+ }}
930
+ onClick={onClick}
931
+ onDoubleClick={(event) => {
932
+ onDoubleClick?.(event)
933
+ if (!event.defaultPrevented && editable && config.type !== "boolean") grid.startEditing(address)
934
+ }}
935
+ onKeyDown={handleKeyDown}
936
+ {...props}
937
+ >
938
+ {isEditing ? (
939
+ <CellEditor
940
+ address={address}
941
+ config={config}
942
+ row={row}
943
+ value={value}
944
+ format={format}
945
+ editor={editor}
946
+ display={children}
947
+ />
948
+ ) : children !== undefined ? (
949
+ children
950
+ ) : config.type === "boolean" ? (
951
+ <Checkbox
952
+ checked={!!value}
953
+ disabled={!editable}
954
+ onCheckedChange={({ checked }) => grid.commit(address, checked === true)}
955
+ aria-label={columnId}
956
+ />
957
+ ) : (
958
+ <span className="truncate">{format(value)}</span>
959
+ )}
960
+ </div>
961
+ )
962
+ }
963
+
964
+ function CellEditor({
965
+ address,
966
+ config,
967
+ row,
968
+ value,
969
+ format,
970
+ editor,
971
+ display,
972
+ }: {
973
+ address: CellAddress
974
+ config: DataGridColumnConfig
975
+ row: DataTableRow<unknown>
976
+ value: unknown
977
+ format: (value: unknown) => string
978
+ editor?: (props: EditorRenderProps) => React.ReactNode | undefined
979
+ display?: React.ReactNode
980
+ }) {
981
+ const grid = useDataGrid()
982
+ const seed = grid.editing?.seed
983
+ const [text, setText] = React.useState(() => seed ?? format(value))
984
+ const inputRef = React.useRef<HTMLInputElement>(null)
985
+ const done = React.useRef(false)
986
+ React.useLayoutEffect(() => {
987
+ const el = inputRef.current
988
+ if (!el) return
989
+ el.focus()
990
+ if (seed === undefined && "select" in el && typeof el.select === "function" && el.type !== "date") el.select()
991
+ }, [seed])
992
+
993
+ const commit = (next?: string, then?: () => void) => {
994
+ if (done.current) return
995
+ done.current = true
996
+ grid.commit(address, parseInput(config, next ?? text, value))
997
+ grid.stopEditing({ refocus: !then })
998
+ then?.()
999
+ }
1000
+ const cancel = () => {
1001
+ if (done.current) return
1002
+ done.current = true
1003
+ grid.stopEditing()
1004
+ }
1005
+ const isSelect = config.type === "select" && !!config.options
1006
+ const commitRef = React.useRef(commit)
1007
+ const cancelRef = React.useRef(cancel)
1008
+ React.useEffect(() => {
1009
+ commitRef.current = commit
1010
+ cancelRef.current = cancel
1011
+ })
1012
+ const { registerEditor } = grid
1013
+ // Scrolling the grid closes the editor (text commits, an unpicked select cancels) before its
1014
+ // row can leave the virtual window, so the editor never unmounts mid-edit.
1015
+ React.useEffect(
1016
+ () => registerEditor({ close: () => (isSelect ? cancelRef.current() : commitRef.current()) }),
1017
+ [registerEditor, isSelect]
1018
+ )
1019
+ const moveAfter = (dr: number, dc: number) => () => {
1020
+ const col = grid.layout.findIndex((c) => c.id === address.columnId)
1021
+ const next = grid.layout[clamp(col + dc, 0, grid.layout.length - 1)]
1022
+ grid.focusCell({ rowIndex: address.rowIndex + dr, columnId: next.id })
1023
+ }
1024
+ const onKeyDown = (event: React.KeyboardEvent) => {
1025
+ if (event.key === "Enter") {
1026
+ event.preventDefault()
1027
+ commit(undefined, moveAfter(1, 0))
1028
+ } else if (event.key === "Tab") {
1029
+ event.preventDefault()
1030
+ commit(undefined, moveAfter(0, event.shiftKey ? -1 : 1))
1031
+ } else if (event.key === "Escape") {
1032
+ event.preventDefault()
1033
+ cancel()
1034
+ }
1035
+ event.stopPropagation()
1036
+ }
1037
+ const inputProps: EditorRenderProps["inputProps"] = {
1038
+ ref: inputRef,
1039
+ value: text,
1040
+ onChange: (e) => setText(e.target.value),
1041
+ onKeyDown,
1042
+ onBlur: () => commit(),
1043
+ className: "size-full bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground",
1044
+ "aria-label": "Edit value",
1045
+ }
1046
+ const custom = editor?.({ value: text, onChange: setText, commit, cancel, column: config, row, inputProps })
1047
+ if (custom !== undefined) return <>{custom}</>
1048
+
1049
+ if (config.type === "select" && config.options) {
1050
+ return (
1051
+ <SelectEditor
1052
+ options={config.options}
1053
+ value={text}
1054
+ display={display}
1055
+ onCommit={(v) => commit(v, moveAfter(1, 0))}
1056
+ onCancel={cancel}
1057
+ />
1058
+ )
1059
+ }
1060
+ const type = config.type === "number" ? "number" : config.type === "date" ? "date" : "text"
1061
+ return <input data-slot="data-grid-editor" type={type} {...inputProps} />
1062
+ }
1063
+
1064
+ /** Opens the toolkit Select immediately; picking commits, closing without a pick cancels. */
1065
+ function SelectEditor({
1066
+ options,
1067
+ value,
1068
+ display,
1069
+ onCommit,
1070
+ onCancel,
1071
+ }: {
1072
+ options: DataGridOption[]
1073
+ value: string
1074
+ /** What the cell normally shows, so the open editor looks like the cell. */
1075
+ display?: React.ReactNode
1076
+ onCommit: (value: string) => void
1077
+ onCancel: () => void
1078
+ }) {
1079
+ const collection = React.useMemo(
1080
+ () => createListCollection({ items: options, itemToValue: (o) => o.value, itemToString: (o) => o.label }),
1081
+ [options]
1082
+ )
1083
+ const picked = React.useRef(false)
1084
+ const contentId = React.useId()
1085
+ // Ark handles list navigation on the open content, so focus it (it is portaled) once mounted.
1086
+ React.useLayoutEffect(() => {
1087
+ const focusContent = () => document.getElementById(contentId)?.focus({ preventScroll: true })
1088
+ focusContent()
1089
+ const timer = setTimeout(focusContent, 0)
1090
+ return () => clearTimeout(timer)
1091
+ }, [contentId])
1092
+ const label = options.find((o) => o.value === value)?.label ?? value
1093
+ return (
1094
+ <Select
1095
+ collection={collection}
1096
+ value={value ? [value] : []}
1097
+ defaultOpen
1098
+ ids={{ content: contentId }}
1099
+ positioning={{ sameWidth: true, gutter: 2 }}
1100
+ onValueChange={({ value: next }) => {
1101
+ if (next[0] === undefined) return
1102
+ picked.current = true
1103
+ onCommit(next[0])
1104
+ }}
1105
+ onOpenChange={({ open }) => {
1106
+ if (!open && !picked.current) onCancel()
1107
+ }}
1108
+ onPointerDown={(event) => event.stopPropagation()}
1109
+ className="size-full"
1110
+ >
1111
+ <SelectControl className="size-full">
1112
+ <SelectTrigger
1113
+ variant="unstyled"
1114
+ data-slot="data-grid-select-editor"
1115
+ className="size-full px-2 [&_svg]:text-muted-foreground"
1116
+ aria-label="Edit value"
1117
+ >
1118
+ <SelectValue placeholder="Choose…">{display ?? label}</SelectValue>
1119
+ </SelectTrigger>
1120
+ </SelectControl>
1121
+ <SelectContent>
1122
+ {options.map((o) => (
1123
+ <SelectItem key={o.value} item={o}>
1124
+ <SelectItemText>{o.label}</SelectItemText>
1125
+ <SelectItemIndicator />
1126
+ </SelectItem>
1127
+ ))}
1128
+ </SelectContent>
1129
+ </Select>
1130
+ )
1131
+ }
1132
+
1133
+ /* ---------------------------------------------------------------------------
1134
+ * Selection cells, empty
1135
+ * ------------------------------------------------------------------------- */
1136
+
1137
+ function DataGridSelectAll({ className, ...props }: React.ComponentProps<"div">) {
1138
+ const { table, layoutById } = useDataGrid()
1139
+ const layout = layoutById.get("select")
1140
+ const t = table as DataTableInstance<unknown> & {
1141
+ getIsAllPageRowsSelected?: () => boolean
1142
+ getIsSomePageRowsSelected?: () => boolean
1143
+ toggleAllPageRowsSelected?: (v?: boolean) => void
1144
+ }
1145
+ const all = t.getIsAllPageRowsSelected?.() ?? false
1146
+ const some = t.getIsSomePageRowsSelected?.() ?? false
1147
+ return (
1148
+ <div
1149
+ data-slot="data-grid-select-all"
1150
+ role="columnheader"
1151
+ className={cn(
1152
+ "flex items-center justify-center",
1153
+ layout?.pinned && "sticky z-10 bg-muted",
1154
+ layout?.pinned === "left" && "border-e",
1155
+ className
1156
+ )}
1157
+ style={pinnedStyle(layout)}
1158
+ {...props}
1159
+ >
1160
+ <Checkbox
1161
+ checked={all ? true : some ? "indeterminate" : false}
1162
+ onCheckedChange={({ checked }) => t.toggleAllPageRowsSelected?.(checked === true)}
1163
+ aria-label="Select all"
1164
+ />
1165
+ </div>
1166
+ )
1167
+ }
1168
+
1169
+ function DataGridSelectRow({ className, ...props }: React.ComponentProps<"div">) {
1170
+ const { layoutById } = useDataGrid()
1171
+ const { row } = useDataGridRow()
1172
+ const layout = layoutById.get("select")
1173
+ return (
1174
+ <div
1175
+ data-slot="data-grid-select-row"
1176
+ role="gridcell"
1177
+ className={cn(
1178
+ "flex items-center justify-center",
1179
+ layout?.pinned && "sticky z-20 bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
1180
+ layout?.pinned === "left" && "border-e",
1181
+ className
1182
+ )}
1183
+ style={pinnedStyle(layout)}
1184
+ {...props}
1185
+ >
1186
+ <Checkbox
1187
+ checked={row.getIsSelected()}
1188
+ onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}
1189
+ aria-label="Select row"
1190
+ />
1191
+ </div>
1192
+ )
1193
+ }
1194
+
1195
+ function DataGridEmpty({ className, ...props }: React.ComponentProps<"div">) {
1196
+ const { rows } = useDataGrid()
1197
+ if (rows.length) return null
1198
+ return (
1199
+ <div
1200
+ data-slot="data-grid-empty"
1201
+ className={cn("flex h-24 items-center justify-center text-sm text-muted-foreground", className)}
1202
+ {...props}
1203
+ />
1204
+ )
1205
+ }
1206
+
1207
+ export {
1208
+ DataGrid,
1209
+ DataGridContainer,
1210
+ DataGridHeader,
1211
+ DataGridHeaderRow,
1212
+ DataGridHead,
1213
+ DataGridHeadWithResize,
1214
+ DataGridResizeHandle,
1215
+ DataGridBody,
1216
+ DataGridRow,
1217
+ DataGridCell,
1218
+ DataGridSelectAll,
1219
+ DataGridSelectRow,
1220
+ DataGridEmpty,
1221
+ useDataGrid,
1222
+ useDataGridRow,
1223
+ type DataGridColumnConfig,
1224
+ type DataGridColumnType,
1225
+ type DataGridCellChange,
1226
+ type DataGridOption,
1227
+ }