@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 +1 -1
- package/package.json +9 -3
- package/src/index.ts +2 -0
- package/src/spree/collapsible-card.tsx +66 -0
- package/src/spree/form-actions.tsx +5 -3
- package/src/spree/icons.ts +1 -0
- package/src/spree/metadata/metadata-card.tsx +140 -35
- package/src/spree/step-icon.tsx +74 -0
- package/src/styles.css +2 -2
- package/src/ui/badge.tsx +9 -0
- package/src/ui/dropdown-menu.tsx +7 -3
- package/src/ui/field.tsx +1 -1
- package/src/ui/tabs.tsx +17 -2
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/src/spree/icons.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
20
|
-
|
|
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
|
-
<
|
|
24
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
56
|
-
</
|
|
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.
|
|
454
|
-
--radius-2xl:
|
|
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
|
package/src/ui/dropdown-menu.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|