@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/package.json +86 -0
  4. package/src/hooks/use-copy-to-clipboard.ts +44 -0
  5. package/src/hooks/use-mobile.ts +19 -0
  6. package/src/hooks/use-scrolled.ts +30 -0
  7. package/src/index.ts +99 -0
  8. package/src/lib/date-locale.ts +29 -0
  9. package/src/lib/utils.ts +6 -0
  10. package/src/lib/validation-messages.ts +19 -0
  11. package/src/spree/address-block.tsx +74 -0
  12. package/src/spree/back-button.tsx +48 -0
  13. package/src/spree/bulk-dialog.tsx +71 -0
  14. package/src/spree/bulk-price-table.tsx +254 -0
  15. package/src/spree/calculator-summary.tsx +120 -0
  16. package/src/spree/color-picker.tsx +202 -0
  17. package/src/spree/confirm-dialog.tsx +82 -0
  18. package/src/spree/copy-to-clipboard-button.tsx +90 -0
  19. package/src/spree/country-flag.tsx +30 -0
  20. package/src/spree/data-grid/cells.tsx +415 -0
  21. package/src/spree/data-grid/context.ts +59 -0
  22. package/src/spree/data-grid/data-grid.tsx +214 -0
  23. package/src/spree/data-grid/fill-handle.tsx +153 -0
  24. package/src/spree/data-grid/helpers.ts +18 -0
  25. package/src/spree/data-grid/index.ts +5 -0
  26. package/src/spree/data-grid/types.ts +36 -0
  27. package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
  28. package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
  29. package/src/spree/drag-handle.tsx +51 -0
  30. package/src/spree/form-actions.tsx +140 -0
  31. package/src/spree/json-preview-drawer.tsx +254 -0
  32. package/src/spree/json-value-view.tsx +149 -0
  33. package/src/spree/language-menu-items.tsx +54 -0
  34. package/src/spree/metadata/metadata-card.tsx +62 -0
  35. package/src/spree/relative-time.tsx +49 -0
  36. package/src/spree/resource-combobox.tsx +108 -0
  37. package/src/spree/resource-layout.tsx +31 -0
  38. package/src/spree/resource-multi-autocomplete.tsx +180 -0
  39. package/src/spree/resource-name-cell.tsx +44 -0
  40. package/src/spree/route-error-boundary.tsx +102 -0
  41. package/src/spree/row-actions.tsx +85 -0
  42. package/src/spree/row-click-bridge.tsx +17 -0
  43. package/src/spree/secret-input.tsx +137 -0
  44. package/src/spree/storefront-visible-switch.tsx +58 -0
  45. package/src/spree/tag-list.tsx +26 -0
  46. package/src/spree/theme-provider.tsx +83 -0
  47. package/src/spree/theme-toggle.tsx +35 -0
  48. package/src/styles.css +338 -0
  49. package/src/ui/attachment.tsx +187 -0
  50. package/src/ui/avatar.tsx +94 -0
  51. package/src/ui/badge.tsx +161 -0
  52. package/src/ui/breadcrumb.tsx +109 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/calendar.tsx +66 -0
  55. package/src/ui/card.tsx +72 -0
  56. package/src/ui/chart.tsx +94 -0
  57. package/src/ui/checkbox.tsx +50 -0
  58. package/src/ui/collapsible.tsx +18 -0
  59. package/src/ui/combobox.tsx +270 -0
  60. package/src/ui/command.tsx +172 -0
  61. package/src/ui/data-table.tsx +60 -0
  62. package/src/ui/date-picker.tsx +253 -0
  63. package/src/ui/date-range-picker.tsx +150 -0
  64. package/src/ui/dialog.tsx +214 -0
  65. package/src/ui/dropdown-menu.tsx +345 -0
  66. package/src/ui/empty.tsx +94 -0
  67. package/src/ui/field.tsx +221 -0
  68. package/src/ui/input-group.tsx +141 -0
  69. package/src/ui/input.tsx +19 -0
  70. package/src/ui/label.tsx +29 -0
  71. package/src/ui/pagination.tsx +123 -0
  72. package/src/ui/popover.tsx +76 -0
  73. package/src/ui/progress.tsx +73 -0
  74. package/src/ui/radio-group.tsx +42 -0
  75. package/src/ui/rich-text-editor.tsx +263 -0
  76. package/src/ui/select.tsx +213 -0
  77. package/src/ui/separator.tsx +31 -0
  78. package/src/ui/sheet.tsx +208 -0
  79. package/src/ui/sidebar.tsx +679 -0
  80. package/src/ui/skeleton.tsx +13 -0
  81. package/src/ui/slot.tsx +35 -0
  82. package/src/ui/sonner.tsx +47 -0
  83. package/src/ui/switch.tsx +39 -0
  84. package/src/ui/textarea.tsx +18 -0
  85. 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
+ }