@spree/dashboard-ui 6.0.3 → 6.0.4

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 (84) hide show
  1. package/package.json +20 -19
  2. package/src/index.ts +5 -0
  3. package/src/lib/field-trigger.ts +10 -0
  4. package/src/lib/same-rich-text.ts +12 -3
  5. package/src/lib/utils.ts +14 -1
  6. package/src/spree/address-block.tsx +3 -3
  7. package/src/spree/address-book-row.tsx +88 -0
  8. package/src/spree/bulk-price-table.tsx +3 -3
  9. package/src/spree/claim-resolve-dialog.tsx +1 -1
  10. package/src/spree/collapsible-card.tsx +2 -2
  11. package/src/spree/color-picker.tsx +1 -1
  12. package/src/spree/currency-label.tsx +71 -0
  13. package/src/spree/data-grid/cells.tsx +2 -2
  14. package/src/spree/data-grid/data-grid.tsx +2 -2
  15. package/src/spree/delivery-form-dialog.tsx +1 -1
  16. package/src/spree/drag-handle.tsx +1 -1
  17. package/src/spree/editable-note-card.tsx +2 -2
  18. package/src/spree/form-actions.tsx +32 -10
  19. package/src/spree/fulfill-items-form.tsx +20 -22
  20. package/src/spree/fulfillment-edit-dialog.tsx +1 -1
  21. package/src/spree/fulfillment-item-list.tsx +87 -28
  22. package/src/spree/fulfillment-panel.tsx +2 -2
  23. package/src/spree/icons.ts +8 -0
  24. package/src/spree/json-preview-drawer.tsx +32 -7
  25. package/src/spree/json-value-view.tsx +14 -4
  26. package/src/spree/label-upload-dialog.tsx +1 -1
  27. package/src/spree/language-menu-items.tsx +1 -1
  28. package/src/spree/metadata/metadata-card.tsx +2 -2
  29. package/src/spree/order-cancel-dialog.tsx +3 -3
  30. package/src/spree/post-sale-create-dialogs.tsx +36 -33
  31. package/src/spree/post-sale-fields.tsx +30 -1
  32. package/src/spree/product-identity.tsx +74 -0
  33. package/src/spree/product-membership-list.tsx +79 -41
  34. package/src/spree/products/status-card.tsx +1 -1
  35. package/src/spree/quantity-tier-editor.tsx +1 -1
  36. package/src/spree/return-dialogs.tsx +2 -2
  37. package/src/spree/search-input.tsx +3 -1
  38. package/src/spree/secret-input.tsx +1 -1
  39. package/src/spree/section.tsx +55 -0
  40. package/src/spree/stat-item.tsx +27 -0
  41. package/src/spree/tax-identifiers-card.tsx +117 -76
  42. package/src/spree/theme-toggle.tsx +1 -1
  43. package/src/spree/wizard.tsx +2 -2
  44. package/src/styles.css +226 -120
  45. package/src/ui/alert.tsx +20 -17
  46. package/src/ui/attachment.tsx +1 -1
  47. package/src/ui/avatar.tsx +2 -2
  48. package/src/ui/badge.tsx +1 -1
  49. package/src/ui/breadcrumb.tsx +1 -1
  50. package/src/ui/button.tsx +14 -11
  51. package/src/ui/calendar.tsx +2 -2
  52. package/src/ui/card.tsx +14 -37
  53. package/src/ui/chart.tsx +1 -1
  54. package/src/ui/checkbox.tsx +2 -2
  55. package/src/ui/code-editor.tsx +6 -2
  56. package/src/ui/collapsible.tsx +21 -2
  57. package/src/ui/combobox.tsx +12 -16
  58. package/src/ui/command.tsx +8 -6
  59. package/src/ui/context-menu.tsx +1 -1
  60. package/src/ui/data-table.tsx +58 -15
  61. package/src/ui/date-picker.tsx +3 -3
  62. package/src/ui/date-range-panel.tsx +102 -0
  63. package/src/ui/date-range-picker.tsx +33 -72
  64. package/src/ui/dialog.tsx +3 -3
  65. package/src/ui/dropdown-menu.tsx +6 -6
  66. package/src/ui/empty.tsx +3 -3
  67. package/src/ui/field.tsx +33 -12
  68. package/src/ui/input-group.tsx +17 -6
  69. package/src/ui/input.tsx +148 -4
  70. package/src/ui/item.tsx +7 -4
  71. package/src/ui/label.tsx +1 -1
  72. package/src/ui/media-preview.tsx +40 -5
  73. package/src/ui/pagination.tsx +16 -4
  74. package/src/ui/progress.tsx +2 -2
  75. package/src/ui/radio-group.tsx +2 -2
  76. package/src/ui/rich-text-editor.tsx +3 -3
  77. package/src/ui/select.tsx +7 -16
  78. package/src/ui/sheet.tsx +13 -5
  79. package/src/ui/sidebar.tsx +5 -5
  80. package/src/ui/switch.tsx +8 -2
  81. package/src/ui/tabs.tsx +45 -9
  82. package/src/ui/textarea.tsx +1 -1
  83. package/src/ui/toast.tsx +2 -2
  84. package/src/ui/tooltip.tsx +1 -1
@@ -3,12 +3,41 @@ import { Field, FieldLabel } from '../ui/field'
3
3
  import { Input } from '../ui/input'
4
4
  import { Item, ItemActions, ItemContent, ItemTitle } from '../ui/item'
5
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
6
+ import { ProductIdentity } from './product-identity'
6
7
 
7
8
  /** A unit on the order that a return, exchange or claim can name. */
8
9
  export type PostSaleUnit = {
9
10
  id: string
11
+ /** Also the quantity field's accessible name. */
10
12
  label: string
11
13
  quantity: number
14
+ /** Shown as the product it is — image, name, options — when known. */
15
+ product?: PostSaleUnitProduct
16
+ }
17
+
18
+ export type PostSaleUnitProduct = {
19
+ name: string
20
+ optionsText?: string | null
21
+ thumbnailUrl?: string | null
22
+ }
23
+
24
+ /**
25
+ * A unit as the product it is, the same row the order's item lists use, or
26
+ * its plain label when the caller has nothing more.
27
+ */
28
+ export function PostSaleUnitLabel({ unit }: { unit: PostSaleUnit }) {
29
+ if (!unit.product) {
30
+ return <ItemTitle className="block truncate font-normal">{unit.label}</ItemTitle>
31
+ }
32
+
33
+ return (
34
+ <ProductIdentity
35
+ name={unit.product.name}
36
+ subtitle={unit.product.optionsText}
37
+ thumbnailUrl={unit.product.thumbnailUrl}
38
+ size="sm"
39
+ />
40
+ )
12
41
  }
13
42
 
14
43
  /** How many of each unit a record covers; zero means "not included". */
@@ -34,7 +63,7 @@ export function QuantityPicker({
34
63
  {units.map((unit) => (
35
64
  <Item key={unit.id} variant="outline" className="flex-nowrap border-border-subtle">
36
65
  <ItemContent className="min-w-0">
37
- <ItemTitle className="block truncate font-normal">{unit.label}</ItemTitle>
66
+ <PostSaleUnitLabel unit={unit} />
38
67
  </ItemContent>
39
68
  <ItemActions>
40
69
  <Input
@@ -0,0 +1,74 @@
1
+ import { mergeProps } from '@base-ui/react/merge-props'
2
+ import { useRender } from '@base-ui/react/use-render'
3
+ import type { ReactNode } from 'react'
4
+ import { cn } from '../lib/utils'
5
+ import { Thumbnail } from '../ui/thumbnail'
6
+ import { PackageIcon } from './icons'
7
+
8
+ export interface ProductIdentityProps extends useRender.ComponentProps<'span'> {
9
+ name?: string | null
10
+ /** A second line: the SKU, option values or a variant's name. */
11
+ subtitle?: ReactNode
12
+ thumbnailUrl?: string | null
13
+ size?: 'sm' | 'md' | 'lg'
14
+ /** Badges and other markers shown after the name. */
15
+ badges?: ReactNode
16
+ /** Strikes the name through, for a row that is about to be removed. */
17
+ struckThrough?: boolean
18
+ }
19
+
20
+ /**
21
+ * A product or variant, the same way in every list: its image, its name and
22
+ * an optional second line.
23
+ *
24
+ * Pass `render` (a router link, or a button a table listens to) to make the
25
+ * whole block the target — image and name together, never the name alone.
26
+ */
27
+ export function ProductIdentity({
28
+ name,
29
+ subtitle,
30
+ thumbnailUrl,
31
+ size = 'md',
32
+ badges,
33
+ struckThrough,
34
+ render,
35
+ className,
36
+ ...props
37
+ }: ProductIdentityProps) {
38
+ const interactive = render !== undefined
39
+
40
+ return useRender({
41
+ defaultTagName: 'span',
42
+ render,
43
+ props: mergeProps<'span'>(
44
+ {
45
+ className: cn(
46
+ 'flex min-w-0 items-center gap-3 text-left text-foreground',
47
+ interactive &&
48
+ 'group cursor-pointer rounded-md no-underline outline-none focus-visible:shadow-field-focus',
49
+ className,
50
+ ),
51
+ children: (
52
+ <>
53
+ <Thumbnail src={thumbnailUrl} size={size} fallback={<PackageIcon />} />
54
+ <span className="flex min-w-0 flex-col">
55
+ <span className="flex min-w-0 items-center gap-2">
56
+ <span
57
+ className={cn('truncate font-medium text-base', struckThrough && 'line-through')}
58
+ >
59
+ {name ?? '—'}
60
+ </span>
61
+ {badges}
62
+ </span>
63
+ {subtitle && (
64
+ <span className="truncate text-muted-foreground text-xs">{subtitle}</span>
65
+ )}
66
+ </span>
67
+ </>
68
+ ),
69
+ },
70
+ props,
71
+ ),
72
+ state: { slot: 'product-identity' },
73
+ })
74
+ }
@@ -15,16 +15,17 @@ import {
15
15
  verticalListSortingStrategy,
16
16
  } from '@dnd-kit/sortable'
17
17
  import { CSS } from '@dnd-kit/utilities'
18
- import { Fragment, type ReactNode, useEffect, useMemo, useState } from 'react'
18
+ import { Fragment, type ReactElement, type ReactNode, useEffect, useMemo, useState } from 'react'
19
19
  import { cn } from '../lib/utils'
20
20
  import { Badge } from '../ui/badge'
21
21
  import { Button } from '../ui/button'
22
22
  import { Checkbox } from '../ui/checkbox'
23
23
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/data-table'
24
+ import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty'
24
25
  import { Pagination, type PaginationMeta } from '../ui/pagination'
25
- import { Thumbnail } from '../ui/thumbnail'
26
26
  import { DragHandle } from './drag-handle'
27
- import { Undo2Icon, XIcon } from './icons'
27
+ import { PackageIcon, Undo2Icon, XIcon } from './icons'
28
+ import { ProductIdentity } from './product-identity'
28
29
  import { SearchInput } from './search-input'
29
30
 
30
31
  /** One product row in a membership list. */
@@ -92,8 +93,8 @@ export interface ProductMembershipListProps {
92
93
  * (page offset already applied). A rejected promise rolls the row back.
93
94
  */
94
95
  onReorder?: (id: string, position: number) => Promise<unknown> | undefined
95
- /** Row title cell — pass a link to the product page; defaults to plain text. */
96
- renderTitle?: (row: ProductMembershipRow) => ReactNode
96
+ /** The element a row's image and name render as — a link to the product page. */
97
+ rowLink?: (row: ProductMembershipRow) => ReactElement
97
98
  /**
98
99
  * Extra columns between the product name and the remove button, for a
99
100
  * parent whose membership carries data of its own — a catalog's quantity
@@ -143,7 +144,7 @@ export function ProductMembershipList({
143
144
  onRestore,
144
145
  reorderable = false,
145
146
  onReorder,
146
- renderTitle,
147
+ rowLink,
147
148
  renderSubRows,
148
149
  extraColumns,
149
150
  }: ProductMembershipListProps) {
@@ -229,17 +230,38 @@ export function ProductMembershipList({
229
230
  }
230
231
 
231
232
  if (loading) {
232
- return <p className="p-6 text-muted-foreground text-sm">{labels.loading}</p>
233
+ return <p className="p-6 text-muted-foreground text-base">{labels.loading}</p>
233
234
  }
234
235
 
235
236
  if (order.length === 0) {
236
- return <p className="p-6 text-muted-foreground text-sm">{labels.empty}</p>
237
+ return (
238
+ <Empty>
239
+ <EmptyHeader>
240
+ <EmptyMedia variant="icon">
241
+ <PackageIcon />
242
+ </EmptyMedia>
243
+ <EmptyTitle>{labels.empty}</EmptyTitle>
244
+ </EmptyHeader>
245
+ </Empty>
246
+ )
237
247
  }
238
248
 
249
+ // A single product column needs no heading; extra columns (prices) do.
250
+ const showHeader = !!extraColumns
251
+
239
252
  return (
240
253
  <>
241
- <div className="border-border-subtle border-b p-3">
254
+ <div className="flex items-center gap-3 border-border-subtle border-b px-4 py-3">
255
+ {/* Select-all lives here when the table has no header row to hold it. */}
256
+ {curatable && !showHeader && visible.length > 0 && (
257
+ <Checkbox
258
+ checked={allSelected}
259
+ onCheckedChange={toggleAll}
260
+ aria-label={labels.selectAll}
261
+ />
262
+ )}
242
263
  <SearchInput
264
+ className="flex-1"
243
265
  value={query}
244
266
  onValueChange={setQuery}
245
267
  onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
@@ -248,32 +270,43 @@ export function ProductMembershipList({
248
270
  />
249
271
  </div>
250
272
  {visible.length === 0 ? (
251
- <p className="p-6 text-muted-foreground text-sm">{labels.noMatches}</p>
273
+ <p className="p-6 text-muted-foreground text-base">{labels.noMatches}</p>
252
274
  ) : (
253
275
  <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
254
276
  <Table>
255
- <TableHeader>
256
- <TableRow>
257
- {curatable && (
258
- <TableHead className="w-10">
259
- <Checkbox
260
- checked={allSelected}
261
- onCheckedChange={toggleAll}
262
- aria-label={labels.selectAll}
263
- />
264
- </TableHead>
265
- )}
266
- {reorderable && <TableHead className="w-8" />}
267
- {/* `w-full max-w-0`: table layout sizes a column to its
277
+ {/* Without a header row, the column widths it set live here. */}
278
+ {!showHeader && (
279
+ <colgroup>
280
+ {curatable && <col className="w-10" />}
281
+ {reorderable && <col className="w-8" />}
282
+ <col className="w-full" />
283
+ <col className="w-10" />
284
+ </colgroup>
285
+ )}
286
+ {showHeader && (
287
+ <TableHeader>
288
+ <TableRow>
289
+ {curatable && (
290
+ <TableHead className="w-10">
291
+ <Checkbox
292
+ checked={allSelected}
293
+ onCheckedChange={toggleAll}
294
+ aria-label={labels.selectAll}
295
+ />
296
+ </TableHead>
297
+ )}
298
+ {reorderable && <TableHead className="w-8" />}
299
+ {/* `w-full max-w-0`: table layout sizes a column to its
268
300
  content, so a long product or variant name would push the
269
301
  columns beside it out of the card — which clips them
270
302
  rather than scrolling. This makes the product column take
271
303
  whatever is left instead, and its content truncate. */}
272
- <TableHead className="w-full max-w-0">{labels.columnProduct}</TableHead>
273
- {extraColumns?.headers}
274
- <TableHead className="w-10" />
275
- </TableRow>
276
- </TableHeader>
304
+ <TableHead className="w-full max-w-0">{labels.columnProduct}</TableHead>
305
+ {extraColumns?.headers}
306
+ <TableHead className="w-10" />
307
+ </TableRow>
308
+ </TableHeader>
309
+ )}
277
310
  <SortableContext items={ids} strategy={verticalListSortingStrategy}>
278
311
  {/* No border-t: TableHead draws the rule as an inset shadow,
279
312
  and a border alongside it stacks into a 2px line. */}
@@ -292,7 +325,7 @@ export function ProductMembershipList({
292
325
  onRemove={onRemove ? () => onRemove(row.id) : undefined}
293
326
  onRestore={onRestore ? () => onRestore(row.id) : undefined}
294
327
  labels={labels}
295
- renderTitle={renderTitle}
328
+ rowLink={rowLink}
296
329
  extraCells={extraColumns?.renderCells(row)}
297
330
  subRows={renderSubRows?.(row, subRowLayout)}
298
331
  />
@@ -302,8 +335,7 @@ export function ProductMembershipList({
302
335
  </Table>
303
336
  </DndContext>
304
337
  )}
305
- {/* Pagination brings its own top border + padding. */}
306
- {meta && onPageChange && <Pagination meta={meta} onPageChange={onPageChange} />}
338
+ {meta && onPageChange && <Pagination inset meta={meta} onPageChange={onPageChange} />}
307
339
  </>
308
340
  )
309
341
  }
@@ -318,7 +350,7 @@ function MembershipRow({
318
350
  onRemove,
319
351
  onRestore,
320
352
  labels,
321
- renderTitle,
353
+ rowLink,
322
354
  extraCells,
323
355
  subRows,
324
356
  }: {
@@ -331,7 +363,7 @@ function MembershipRow({
331
363
  onRemove?: () => void
332
364
  onRestore?: () => void
333
365
  labels: ProductMembershipListLabels
334
- renderTitle?: (row: ProductMembershipRow) => ReactNode
366
+ rowLink?: ProductMembershipListProps['rowLink']
335
367
  extraCells?: ReactNode
336
368
  subRows?: ReactNode
337
369
  }) {
@@ -374,13 +406,19 @@ function MembershipRow({
374
406
  </TableCell>
375
407
  )}
376
408
  <TableCell className="max-w-0">
377
- <span className={cn('flex min-w-0 items-center gap-3', removed && 'line-through')}>
378
- <Thumbnail src={row.thumbnailUrl} size="sm" />
379
- {renderTitle ? renderTitle(row) : <span className="truncate text-sm">{row.name}</span>}
380
- {added && labels.pendingAddedBadge && (
381
- <Badge variant="secondary">{labels.pendingAddedBadge}</Badge>
382
- )}
383
- </span>
409
+ <ProductIdentity
410
+ name={row.name}
411
+ thumbnailUrl={row.thumbnailUrl}
412
+ size="sm"
413
+ struckThrough={removed}
414
+ render={rowLink?.(row)}
415
+ badges={
416
+ added &&
417
+ labels.pendingAddedBadge && (
418
+ <Badge variant="secondary">{labels.pendingAddedBadge}</Badge>
419
+ )
420
+ }
421
+ />
384
422
  </TableCell>
385
423
  {extraCells}
386
424
  <TableCell className="text-right">
@@ -400,7 +438,7 @@ function MembershipRow({
400
438
  : onRemove && (
401
439
  <Button
402
440
  type="button"
403
- variant="ghost"
441
+ variant="destructive-ghost"
404
442
  size="icon"
405
443
  onClick={onRemove}
406
444
  aria-label={labels.remove}
@@ -75,7 +75,7 @@ export function StatusCard<TFieldValues extends FieldValues>({
75
75
  </Select>
76
76
  )}
77
77
  />
78
- {description && <p className="text-muted-foreground text-sm">{description}</p>}
78
+ {description && <p className="text-muted-foreground text-base">{description}</p>}
79
79
  </Field>
80
80
  {actions && <div className="flex flex-wrap gap-2">{actions}</div>}
81
81
  </CardContent>
@@ -123,7 +123,7 @@ export function QuantityTierEditor({
123
123
  ) : (
124
124
  <Button
125
125
  type="button"
126
- variant="ghost"
126
+ variant="destructive-ghost"
127
127
  size="icon"
128
128
  className="size-8 shrink-0"
129
129
  disabled={disabled}
@@ -64,7 +64,7 @@ export function ReturnReceiveDialog({
64
64
  <DialogBody className="flex flex-col gap-4">
65
65
  {lines.map((line) => (
66
66
  <div key={line.id} className="flex flex-col gap-3">
67
- <span className="font-medium text-sm">{line.label}</span>
67
+ <span className="font-medium text-base">{line.label}</span>
68
68
  <div className="flex flex-col gap-3">
69
69
  <Field>
70
70
  <FieldLabel htmlFor={`qty-${line.id}`}>
@@ -193,7 +193,7 @@ export function ReturnRefundDialog({
193
193
  </DialogHeader>
194
194
  <DialogBody className="flex flex-col gap-4">
195
195
  {owesNothing ? (
196
- <p className="text-muted-foreground text-sm">
196
+ <p className="text-muted-foreground text-base">
197
197
  {t('admin.pages.orders.detail.returns.nothing_owed')}
198
198
  </p>
199
199
  ) : (
@@ -35,7 +35,9 @@ export function SearchInput({
35
35
  const inputRef = useRef<HTMLInputElement>(null)
36
36
 
37
37
  return (
38
- <InputGroup className={className}>
38
+ // The outline button's edge rather than a form field's, so the search box
39
+ // reads as one of the toolbar's controls beside the buttons it sits with.
40
+ <InputGroup className={cn('border-border/80', className)}>
39
41
  <InputGroupAddon>
40
42
  <SearchIcon className="size-4" />
41
43
  </InputGroupAddon>
@@ -70,7 +70,7 @@ export function SecretInput({
70
70
  <FieldLabel htmlFor={id}>{label}</FieldLabel>
71
71
  <div className="flex items-center gap-2">
72
72
  <div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5">
73
- <span className="font-mono text-sm tabular-nums text-muted-foreground" id={id}>
73
+ <span className="font-mono text-base tabular-nums text-muted-foreground" id={id}>
74
74
  {storedMask}
75
75
  </span>
76
76
  </div>
@@ -0,0 +1,55 @@
1
+ import type * as React from 'react'
2
+ import { cn } from '../lib/utils'
3
+
4
+ /**
5
+ * A titled group of cards on a page — an order's fulfillments, its returns.
6
+ * The heading sits on the page with no frame of its own, and each record
7
+ * below it is a card: a card inside a card would box the records twice.
8
+ */
9
+ function Section({ className, ...props }: React.ComponentProps<'section'>) {
10
+ return (
11
+ <section
12
+ data-slot="section"
13
+ className={cn('flex min-w-0 flex-col gap-3', className)}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ /** The heading row: a `SectionTitle`, then `SectionActions` at the end. */
20
+ function SectionHeader({ className, ...props }: React.ComponentProps<'div'>) {
21
+ return (
22
+ <div
23
+ data-slot="section-header"
24
+ className={cn('flex min-h-8 flex-wrap items-center gap-2', className)}
25
+ {...props}
26
+ />
27
+ )
28
+ }
29
+
30
+ /** A leading icon is set in a tile, so the heading reads as one without a frame. */
31
+ function SectionTitle({ className, ...props }: React.ComponentProps<'h2'>) {
32
+ return (
33
+ <h2
34
+ data-slot="section-title"
35
+ className={cn(
36
+ 'flex min-w-0 flex-1 items-center gap-2 font-semibold text-base',
37
+ '[&>svg:first-child]:box-content [&>svg:first-child]:size-4 [&>svg:first-child]:shrink-0 [&>svg:first-child]:rounded-lg [&>svg:first-child]:bg-accent-strong [&>svg:first-child]:p-1.5',
38
+ className,
39
+ )}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+
45
+ function SectionActions({ className, ...props }: React.ComponentProps<'div'>) {
46
+ return (
47
+ <div
48
+ data-slot="section-actions"
49
+ className={cn('flex shrink-0 items-center gap-2', className)}
50
+ {...props}
51
+ />
52
+ )
53
+ }
54
+
55
+ export { Section, SectionActions, SectionHeader, SectionTitle }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from '../lib/utils'
3
+ import { Item, ItemContent, ItemDescription, ItemTitle } from '../ui/item'
4
+
5
+ /**
6
+ * One figure in a row of headline numbers at the top of a record page: a
7
+ * small label over a large value, each in its own outline item. Lay several
8
+ * out in a grid rather than inside a card.
9
+ */
10
+ export function StatItem({
11
+ label,
12
+ value,
13
+ className,
14
+ }: {
15
+ label: ReactNode
16
+ value: ReactNode
17
+ className?: string
18
+ }) {
19
+ return (
20
+ <Item variant="outline" className={cn('items-start', className)}>
21
+ <ItemContent>
22
+ <ItemDescription>{label}</ItemDescription>
23
+ <ItemTitle className="text-lg font-semibold">{value}</ItemTitle>
24
+ </ItemContent>
25
+ </Item>
26
+ )
27
+ }