@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.
- package/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- 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
|
+
}
|