@spree/dashboard-ui 0.13.1 → 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
@@ -0,0 +1,83 @@
1
+ import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'
2
+ import type * as React from 'react'
3
+ import { cn } from '../lib/utils'
4
+ import { ChevronDownIcon } from '../spree/icons'
5
+
6
+ /**
7
+ * Vertically stacked, expandable sections sharing one frame.
8
+ *
9
+ * One item is open at a time by default: a list of steps is read in order, and
10
+ * letting several stand open turns it back into the wall of text the collapse
11
+ * exists to avoid. Pass `multiple` where the sections are independent rather
12
+ * than sequential.
13
+ */
14
+ function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
15
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
16
+ }
17
+
18
+ /** One section. `value` identifies it for `defaultValue` and controlled use. */
19
+ function AccordionItem({
20
+ className,
21
+ ...props
22
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
23
+ return (
24
+ <AccordionPrimitive.Item
25
+ data-slot="accordion-item"
26
+ // Rules between items rather than around each: the group is one object,
27
+ // so the last item closes on the container's own edge.
28
+ className={cn('border-border-subtle border-b last:border-b-0', className)}
29
+ {...props}
30
+ />
31
+ )
32
+ }
33
+
34
+ /**
35
+ * The always-visible row that opens its section. Renders a chevron after the
36
+ * children, so callers pass only the label's own content.
37
+ */
38
+ function AccordionTrigger({
39
+ className,
40
+ children,
41
+ ...props
42
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
43
+ return (
44
+ <AccordionPrimitive.Header className="flex">
45
+ <AccordionPrimitive.Trigger
46
+ data-slot="accordion-trigger"
47
+ className={cn(
48
+ 'group/accordion-trigger flex flex-1 cursor-pointer items-center gap-3 px-4 py-3.5 text-left text-base font-medium transition-colors outline-none hover:bg-accent/50 focus-visible:bg-accent',
49
+ className,
50
+ )}
51
+ {...props}
52
+ >
53
+ {children}
54
+ <ChevronDownIcon className="ms-auto size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out group-data-[panel-open]/accordion-trigger:rotate-180 motion-reduce:transition-none" />
55
+ </AccordionPrimitive.Trigger>
56
+ </AccordionPrimitive.Header>
57
+ )
58
+ }
59
+
60
+ /**
61
+ * The revealed panel. Carries the section's padding, so `className` styles the
62
+ * content rather than the animated box around it.
63
+ */
64
+ function AccordionContent({
65
+ className,
66
+ children,
67
+ ...props
68
+ }: React.ComponentProps<typeof AccordionPrimitive.Panel>) {
69
+ return (
70
+ <AccordionPrimitive.Panel
71
+ data-slot="accordion-content"
72
+ // Height animates off Base UI's own `--accordion-panel-height`, which it
73
+ // measures for us — `height: auto` is not animatable, so without the
74
+ // variable the panel would snap open.
75
+ className="h-(--accordion-panel-height) overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none"
76
+ {...props}
77
+ >
78
+ <div className={cn('px-4 pb-4', className)}>{children}</div>
79
+ </AccordionPrimitive.Panel>
80
+ )
81
+ }
82
+
83
+ export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
@@ -0,0 +1,79 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import * as React from 'react'
3
+
4
+ import { cn } from '../lib/utils'
5
+
6
+ const alertVariants = cva(
7
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'bg-card text-card-foreground',
12
+ destructive:
13
+ 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
14
+ // Custom colours, per the shadcn recipe: a tinted fill with matching
15
+ // text rather than the card background, so an advisory note reads as
16
+ // information instead of as a card of its own. Mirrors the `success`
17
+ // recipe in badge.tsx, including the dark-mode text bump for legibility.
18
+ info: 'border-blue-500/20 bg-blue-500/10 text-blue-800 *:data-[slot=alert-description]:text-blue-800/90 dark:text-blue-300 dark:*:data-[slot=alert-description]:text-blue-300/90',
19
+ // Amber rather than the `--color-warning` token, which is yellow-500 —
20
+ // a fill colour too light to read as body text. Amber is what the rest
21
+ // of the dashboard already uses to mark a warning.
22
+ warning:
23
+ 'border-amber-500/20 bg-amber-500/10 text-amber-800 *:data-[slot=alert-description]:text-amber-800/90 dark:text-amber-300 dark:*:data-[slot=alert-description]:text-amber-300/90',
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: 'default',
28
+ },
29
+ },
30
+ )
31
+
32
+ function Alert({
33
+ className,
34
+ variant,
35
+ ...props
36
+ }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
37
+ return (
38
+ <div
39
+ data-slot="alert"
40
+ role="alert"
41
+ className={cn(alertVariants({ variant }), className)}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
48
+ return (
49
+ <div
50
+ data-slot="alert-title"
51
+ className={cn(
52
+ 'font-semibold group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
61
+ return (
62
+ <div
63
+ data-slot="alert-description"
64
+ className={cn(
65
+ 'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
66
+ className,
67
+ )}
68
+ {...props}
69
+ />
70
+ )
71
+ }
72
+
73
+ function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
74
+ return (
75
+ <div data-slot="alert-action" className={cn('absolute top-2 right-2', className)} {...props} />
76
+ )
77
+ }
78
+
79
+ export { Alert, AlertAction, AlertDescription, AlertTitle }
@@ -12,7 +12,7 @@ import { Button } from './button'
12
12
  // `cn-*` preset classes flattened into plain utilities (this package predates
13
13
  // the shadcn CSS layer).
14
14
  const attachmentVariants = cva(
15
- 'group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap rounded-lg border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed',
15
+ 'group/attachment relative flex max-w-full min-w-0 shrink-0 flex-wrap rounded-lg border bg-card text-card-foreground transition-colors has-[>a,>button]:hover:bg-accent/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed',
16
16
  {
17
17
  variants: {
18
18
  size: {
package/src/ui/badge.tsx CHANGED
@@ -1,28 +1,39 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority'
2
- import { CheckIcon } from 'lucide-react'
3
- import * as React from 'react'
2
+ import type * as React from 'react'
4
3
  import { useTranslation } from 'react-i18next'
5
4
  import { cn } from '../lib/utils'
6
5
  import { Slot } from './slot'
7
6
 
8
7
  const badgeVariants = cva(
9
- 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
8
+ // No `border-transparent` here: it and a variant's `border-*` have the same
9
+ // specificity, so the one Tailwind emits last wins regardless of the order
10
+ // they appear in the class list — which silently erased the outline
11
+ // variant's border. Each variant states its own border colour instead.
12
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-sm border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] duration-100 ease-out focus-visible:border-ring focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
10
13
  {
11
14
  variants: {
12
15
  variant: {
13
- default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
14
- secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
16
+ default: 'border-transparent bg-primary text-primary-foreground [a]:hover:bg-primary/80',
17
+ secondary:
18
+ 'border-transparent bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
19
+ // The four status variants below share one recipe: a tint from the
20
+ // status ramp, text from the same ramp's reading step, and a border
21
+ // that firms the shape up on busy surfaces. Both themes get real
22
+ // colours rather than one hue at reduced opacity — an opacity tint
23
+ // takes on whatever sits behind it, which turns a badge muddy over a
24
+ // striped table row and washes it out entirely on a dark card. Linked
25
+ // badges darken to the border step on hover, which is the next stop up
26
+ // the same ramp rather than a second colour.
15
27
  destructive:
16
- 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
17
- // Mirrors the `destructive` recipe (tinted bg + matching text) with a
18
- // green hue for "succeeded / completed / paid / shipped" states. Dark
19
- // mode bumps text to `green-400` so it stays legible on dark fills.
20
- success:
21
- 'bg-green-500/15 text-green-700 dark:bg-green-500/15 dark:text-green-400 [a]:hover:bg-green-500/25',
28
+ 'border-danger-border bg-danger-bg text-danger focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-danger-border',
29
+ success: 'border-success-border bg-success-bg text-success [a]:hover:bg-success-border',
30
+ info: 'border-info-border bg-info-bg text-info [a]:hover:bg-info-border',
31
+ warning: 'border-warning-border bg-warning-bg text-warning [a]:hover:bg-warning-border',
22
32
  outline:
23
- 'border-border text-foreground/75 [a]:hover:bg-muted [a]:hover:text-muted-foreground',
24
- ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
25
- link: 'text-primary underline-offset-4 hover:underline',
33
+ 'border-border bg-card/50 text-foreground/75 [a]:hover:bg-accent [a]:hover:text-muted-foreground',
34
+ ghost:
35
+ 'border-transparent hover:bg-accent hover:text-muted-foreground dark:hover:bg-accent/50',
36
+ link: 'border-transparent text-primary underline-offset-4 hover:underline',
26
37
  },
27
38
  },
28
39
  defaultVariants: {
@@ -49,61 +60,193 @@ function Badge({
49
60
  )
50
61
  }
51
62
 
52
- // Maps order/payment/etc. status strings to one of the canonical Badge
53
- // variants. Success-y states ("active", "paid", "shipped", …) route through
54
- // the `success` variant so the UI stays color-coded; everything else leans
55
- // on the canonical shadcn variants with shape + label carrying meaning.
56
- const statusVariantMap: Record<string, VariantProps<typeof badgeVariants>['variant']> = {
63
+ /**
64
+ * Semantic tone of a status, independent of how it is drawn. `StatusBadge`
65
+ * renders it as a coloured dot, `Badge` as a tinted pill — same vocabulary,
66
+ * two presentations.
67
+ */
68
+ type StatusTone = 'success' | 'warning' | 'destructive' | 'info' | 'neutral'
69
+
70
+ /**
71
+ * Maps an order/payment/product status code to its semantic tone.
72
+ *
73
+ * Kept as one table because the same codes surface in tables, cards, page
74
+ * headers and filter menus — a local copy in any one of those is how two
75
+ * surfaces end up disagreeing about what "pending" looks like. Extend it
76
+ * rather than branching on status at a call site.
77
+ */
78
+ const statusToneMap: Record<string, StatusTone> = {
57
79
  active: 'success',
80
+ authorized: 'info',
58
81
  complete: 'success',
59
82
  completed: 'success',
60
83
  paid: 'success',
61
- shipped: 'success',
84
+ fulfilled: 'success',
85
+ delivered: 'success',
86
+ unfulfilled: 'warning',
87
+ proposed: 'warning',
62
88
  published: 'success',
63
89
  approved: 'success',
64
- ready: 'success',
65
- draft: 'outline',
66
- pending: 'outline',
67
- processing: 'outline',
68
- inactive: 'outline',
69
- cart: 'outline',
70
- address: 'outline',
71
- delivery: 'outline',
72
- payment: 'outline',
73
- confirm: 'outline',
74
- resumed: 'secondary',
75
- partial: 'secondary',
76
- archived: 'secondary',
77
- returned: 'secondary',
78
- backorder: 'secondary',
79
- balance_due: 'secondary',
80
- credit_owed: 'secondary',
90
+ verified: 'success',
91
+ unverified: 'destructive',
92
+ unavailable: 'neutral',
93
+ unsupported: 'neutral',
94
+ revoked: 'destructive',
95
+ expired: 'destructive',
96
+ ready: 'warning',
97
+ available: 'success',
98
+ // Stock states, as the Inventory page's filter names them: sellable is
99
+ // good, nothing to sell is the problem, and the two middle states are units
100
+ // that exist but cannot be sold today — held by a checkout, or still on the
101
+ // road.
102
+ in_stock: 'success',
103
+ out_of_stock: 'destructive',
104
+ with_reserved: 'warning',
105
+ with_incoming: 'info',
106
+ draft: 'neutral',
107
+ pending: 'warning',
108
+ processing: 'warning',
109
+ inactive: 'neutral',
110
+ confirm: 'neutral',
111
+ resumed: 'neutral',
112
+ partial: 'info',
113
+ // What a carrier reports about a consignment in flight. `pending` and
114
+ // `delivered` are already above — they mean the same thing here.
115
+ pre_transit: 'info',
116
+ in_transit: 'info',
117
+ out_for_delivery: 'info',
118
+ available_for_pickup: 'warning',
119
+ return_to_sender: 'destructive',
120
+ failure: 'destructive',
121
+ unknown: 'neutral',
122
+ archived: 'neutral',
123
+ returned: 'success',
124
+ backorder: 'warning',
125
+ // Order payment status: amber while money is still owed either way, blue
126
+ // for a hold or a part refund, red once the authorisation is gone.
127
+ none: 'neutral',
128
+ partially_paid: 'warning',
129
+ partially_refunded: 'info',
130
+ overcharged: 'warning',
131
+ voided: 'destructive',
132
+ balance_due: 'warning',
133
+ credit_owed: 'warning',
81
134
  canceled: 'destructive',
135
+ suspended: 'destructive',
136
+ invited: 'info',
137
+ onboarding: 'warning',
138
+ incomplete: 'neutral',
139
+ ready_for_review: 'warning',
82
140
  failed: 'destructive',
83
141
  void: 'destructive',
84
142
  error: 'destructive',
85
143
  rejected: 'destructive',
144
+ used_up: 'destructive',
145
+ // Returns, exchanges and claims: amber while the merchant still owes an
146
+ // action, green once settled, muted when it went nowhere.
147
+ requested: 'warning',
148
+ open: 'warning',
149
+ received: 'warning',
150
+ refunded: 'success',
151
+ resolved: 'success',
152
+ denied: 'destructive',
153
+ // Inventory operations. `received` is deliberately absent: on a return it
154
+ // means the merchant still owes a refund, on a transfer or purchase order it
155
+ // means the job is done — one code, two tones, so those two callers pass
156
+ // `tone` explicitly.
157
+ ready_to_ship: 'warning',
158
+ ordered: 'info',
159
+ partially_received: 'info',
160
+ // Gift cards.
161
+ partially_redeemed: 'info',
162
+ redeemed: 'neutral',
163
+ // Price lists — `scheduled` is live-but-not-yet, which is the same shape as
164
+ // any other "waiting on the clock" state.
165
+ scheduled: 'info',
166
+ // Boolean filter values. A yes/no pair is a state like any other — "in
167
+ // stock: no" is the row an operator is looking for, and reading it in the
168
+ // same green/red the rest of the app uses saves them parsing the word.
169
+ true: 'success',
170
+ false: 'destructive',
171
+ }
172
+
173
+ // Dot fills, by tone. These read the same `--status-*` ramps the tinted Badge
174
+ // variants use, but take the *reading* step rather than the fill step: a 6px
175
+ // dot needs the saturated end of the ramp to register at all, where a pill can
176
+ // rely on area.
177
+ const dotToneClasses: Record<StatusTone, string> = {
178
+ success: 'bg-success',
179
+ warning: 'bg-warning',
180
+ destructive: 'bg-danger',
181
+ info: 'bg-info',
182
+ neutral: 'bg-muted-foreground/50',
183
+ }
184
+
185
+ /**
186
+ * Bare status dot. Decorative by construction — it carries no accessible name,
187
+ * so it must always sit beside text that says the same thing. Use it to align
188
+ * a status with its label in a menu or list; use `StatusBadge` when you want
189
+ * the dot and its label together.
190
+ */
191
+ function StatusDot({
192
+ status,
193
+ tone: toneOverride,
194
+ className,
195
+ }: {
196
+ status: string
197
+ tone?: StatusTone
198
+ className?: string
199
+ }) {
200
+ const tone = toneOverride ?? statusToneMap[status] ?? 'neutral'
201
+ return (
202
+ <span
203
+ aria-hidden
204
+ className={cn('inline-block size-2 shrink-0 rounded-full', dotToneClasses[tone], className)}
205
+ />
206
+ )
86
207
  }
87
208
 
88
209
  /**
89
- * Status pill whose color is derived from the raw status code. Stays headless:
90
- * pass a translated `label` from the app layer; without one it humanizes the
91
- * code itself (`balance_due` → `balance due`) as a best-effort fallback.
210
+ * Status indicator: a coloured dot beside the label, rather than a tinted pill.
211
+ *
212
+ * A table column of pills reads as a column of coloured blocks — the eye lands
213
+ * on the fills instead of the words, and a row where three statuses sit side by
214
+ * side becomes a bar chart of nothing. A dot puts the colour where it belongs:
215
+ * a small mark that the label does the talking for. Colour is never the only
216
+ * channel, since the label is always rendered.
217
+ *
218
+ * Stays headless: pass a translated `label` from the app layer; without one it
219
+ * humanizes the code itself (`balance_due` → `balance due`) as a best-effort
220
+ * fallback.
221
+ *
222
+ * `tone` overrides the shared map, for the rare code that means different
223
+ * things in two domains — a `received` return still owes a refund, a
224
+ * `received` stock transfer is finished. Reach for it only then: everything
225
+ * else belongs in `statusToneMap`, or two surfaces will disagree.
92
226
  */
93
227
  function StatusBadge({
94
228
  status,
95
229
  label,
230
+ tone,
96
231
  className,
97
232
  }: {
98
233
  status: string
99
234
  label?: string
235
+ tone?: StatusTone
100
236
  className?: string
101
237
  }) {
102
- const variant = statusVariantMap[status] ?? 'outline'
103
238
  return (
104
- <Badge variant={variant} className={cn('capitalize', className)}>
239
+ <span
240
+ data-slot="status-badge"
241
+ data-status={status}
242
+ className={cn(
243
+ 'inline-flex w-fit shrink-0 items-center gap-1.5 whitespace-nowrap text-sm capitalize',
244
+ className,
245
+ )}
246
+ >
247
+ <StatusDot status={status} tone={tone} />
105
248
  {label ?? status.replace(/_/g, ' ')}
106
- </Badge>
249
+ </span>
107
250
  )
108
251
  }
109
252
 
@@ -140,22 +283,21 @@ function ActiveBadge({
140
283
  const { t } = useTranslation()
141
284
  const resolvedActiveLabel = activeLabel ?? t('admin.common.yes')
142
285
  const resolvedInactiveLabel = inactiveLabel ?? t('admin.common.no')
143
- if (active) {
144
- return (
145
- <Badge variant="success" className={className}>
146
- <CheckIcon />
147
- {resolvedActiveLabel}
148
- </Badge>
149
- )
150
- }
151
- if (dashWhenInactive) {
286
+ if (dashWhenInactive && !active) {
152
287
  return <span className={cn('text-muted-foreground', className)}>—</span>
153
288
  }
289
+ // Boolean states read through the same map as every other status, so an
290
+ // "Active / Inactive" column and a "Paid / Failed" one agree on what green
291
+ // and red mean. The tick the active case used to carry is redundant beside
292
+ // a dot that already says the same thing.
154
293
  return (
155
- <Badge variant="outline" className={className}>
156
- {resolvedInactiveLabel}
157
- </Badge>
294
+ <StatusBadge
295
+ status={active ? 'true' : 'false'}
296
+ label={active ? resolvedActiveLabel : resolvedInactiveLabel}
297
+ className={className}
298
+ />
158
299
  )
159
300
  }
160
301
 
161
- export { ActiveBadge, Badge, badgeVariants, StatusBadge }
302
+ export type { StatusTone }
303
+ export { ActiveBadge, Badge, badgeVariants, StatusBadge, StatusDot, statusToneMap }
@@ -1,7 +1,7 @@
1
1
  import i18n from 'i18next'
2
- import { ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'
3
2
  import type * as React from 'react'
4
3
  import { cn } from '../lib/utils'
4
+ import { ChevronRightIcon, MoreHorizontalIcon } from '../spree/icons'
5
5
  import { Slot } from './slot'
6
6
 
7
7
  function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
package/src/ui/button.tsx CHANGED
@@ -4,26 +4,38 @@ import { cn } from '../lib/utils'
4
4
  import { Slot } from './slot'
5
5
 
6
6
  const buttonVariants = cva(
7
- "group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-xl border border-transparent text-sm font-medium whitespace-nowrap cursor-pointer select-none no-underline transition-[color,background-color,border-color,box-shadow] duration-100 ease-linear outline-none focus-visible:border-blue-500 focus-visible:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] disabled:pointer-events-none disabled:opacity-70 disabled:cursor-not-allowed aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
7
+ "group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent text-sm font-medium whitespace-nowrap cursor-pointer select-none no-underline transition-[color,background-color,border-color,box-shadow,transform,translate,scale] duration-100 ease-out outline-none active:scale-[0.98] motion-reduce:active:scale-100 focus-visible:border-blue-500 focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:pointer-events-none disabled:opacity-70 disabled:cursor-not-allowed aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
- default:
12
- 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/85 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-500',
11
+ // `shadow-xs` like the outline and destructive variants: the primary
12
+ // action is the one thing on a page that should sit slightly above it,
13
+ // and it was the only variant with no lift at all.
14
+ default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/85',
13
15
  outline:
14
- 'border-border bg-muted text-foreground shadow-xs hover:bg-accent hover:text-foreground aria-expanded:bg-accent',
15
- ghost: 'hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent',
16
- destructive: 'text-destructive bg-muted border-border shadow-xs hover:bg-destructive/10',
16
+ 'border-border bg-card shadow-xs text-foreground hover:bg-accent hover:text-foreground aria-expanded:bg-accent',
17
+ // `group-hover/row:` — inside a hovered table row the row already wears
18
+ // --accent, so repeating it here leaves the button at 1.03:1 against its
19
+ // own background: invisible exactly when the pointer is on it. On a
20
+ // hovered row it steps to --accent-strong instead.
21
+ ghost:
22
+ 'hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent group-hover/row:hover:bg-accent-strong',
23
+ destructive: 'text-destructive bg-card border-border shadow-xs hover:bg-destructive/10',
24
+ // Ghost's chrome (none until hover) with destructive's colour. The
25
+ // inline remove buttons that sit inside a row or field want no box of
26
+ // their own, but still need the red tint on hover rather than ghost's
27
+ // neutral grey — several had been reproducing exactly this by hand.
28
+ 'destructive-ghost': 'text-destructive hover:bg-destructive/10 hover:text-destructive',
17
29
  link: 'text-primary underline-offset-4 hover:underline',
18
30
  },
19
31
  size: {
20
32
  default: 'h-9 px-3 py-1.5',
21
- xs: "h-6 gap-1 rounded-lg px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
22
- sm: "h-7 gap-1 rounded-lg px-2 py-1 text-sm [&_svg:not([class*='size-'])]:size-3.5",
33
+ xs: "h-6 gap-1 rounded-md px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
34
+ sm: "h-7 gap-1 rounded-md px-2 py-1 text-sm [&_svg:not([class*='size-'])]:size-3.5",
23
35
  lg: 'h-10 px-3 py-2 text-base',
24
36
  icon: 'size-9',
25
- 'icon-xs': "size-6 rounded-lg [&_svg:not([class*='size-'])]:size-3",
26
- 'icon-sm': 'size-7 rounded-lg',
37
+ 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
38
+ 'icon-sm': 'size-7 rounded-md',
27
39
  'icon-lg': 'size-10',
28
40
  },
29
41
  },
@@ -1,8 +1,8 @@
1
- import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
2
1
  import type * as React from 'react'
3
2
  import { DayPicker } from 'react-day-picker'
4
3
  import { activeDateLocale } from '../lib/date-locale'
5
4
  import { cn } from '../lib/utils'
5
+ import { ChevronLeftIcon, ChevronRightIcon } from '../spree/icons'
6
6
 
7
7
  function Calendar({
8
8
  className,
package/src/ui/card.tsx CHANGED
@@ -2,12 +2,50 @@ import type * as React from 'react'
2
2
 
3
3
  import { cn } from '../lib/utils'
4
4
 
5
- function Card({ className, ...props }: React.ComponentProps<'div'>) {
5
+ function Card({
6
+ className,
7
+ variant = 'default',
8
+ ...props
9
+ }: React.ComponentProps<'div'> & {
10
+ /**
11
+ * `nested` is one resource listed inside a card — a fulfillment on an order,
12
+ * a return, a rule. It sits on a recessed surface so it reads as a thing
13
+ * *within* the card rather than another card beside it, and carries its own
14
+ * horizontal padding so its sections divide edge to edge.
15
+ *
16
+ * The box is load-bearing once there is more than one of them: a fulfillment
17
+ * already divides its own rows, so grouping by rules alone would draw the
18
+ * boundary between two fulfillments as the same line at the same inset as
19
+ * the boundary between two items — and the panel's own actions would have no
20
+ * visible owner.
21
+ *
22
+ * `container` is the card those nested records sit in. Pair the two: the
23
+ * container recedes, the records stay raised. A `default` card wrapping
24
+ * `nested` ones puts the content in a well and the frame in front of it,
25
+ * which reads as the content being demoted.
26
+ */
27
+ variant?: 'default' | 'nested' | 'container'
28
+ }) {
6
29
  return (
7
30
  <div
8
31
  data-slot="card"
32
+ data-variant={variant}
9
33
  className={cn(
10
- 'group/card flex flex-col min-w-0 break-words bg-card text-card-foreground border border-border rounded-xl shadow-sm',
34
+ // `overflow-x-clip`: a wide child (a table sized to its columns) otherwise
35
+ // propagates its width up through every `overflow: visible` ancestor to
36
+ // the document, laying the whole page out wider than the viewport. `clip`
37
+ // rather than `hidden` so `overflow-y` stays `visible` and sticky
38
+ // descendants keep resolving against the page.
39
+ 'group/card flex flex-col min-w-0 overflow-x-clip break-words text-card-foreground shadow-xs',
40
+ variant === 'nested' &&
41
+ 'rounded-lg border border-border-subtle bg-nested-raised px-3 shadow-none',
42
+ // Holds nested records rather than content, so it recedes and lets
43
+ // them be the raised, readable thing.
44
+ variant === 'container' && 'rounded-xl border border-border-card bg-card-container',
45
+ // `--border-card`, not `--border`: a card is a region of the sheet
46
+ // rather than a surface floating above it, so its edge is softer than
47
+ // the structural one the sheet, dialogs and dropdowns draw.
48
+ variant === 'default' && 'rounded-xl border border-border-card bg-card',
11
49
  className,
12
50
  )}
13
51
  {...props}
@@ -20,7 +58,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
20
58
  <div
21
59
  data-slot="card-header"
22
60
  className={cn(
23
- 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] px-3 py-2.5 border-b border-border rounded-t-[calc(var(--radius-2xl)-1px)]',
61
+ 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] px-3 py-2.5 border-b border-border-subtle rounded-t-[calc(var(--radius-2xl)-1px)] group-data-[variant=nested]/card:rounded-none group-data-[variant=nested]/card:px-0 group-data-[variant=nested]/card:py-2 group-data-[variant=nested]/card:items-center',
24
62
  className,
25
63
  )}
26
64
  {...props}
@@ -32,7 +70,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
32
70
  return (
33
71
  <div
34
72
  data-slot="card-title"
35
- className={cn('text-lg font-medium flex items-center gap-2', className)}
73
+ // Same size and weight as SheetTitle and DialogTitle: a card, a sheet
74
+ // and a dialog heading are the same rank of thing.
75
+ className={cn('text-base font-semibold flex items-center gap-2', className)}
36
76
  {...props}
37
77
  />
38
78
  )
@@ -53,7 +93,13 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
53
93
  }
54
94
 
55
95
  function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
56
- return <div data-slot="card-content" className={cn('p-3', className)} {...props} />
96
+ return (
97
+ <div
98
+ data-slot="card-content"
99
+ className={cn('p-3 group-data-[variant=nested]/card:px-0', className)}
100
+ {...props}
101
+ />
102
+ )
57
103
  }
58
104
 
59
105
  function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
@@ -61,7 +107,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
61
107
  <div
62
108
  data-slot="card-footer"
63
109
  className={cn(
64
- 'flex items-center p-3 bg-transparent border-t border-border rounded-b-[calc(var(--radius-xl)-1px)]',
110
+ 'flex items-center p-3 bg-transparent border-t border-border-subtle rounded-b-[calc(var(--radius-xl)-1px)] group-data-[variant=nested]/card:rounded-none group-data-[variant=nested]/card:px-0',
65
111
  className,
66
112
  )}
67
113
  {...props}