@spree/dashboard-ui 0.13.0 → 1.0.0-beta.1

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 (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +39 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. package/src/ui/sonner.tsx +0 -47
@@ -80,12 +80,20 @@ export function NumberCell({
80
80
  commit()
81
81
  ctx.setEditing(null)
82
82
  // Move down to mirror Excel.
83
- const next = { row: coords.row + 1, col: coords.col }
83
+ // Clamped like arrow navigation: on the last row Enter would otherwise
84
+ // anchor outside the grid, and every arrow key after it would compute
85
+ // from a position that does not exist.
86
+ const next = { row: Math.min(coords.row + 1, ctx.bounds.maxRow), col: coords.col }
84
87
  ctx.setAnchor(next)
85
88
  ctx.setExtent(next)
86
- ctx.cells.get(`${next.row}.${next.col}`)?.focus()
89
+ ctx.focusCell(next)
87
90
  } else if (event.key === 'Escape') {
88
91
  event.preventDefault()
92
+ // Base UI's dialog listens for Escape on the document, so preventing
93
+ // the default is not enough to keep it from closing the sheet behind
94
+ // this cell: the key has to stop here (docs/plans/6.0-volume-pricing.md).
95
+ event.stopPropagation()
96
+ event.nativeEvent.stopImmediatePropagation()
89
97
  setDraft(value.toString())
90
98
  ctx.setEditing(null)
91
99
  } else if (event.key === 'Tab') {
@@ -96,7 +104,7 @@ export function NumberCell({
96
104
  const next = { row: coords.row, col: coords.col + dx }
97
105
  ctx.setAnchor(next)
98
106
  ctx.setExtent(next)
99
- ctx.cells.get(`${next.row}.${next.col}`)?.focus()
107
+ ctx.focusCell(next)
100
108
  }
101
109
  }
102
110
 
@@ -140,8 +148,9 @@ interface MoneyCellProps extends BaseCellProps {
140
148
  * `"12,50"` are fine, the backend's `Spree::LocalizedNumber` does the
141
149
  * parsing. `null` only when the user explicitly blanks the field. */
142
150
  onChange: (next: string | null) => void
143
- /** Currency symbol rendered as a non-editable prefix (`$`, `€`, `kr`, …). */
144
- symbol: string
151
+ /** Currency symbol rendered as a non-editable prefix (`$`, `€`, `kr`, …).
152
+ * Omit for a plain decimal cell with no prefix. */
153
+ symbol?: string
145
154
  /**
146
155
  * Locale decimal separator used only for display (canonical `.` →
147
156
  * locale char). Whatever the user types is shipped as-is; the
@@ -222,12 +231,20 @@ export function MoneyCell({
222
231
  event.preventDefault()
223
232
  commit()
224
233
  ctx.setEditing(null)
225
- const next = { row: coords.row + 1, col: coords.col }
234
+ // Clamped like arrow navigation: on the last row Enter would otherwise
235
+ // anchor outside the grid, and every arrow key after it would compute
236
+ // from a position that does not exist.
237
+ const next = { row: Math.min(coords.row + 1, ctx.bounds.maxRow), col: coords.col }
226
238
  ctx.setAnchor(next)
227
239
  ctx.setExtent(next)
228
- ctx.cells.get(`${next.row}.${next.col}`)?.focus()
240
+ ctx.focusCell(next)
229
241
  } else if (event.key === 'Escape') {
230
242
  event.preventDefault()
243
+ // Base UI's dialog listens for Escape on the document, so preventing
244
+ // the default is not enough to keep it from closing the sheet behind
245
+ // this cell: the key has to stop here (docs/plans/6.0-volume-pricing.md).
246
+ event.stopPropagation()
247
+ event.nativeEvent.stopImmediatePropagation()
231
248
  setDraft(formatDisplay(value))
232
249
  ctx.setEditing(null)
233
250
  } else if (event.key === 'Tab') {
@@ -238,7 +255,7 @@ export function MoneyCell({
238
255
  const next = { row: coords.row, col: coords.col + dx }
239
256
  ctx.setAnchor(next)
240
257
  ctx.setExtent(next)
241
- ctx.cells.get(`${next.row}.${next.col}`)?.focus()
258
+ ctx.focusCell(next)
242
259
  }
243
260
  }
244
261
 
@@ -252,12 +269,14 @@ export function MoneyCell({
252
269
  isEditing && 'bg-card ring-2 ring-blue-500 ring-inset',
253
270
  )}
254
271
  >
255
- <span
256
- className="pl-3 text-muted-foreground pointer-events-none select-none tabular-nums"
257
- aria-hidden
258
- >
259
- {symbol}
260
- </span>
272
+ {symbol != null && (
273
+ <span
274
+ className="pl-3 text-muted-foreground pointer-events-none select-none tabular-nums"
275
+ aria-hidden
276
+ >
277
+ {symbol}
278
+ </span>
279
+ )}
261
280
  <input
262
281
  ref={inputRef}
263
282
  type="text"
@@ -279,7 +298,133 @@ export function MoneyCell({
279
298
  }}
280
299
  aria-label={ariaLabel}
281
300
  className={cn(
282
- 'block h-9 w-full cursor-cell border-0 bg-transparent pl-1 pr-3 text-right tabular-nums outline-none',
301
+ 'block h-9 w-full cursor-cell border-0 bg-transparent pr-3 text-right tabular-nums outline-none',
302
+ symbol != null ? 'pl-1' : 'pl-3',
303
+ isEditing && 'cursor-text',
304
+ )}
305
+ />
306
+ </div>
307
+ )
308
+ }
309
+
310
+ /** Editable decimal cell — `MoneyCell` without the currency prefix. Keeps
311
+ * decimals (unlike `NumberCell`, which truncates to integers); for
312
+ * measurements like weight and dimensions. */
313
+ export function DecimalCell(props: Omit<MoneyCellProps, 'symbol'>) {
314
+ return <MoneyCell {...props} />
315
+ }
316
+
317
+ interface TextCellProps extends BaseCellProps {
318
+ value: string | null
319
+ /** Receives the user's raw input; `null` when the field is blanked. */
320
+ onChange: (next: string | null) => void
321
+ }
322
+
323
+ /** Editable free-text cell (SKU, barcode, …). Left-aligned; ships the raw
324
+ * input on commit, `null` when blanked. */
325
+ export function TextCell({ coords, value, onChange, ariaLabel }: TextCellProps) {
326
+ const ctx = useDataGridContext()
327
+ const inputRef = useRef<HTMLInputElement | null>(null)
328
+ const [draft, setDraft] = useState(value ?? '')
329
+ const isSelected = ctx.isSelected(coords)
330
+ const mode = modeFor(ctx.editing, coords)
331
+ const isEditing = mode === 'edit'
332
+ const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
333
+
334
+ // Resync draft with external value when not editing.
335
+ useEffect(() => {
336
+ if (!isEditing) setDraft(value ?? '')
337
+ }, [value, isEditing])
338
+
339
+ // Auto-focus + select when entering edit mode.
340
+ useEffect(() => {
341
+ if (isEditing) {
342
+ const el = inputRef.current
343
+ if (el) {
344
+ el.focus()
345
+ el.select()
346
+ }
347
+ }
348
+ }, [isEditing])
349
+
350
+ const wrapRef = useRef<HTMLDivElement | null>(null)
351
+ useStableCellRegistration(coords, {
352
+ focus: () => inputRef.current?.focus(),
353
+ read: () => value ?? '',
354
+ write: (next) => onChange(next.trim() ? next : null),
355
+ canWrite: () => true,
356
+ getElement: () => wrapRef.current,
357
+ })
358
+
359
+ function commit() {
360
+ onChange(draft.trim() ? draft : null)
361
+ }
362
+
363
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
364
+ if (!isEditing) return
365
+ if (event.key === 'Enter') {
366
+ event.preventDefault()
367
+ commit()
368
+ ctx.setEditing(null)
369
+ // Clamped like arrow navigation: on the last row Enter would otherwise
370
+ // anchor outside the grid, and every arrow key after it would compute
371
+ // from a position that does not exist.
372
+ const next = { row: Math.min(coords.row + 1, ctx.bounds.maxRow), col: coords.col }
373
+ ctx.setAnchor(next)
374
+ ctx.setExtent(next)
375
+ ctx.focusCell(next)
376
+ } else if (event.key === 'Escape') {
377
+ event.preventDefault()
378
+ // Base UI's dialog listens for Escape on the document, so preventing
379
+ // the default is not enough to keep it from closing the sheet behind
380
+ // this cell: the key has to stop here (docs/plans/6.0-volume-pricing.md).
381
+ event.stopPropagation()
382
+ event.nativeEvent.stopImmediatePropagation()
383
+ setDraft(value ?? '')
384
+ ctx.setEditing(null)
385
+ } else if (event.key === 'Tab') {
386
+ event.preventDefault()
387
+ commit()
388
+ ctx.setEditing(null)
389
+ const dx = event.shiftKey ? -1 : 1
390
+ const next = { row: coords.row, col: coords.col + dx }
391
+ ctx.setAnchor(next)
392
+ ctx.setExtent(next)
393
+ ctx.focusCell(next)
394
+ }
395
+ }
396
+
397
+ return (
398
+ <div
399
+ ref={wrapRef}
400
+ className={cn(
401
+ 'flex h-9 w-full items-center transition-colors',
402
+ isSelected && 'bg-blue-500/10',
403
+ isFocused && !isEditing && 'ring-2 ring-blue-500 ring-inset',
404
+ isEditing && 'bg-card ring-2 ring-blue-500 ring-inset',
405
+ )}
406
+ >
407
+ <input
408
+ ref={inputRef}
409
+ type="text"
410
+ value={isEditing ? draft : (value ?? '')}
411
+ readOnly={!isEditing}
412
+ onChange={(e) => setDraft(e.target.value)}
413
+ onFocus={() => {
414
+ ctx.setAnchor(coords)
415
+ ctx.setExtent(coords)
416
+ }}
417
+ onDoubleClick={() => ctx.setEditing(coords)}
418
+ onKeyDown={onKeyDown}
419
+ onBlur={() => {
420
+ if (isEditing) {
421
+ commit()
422
+ ctx.setEditing(null)
423
+ }
424
+ }}
425
+ aria-label={ariaLabel}
426
+ className={cn(
427
+ 'block h-9 w-full cursor-cell border-0 bg-transparent px-3 outline-none',
283
428
  isEditing && 'cursor-text',
284
429
  )}
285
430
  />
@@ -343,18 +488,28 @@ export function SwitchCell({ coords, value, onChange, ariaLabel }: SwitchCellPro
343
488
  }
344
489
 
345
490
  interface SelectCellProps<V extends string> extends BaseCellProps {
346
- value: V
347
- onChange: (next: V) => void
348
- options: ReadonlyArray<{ value: V; label: string }>
491
+ value: V | null
492
+ /** `null` when the cell is cleared (Delete key, or pasting a blank). */
493
+ onChange: (next: V | null) => void
494
+ /**
495
+ * `label` is what the cell shows and what a pasted value matches against, so
496
+ * it stays the plain name. `hint` is extra detail shown only in the open
497
+ * list — a size, a code — for telling similar options apart.
498
+ */
499
+ options: ReadonlyArray<{ value: V; label: string; hint?: string }>
500
+ /** Shown when value is null — e.g. "—" for "inherit the default". */
501
+ nullLabel?: string
349
502
  }
350
503
 
351
504
  /** Cell for fixed-option pickers. Enter opens the Select; arrow keys inside
352
- * the open Select navigate options (Base UI default). Esc closes. */
505
+ * the open Select navigate options (Base UI default). Esc closes. Pasted
506
+ * values match by option value or label; blanks clear to null. */
353
507
  export function SelectCell<V extends string>({
354
508
  coords,
355
509
  value,
356
510
  onChange,
357
511
  options,
512
+ nullLabel,
358
513
  ariaLabel,
359
514
  }: SelectCellProps<V>) {
360
515
  const ctx = useDataGridContext()
@@ -363,17 +518,27 @@ export function SelectCell<V extends string>({
363
518
  const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
364
519
  const id = useId()
365
520
 
521
+ const matchOption = (raw: string) => {
522
+ const needle = raw.trim()
523
+ return options.find((o) => o.value === needle || o.label === needle)
524
+ }
525
+
366
526
  useStableCellRegistration(coords, {
367
527
  focus: () => triggerRef.current?.focus(),
368
- read: () => value,
528
+ read: () => value ?? '',
369
529
  write: (next) => {
370
- if (options.some((o) => o.value === next)) onChange(next as V)
530
+ if (next.trim() === '') {
531
+ onChange(null)
532
+ return
533
+ }
534
+ const match = matchOption(next)
535
+ if (match) onChange(match.value)
371
536
  },
372
- canWrite: (next) => options.some((o) => o.value === next) || next === '',
537
+ canWrite: (next) => next.trim() === '' || matchOption(next) !== undefined,
373
538
  })
374
539
 
375
540
  return (
376
- <Select items={options} value={value} onValueChange={(v) => onChange(v as V)}>
541
+ <Select items={options} value={value ?? ''} onValueChange={(v) => onChange((v || null) as V)}>
377
542
  <SelectTrigger
378
543
  ref={triggerRef}
379
544
  id={id}
@@ -390,12 +555,21 @@ export function SelectCell<V extends string>({
390
555
  isFocused && 'ring-2 ring-blue-500',
391
556
  )}
392
557
  >
393
- <SelectValue />
558
+ <SelectValue>
559
+ {(v) => options.find((o) => o.value === v)?.label ?? nullLabel ?? ''}
560
+ </SelectValue>
394
561
  </SelectTrigger>
395
562
  <SelectContent>
396
563
  {options.map((opt) => (
397
564
  <SelectItem key={opt.value} value={opt.value}>
398
- {opt.label}
565
+ {opt.hint ? (
566
+ <span className="flex w-full items-baseline justify-between gap-4">
567
+ <span>{opt.label}</span>
568
+ <span className="text-xs text-muted-foreground tabular-nums">{opt.hint}</span>
569
+ </span>
570
+ ) : (
571
+ opt.label
572
+ )}
399
573
  </SelectItem>
400
574
  ))}
401
575
  </SelectContent>
@@ -23,6 +23,12 @@ export interface DataGridContextValue {
23
23
 
24
24
  /** Dispatchers — components react via subscribed selectors below. */
25
25
  setAnchor: (coords: CellCoords | null) => void
26
+ /**
27
+ * Moves focus to a cell, waiting for it to register when the commit that
28
+ * asked for it also added the row. Prefer this over reading `cells`
29
+ * directly — a lookup that misses silently drops keyboard navigation.
30
+ */
31
+ focusCell: (coords: CellCoords) => void
26
32
  setExtent: (coords: CellCoords | null) => void
27
33
  setEditing: (coords: CellCoords | null) => void
28
34
 
@@ -26,6 +26,17 @@ interface DataGridProps<T> {
26
26
  className?: string
27
27
  /** Optional caption-row aria-label etc. */
28
28
  'aria-label'?: string
29
+ /**
30
+ * Whether the grid scrolls inside its own box.
31
+ *
32
+ * `true` for a grid too wide or tall for where it sits — the bulk variant
33
+ * editor in its dialog, which has columns well past any viewport. `false`
34
+ * when the container already handles it, or when the grid is sized to fit:
35
+ * the fill handle straddles the selection's bottom-right corner and hangs a
36
+ * few pixels past the table, which a scroll container counts as content, so
37
+ * selecting an edge cell raises scrollbars on a grid that fits.
38
+ */
39
+ scrollable?: boolean
29
40
  }
30
41
 
31
42
  export function DataGrid<T>({
@@ -35,6 +46,7 @@ export function DataGrid<T>({
35
46
  renderSectionHeader,
36
47
  className,
37
48
  'aria-label': ariaLabel,
49
+ scrollable = false,
38
50
  }: DataGridProps<T>) {
39
51
  const table = useReactTable<T>({
40
52
  data: rows,
@@ -49,6 +61,7 @@ export function DataGrid<T>({
49
61
  renderSectionHeader={renderSectionHeader}
50
62
  className={className}
51
63
  ariaLabel={ariaLabel}
64
+ scrollable={scrollable}
52
65
  />
53
66
  )
54
67
  }
@@ -58,16 +71,22 @@ function DataGridShell<T>({
58
71
  renderSectionHeader,
59
72
  className,
60
73
  ariaLabel,
74
+ scrollable,
61
75
  }: {
62
76
  table: Table<T>
63
77
  renderSectionHeader?: RenderSectionHeader<T>
64
78
  className?: string
65
79
  ariaLabel?: string
80
+ scrollable?: boolean
66
81
  }) {
67
82
  const gridRef = useRef<HTMLTableElement | null>(null)
68
83
  const cellsRef = useRef<Map<CellKey, CellRegistration>>(new Map())
69
84
  const [bounds, setBounds] = useState<{ maxRow: number; maxCol: number }>({ maxRow: 0, maxCol: 0 })
70
85
  const [anchor, setAnchor] = useState<CellCoords | null>(null)
86
+ // Read by the blur handler, which fires from a DOM event and would
87
+ // otherwise close over a stale anchor.
88
+ const anchorRef = useRef<CellCoords | null>(null)
89
+ anchorRef.current = anchor
71
90
  const [extent, setExtent] = useState<CellCoords | null>(null)
72
91
  const [editing, setEditing] = useState<CellCoords | null>(null)
73
92
 
@@ -83,11 +102,53 @@ function DataGridShell<T>({
83
102
  )
84
103
  }, [])
85
104
 
105
+ // A cell the grid wants focused that has not registered yet. Cells register
106
+ // in an effect, so a commit that ADDS a row — a spreadsheet whose last line
107
+ // is a blank waiting to be filled — asks for a cell that does not exist
108
+ // until the next render. Without this the focus is dropped on the floor and
109
+ // the arrow keys stop responding, since nothing holds the selection
110
+ // (docs/plans/6.0-volume-pricing.md).
111
+ const pendingFocusRef = useRef<CellCoords | null>(null)
112
+
113
+ const focusCell = useCallback((coords: CellCoords) => {
114
+ const target = cellsRef.current.get(cellKey(coords))
115
+ // Focus now if the cell is already there, but ask again once the render
116
+ // this commit triggers has settled. Committing an edit re-renders the
117
+ // row, and React replaces the input we just focused — inside a dialog
118
+ // the browser then falls back to the dialog itself, which leaves nothing
119
+ // holding the selection and the arrow keys dead until the merchant
120
+ // clicks a cell (docs/plans/6.0-volume-pricing.md).
121
+ target?.focus()
122
+ pendingFocusRef.current = coords
123
+ requestAnimationFrame(() => {
124
+ const pending = pendingFocusRef.current
125
+ if (!pending || cellKey(pending) !== cellKey(coords)) return
126
+ pendingFocusRef.current = null
127
+ const cell = cellsRef.current.get(cellKey(coords))
128
+ if (cell) {
129
+ cell.focus()
130
+ return
131
+ }
132
+ // No cell to focus — keep the keyboard in the grid rather than letting
133
+ // it fall back to whatever contains it.
134
+ gridRef.current?.focus()
135
+ })
136
+ }, [])
137
+
86
138
  const registerCell = useCallback(
87
139
  (reg: CellRegistration) => {
88
140
  const key = cellKey(reg.coords)
89
141
  cellsRef.current.set(key, reg)
90
142
  recomputeBounds()
143
+
144
+ // The cell someone asked for has arrived — give it the focus it was
145
+ // promised, after this render commits.
146
+ const pending = pendingFocusRef.current
147
+ if (pending && cellKey(pending) === key) {
148
+ pendingFocusRef.current = null
149
+ queueMicrotask(() => cellsRef.current.get(key)?.focus())
150
+ }
151
+
91
152
  return () => {
92
153
  cellsRef.current.delete(key)
93
154
  recomputeBounds()
@@ -121,38 +182,119 @@ function DataGridShell<T>({
121
182
  setExtent,
122
183
  setEditing,
123
184
  registerCell,
185
+ focusCell,
124
186
  isSelected,
125
187
  selectedCells,
126
188
  }),
127
- [bounds, anchor, extent, editing, registerCell, isSelected, selectedCells],
189
+ [bounds, anchor, extent, editing, registerCell, focusCell, isSelected, selectedCells],
128
190
  )
129
191
 
130
192
  return (
131
193
  <DataGridContext.Provider value={ctx}>
132
194
  <DataGridKeyboardMount gridRef={gridRef} />
133
- <div className="relative overflow-hidden rounded-md">
195
+ {/* Scrolls sideways rather than clipping: a grid wide enough to need it
196
+ (the bulk variant editor sets a min-width well past any viewport) had
197
+ its right-hand columns cut off with no way to reach them, because the
198
+ `overflow-hidden` that used to clip this wrapper also beat the
199
+ caller's own scroll container. Nothing rounds the corners now — the
200
+ table's own cell borders draw its edges.
201
+
202
+ `max-h-full` is what keeps the sticky header working. Declaring one
203
+ overflow axis makes the other a scroll container too whatever it is
204
+ declared as, so this div scrolls vertically whether or not we ask it
205
+ to — and an unbounded one grows to its full content height, which
206
+ leaves `position: sticky` with nothing to stick within. Bounding it
207
+ to the caller's height gives the header a viewport again, and a
208
+ caller that imposes no height (a grid that scrolls with the page)
209
+ still resolves to no constraint.
210
+
211
+ All of it is behind `scrollable`, because a scroll container is not
212
+ free: `position: absolute` takes the fill handle out of layout flow
213
+ but NOT out of a scroller's scrollable overflow, and the handle
214
+ straddles the selection's bottom-right corner. On a cell at the
215
+ grid's edge it therefore hangs a few pixels past the table and the
216
+ container grows to contain it — so selecting an edge cell raised
217
+ scrollbars on a grid that fits its card. A grid that does not need
218
+ to scroll should not be a scroll container. */}
219
+ <div className={cn('relative', scrollable && 'themed-scrollbar max-h-full overflow-auto')}>
134
220
  <table
135
221
  ref={gridRef}
222
+ // Focusable so the grid itself can hold the keyboard when a cell's
223
+ // input goes away. Committing an edit re-renders the row and React
224
+ // replaces that input; inside a dialog the focus trap then pulls
225
+ // focus to the dialog, which is outside the grid, and every arrow
226
+ // key after it lands on nothing (docs/plans/6.0-volume-pricing.md).
227
+ tabIndex={-1}
136
228
  className={cn(
137
- 'w-full border-collapse text-sm [&_td]:border [&_th]:border [&_td]:border-border [&_th]:border-border',
229
+ 'w-full border-collapse text-sm outline-none [&_td]:border [&_th]:border [&_td]:border-border [&_th]:border-border',
230
+ // Internal rules only. `border` on every cell also draws the grid's
231
+ // outer edge — `border-collapse` merges neighbours, so the outermost
232
+ // cells' outer sides become the table's own frame, doubling the
233
+ // card's border a pixel away from it. These grids are always inside
234
+ // a container that draws that edge already.
235
+ '[&_tr>*:first-child]:border-l-0 [&_tr>*:last-child]:border-r-0 [&_thead_tr:first-child>*]:border-t-0 [&_tbody_tr:last-child>*]:border-b-0',
236
+ // The header draws its bottom rule as an inset shadow (see the
237
+ // `<th>` below). Both the header's own bottom border and the
238
+ // first body row's top border would stack beside that shadow, so
239
+ // the one rule between the header and the body is the shadow.
240
+ '[&_thead_th]:border-b-0 [&_tbody_tr:first-child>*]:border-t-0',
138
241
  className,
139
242
  )}
140
243
  aria-label={ariaLabel}
141
244
  onBlurCapture={(e) => {
142
- // If focus leaves the grid entirely, drop the selection so a
143
- // fresh focus-in starts clean.
245
+ // A cell unmounting mid-commit is not the merchant leaving the
246
+ // grid: `relatedTarget` is null there, and the dialog's focus
247
+ // trap then claims the focus a frame later. Take it back on the
248
+ // spot and keep the selection, rather than racing that trap on a
249
+ // timer (docs/plans/6.0-volume-pricing.md).
144
250
  const next = e.relatedTarget as Node | null
145
251
  if (next && gridRef.current?.contains(next)) return
252
+ // Two hops the merchant did not ask for, both caused by a cell
253
+ // being replaced mid-commit: the browser dropping focus
254
+ // (`relatedTarget` null), and the surrounding dialog's focus trap
255
+ // then claiming it. Neither means the grid was left, so keep the
256
+ // selection and take the keyboard back.
257
+ const trapped =
258
+ next === null ||
259
+ (next instanceof Element &&
260
+ next.closest('[role="dialog"]')?.contains(gridRef.current ?? null) === true)
261
+ if (trapped) {
262
+ // After this event resolves, or the trap that prompted it wins.
263
+ if (anchorRef.current) {
264
+ queueMicrotask(() => gridRef.current?.focus({ preventScroll: true }))
265
+ }
266
+ return
267
+ }
268
+ // Focus moved somewhere real outside the grid — the merchant is
269
+ // done here, so drop the selection and let a fresh focus-in start
270
+ // clean.
146
271
  setAnchor(null)
147
272
  setExtent(null)
148
273
  setEditing(null)
149
274
  }}
150
275
  >
151
- <thead className="sticky top-0 z-10 bg-muted/60 text-xs text-muted-foreground">
276
+ {/* The fill is on the cells, not the row: under `border-collapse` a
277
+ background on `<thead>` or `<tr>` paints unreliably, and it has to
278
+ be opaque — this row pins over the grid's own scrolling content,
279
+ so a translucent one (it was `bg-muted/60`) lets the rows behind
280
+ it read straight through the column labels. */}
281
+ <thead className="sticky top-0 z-10 text-xs text-muted-foreground">
152
282
  {table.getHeaderGroups().map((group) => (
153
283
  <tr key={group.id}>
154
284
  {group.headers.map((header) => (
155
- <th key={header.id} className="h-8 px-3 text-left font-medium">
285
+ <th
286
+ key={header.id}
287
+ // The bottom rule is an inset shadow, not the cell's own
288
+ // border: under `border-collapse` the collapsed borders
289
+ // belong to the table, so they scroll away with it and a
290
+ // pinned header ends up with rows sliding flush against its
291
+ // labels. A shadow belongs to the cell and pins with it.
292
+ //
293
+ // The table-level classes zero this cell's own top and
294
+ // bottom borders: a border sits outside the padding box and
295
+ // the shadow inside, so leaving both draws two rules at rest.
296
+ className="h-8 bg-muted px-3 text-left font-medium shadow-[inset_0_-1px_0_0_var(--border)]"
297
+ >
156
298
  {header.isPlaceholder
157
299
  ? null
158
300
  : flexRender(header.column.columnDef.header, header.getContext())}
@@ -3,14 +3,18 @@
3
3
  * the cell at that position has stable grid coords that exclude section
4
4
  * headers. Used by every spreadsheet that mixes a `renderSectionHeader`
5
5
  * with `<NumberCell>` / `<MoneyCell>` etc.
6
+ *
7
+ * Every kind but `header` counts, so a sheet that indents child rows under a
8
+ * parent — a variant's quantity breaks, say — keeps Tab and the arrow keys
9
+ * flowing through them in the order they are read.
6
10
  */
7
- export function editableRowIndex<T extends { kind: 'header' | 'item'; id: string }>(
11
+ export function editableRowIndex<T extends { kind: string; id: string }>(
8
12
  rows: ReadonlyArray<{ id: string; original: T }>,
9
13
  rowId: string,
10
14
  ): number {
11
15
  let i = 0
12
16
  for (const row of rows) {
13
- if (row.original.kind !== 'item') continue
17
+ if (row.original.kind === 'header') continue
14
18
  if (row.id === rowId) return i
15
19
  i++
16
20
  }
@@ -1,4 +1,12 @@
1
- export { MoneyCell, NumberCell, ReadOnlyCell, SelectCell, SwitchCell } from './cells'
1
+ export {
2
+ DecimalCell,
3
+ MoneyCell,
4
+ NumberCell,
5
+ ReadOnlyCell,
6
+ SelectCell,
7
+ SwitchCell,
8
+ TextCell,
9
+ } from './cells'
2
10
  export { useDataGridContext } from './context'
3
11
  export { DataGrid } from './data-grid'
4
12
  export { editableRowIndex } from './helpers'
@@ -9,8 +9,10 @@ export function useDataGridKeyboard(gridRef: RefObject<HTMLElement | null>) {
9
9
  const target = gridRef
10
10
 
11
11
  function focusCoords(coords: CellCoords) {
12
- const cell = ctx.cells.get(`${coords.row}.${coords.col}`)
13
- cell?.focus()
12
+ // Through the grid rather than the registry: a cell added by the same
13
+ // commit has not registered yet, and a missed lookup silently drops
14
+ // keyboard navigation.
15
+ ctx.focusCell(coords)
14
16
  }
15
17
 
16
18
  function navigate(dx: number, dy: number) {