@spree/dashboard-ui 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/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
flexRender,
|
|
3
|
+
getCoreRowModel,
|
|
4
|
+
type Row,
|
|
5
|
+
type Table,
|
|
6
|
+
type TableOptions,
|
|
7
|
+
useReactTable,
|
|
8
|
+
} from '@tanstack/react-table'
|
|
9
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
10
|
+
import { cn } from '../../lib/utils'
|
|
11
|
+
import { coordsInRect, DataGridContext, type DataGridContextValue } from './context'
|
|
12
|
+
import { FillHandle } from './fill-handle'
|
|
13
|
+
import type { CellCoords, CellKey, CellRegistration, RenderSectionHeader } from './types'
|
|
14
|
+
import { cellKey } from './types'
|
|
15
|
+
import { useDataGridKeyboard } from './use-data-grid-keyboard'
|
|
16
|
+
|
|
17
|
+
interface DataGridProps<T> {
|
|
18
|
+
rows: T[]
|
|
19
|
+
columns: TableOptions<T>['columns']
|
|
20
|
+
getRowId: (row: T) => string
|
|
21
|
+
/** Optional grouping. Return a node spanning all columns to render this row
|
|
22
|
+
* as a (non-editable) section header. Return null to render the row as
|
|
23
|
+
* normal editable cells. */
|
|
24
|
+
renderSectionHeader?: RenderSectionHeader<T>
|
|
25
|
+
/** Optional className on the outer table. */
|
|
26
|
+
className?: string
|
|
27
|
+
/** Optional caption-row aria-label etc. */
|
|
28
|
+
'aria-label'?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DataGrid<T>({
|
|
32
|
+
rows,
|
|
33
|
+
columns,
|
|
34
|
+
getRowId,
|
|
35
|
+
renderSectionHeader,
|
|
36
|
+
className,
|
|
37
|
+
'aria-label': ariaLabel,
|
|
38
|
+
}: DataGridProps<T>) {
|
|
39
|
+
const table = useReactTable<T>({
|
|
40
|
+
data: rows,
|
|
41
|
+
columns,
|
|
42
|
+
getRowId,
|
|
43
|
+
getCoreRowModel: getCoreRowModel(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<DataGridShell
|
|
48
|
+
table={table}
|
|
49
|
+
renderSectionHeader={renderSectionHeader}
|
|
50
|
+
className={className}
|
|
51
|
+
ariaLabel={ariaLabel}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function DataGridShell<T>({
|
|
57
|
+
table,
|
|
58
|
+
renderSectionHeader,
|
|
59
|
+
className,
|
|
60
|
+
ariaLabel,
|
|
61
|
+
}: {
|
|
62
|
+
table: Table<T>
|
|
63
|
+
renderSectionHeader?: RenderSectionHeader<T>
|
|
64
|
+
className?: string
|
|
65
|
+
ariaLabel?: string
|
|
66
|
+
}) {
|
|
67
|
+
const gridRef = useRef<HTMLTableElement | null>(null)
|
|
68
|
+
const cellsRef = useRef<Map<CellKey, CellRegistration>>(new Map())
|
|
69
|
+
const [bounds, setBounds] = useState<{ maxRow: number; maxCol: number }>({ maxRow: 0, maxCol: 0 })
|
|
70
|
+
const [anchor, setAnchor] = useState<CellCoords | null>(null)
|
|
71
|
+
const [extent, setExtent] = useState<CellCoords | null>(null)
|
|
72
|
+
const [editing, setEditing] = useState<CellCoords | null>(null)
|
|
73
|
+
|
|
74
|
+
const recomputeBounds = useCallback(() => {
|
|
75
|
+
let maxRow = 0
|
|
76
|
+
let maxCol = 0
|
|
77
|
+
for (const reg of cellsRef.current.values()) {
|
|
78
|
+
if (reg.coords.row > maxRow) maxRow = reg.coords.row
|
|
79
|
+
if (reg.coords.col > maxCol) maxCol = reg.coords.col
|
|
80
|
+
}
|
|
81
|
+
setBounds((prev) =>
|
|
82
|
+
prev.maxRow === maxRow && prev.maxCol === maxCol ? prev : { maxRow, maxCol },
|
|
83
|
+
)
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
const registerCell = useCallback(
|
|
87
|
+
(reg: CellRegistration) => {
|
|
88
|
+
const key = cellKey(reg.coords)
|
|
89
|
+
cellsRef.current.set(key, reg)
|
|
90
|
+
recomputeBounds()
|
|
91
|
+
return () => {
|
|
92
|
+
cellsRef.current.delete(key)
|
|
93
|
+
recomputeBounds()
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[recomputeBounds],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const isSelected = useCallback(
|
|
100
|
+
(coords: CellCoords) => coordsInRect(coords, anchor, extent),
|
|
101
|
+
[anchor, extent],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const selectedCells = useCallback((): CellRegistration[] => {
|
|
105
|
+
if (!anchor || !extent) return []
|
|
106
|
+
const out: CellRegistration[] = []
|
|
107
|
+
for (const reg of cellsRef.current.values()) {
|
|
108
|
+
if (coordsInRect(reg.coords, anchor, extent)) out.push(reg)
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}, [anchor, extent])
|
|
112
|
+
|
|
113
|
+
const ctx: DataGridContextValue = useMemo(
|
|
114
|
+
() => ({
|
|
115
|
+
cells: cellsRef.current,
|
|
116
|
+
bounds,
|
|
117
|
+
anchor,
|
|
118
|
+
extent,
|
|
119
|
+
editing,
|
|
120
|
+
setAnchor,
|
|
121
|
+
setExtent,
|
|
122
|
+
setEditing,
|
|
123
|
+
registerCell,
|
|
124
|
+
isSelected,
|
|
125
|
+
selectedCells,
|
|
126
|
+
}),
|
|
127
|
+
[bounds, anchor, extent, editing, registerCell, isSelected, selectedCells],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<DataGridContext.Provider value={ctx}>
|
|
132
|
+
<DataGridKeyboardMount gridRef={gridRef} />
|
|
133
|
+
<div className="relative overflow-hidden rounded-md">
|
|
134
|
+
<table
|
|
135
|
+
ref={gridRef}
|
|
136
|
+
className={cn(
|
|
137
|
+
'w-full border-collapse text-sm [&_td]:border [&_th]:border [&_td]:border-border [&_th]:border-border',
|
|
138
|
+
className,
|
|
139
|
+
)}
|
|
140
|
+
aria-label={ariaLabel}
|
|
141
|
+
onBlurCapture={(e) => {
|
|
142
|
+
// If focus leaves the grid entirely, drop the selection so a
|
|
143
|
+
// fresh focus-in starts clean.
|
|
144
|
+
const next = e.relatedTarget as Node | null
|
|
145
|
+
if (next && gridRef.current?.contains(next)) return
|
|
146
|
+
setAnchor(null)
|
|
147
|
+
setExtent(null)
|
|
148
|
+
setEditing(null)
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<thead className="sticky top-0 z-10 bg-muted/60 text-xs text-muted-foreground">
|
|
152
|
+
{table.getHeaderGroups().map((group) => (
|
|
153
|
+
<tr key={group.id}>
|
|
154
|
+
{group.headers.map((header) => (
|
|
155
|
+
<th key={header.id} className="h-8 px-3 text-left font-medium">
|
|
156
|
+
{header.isPlaceholder
|
|
157
|
+
? null
|
|
158
|
+
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
159
|
+
</th>
|
|
160
|
+
))}
|
|
161
|
+
</tr>
|
|
162
|
+
))}
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody>
|
|
165
|
+
{table.getRowModel().rows.map((row) => (
|
|
166
|
+
<DataGridRow
|
|
167
|
+
key={row.id}
|
|
168
|
+
row={row}
|
|
169
|
+
renderSectionHeader={renderSectionHeader}
|
|
170
|
+
columnCount={table.getAllColumns().length}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</tbody>
|
|
174
|
+
</table>
|
|
175
|
+
<FillHandle gridRef={gridRef} />
|
|
176
|
+
</div>
|
|
177
|
+
</DataGridContext.Provider>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function DataGridRow<T>({
|
|
182
|
+
row,
|
|
183
|
+
renderSectionHeader,
|
|
184
|
+
columnCount,
|
|
185
|
+
}: {
|
|
186
|
+
row: Row<T>
|
|
187
|
+
renderSectionHeader?: RenderSectionHeader<T>
|
|
188
|
+
columnCount: number
|
|
189
|
+
}) {
|
|
190
|
+
const headerContent = renderSectionHeader?.(row.original)
|
|
191
|
+
if (headerContent) {
|
|
192
|
+
return (
|
|
193
|
+
<tr>
|
|
194
|
+
<td colSpan={columnCount} className="bg-muted/60 px-3 py-2 text-sm">
|
|
195
|
+
{headerContent}
|
|
196
|
+
</td>
|
|
197
|
+
</tr>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
return (
|
|
201
|
+
<tr>
|
|
202
|
+
{row.getVisibleCells().map((cell) => (
|
|
203
|
+
<td key={cell.id} className="h-9 p-0 align-middle">
|
|
204
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
205
|
+
</td>
|
|
206
|
+
))}
|
|
207
|
+
</tr>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function DataGridKeyboardMount({ gridRef }: { gridRef: React.RefObject<HTMLElement | null> }) {
|
|
212
|
+
useDataGridKeyboard(gridRef)
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useDataGridContext } from './context'
|
|
3
|
+
import type { CellCoords, CellKey } from './types'
|
|
4
|
+
import { cellKey } from './types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Excel-style fill handle. Renders a 9×9 square anchored to the
|
|
8
|
+
* bottom-right of the cell that's the current selection's far corner.
|
|
9
|
+
* Pointer-drag from the handle expands the selection rectangle; on
|
|
10
|
+
* release we copy the anchor cell's value into every other cell in
|
|
11
|
+
* the rectangle (skipping cells whose `canWrite` rejects the value).
|
|
12
|
+
*
|
|
13
|
+
* Positioning is absolute over the grid's `<table>`. The handle reads
|
|
14
|
+
* the far-corner cell's bounding rect via `getElement()` (provided by
|
|
15
|
+
* each cell's registration) and the table's own rect, then offsets so
|
|
16
|
+
* the handle sits inside the cell's corner.
|
|
17
|
+
*/
|
|
18
|
+
export function FillHandle({ gridRef }: { gridRef: React.RefObject<HTMLElement | null> }) {
|
|
19
|
+
const ctx = useDataGridContext()
|
|
20
|
+
const { anchor, extent, cells, editing } = ctx
|
|
21
|
+
|
|
22
|
+
// Local drag state — null when idle.
|
|
23
|
+
const [drag, setDrag] = useState<{
|
|
24
|
+
sourceKey: CellKey
|
|
25
|
+
sourceValue: string
|
|
26
|
+
/** The current rect we're previewing as the fill target. */
|
|
27
|
+
preview: { row: number; col: number } | null
|
|
28
|
+
} | null>(null)
|
|
29
|
+
const [tick, setTick] = useState(0)
|
|
30
|
+
// Re-measure on scroll/resize so the handle follows the cell.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
function bump() {
|
|
33
|
+
setTick((t) => t + 1)
|
|
34
|
+
}
|
|
35
|
+
window.addEventListener('resize', bump)
|
|
36
|
+
window.addEventListener('scroll', bump, true)
|
|
37
|
+
return () => {
|
|
38
|
+
window.removeEventListener('resize', bump)
|
|
39
|
+
window.removeEventListener('scroll', bump, true)
|
|
40
|
+
}
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
// Hide the handle while editing or when there's no selection.
|
|
44
|
+
if (!anchor || !extent || editing) return null
|
|
45
|
+
|
|
46
|
+
// Far corner of the current selection (the cell whose bottom-right
|
|
47
|
+
// gets the handle). Drag previews use the same anchor as the source.
|
|
48
|
+
const farRow = Math.max(anchor.row, extent.row)
|
|
49
|
+
const farCol = Math.max(anchor.col, extent.col)
|
|
50
|
+
const farReg = cells.get(cellKey({ row: farRow, col: farCol }))
|
|
51
|
+
const farEl = farReg?.getElement?.()
|
|
52
|
+
const gridEl = gridRef.current
|
|
53
|
+
if (!farEl || !gridEl) return null
|
|
54
|
+
|
|
55
|
+
// Position relative to the gridEl's positioning context. Use offsetParent
|
|
56
|
+
// math through getBoundingClientRect for portability.
|
|
57
|
+
const farRect = farEl.getBoundingClientRect()
|
|
58
|
+
const gridRect = gridEl.getBoundingClientRect()
|
|
59
|
+
// Sit the handle so its center is exactly on the cell's bottom-right corner.
|
|
60
|
+
const left = farRect.right - gridRect.left - 4
|
|
61
|
+
const top = farRect.bottom - gridRect.top - 4
|
|
62
|
+
|
|
63
|
+
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
e.stopPropagation()
|
|
66
|
+
// Source is always the *anchor* — that's what users instinctively
|
|
67
|
+
// expect to be copied. If they had a multi-cell selection going,
|
|
68
|
+
// we still copy from the anchor (top-left), matching Excel.
|
|
69
|
+
const sourceReg = cells.get(cellKey(anchor!))
|
|
70
|
+
if (!sourceReg) return
|
|
71
|
+
const sourceValue = sourceReg.read()
|
|
72
|
+
setDrag({
|
|
73
|
+
sourceKey: cellKey(anchor!),
|
|
74
|
+
sourceValue,
|
|
75
|
+
preview: { row: farRow, col: farCol },
|
|
76
|
+
})
|
|
77
|
+
// Capture pointer so we keep getting move events even outside the
|
|
78
|
+
// handle div.
|
|
79
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
|
83
|
+
if (!drag) return
|
|
84
|
+
// Hit-test the cell under the pointer.
|
|
85
|
+
const target = document.elementFromPoint(e.clientX, e.clientY)
|
|
86
|
+
if (!target) return
|
|
87
|
+
// Walk up from the hit target looking for a cell with a known
|
|
88
|
+
// coords attribute. We don't tag DOM nodes, so do it by linear scan
|
|
89
|
+
// through the registry — small (<a few hundred cells per grid).
|
|
90
|
+
let hit: CellCoords | null = null
|
|
91
|
+
for (const reg of cells.values()) {
|
|
92
|
+
const el = reg.getElement?.()
|
|
93
|
+
if (el?.contains(target as Node)) {
|
|
94
|
+
hit = reg.coords
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!hit) return
|
|
99
|
+
// Constrain to the same column as the source (Excel: fill flows
|
|
100
|
+
// down a column by default). Allow row movement only.
|
|
101
|
+
setDrag({
|
|
102
|
+
...drag,
|
|
103
|
+
preview: { row: hit.row, col: anchor!.col },
|
|
104
|
+
})
|
|
105
|
+
// Drive the grid's extent so the standard selection highlight
|
|
106
|
+
// matches the preview region.
|
|
107
|
+
ctx.setExtent({ row: hit.row, col: anchor!.col })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onPointerUp() {
|
|
111
|
+
if (!drag) return
|
|
112
|
+
const preview = drag.preview
|
|
113
|
+
if (preview) {
|
|
114
|
+
const minRow = Math.min(anchor!.row, preview.row)
|
|
115
|
+
const maxRow = Math.max(anchor!.row, preview.row)
|
|
116
|
+
// Copy into every cell in the column range, skipping the source.
|
|
117
|
+
for (let r = minRow; r <= maxRow; r += 1) {
|
|
118
|
+
if (r === anchor!.row) continue
|
|
119
|
+
const reg = cells.get(cellKey({ row: r, col: anchor!.col }))
|
|
120
|
+
if (!reg) continue
|
|
121
|
+
if (reg.canWrite(drag.sourceValue)) reg.write(drag.sourceValue)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
setDrag(null)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The `tick` value forces position re-measurement when the table
|
|
128
|
+
// reflows. Read it once so React doesn't optimize the var away.
|
|
129
|
+
void tick
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
// biome-ignore lint/a11y/useSemanticElements: this is a drag handle, not actionable on its own
|
|
134
|
+
role="presentation"
|
|
135
|
+
onPointerDown={onPointerDown}
|
|
136
|
+
onPointerMove={onPointerMove}
|
|
137
|
+
onPointerUp={onPointerUp}
|
|
138
|
+
style={{
|
|
139
|
+
position: 'absolute',
|
|
140
|
+
left,
|
|
141
|
+
top,
|
|
142
|
+
width: 9,
|
|
143
|
+
height: 9,
|
|
144
|
+
zIndex: 20,
|
|
145
|
+
background: 'rgb(59 130 246)',
|
|
146
|
+
border: '1px solid white',
|
|
147
|
+
cursor: 'crosshair',
|
|
148
|
+
boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
|
|
149
|
+
}}
|
|
150
|
+
aria-hidden
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts editable (non-header) rows up to and including the given row id so
|
|
3
|
+
* the cell at that position has stable grid coords that exclude section
|
|
4
|
+
* headers. Used by every spreadsheet that mixes a `renderSectionHeader`
|
|
5
|
+
* with `<NumberCell>` / `<MoneyCell>` etc.
|
|
6
|
+
*/
|
|
7
|
+
export function editableRowIndex<T extends { kind: 'header' | 'item'; id: string }>(
|
|
8
|
+
rows: ReadonlyArray<{ id: string; original: T }>,
|
|
9
|
+
rowId: string,
|
|
10
|
+
): number {
|
|
11
|
+
let i = 0
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
if (row.original.kind !== 'item') continue
|
|
14
|
+
if (row.id === rowId) return i
|
|
15
|
+
i++
|
|
16
|
+
}
|
|
17
|
+
return i
|
|
18
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { MoneyCell, NumberCell, ReadOnlyCell, SelectCell, SwitchCell } from './cells'
|
|
2
|
+
export { useDataGridContext } from './context'
|
|
3
|
+
export { DataGrid } from './data-grid'
|
|
4
|
+
export { editableRowIndex } from './helpers'
|
|
5
|
+
export type { CellCoords, RenderSectionHeader } from './types'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Cell coords used for keyboard navigation and selection.
|
|
4
|
+
* `row` is a flat row index across the rendered table (after section
|
|
5
|
+
* headers are inserted); `col` is the column index in the editable order. */
|
|
6
|
+
export interface CellCoords {
|
|
7
|
+
row: number
|
|
8
|
+
col: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CellKey = `${number}.${number}`
|
|
12
|
+
|
|
13
|
+
export function cellKey(coords: CellCoords): CellKey {
|
|
14
|
+
return `${coords.row}.${coords.col}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GridMode = 'select' | 'edit'
|
|
18
|
+
|
|
19
|
+
export interface CellRegistration {
|
|
20
|
+
coords: CellCoords
|
|
21
|
+
/** Imperatively focus the cell's interactive element. */
|
|
22
|
+
focus: () => void
|
|
23
|
+
/** Read the current display value (for copy / fill-down). */
|
|
24
|
+
read: () => string
|
|
25
|
+
/** Write a new value (for paste / fill-down / clear). */
|
|
26
|
+
write: (value: string) => void
|
|
27
|
+
/** Whether this cell accepts a write — Switch cells, for example, only
|
|
28
|
+
* accept "true" / "false" / "" so we can no-op on non-boolean pastes. */
|
|
29
|
+
canWrite: (value: string) => boolean
|
|
30
|
+
/** Resolve the cell's interactive root element. Used by the fill
|
|
31
|
+
* handle to measure positions and hit-test drag targets. */
|
|
32
|
+
getElement?: () => HTMLElement | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Public render-prop API for grouped rows (e.g. "Variant: Small / Navy"). */
|
|
36
|
+
export type RenderSectionHeader<T> = (row: T) => ReactNode | null
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useHotkey } from '@tanstack/react-hotkeys'
|
|
2
|
+
import type { RefObject } from 'react'
|
|
3
|
+
import { useDataGridContext } from './context'
|
|
4
|
+
import type { CellCoords } from './types'
|
|
5
|
+
|
|
6
|
+
/** Mounts grid-wide keyboard bindings scoped to a container ref. */
|
|
7
|
+
export function useDataGridKeyboard(gridRef: RefObject<HTMLElement | null>) {
|
|
8
|
+
const ctx = useDataGridContext()
|
|
9
|
+
const target = gridRef
|
|
10
|
+
|
|
11
|
+
function focusCoords(coords: CellCoords) {
|
|
12
|
+
const cell = ctx.cells.get(`${coords.row}.${coords.col}`)
|
|
13
|
+
cell?.focus()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function navigate(dx: number, dy: number) {
|
|
17
|
+
const from = ctx.anchor
|
|
18
|
+
if (!from) return
|
|
19
|
+
// Clamp inside the grid, no wrap-around.
|
|
20
|
+
let row = from.row + dy
|
|
21
|
+
let col = from.col + dx
|
|
22
|
+
row = Math.max(0, Math.min(ctx.bounds.maxRow, row))
|
|
23
|
+
col = Math.max(0, Math.min(ctx.bounds.maxCol, col))
|
|
24
|
+
// If the target slot has no registered cell (e.g. a sparse row where the
|
|
25
|
+
// column index doesn't render), find the nearest existing cell in the
|
|
26
|
+
// direction of travel. Falls back to the original if nothing is reachable.
|
|
27
|
+
const next = findExistingCoords({ row, col }, dx, dy)
|
|
28
|
+
if (!next) return
|
|
29
|
+
ctx.setAnchor(next)
|
|
30
|
+
ctx.setExtent(next)
|
|
31
|
+
ctx.setEditing(null)
|
|
32
|
+
focusCoords(next)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extendSelection(dx: number, dy: number) {
|
|
36
|
+
const from = ctx.extent ?? ctx.anchor
|
|
37
|
+
if (!from) return
|
|
38
|
+
let row = from.row + dy
|
|
39
|
+
let col = from.col + dx
|
|
40
|
+
row = Math.max(0, Math.min(ctx.bounds.maxRow, row))
|
|
41
|
+
col = Math.max(0, Math.min(ctx.bounds.maxCol, col))
|
|
42
|
+
const next = findExistingCoords({ row, col }, dx, dy)
|
|
43
|
+
if (!next) return
|
|
44
|
+
ctx.setExtent(next)
|
|
45
|
+
focusCoords(next)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findExistingCoords(start: CellCoords, dx: number, dy: number): CellCoords | null {
|
|
49
|
+
if (ctx.cells.has(`${start.row}.${start.col}`)) return start
|
|
50
|
+
// Step in the direction of travel until we find a registered cell or
|
|
51
|
+
// run out of grid. Bounded by maxRow*maxCol steps.
|
|
52
|
+
let row = start.row
|
|
53
|
+
let col = start.col
|
|
54
|
+
for (let i = 0; i < (ctx.bounds.maxRow + 1) * (ctx.bounds.maxCol + 1); i++) {
|
|
55
|
+
row += dy === 0 ? 0 : dy
|
|
56
|
+
col += dx === 0 ? 0 : dx
|
|
57
|
+
// If we're walking pure-row, also step columns to find sparse hits.
|
|
58
|
+
if (dx === 0 && dy === 0) return null
|
|
59
|
+
if (row < 0 || col < 0 || row > ctx.bounds.maxRow || col > ctx.bounds.maxCol) return null
|
|
60
|
+
if (ctx.cells.has(`${row}.${col}`)) return { row, col }
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Selection-mode navigation. Edit-mode is handled inside the cell itself
|
|
66
|
+
// (Enter / Esc / Tab there route to the grid via callbacks).
|
|
67
|
+
useHotkey('ArrowUp', () => navigate(0, -1), {
|
|
68
|
+
target,
|
|
69
|
+
ignoreInputs: false,
|
|
70
|
+
enabled: !ctx.editing,
|
|
71
|
+
})
|
|
72
|
+
useHotkey('ArrowDown', () => navigate(0, 1), {
|
|
73
|
+
target,
|
|
74
|
+
ignoreInputs: false,
|
|
75
|
+
enabled: !ctx.editing,
|
|
76
|
+
})
|
|
77
|
+
useHotkey('ArrowLeft', () => navigate(-1, 0), {
|
|
78
|
+
target,
|
|
79
|
+
ignoreInputs: false,
|
|
80
|
+
enabled: !ctx.editing,
|
|
81
|
+
})
|
|
82
|
+
useHotkey('ArrowRight', () => navigate(1, 0), {
|
|
83
|
+
target,
|
|
84
|
+
ignoreInputs: false,
|
|
85
|
+
enabled: !ctx.editing,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
useHotkey('Shift+ArrowUp', () => extendSelection(0, -1), {
|
|
89
|
+
target,
|
|
90
|
+
ignoreInputs: false,
|
|
91
|
+
enabled: !ctx.editing,
|
|
92
|
+
})
|
|
93
|
+
useHotkey('Shift+ArrowDown', () => extendSelection(0, 1), {
|
|
94
|
+
target,
|
|
95
|
+
ignoreInputs: false,
|
|
96
|
+
enabled: !ctx.editing,
|
|
97
|
+
})
|
|
98
|
+
useHotkey('Shift+ArrowLeft', () => extendSelection(-1, 0), {
|
|
99
|
+
target,
|
|
100
|
+
ignoreInputs: false,
|
|
101
|
+
enabled: !ctx.editing,
|
|
102
|
+
})
|
|
103
|
+
useHotkey('Shift+ArrowRight', () => extendSelection(1, 0), {
|
|
104
|
+
target,
|
|
105
|
+
ignoreInputs: false,
|
|
106
|
+
enabled: !ctx.editing,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Tab: navigate within the grid; let the browser handle when at the edge
|
|
110
|
+
// so the user can Tab past the grid into the next form field.
|
|
111
|
+
useHotkey(
|
|
112
|
+
'Tab',
|
|
113
|
+
(event) => {
|
|
114
|
+
const from = ctx.anchor
|
|
115
|
+
if (!from) return
|
|
116
|
+
const atLastCell = from.row === ctx.bounds.maxRow && from.col === ctx.bounds.maxCol
|
|
117
|
+
if (atLastCell) return // let browser focus the next form field
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
navigate(1, 0)
|
|
120
|
+
// If we just walked off the right edge, wrap to the next row.
|
|
121
|
+
const next = ctx.anchor
|
|
122
|
+
if (next && next.row === from.row && next.col === from.col) {
|
|
123
|
+
navigate(-ctx.bounds.maxCol, 1)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{ target, ignoreInputs: false, preventDefault: false, enabled: !ctx.editing },
|
|
127
|
+
)
|
|
128
|
+
useHotkey(
|
|
129
|
+
'Shift+Tab',
|
|
130
|
+
(event) => {
|
|
131
|
+
const from = ctx.anchor
|
|
132
|
+
if (!from) return
|
|
133
|
+
const atFirst = from.row === 0 && from.col === 0
|
|
134
|
+
if (atFirst) return // let browser walk focus backward out of the grid
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
navigate(-1, 0)
|
|
137
|
+
},
|
|
138
|
+
{ target, ignoreInputs: false, preventDefault: false, enabled: !ctx.editing },
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// Enter — toggle into edit mode for the focused cell.
|
|
142
|
+
useHotkey(
|
|
143
|
+
'Enter',
|
|
144
|
+
() => {
|
|
145
|
+
if (ctx.anchor) ctx.setEditing(ctx.anchor)
|
|
146
|
+
},
|
|
147
|
+
{ target, ignoreInputs: false, enabled: !ctx.editing },
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Escape — clear selection. (Edit-mode Escape is handled inside the cell
|
|
151
|
+
// since it needs to revert the in-flight value before exiting.)
|
|
152
|
+
useHotkey(
|
|
153
|
+
'Escape',
|
|
154
|
+
() => {
|
|
155
|
+
ctx.setExtent(ctx.anchor)
|
|
156
|
+
},
|
|
157
|
+
{ target, ignoreInputs: false, enabled: !ctx.editing },
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// Delete / Backspace — clear all selected cells. Skipped while editing
|
|
161
|
+
// so the user can use these keys to edit text.
|
|
162
|
+
function clearSelected() {
|
|
163
|
+
for (const cell of ctx.selectedCells()) {
|
|
164
|
+
if (cell.canWrite('')) cell.write('')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
useHotkey('Delete', clearSelected, { target, ignoreInputs: false, enabled: !ctx.editing })
|
|
168
|
+
useHotkey('Backspace', clearSelected, { target, ignoreInputs: false, enabled: !ctx.editing })
|
|
169
|
+
|
|
170
|
+
// Cmd/Ctrl+D — fill down: take the top-most cell of each selected column
|
|
171
|
+
// and broadcast its value to the rest of that column's selection.
|
|
172
|
+
useHotkey(
|
|
173
|
+
'Mod+D',
|
|
174
|
+
() => {
|
|
175
|
+
const cells = ctx.selectedCells()
|
|
176
|
+
if (cells.length < 2) return
|
|
177
|
+
const byCol = new Map<number, typeof cells>()
|
|
178
|
+
for (const c of cells) {
|
|
179
|
+
const arr = byCol.get(c.coords.col) ?? []
|
|
180
|
+
arr.push(c)
|
|
181
|
+
byCol.set(c.coords.col, arr)
|
|
182
|
+
}
|
|
183
|
+
for (const colCells of byCol.values()) {
|
|
184
|
+
colCells.sort((a, b) => a.coords.row - b.coords.row)
|
|
185
|
+
const source = colCells[0].read()
|
|
186
|
+
for (let i = 1; i < colCells.length; i++) {
|
|
187
|
+
if (colCells[i].canWrite(source)) colCells[i].write(source)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
{ target, ignoreInputs: false, enabled: !ctx.editing },
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// Cmd/Ctrl+C — write the selection to the OS clipboard as TSV.
|
|
195
|
+
useHotkey(
|
|
196
|
+
'Mod+C',
|
|
197
|
+
async () => {
|
|
198
|
+
const cells = ctx.selectedCells()
|
|
199
|
+
if (cells.length === 0) return
|
|
200
|
+
const rows = new Map<number, Map<number, string>>()
|
|
201
|
+
let minCol = Infinity
|
|
202
|
+
let maxCol = -Infinity
|
|
203
|
+
for (const c of cells) {
|
|
204
|
+
const row = rows.get(c.coords.row) ?? new Map()
|
|
205
|
+
row.set(c.coords.col, c.read())
|
|
206
|
+
rows.set(c.coords.row, row)
|
|
207
|
+
if (c.coords.col < minCol) minCol = c.coords.col
|
|
208
|
+
if (c.coords.col > maxCol) maxCol = c.coords.col
|
|
209
|
+
}
|
|
210
|
+
const rowKeys = Array.from(rows.keys()).sort((a, b) => a - b)
|
|
211
|
+
const tsv = rowKeys
|
|
212
|
+
.map((r) => {
|
|
213
|
+
const row = rows.get(r)
|
|
214
|
+
const out: string[] = []
|
|
215
|
+
for (let col = minCol; col <= maxCol; col++) {
|
|
216
|
+
out.push(row?.get(col) ?? '')
|
|
217
|
+
}
|
|
218
|
+
return out.join('\t')
|
|
219
|
+
})
|
|
220
|
+
.join('\n')
|
|
221
|
+
try {
|
|
222
|
+
await navigator.clipboard.writeText(tsv)
|
|
223
|
+
} catch {
|
|
224
|
+
// Clipboard write may fail in non-secure contexts or when the page
|
|
225
|
+
// doesn't have focus. Silent — the alternative is a noisy toast on
|
|
226
|
+
// a routine Cmd+C and there's no recovery path here.
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
{ target, ignoreInputs: false, enabled: !ctx.editing },
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// Cmd/Ctrl+V — broadcast a single clipboard value to all selected cells.
|
|
233
|
+
// Multi-cell TSV paste with row/col pattern matching is deferred.
|
|
234
|
+
useHotkey(
|
|
235
|
+
'Mod+V',
|
|
236
|
+
async () => {
|
|
237
|
+
const cells = ctx.selectedCells()
|
|
238
|
+
if (cells.length === 0) return
|
|
239
|
+
let text = ''
|
|
240
|
+
try {
|
|
241
|
+
text = await navigator.clipboard.readText()
|
|
242
|
+
} catch {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
// First-pass: only use the first line, first column of whatever was on
|
|
246
|
+
// the clipboard. Excel TSV will collapse cleanly to its first cell.
|
|
247
|
+
const value = text.split('\n')[0]?.split('\t')[0] ?? ''
|
|
248
|
+
for (const c of cells) {
|
|
249
|
+
if (c.canWrite(value)) c.write(value)
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
{ target, ignoreInputs: false, enabled: !ctx.editing },
|
|
253
|
+
)
|
|
254
|
+
}
|