@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,90 @@
1
+ import { CheckIcon, CopyIcon } from 'lucide-react'
2
+ import type { ComponentProps, ReactNode } from 'react'
3
+ import { useCopyToClipboard } from '../hooks/use-copy-to-clipboard'
4
+ import { cn } from '../lib/utils'
5
+ import { Button } from '../ui/button'
6
+
7
+ type ButtonProps = ComponentProps<typeof Button>
8
+
9
+ type CopyToClipboardButtonProps = Omit<ButtonProps, 'onClick' | 'children'> & {
10
+ /** Text to write to the clipboard. */
11
+ value: string
12
+ /** Accessible label for the button — required by a11y rules. */
13
+ 'aria-label': string
14
+ /** Custom icon for the idle state. Defaults to `CopyIcon`. */
15
+ icon?: ReactNode
16
+ /** Custom icon for the copied state. Defaults to `CheckIcon`. */
17
+ copiedIcon?: ReactNode
18
+ /** Optional label rendered alongside the icon (e.g. "Copy"). */
19
+ label?: ReactNode
20
+ /** Optional label rendered alongside the icon while in the copied state. */
21
+ copiedLabel?: ReactNode
22
+ /**
23
+ * How long the copied state stays true after a successful copy. Forwarded to
24
+ * `useCopyToClipboard`. Defaults to 1200ms (the hook's default).
25
+ */
26
+ resetMs?: number
27
+ /**
28
+ * Stop the click event from bubbling. Useful inside `<ResourceTable>` rows
29
+ * where `useRowClickBridge` would otherwise also fire. Defaults to `true`
30
+ * since that's the common case — pass `false` to opt out.
31
+ */
32
+ stopPropagation?: boolean
33
+ }
34
+
35
+ /**
36
+ * Universal copy-to-clipboard button. Renders a standard `<Button>` with a
37
+ * copy icon that flips to a check icon for a short flash after a successful
38
+ * copy. Forwards every `<Button>` prop except `onClick` + `children`, so the
39
+ * caller controls variant, size, className, etc.
40
+ *
41
+ * @example Bare icon button next to a value
42
+ * <span>
43
+ * <code>{token}</code>
44
+ * <CopyToClipboardButton value={token} aria-label="Copy token" size="icon-xs" variant="ghost" />
45
+ * </span>
46
+ *
47
+ * @example Labelled button inside a modal
48
+ * <CopyToClipboardButton
49
+ * value={secret}
50
+ * aria-label="Copy secret"
51
+ * size="sm"
52
+ * variant="outline"
53
+ * label="Copy"
54
+ * copiedLabel="Copied"
55
+ * />
56
+ */
57
+ export function CopyToClipboardButton({
58
+ value,
59
+ icon = <CopyIcon />,
60
+ copiedIcon = <CheckIcon />,
61
+ label,
62
+ copiedLabel,
63
+ resetMs,
64
+ stopPropagation = true,
65
+ className,
66
+ size = 'icon-xs',
67
+ variant = 'ghost',
68
+ type = 'button',
69
+ ...rest
70
+ }: CopyToClipboardButtonProps) {
71
+ const { copied, copy } = useCopyToClipboard(resetMs !== undefined ? { resetMs } : undefined)
72
+ const displayedLabel = copied && copiedLabel !== undefined ? copiedLabel : label
73
+
74
+ return (
75
+ <Button
76
+ {...rest}
77
+ type={type}
78
+ size={size}
79
+ variant={variant}
80
+ className={cn(className)}
81
+ onClick={(e) => {
82
+ if (stopPropagation) e.stopPropagation()
83
+ copy(value)
84
+ }}
85
+ >
86
+ {copied ? copiedIcon : icon}
87
+ {displayedLabel}
88
+ </Button>
89
+ )
90
+ }
@@ -0,0 +1,30 @@
1
+ import { cn } from '../lib/utils'
2
+
3
+ interface CountryFlagProps {
4
+ /** ISO 3166-1 alpha-2 country code (case-insensitive). */
5
+ iso: string
6
+ /** Override the default `1.333em` × `1em` size. Pass any Tailwind size. */
7
+ className?: string
8
+ }
9
+
10
+ /**
11
+ * Renders a country flag via the `flag-icons` CSS sprite. The sprite resolves
12
+ * `.fi.fi-{iso}` to a `background-image` URL pointing at the matching SVG —
13
+ * the browser lazy-loads each flag only when it actually appears on screen.
14
+ *
15
+ * Decorative by design (`aria-hidden`); the surrounding label/text carries
16
+ * the country name for screen readers.
17
+ */
18
+ export function CountryFlag({ iso, className }: CountryFlagProps) {
19
+ if (!iso) return null
20
+ return (
21
+ <span
22
+ aria-hidden
23
+ className={cn(
24
+ 'fi shrink-0 rounded-[2px] shadow-[0_0_0_0.5px_rgba(0,0,0,0.08)]',
25
+ `fi-${iso.toLowerCase()}`,
26
+ className,
27
+ )}
28
+ />
29
+ )
30
+ }
@@ -0,0 +1,415 @@
1
+ import { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'
4
+ import { Switch } from '../../ui/switch'
5
+ import { modeFor, useDataGridContext } from './context'
6
+ import type { CellCoords } from './types'
7
+ import { useStableCellRegistration } from './use-stable-cell-registration'
8
+
9
+ interface BaseCellProps {
10
+ coords: CellCoords
11
+ ariaLabel: string
12
+ }
13
+
14
+ interface NumberCellProps extends BaseCellProps {
15
+ value: number
16
+ onChange: (next: number) => void
17
+ /** Visual hint — e.g. low-stock items get a colored border. */
18
+ intent?: 'default' | 'warning'
19
+ }
20
+
21
+ /** Editable numeric cell. In selection mode the underlying input is
22
+ * read-only (so arrow keys navigate the grid); in edit mode it accepts
23
+ * typing and arrow keys move the caret. */
24
+ export function NumberCell({
25
+ coords,
26
+ value,
27
+ onChange,
28
+ ariaLabel,
29
+ intent = 'default',
30
+ }: NumberCellProps) {
31
+ const ctx = useDataGridContext()
32
+ const inputRef = useRef<HTMLInputElement | null>(null)
33
+ const [draft, setDraft] = useState(value.toString())
34
+ const isSelected = ctx.isSelected(coords)
35
+ const mode = modeFor(ctx.editing, coords)
36
+ const isEditing = mode === 'edit'
37
+ const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
38
+
39
+ // Resync draft with external value when not editing this cell.
40
+ useEffect(() => {
41
+ if (!isEditing) setDraft(value.toString())
42
+ }, [value, isEditing])
43
+
44
+ // Auto-focus + select when entering edit mode.
45
+ useEffect(() => {
46
+ if (isEditing) {
47
+ const el = inputRef.current
48
+ if (el) {
49
+ el.focus()
50
+ el.select()
51
+ }
52
+ }
53
+ }, [isEditing])
54
+
55
+ useStableCellRegistration(coords, {
56
+ focus: () => inputRef.current?.focus(),
57
+ read: () => value.toString(),
58
+ write: (next) => {
59
+ const n = Number(next)
60
+ if (!Number.isFinite(n)) return
61
+ onChange(Math.trunc(n))
62
+ },
63
+ canWrite: (next) => next === '' || Number.isFinite(Number(next)),
64
+ })
65
+
66
+ function commit() {
67
+ const n = Number(draft)
68
+ if (Number.isFinite(n)) onChange(Math.trunc(n))
69
+ else setDraft(value.toString())
70
+ }
71
+
72
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
73
+ if (!isEditing) {
74
+ // Selection mode: forward navigation keys to the grid by NOT preventing
75
+ // default. Mark this cell as the anchor on plain mouse-click style focus.
76
+ return
77
+ }
78
+ if (event.key === 'Enter') {
79
+ event.preventDefault()
80
+ commit()
81
+ ctx.setEditing(null)
82
+ // Move down to mirror Excel.
83
+ const next = { row: coords.row + 1, col: coords.col }
84
+ ctx.setAnchor(next)
85
+ ctx.setExtent(next)
86
+ ctx.cells.get(`${next.row}.${next.col}`)?.focus()
87
+ } else if (event.key === 'Escape') {
88
+ event.preventDefault()
89
+ setDraft(value.toString())
90
+ ctx.setEditing(null)
91
+ } else if (event.key === 'Tab') {
92
+ event.preventDefault()
93
+ commit()
94
+ ctx.setEditing(null)
95
+ const dx = event.shiftKey ? -1 : 1
96
+ const next = { row: coords.row, col: coords.col + dx }
97
+ ctx.setAnchor(next)
98
+ ctx.setExtent(next)
99
+ ctx.cells.get(`${next.row}.${next.col}`)?.focus()
100
+ }
101
+ }
102
+
103
+ return (
104
+ <input
105
+ ref={inputRef}
106
+ type="text"
107
+ inputMode="numeric"
108
+ value={isEditing ? draft : value.toString()}
109
+ readOnly={!isEditing}
110
+ onChange={(e) => setDraft(e.target.value)}
111
+ onFocus={() => {
112
+ ctx.setAnchor(coords)
113
+ ctx.setExtent(coords)
114
+ }}
115
+ onDoubleClick={() => ctx.setEditing(coords)}
116
+ onKeyDown={onKeyDown}
117
+ onBlur={() => {
118
+ if (isEditing) {
119
+ commit()
120
+ ctx.setEditing(null)
121
+ }
122
+ }}
123
+ aria-label={ariaLabel}
124
+ className={cn(
125
+ 'block h-9 w-full cursor-cell border-0 bg-transparent px-3 text-right tabular-nums outline-none ring-inset transition-colors',
126
+ isSelected && 'bg-blue-500/10',
127
+ isFocused && !isEditing && 'ring-2 ring-blue-500',
128
+ isEditing && 'cursor-text bg-card ring-2 ring-blue-500',
129
+ intent === 'warning' && !isSelected && !isFocused && 'text-amber-700',
130
+ )}
131
+ />
132
+ )
133
+ }
134
+
135
+ interface MoneyCellProps extends BaseCellProps {
136
+ /** Canonical decimal string (`12.50`) coming back from the API, or
137
+ * `null`/blank for unset. */
138
+ value: string | null
139
+ /** Receives the user's raw input — locale-formatted strings like
140
+ * `"12,50"` are fine, the backend's `Spree::LocalizedNumber` does the
141
+ * parsing. `null` only when the user explicitly blanks the field. */
142
+ onChange: (next: string | null) => void
143
+ /** Currency symbol rendered as a non-editable prefix (`$`, `€`, `kr`, …). */
144
+ symbol: string
145
+ /**
146
+ * Locale decimal separator used only for display (canonical `.` →
147
+ * locale char). Whatever the user types is shipped as-is; the
148
+ * backend handles thousands separators, grouped digits, and so on.
149
+ * Default `.`.
150
+ */
151
+ decimal?: string
152
+ }
153
+
154
+ /**
155
+ * Editable money cell. Like `NumberCell` but keeps decimals (no truncation)
156
+ * and renders a currency-symbol prefix. Mirrors the rails admin's
157
+ * `edit_prices` bulk editor: format the API's canonical value with the
158
+ * locale's decimal mark for display, but ship the user's raw input
159
+ * untouched on save. `Spree::LocalizedNumber.parse` on the backend handles
160
+ * locale-aware parsing (thousands separators, decimal comma, etc.) so the
161
+ * SPA doesn't have to reimplement it.
162
+ */
163
+ export function MoneyCell({
164
+ coords,
165
+ value,
166
+ onChange,
167
+ symbol,
168
+ decimal = '.',
169
+ ariaLabel,
170
+ }: MoneyCellProps) {
171
+ const ctx = useDataGridContext()
172
+ const inputRef = useRef<HTMLInputElement | null>(null)
173
+
174
+ const formatDisplay = (v: string | null): string =>
175
+ v == null || v === '' ? '' : v.replace('.', decimal)
176
+
177
+ const [draft, setDraft] = useState(formatDisplay(value))
178
+ const isSelected = ctx.isSelected(coords)
179
+ const mode = modeFor(ctx.editing, coords)
180
+ const isEditing = mode === 'edit'
181
+ const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
182
+
183
+ // Resync draft with external value when not editing.
184
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional resync
185
+ useEffect(() => {
186
+ if (!isEditing) setDraft(formatDisplay(value))
187
+ }, [value, isEditing, decimal])
188
+
189
+ // Auto-focus + select when entering edit mode.
190
+ useEffect(() => {
191
+ if (isEditing) {
192
+ const el = inputRef.current
193
+ if (el) {
194
+ el.focus()
195
+ el.select()
196
+ }
197
+ }
198
+ }, [isEditing])
199
+
200
+ // The wrapping div is what the FillHandle measures and what
201
+ // elementFromPoint hits during a fill drag — register it as the
202
+ // cell's element. The input itself is what we focus.
203
+ const wrapRef = useRef<HTMLDivElement | null>(null)
204
+ useStableCellRegistration(coords, {
205
+ focus: () => inputRef.current?.focus(),
206
+ read: () => formatDisplay(value),
207
+ write: (next) => onChange(next.trim() ? next : null),
208
+ canWrite: () => true,
209
+ getElement: () => wrapRef.current,
210
+ })
211
+
212
+ function commit() {
213
+ // Ship the user's raw input; the backend's `Spree::LocalizedNumber`
214
+ // handles locale-aware parsing. Empty string is the only "clear"
215
+ // signal we generate frontend-side.
216
+ onChange(draft.trim() ? draft : null)
217
+ }
218
+
219
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
220
+ if (!isEditing) return
221
+ if (event.key === 'Enter') {
222
+ event.preventDefault()
223
+ commit()
224
+ ctx.setEditing(null)
225
+ const next = { row: coords.row + 1, col: coords.col }
226
+ ctx.setAnchor(next)
227
+ ctx.setExtent(next)
228
+ ctx.cells.get(`${next.row}.${next.col}`)?.focus()
229
+ } else if (event.key === 'Escape') {
230
+ event.preventDefault()
231
+ setDraft(formatDisplay(value))
232
+ ctx.setEditing(null)
233
+ } else if (event.key === 'Tab') {
234
+ event.preventDefault()
235
+ commit()
236
+ ctx.setEditing(null)
237
+ const dx = event.shiftKey ? -1 : 1
238
+ const next = { row: coords.row, col: coords.col + dx }
239
+ ctx.setAnchor(next)
240
+ ctx.setExtent(next)
241
+ ctx.cells.get(`${next.row}.${next.col}`)?.focus()
242
+ }
243
+ }
244
+
245
+ return (
246
+ <div
247
+ ref={wrapRef}
248
+ className={cn(
249
+ 'flex h-9 w-full items-center transition-colors',
250
+ isSelected && 'bg-blue-500/10',
251
+ isFocused && !isEditing && 'ring-2 ring-blue-500 ring-inset',
252
+ isEditing && 'bg-card ring-2 ring-blue-500 ring-inset',
253
+ )}
254
+ >
255
+ <span
256
+ className="pl-3 text-muted-foreground pointer-events-none select-none tabular-nums"
257
+ aria-hidden
258
+ >
259
+ {symbol}
260
+ </span>
261
+ <input
262
+ ref={inputRef}
263
+ type="text"
264
+ inputMode="decimal"
265
+ value={isEditing ? draft : formatDisplay(value)}
266
+ readOnly={!isEditing}
267
+ onChange={(e) => setDraft(e.target.value)}
268
+ onFocus={() => {
269
+ ctx.setAnchor(coords)
270
+ ctx.setExtent(coords)
271
+ }}
272
+ onDoubleClick={() => ctx.setEditing(coords)}
273
+ onKeyDown={onKeyDown}
274
+ onBlur={() => {
275
+ if (isEditing) {
276
+ commit()
277
+ ctx.setEditing(null)
278
+ }
279
+ }}
280
+ aria-label={ariaLabel}
281
+ className={cn(
282
+ 'block h-9 w-full cursor-cell border-0 bg-transparent pl-1 pr-3 text-right tabular-nums outline-none',
283
+ isEditing && 'cursor-text',
284
+ )}
285
+ />
286
+ </div>
287
+ )
288
+ }
289
+
290
+ interface SwitchCellProps extends BaseCellProps {
291
+ value: boolean
292
+ onChange: (next: boolean) => void
293
+ }
294
+
295
+ /** Boolean cell. Space toggles; there's no separate edit mode because the
296
+ * switch is a single-action control. */
297
+ export function SwitchCell({ coords, value, onChange, ariaLabel }: SwitchCellProps) {
298
+ const ctx = useDataGridContext()
299
+ const wrapRef = useRef<HTMLDivElement | null>(null)
300
+ const isSelected = ctx.isSelected(coords)
301
+ const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
302
+
303
+ useStableCellRegistration(coords, {
304
+ focus: () => wrapRef.current?.focus(),
305
+ read: () => (value ? 'true' : 'false'),
306
+ write: (next) => {
307
+ const lower = next.toLowerCase().trim()
308
+ if (lower === 'true' || lower === '1' || lower === 'yes') onChange(true)
309
+ else if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') onChange(false)
310
+ },
311
+ canWrite: (next) =>
312
+ ['', 'true', 'false', '1', '0', 'yes', 'no'].includes(next.toLowerCase().trim()),
313
+ })
314
+
315
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
316
+ if (event.key === ' ' || event.key === 'Enter') {
317
+ event.preventDefault()
318
+ onChange(!value)
319
+ }
320
+ }
321
+
322
+ return (
323
+ <div
324
+ ref={wrapRef}
325
+ tabIndex={0}
326
+ role="switch"
327
+ aria-checked={value}
328
+ aria-label={ariaLabel}
329
+ onFocus={() => {
330
+ ctx.setAnchor(coords)
331
+ ctx.setExtent(coords)
332
+ }}
333
+ onKeyDown={onKeyDown}
334
+ className={cn(
335
+ 'flex h-9 w-full cursor-cell items-center px-3 outline-none ring-inset transition-colors',
336
+ isSelected && 'bg-blue-500/10',
337
+ isFocused && 'ring-2 ring-blue-500',
338
+ )}
339
+ >
340
+ <Switch checked={value} onCheckedChange={onChange} aria-hidden tabIndex={-1} />
341
+ </div>
342
+ )
343
+ }
344
+
345
+ interface SelectCellProps<V extends string> extends BaseCellProps {
346
+ value: V
347
+ onChange: (next: V) => void
348
+ options: ReadonlyArray<{ value: V; label: string }>
349
+ }
350
+
351
+ /** Cell for fixed-option pickers. Enter opens the Select; arrow keys inside
352
+ * the open Select navigate options (Base UI default). Esc closes. */
353
+ export function SelectCell<V extends string>({
354
+ coords,
355
+ value,
356
+ onChange,
357
+ options,
358
+ ariaLabel,
359
+ }: SelectCellProps<V>) {
360
+ const ctx = useDataGridContext()
361
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
362
+ const isSelected = ctx.isSelected(coords)
363
+ const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
364
+ const id = useId()
365
+
366
+ useStableCellRegistration(coords, {
367
+ focus: () => triggerRef.current?.focus(),
368
+ read: () => value,
369
+ write: (next) => {
370
+ if (options.some((o) => o.value === next)) onChange(next as V)
371
+ },
372
+ canWrite: (next) => options.some((o) => o.value === next) || next === '',
373
+ })
374
+
375
+ return (
376
+ <Select items={options} value={value} onValueChange={(v) => onChange(v as V)}>
377
+ <SelectTrigger
378
+ ref={triggerRef}
379
+ id={id}
380
+ aria-label={ariaLabel}
381
+ onFocus={() => {
382
+ ctx.setAnchor(coords)
383
+ ctx.setExtent(coords)
384
+ }}
385
+ className={cn(
386
+ // Strip the Select's standard chrome so the cell reads as a flat
387
+ // grid slot. The cell's own ring/bg comes from below.
388
+ 'h-9 min-h-0 w-full cursor-cell rounded-none border-0 bg-transparent px-3 text-sm shadow-none ring-inset transition-colors focus:border-0 focus:shadow-none',
389
+ isSelected && 'bg-blue-500/10',
390
+ isFocused && 'ring-2 ring-blue-500',
391
+ )}
392
+ >
393
+ <SelectValue />
394
+ </SelectTrigger>
395
+ <SelectContent>
396
+ {options.map((opt) => (
397
+ <SelectItem key={opt.value} value={opt.value}>
398
+ {opt.label}
399
+ </SelectItem>
400
+ ))}
401
+ </SelectContent>
402
+ </Select>
403
+ )
404
+ }
405
+
406
+ interface ReadOnlyCellProps {
407
+ children: ReactNode
408
+ className?: string
409
+ }
410
+
411
+ /** Plain non-editable cell — first column of a row (e.g. label / link).
412
+ * Not registered with the grid, so Tab/arrows skip it. */
413
+ export function ReadOnlyCell({ children, className }: ReadOnlyCellProps) {
414
+ return <div className={cn('flex h-9 items-center px-3 text-sm', className)}>{children}</div>
415
+ }
@@ -0,0 +1,59 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { CellCoords, CellKey, CellRegistration, GridMode } from './types'
3
+
4
+ export interface DataGridContextValue {
5
+ /** Stable map of `${row}.${col}` → registration. Mutated imperatively by
6
+ * cells in their mount effect so we don't churn React state on every
7
+ * render. Consumers should iterate via `eachCell()` rather than peeking
8
+ * at the Map directly. */
9
+ cells: Map<CellKey, CellRegistration>
10
+
11
+ /** Bounds, recomputed when cells register/unregister. */
12
+ bounds: { maxRow: number; maxCol: number }
13
+
14
+ /** Selection anchor (origin of a Shift+Arrow extension) and extent (current
15
+ * far corner). When equal, only one cell is selected. Null means no
16
+ * selection at all. */
17
+ anchor: CellCoords | null
18
+ extent: CellCoords | null
19
+
20
+ /** Currently editing cell. When set, keyboard nav shortcuts are routed
21
+ * through the cell instead of the grid. */
22
+ editing: CellCoords | null
23
+
24
+ /** Dispatchers — components react via subscribed selectors below. */
25
+ setAnchor: (coords: CellCoords | null) => void
26
+ setExtent: (coords: CellCoords | null) => void
27
+ setEditing: (coords: CellCoords | null) => void
28
+
29
+ /** Register/unregister a cell. Returns a cleanup. */
30
+ registerCell: (reg: CellRegistration) => () => void
31
+
32
+ /** Snapshot helpers used by the keyboard hook. */
33
+ isSelected: (coords: CellCoords) => boolean
34
+ selectedCells: () => CellRegistration[]
35
+ }
36
+
37
+ export const DataGridContext = createContext<DataGridContextValue | null>(null)
38
+
39
+ export function useDataGridContext(): DataGridContextValue {
40
+ const ctx = useContext(DataGridContext)
41
+ if (!ctx) {
42
+ throw new Error('Data grid cells must be rendered inside <DataGrid>.')
43
+ }
44
+ return ctx
45
+ }
46
+
47
+ /** Inclusive rectangle test. */
48
+ export function coordsInRect(c: CellCoords, a: CellCoords | null, b: CellCoords | null): boolean {
49
+ if (!a || !b) return false
50
+ const minRow = Math.min(a.row, b.row)
51
+ const maxRow = Math.max(a.row, b.row)
52
+ const minCol = Math.min(a.col, b.col)
53
+ const maxCol = Math.max(a.col, b.col)
54
+ return c.row >= minRow && c.row <= maxRow && c.col >= minCol && c.col <= maxCol
55
+ }
56
+
57
+ export function modeFor(editing: CellCoords | null, coords: CellCoords): GridMode {
58
+ return editing && editing.row === coords.row && editing.col === coords.col ? 'edit' : 'select'
59
+ }