@spree/dashboard-ui 6.0.0-rc.7 → 6.0.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Spree Dashboard design system. Shadcn primitives + headless composed components + design tokens.
4
4
 
5
- > **Developer Preview.** APIs may change between 0.x releases. Docs: [React Dashboard](https://spreecommerce.org/docs/developer/dashboard/overview).
5
+ Docs: [React Dashboard](https://spreecommerce.org/docs/developer/dashboard/overview).
6
6
 
7
7
  ## What's inside
8
8
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "6.0.0-rc.7",
5
- "description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
4
+ "version": "6.0.1",
5
+ "description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
8
8
  "repository": {
@@ -86,11 +86,17 @@
86
86
  "shadcn": "4.22.1",
87
87
  "tailwind-merge": "3.7.0",
88
88
  "tw-animate-css": "1.4.0",
89
- "@spree/core": "^6.0.0-rc.7"
89
+ "@spree/core": "^6.0.1"
90
90
  },
91
91
  "devDependencies": {
92
+ "@tanstack/react-router": "^1.170.45",
92
93
  "@types/react": "^19.2.14",
93
94
  "@types/react-dom": "^19.2.3",
95
+ "i18next": "^26.5.3",
96
+ "react": "^19.3.1",
97
+ "react-dom": "^19.3.1",
98
+ "react-hook-form": "^7.90.0",
99
+ "react-i18next": "^17.1.0",
94
100
  "tailwindcss": "^4.2.4",
95
101
  "typescript": "~7.0.2",
96
102
  "vite": "^8.3.4"
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export * from './spree/bulk-price-table'
39
39
  export * from './spree/bulk-variants-table'
40
40
  export * from './spree/calculator-summary'
41
41
  export * from './spree/claim-resolve-dialog'
42
+ export * from './spree/collapsible-card'
42
43
  export * from './spree/color-picker'
43
44
  export * from './spree/confirm-dialog'
44
45
  export * from './spree/copy-to-clipboard-button'
@@ -85,6 +86,7 @@ export * from './spree/row-actions'
85
86
  export * from './spree/row-click-bridge'
86
87
  export * from './spree/search-input'
87
88
  export * from './spree/secret-input'
89
+ export * from './spree/step-icon'
88
90
  export * from './spree/storefront-visible-switch'
89
91
  export * from './spree/tag-list'
90
92
  export * from './spree/tax-identifiers-card'
@@ -0,0 +1,66 @@
1
+ import { type ReactNode, useState } from 'react'
2
+ import { cn } from '../lib/utils'
3
+ import { Badge } from '../ui/badge'
4
+ import { Card, CardContent, CardHeader } from '../ui/card'
5
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
6
+ import { ChevronRightIcon } from './icons'
7
+
8
+ interface CollapsibleCardProps {
9
+ title: ReactNode
10
+ icon?: ReactNode
11
+ /** Shown as a badge beside the title when above zero, so a closed card still says whether it holds anything. */
12
+ count?: number
13
+ /** Header controls kept outside the toggle, e.g. an Edit button. */
14
+ actions?: ReactNode
15
+ open?: boolean
16
+ defaultOpen?: boolean
17
+ onOpenChange?: (open: boolean) => void
18
+ children: ReactNode
19
+ }
20
+
21
+ /** A secondary card that starts folded to its header, for content most visits don't need. */
22
+ export function CollapsibleCard({
23
+ title,
24
+ icon,
25
+ count = 0,
26
+ actions,
27
+ open,
28
+ defaultOpen = false,
29
+ onOpenChange,
30
+ children,
31
+ }: CollapsibleCardProps) {
32
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
33
+ const isOpen = open ?? uncontrolledOpen
34
+
35
+ function setOpen(next: boolean) {
36
+ if (open === undefined) setUncontrolledOpen(next)
37
+ onOpenChange?.(next)
38
+ }
39
+
40
+ return (
41
+ <Card>
42
+ <Collapsible open={isOpen} onOpenChange={setOpen}>
43
+ <CardHeader
44
+ className={cn('flex flex-row items-center gap-2 py-1.5', !isOpen && 'border-b-0')}
45
+ >
46
+ <CollapsibleTrigger className="group -mx-1 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent/50">
47
+ <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]:rotate-90 motion-reduce:transition-none" />
48
+ {/* CardTitle's styling on a span: a button may only hold phrasing content. */}
49
+ <span
50
+ data-slot="card-title"
51
+ className="flex items-center gap-2 text-base font-semibold"
52
+ >
53
+ {icon}
54
+ {title}
55
+ </span>
56
+ {count > 0 && <Badge variant="secondary">{count}</Badge>}
57
+ </CollapsibleTrigger>
58
+ {actions}
59
+ </CardHeader>
60
+ <CollapsibleContent>
61
+ <CardContent>{children}</CardContent>
62
+ </CollapsibleContent>
63
+ </Collapsible>
64
+ </Card>
65
+ )
66
+ }
@@ -39,9 +39,11 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
39
39
  return (
40
40
  <Button type="submit" disabled={!(isDirty || dirty) || isSubmitting}>
41
41
  {isSubmitting && <Loader2Icon className="animate-spin" />}
42
- {isSubmitting
43
- ? (savingLabel ?? t('admin.actions.saving'))
44
- : (label ?? t('admin.actions.save'))}
42
+ <span>
43
+ {isSubmitting
44
+ ? (savingLabel ?? t('admin.actions.saving'))
45
+ : (label ?? t('admin.actions.save'))}
46
+ </span>
45
47
  </Button>
46
48
  )
47
49
  }
@@ -169,6 +169,7 @@ export {
169
169
  UsersRoundIcon,
170
170
  WarehouseIcon,
171
171
  WebhookIcon,
172
+ WrenchIcon,
172
173
  X,
173
174
  XCircleIcon,
174
175
  XIcon,
@@ -1,46 +1,101 @@
1
1
  import { Fragment } from 'react'
2
- import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
3
2
  import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty'
3
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../../ui/sheet'
4
+ import { CollapsibleCard } from '../collapsible-card'
5
+ import { CopyToClipboardButton } from '../copy-to-clipboard-button'
4
6
  import { BracesIcon } from '../icons'
5
7
 
6
- interface MetadataCardProps {
7
- /**
8
- * Free-form metadata stored on the record. Read-only — apps and integrations
9
- * write to this; admins just see what's there. Always an object (possibly
10
- * empty); the API never returns `null`.
11
- */
12
- metadata: Record<string, unknown>
13
- /** Translated copy (passed by the app — this component stays headless). */
14
- title: string
8
+ /**
9
+ * Free-form metadata stored on a record. Read-only — apps and integrations
10
+ * write to it; admins just see what's there.
11
+ */
12
+ type Metadata = Record<string, unknown> | null | undefined
13
+
14
+ /** The keys external systems (ERP, PIM, CRM) know a record by, as `{ system => key }`. */
15
+ type ExternalReferences = Record<string, string> | null | undefined
16
+
17
+ interface MetadataListProps {
18
+ metadata: Metadata
19
+ externalReferences?: ExternalReferences
20
+ /** Translated copy (passed by the app — these components stay headless). */
15
21
  emptyTitle: string
16
22
  emptyDescription: string
23
+ /** Section headings, shown only when the record has both kinds of data. */
24
+ metadataTitle?: string
25
+ externalReferencesTitle?: string
26
+ /** Accessible label for the copy button next to a system's key. */
27
+ copyLabel?: (system: string) => string
28
+ }
29
+
30
+ export function hasMetadata(metadata: Metadata): metadata is Record<string, unknown> {
31
+ return !!metadata && Object.keys(metadata).length > 0
32
+ }
33
+
34
+ export function hasExternalReferences(
35
+ externalReferences: ExternalReferences,
36
+ ): externalReferences is Record<string, string> {
37
+ return !!externalReferences && Object.keys(externalReferences).length > 0
17
38
  }
18
39
 
19
- export function MetadataCard({ metadata, title, emptyTitle, emptyDescription }: MetadataCardProps) {
20
- const entries = Object.entries(metadata)
40
+ export function MetadataList({
41
+ metadata,
42
+ externalReferences,
43
+ emptyTitle,
44
+ emptyDescription,
45
+ metadataTitle,
46
+ externalReferencesTitle,
47
+ copyLabel,
48
+ }: MetadataListProps) {
49
+ const showMetadata = hasMetadata(metadata)
50
+ const showReferences = hasExternalReferences(externalReferences)
51
+
52
+ if (!showMetadata && !showReferences) {
53
+ return (
54
+ <Empty className="border-0 p-0">
55
+ <EmptyHeader>
56
+ <EmptyMedia variant="icon">
57
+ <BracesIcon />
58
+ </EmptyMedia>
59
+ <EmptyTitle>{emptyTitle}</EmptyTitle>
60
+ <EmptyDescription>{emptyDescription}</EmptyDescription>
61
+ </EmptyHeader>
62
+ </Empty>
63
+ )
64
+ }
65
+
66
+ const both = showMetadata && showReferences
21
67
 
22
68
  return (
23
- <Card>
24
- <CardHeader>
25
- <CardTitle>
26
- <BracesIcon className="size-4" />
27
- {title}
28
- </CardTitle>
29
- </CardHeader>
30
- <CardContent>
31
- {entries.length === 0 ? (
32
- <Empty className="border-0 p-0">
33
- <EmptyHeader>
34
- <EmptyMedia variant="icon">
35
- <BracesIcon />
36
- </EmptyMedia>
37
- <EmptyTitle>{emptyTitle}</EmptyTitle>
38
- <EmptyDescription>{emptyDescription}</EmptyDescription>
39
- </EmptyHeader>
40
- </Empty>
41
- ) : (
69
+ <div className="flex flex-col gap-4">
70
+ {showReferences && (
71
+ <section className="flex flex-col gap-2">
72
+ {externalReferencesTitle && (
73
+ <h4 className="text-xs font-medium text-muted-foreground">{externalReferencesTitle}</h4>
74
+ )}
75
+ <dl className="grid grid-cols-[minmax(160px,1fr)_2fr] items-center gap-x-4 gap-y-1 text-sm">
76
+ {Object.entries(externalReferences).map(([system, key]) => (
77
+ <Fragment key={system}>
78
+ <dt>
79
+ <code className="text-xs text-muted-foreground">{system}</code>
80
+ </dt>
81
+ <dd className="flex min-w-0 items-center gap-1 font-mono text-xs text-foreground/90">
82
+ <span className="break-all">{key}</span>
83
+ {copyLabel && (
84
+ <CopyToClipboardButton value={key} aria-label={copyLabel(system)} />
85
+ )}
86
+ </dd>
87
+ </Fragment>
88
+ ))}
89
+ </dl>
90
+ </section>
91
+ )}
92
+ {showMetadata && (
93
+ <section className="flex flex-col gap-2">
94
+ {both && metadataTitle && (
95
+ <h4 className="text-xs font-medium text-muted-foreground">{metadataTitle}</h4>
96
+ )}
42
97
  <dl className="grid grid-cols-[minmax(160px,1fr)_2fr] gap-x-4 gap-y-2 text-sm">
43
- {entries.map(([key, value]) => (
98
+ {Object.entries(metadata).map(([key, value]) => (
44
99
  <Fragment key={key}>
45
100
  <dt>
46
101
  <code className="text-xs text-muted-foreground">{key}</code>
@@ -51,9 +106,59 @@ export function MetadataCard({ metadata, title, emptyTitle, emptyDescription }:
51
106
  </Fragment>
52
107
  ))}
53
108
  </dl>
54
- )}
55
- </CardContent>
56
- </Card>
109
+ </section>
110
+ )}
111
+ </div>
112
+ )
113
+ }
114
+
115
+ interface MetadataCardProps extends MetadataListProps {
116
+ title: string
117
+ }
118
+
119
+ /** Folded by default: metadata is for integrations, and the entry count says whether there is any. */
120
+ export function MetadataCard({ title, ...listProps }: MetadataCardProps) {
121
+ return (
122
+ <CollapsibleCard
123
+ title={title}
124
+ icon={<BracesIcon className="size-4" />}
125
+ count={
126
+ Object.keys(listProps.metadata ?? {}).length +
127
+ Object.keys(listProps.externalReferences ?? {}).length
128
+ }
129
+ >
130
+ <MetadataList {...listProps} />
131
+ </CollapsibleCard>
132
+ )
133
+ }
134
+
135
+ interface MetadataSheetProps extends MetadataListProps {
136
+ open: boolean
137
+ onOpenChange: (open: boolean) => void
138
+ title: string
139
+ description?: string
140
+ }
141
+
142
+ /** Metadata of a record listed inside another page, opened from its row menu. */
143
+ export function MetadataSheet({
144
+ open,
145
+ onOpenChange,
146
+ title,
147
+ description,
148
+ ...listProps
149
+ }: MetadataSheetProps) {
150
+ return (
151
+ <Sheet open={open} onOpenChange={onOpenChange}>
152
+ <SheetContent>
153
+ <SheetHeader>
154
+ <SheetTitle>{title}</SheetTitle>
155
+ {description && <SheetDescription>{description}</SheetDescription>}
156
+ </SheetHeader>
157
+ <div className="flex-1 overflow-y-auto p-4">
158
+ <MetadataList {...listProps} />
159
+ </div>
160
+ </SheetContent>
161
+ </Sheet>
57
162
  )
58
163
  }
59
164
 
@@ -0,0 +1,74 @@
1
+ import { cn } from '../lib/utils'
2
+
3
+ const R = 6.5
4
+ const CIRCUMFERENCE = 2 * Math.PI * R
5
+
6
+ /** Progress through a checklist as a ring that fills green. */
7
+ export function StepProgressRing({
8
+ value,
9
+ max,
10
+ className,
11
+ }: {
12
+ value: number
13
+ max: number
14
+ className?: string
15
+ }) {
16
+ return (
17
+ <svg viewBox="0 0 16 16" className={cn('size-4 shrink-0 -rotate-90', className)} aria-hidden>
18
+ <circle cx="8" cy="8" r={R} fill="none" strokeWidth="1.5" className="stroke-border" />
19
+ <circle
20
+ cx="8"
21
+ cy="8"
22
+ r={R}
23
+ fill="none"
24
+ strokeWidth="1.5"
25
+ strokeLinecap="round"
26
+ strokeDasharray={CIRCUMFERENCE}
27
+ strokeDashoffset={CIRCUMFERENCE * (1 - value / (max || 1))}
28
+ className="stroke-success"
29
+ />
30
+ </svg>
31
+ )
32
+ }
33
+
34
+ export type StepStatus = 'done' | 'in_progress' | 'todo'
35
+
36
+ /**
37
+ * A checklist step's state: a green check once done, a dashed blue ring while
38
+ * it is started but waiting on someone else (such as a review), and a plain
39
+ * ring before it is started.
40
+ */
41
+ export function StepIcon({ status, className }: { status: StepStatus; className?: string }) {
42
+ const classes = cn('size-4 shrink-0', className)
43
+
44
+ if (status === 'done') {
45
+ return (
46
+ <svg viewBox="0 0 16 16" className={classes} aria-hidden>
47
+ <circle cx="8" cy="8" r="7.5" className="fill-success" />
48
+ <path
49
+ d="M5 8.2 7 10.2 11 6"
50
+ fill="none"
51
+ stroke="white"
52
+ strokeWidth="1.75"
53
+ strokeLinecap="round"
54
+ strokeLinejoin="round"
55
+ />
56
+ </svg>
57
+ )
58
+ }
59
+
60
+ return (
61
+ <svg viewBox="0 0 16 16" className={classes} aria-hidden>
62
+ <circle
63
+ cx="8"
64
+ cy="8"
65
+ r={R}
66
+ fill="none"
67
+ strokeWidth="1.5"
68
+ strokeLinecap="round"
69
+ strokeDasharray={status === 'in_progress' ? '2.5 2.6' : undefined}
70
+ className={status === 'in_progress' ? 'stroke-blue-500' : 'stroke-border'}
71
+ />
72
+ </svg>
73
+ )
74
+ }
package/src/styles.css CHANGED
@@ -450,8 +450,8 @@
450
450
  --radius-sm: 0.4rem;
451
451
  --radius-md: 0.5rem;
452
452
  --radius-lg: 0.625rem;
453
- --radius-xl: 0.875rem;
454
- --radius-2xl: 1.125rem;
453
+ --radius-xl: 0.75rem;
454
+ --radius-2xl: 1rem;
455
455
 
456
456
  /* Sidebar + header tokens */
457
457
  --spacing-sidebar-width: 240px;
package/src/ui/badge.tsx CHANGED
@@ -188,6 +188,15 @@ const statusToneMap: Record<string, StatusTone> = {
188
188
  // same green/red the rest of the app uses saves them parsing the word.
189
189
  true: 'success',
190
190
  false: 'destructive',
191
+ // Maintenance task runs: amber while a worker holds or is about to release
192
+ // the run, blue while it waits for one.
193
+ enqueued: 'info',
194
+ running: 'warning',
195
+ interrupted: 'info',
196
+ cancelling: 'warning',
197
+ cancelled: 'destructive',
198
+ succeeded: 'success',
199
+ errored: 'destructive',
191
200
  } satisfies Record<BuiltInStatus, StatusTone> & Record<string, StatusTone>
192
201
 
193
202
  // Dot fills, by tone. These read the same `--status-*` ramps the tinted Badge
@@ -160,19 +160,23 @@ function DropdownMenuCheckboxItem({
160
160
  data-slot="dropdown-menu-checkbox-item"
161
161
  data-inset={inset}
162
162
  className={cn(
163
- "relative flex cursor-pointer items-center gap-3 rounded-lg py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
163
+ "group/checkbox-item relative flex cursor-pointer items-center gap-2.5 rounded-lg py-1.5 pr-2.5 pl-2.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
164
164
  className,
165
165
  )}
166
166
  checked={checked}
167
167
  onCheckedChange={onCheckedChange ? (checked) => onCheckedChange(checked) : undefined}
168
168
  {...props}
169
169
  >
170
+ {/* Drawn rather than a <Checkbox>: the item is already the checkbox to
171
+ assistive technology, and a second control inside it would be read
172
+ twice. Styled to match <Checkbox> so a menu and a table agree. */}
170
173
  <span
171
- className="pointer-events-none absolute right-2 flex items-center justify-center"
174
+ aria-hidden
172
175
  data-slot="dropdown-menu-checkbox-item-indicator"
176
+ className="pointer-events-none flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-border bg-card text-white shadow-xs transition-colors duration-100 group-data-[checked]/checkbox-item:border-blue-500 group-data-[checked]/checkbox-item:bg-blue-500"
173
177
  >
174
178
  <MenuPrimitive.CheckboxItemIndicator keepMounted={false}>
175
- <CheckIcon />
179
+ <CheckIcon className="size-3.5" />
176
180
  </MenuPrimitive.CheckboxItemIndicator>
177
181
  </span>
178
182
  {children}
package/src/ui/field.tsx CHANGED
@@ -94,7 +94,7 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
94
94
  <Label
95
95
  data-slot="field-label"
96
96
  className={cn(
97
- 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-blue-500/30 has-data-checked:bg-blue-500/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-blue-500/20 dark:has-data-checked:bg-blue-500/10',
97
+ 'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-blue-100 has-data-checked:bg-blue-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-blue-500/20 dark:has-data-checked:bg-blue-500/10',
98
98
  // Card-shaped labels (the ones wrapping a whole `Field`) are clickable
99
99
  // targets, so they need the affordance a bare text label doesn't:
100
100
  // a pointer and a hover tint. Scoped by `has-[>[data-slot=field]]` so
package/src/ui/tabs.tsx CHANGED
@@ -41,7 +41,17 @@ function TabsList({
41
41
  )
42
42
  }
43
43
 
44
- function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Tab>) {
44
+ /**
45
+ * A tab. Pass `count` to append a tally — the number of steps outstanding, or
46
+ * of records in the panel — which is what tells an operator whether a tab is
47
+ * worth opening before they open it.
48
+ */
49
+ function TabsTrigger({
50
+ className,
51
+ children,
52
+ count,
53
+ ...props
54
+ }: React.ComponentProps<typeof TabsPrimitive.Tab> & { count?: number }) {
45
55
  return (
46
56
  <TabsPrimitive.Tab
47
57
  data-slot="tabs-trigger"
@@ -67,7 +77,12 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
67
77
  className,
68
78
  )}
69
79
  {...props}
70
- />
80
+ >
81
+ {children}
82
+ {count !== undefined && (
83
+ <span className="rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums">{count}</span>
84
+ )}
85
+ </TabsPrimitive.Tab>
71
86
  )
72
87
  }
73
88