@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +13 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +38 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- 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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
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 (
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
143
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
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) {
|