@olwiba/ui 0.1.15 → 0.2.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/dist/index.d.ts +149 -35
- package/dist/index.js +790 -408
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AuthSection.tsx +5 -4
- package/src/app/BillingPanel.tsx +18 -12
- package/src/app/OnboardingWizard.tsx +19 -6
- package/src/app/SettingsSection.tsx +1 -1
- package/src/app/TeamMembersPanel.tsx +47 -37
- package/src/components/ActivityFeed.tsx +82 -0
- package/src/components/Chart.tsx +245 -0
- package/src/components/CommandMenu.tsx +7 -1
- package/src/components/DataTable.tsx +23 -13
- package/src/components/FileUpload.tsx +24 -5
- package/src/components/NotificationsPopover.tsx +146 -0
- package/src/components/Notify.tsx +4 -2
- package/src/index.ts +7 -2
- package/src/marketing/ContactSection.tsx +13 -2
- package/src/marketing/CtaSection.tsx +93 -9
- package/src/marketing/FeaturesSection.tsx +27 -12
- package/src/marketing/HeroSection.tsx +5 -2
- package/src/marketing/NewsletterSection.tsx +12 -2
- package/src/marketing/TeamSection.tsx +10 -2
- package/src/mechanics/Carousel.tsx +112 -0
- package/src/mechanics/Sortable.tsx +128 -0
- package/src/marketing/CarouselSection.tsx +0 -126
- package/src/marketing/CtaCardSection.tsx +0 -95
package/package.json
CHANGED
package/src/app/AuthSection.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { Building2, ShieldCheck, Sparkles } from 'lucide-react';
|
|
4
|
+
import { Building2, Loader2, ShieldCheck, Sparkles } from 'lucide-react';
|
|
5
5
|
import {
|
|
6
6
|
Badge,
|
|
7
7
|
CardContent,
|
|
@@ -243,17 +243,18 @@ function DefaultForm({
|
|
|
243
243
|
)}
|
|
244
244
|
|
|
245
245
|
{error && (
|
|
246
|
-
<p className="text-sm font-medium text-destructive">{error}</p>
|
|
246
|
+
<p role="alert" className="text-sm font-medium text-destructive">{error}</p>
|
|
247
247
|
)}
|
|
248
248
|
{success && (
|
|
249
|
-
<p className="text-sm font-medium text-primary">{success}</p>
|
|
249
|
+
<p role="status" className="text-sm font-medium text-primary">{success}</p>
|
|
250
250
|
)}
|
|
251
251
|
|
|
252
252
|
<div className="flex flex-col gap-2">
|
|
253
253
|
<Button type="submit" className="w-full" disabled={loading || (isVerify && code.length < codeLength)}>
|
|
254
|
+
{loading && <Loader2 className="size-4 animate-spin" />}
|
|
254
255
|
{loading ? 'Please wait…' : copy.submit}
|
|
255
256
|
</Button>
|
|
256
|
-
{onSso && mode === 'signin' && (
|
|
257
|
+
{onSso && (mode === 'signin' || mode === 'signup') && (
|
|
257
258
|
<Button type="button" variant="outline" className="w-full" onClick={onSso} disabled={loading}>
|
|
258
259
|
Use SSO
|
|
259
260
|
</Button>
|
package/src/app/BillingPanel.tsx
CHANGED
|
@@ -64,12 +64,14 @@ export function BillingPanel({
|
|
|
64
64
|
onUpdatePaymentMethod,
|
|
65
65
|
className,
|
|
66
66
|
}: BillingPanelProps) {
|
|
67
|
+
// Dates and amounts are display strings — sorting them would be lexicographic, so keep it off
|
|
67
68
|
const invoiceColumns = React.useMemo<ColumnDef<BillingInvoice>[]>(() => [
|
|
68
|
-
{ accessorKey: 'date', header: 'Date' },
|
|
69
|
-
{ accessorKey: 'amount', header: 'Amount' },
|
|
69
|
+
{ accessorKey: 'date', header: 'Date', enableSorting: false },
|
|
70
|
+
{ accessorKey: 'amount', header: 'Amount', enableSorting: false },
|
|
70
71
|
{
|
|
71
72
|
accessorKey: 'status',
|
|
72
73
|
header: 'Status',
|
|
74
|
+
enableSorting: false,
|
|
73
75
|
cell: ({ row }) => (
|
|
74
76
|
<Badge variant={invoiceStatusVariant[row.original.status]} className="capitalize">
|
|
75
77
|
{row.original.status}
|
|
@@ -108,17 +110,21 @@ export function BillingPanel({
|
|
|
108
110
|
</div>
|
|
109
111
|
{usage && usage.length > 0 && (
|
|
110
112
|
<div className="mt-4 space-y-4">
|
|
111
|
-
{usage.map((metric) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
{usage.map((metric) => {
|
|
114
|
+
const overLimit = metric.limit > 0 && metric.used > metric.limit;
|
|
115
|
+
const percent = metric.limit > 0 ? Math.min(100, (metric.used / metric.limit) * 100) : 0;
|
|
116
|
+
return (
|
|
117
|
+
<div key={metric.label} className="space-y-1.5">
|
|
118
|
+
<div className="flex items-center justify-between text-sm">
|
|
119
|
+
<span>{metric.label}</span>
|
|
120
|
+
<span className={overLimit ? 'font-medium text-destructive' : 'text-muted-foreground'}>
|
|
121
|
+
{metric.used}{metric.unit} / {metric.limit}{metric.unit}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
<Progress value={percent} className={overLimit ? '[&>div]:bg-destructive' : undefined} />
|
|
118
125
|
</div>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
))}
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
122
128
|
</div>
|
|
123
129
|
)}
|
|
124
130
|
</SettingsSection>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { Check } from 'lucide-react';
|
|
4
|
+
import { Check, Loader2 } from 'lucide-react';
|
|
5
5
|
import { cn } from '@olwiba/cn';
|
|
6
6
|
import { Button } from '../primitives/Button';
|
|
7
7
|
|
|
@@ -57,8 +57,15 @@ export function OnboardingWizard({
|
|
|
57
57
|
const handleNext = async () => {
|
|
58
58
|
if (current.onNext) {
|
|
59
59
|
setPending(true);
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
let result: boolean | string;
|
|
61
|
+
try {
|
|
62
|
+
result = await current.onNext();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
|
65
|
+
return;
|
|
66
|
+
} finally {
|
|
67
|
+
setPending(false);
|
|
68
|
+
}
|
|
62
69
|
if (result === false) { setError('Please complete this step before continuing.'); return; }
|
|
63
70
|
if (typeof result === 'string') { setError(result); return; }
|
|
64
71
|
}
|
|
@@ -75,9 +82,13 @@ export function OnboardingWizard({
|
|
|
75
82
|
|
|
76
83
|
return (
|
|
77
84
|
<div className={cn('w-full max-w-lg space-y-8', className)}>
|
|
78
|
-
<ol className="flex items-center">
|
|
85
|
+
<ol className="flex items-center" aria-label="Progress">
|
|
79
86
|
{steps.map((step, i) => (
|
|
80
|
-
<li
|
|
87
|
+
<li
|
|
88
|
+
key={step.id}
|
|
89
|
+
aria-current={i === index ? 'step' : undefined}
|
|
90
|
+
className={cn('flex items-center', i !== steps.length - 1 && 'flex-1')}
|
|
91
|
+
>
|
|
81
92
|
<div
|
|
82
93
|
className={cn(
|
|
83
94
|
'flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium',
|
|
@@ -85,6 +96,7 @@ export function OnboardingWizard({
|
|
|
85
96
|
)}
|
|
86
97
|
>
|
|
87
98
|
{i < index ? <Check className="size-4" /> : i + 1}
|
|
99
|
+
<span className="sr-only">{step.title}{i < index ? ' (completed)' : ''}</span>
|
|
88
100
|
</div>
|
|
89
101
|
{i !== steps.length - 1 && (
|
|
90
102
|
<div className={cn('mx-2 h-px flex-1', i < index ? 'bg-primary' : 'bg-border')} />
|
|
@@ -99,7 +111,7 @@ export function OnboardingWizard({
|
|
|
99
111
|
{current.description && <p className="mt-1 text-sm text-muted-foreground">{current.description}</p>}
|
|
100
112
|
</div>
|
|
101
113
|
<div>{current.content}</div>
|
|
102
|
-
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
114
|
+
{error && <p role="alert" className="text-sm font-medium text-destructive">{error}</p>}
|
|
103
115
|
</div>
|
|
104
116
|
|
|
105
117
|
<div className="flex items-center justify-between">
|
|
@@ -107,6 +119,7 @@ export function OnboardingWizard({
|
|
|
107
119
|
Back
|
|
108
120
|
</Button>
|
|
109
121
|
<Button type="button" onClick={handleNext} disabled={pending}>
|
|
122
|
+
{pending && <Loader2 className="size-4 animate-spin" />}
|
|
110
123
|
{pending ? 'Please wait…' : isLast ? completeLabel : 'Continue'}
|
|
111
124
|
</Button>
|
|
112
125
|
</div>
|
|
@@ -6,7 +6,7 @@ export interface SettingsSectionProps {
|
|
|
6
6
|
description?: string;
|
|
7
7
|
/** Form fields, buttons, or any content for the right-hand column — e.g. a `<form>`. */
|
|
8
8
|
children: React.ReactNode;
|
|
9
|
-
/**
|
|
9
|
+
/** Marks the section as a destructive action (e.g. "Delete account") — renders the title in the destructive color. @default false */
|
|
10
10
|
danger?: boolean;
|
|
11
11
|
className?: string;
|
|
12
12
|
}
|
|
@@ -50,39 +50,45 @@ export interface TeamMembersPanelProps {
|
|
|
50
50
|
description?: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function InviteDialog({ roles, onInvite }: { roles: string[]; onInvite?: (email: string, role: string) => void }) {
|
|
53
|
+
function InviteDialog({ roles, onInvite, onClose }: { roles: string[]; onInvite?: (email: string, role: string) => void; onClose: () => void }) {
|
|
54
54
|
const [email, setEmail] = React.useState('');
|
|
55
55
|
const [role, setRole] = React.useState(roles[roles.length - 1] ?? roles[0]);
|
|
56
56
|
|
|
57
57
|
return (
|
|
58
58
|
<DialogContent>
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
<form
|
|
60
|
+
onSubmit={(e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
onInvite?.(email, role);
|
|
63
|
+
onClose();
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<DialogHeader>
|
|
67
|
+
<DialogTitle>Invite a team member</DialogTitle>
|
|
68
|
+
<DialogDescription>They’ll get an email invite to join this workspace.</DialogDescription>
|
|
69
|
+
</DialogHeader>
|
|
70
|
+
<div className="space-y-4 py-4">
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<Label htmlFor="invite-email">Email address</Label>
|
|
73
|
+
<Input id="invite-email" type="email" required placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
74
|
+
</div>
|
|
75
|
+
<div className="space-y-2">
|
|
76
|
+
<Label>Role</Label>
|
|
77
|
+
<Select value={role} onValueChange={setRole}>
|
|
78
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
79
|
+
<SelectContent>
|
|
80
|
+
{roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}
|
|
81
|
+
</SelectContent>
|
|
82
|
+
</Select>
|
|
83
|
+
</div>
|
|
76
84
|
</div>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
</DialogClose>
|
|
85
|
-
</DialogFooter>
|
|
85
|
+
<DialogFooter>
|
|
86
|
+
<DialogClose asChild>
|
|
87
|
+
<Button type="button" variant="outline">Cancel</Button>
|
|
88
|
+
</DialogClose>
|
|
89
|
+
<Button type="submit">Send invite</Button>
|
|
90
|
+
</DialogFooter>
|
|
91
|
+
</form>
|
|
86
92
|
</DialogContent>
|
|
87
93
|
);
|
|
88
94
|
}
|
|
@@ -100,6 +106,7 @@ export function TeamMembersPanel({
|
|
|
100
106
|
title = 'Team members',
|
|
101
107
|
description = 'Manage who has access to this workspace.',
|
|
102
108
|
}: TeamMembersPanelProps) {
|
|
109
|
+
const [inviteOpen, setInviteOpen] = React.useState(false);
|
|
103
110
|
const columns = React.useMemo<ColumnDef<TeamMemberRecord>[]>(() => [
|
|
104
111
|
{
|
|
105
112
|
accessorKey: 'name',
|
|
@@ -132,7 +139,7 @@ export function TeamMembersPanel({
|
|
|
132
139
|
<Badge variant="secondary">{row.original.role}</Badge>
|
|
133
140
|
),
|
|
134
141
|
},
|
|
135
|
-
{
|
|
142
|
+
...(onRemove ? [{
|
|
136
143
|
id: 'actions',
|
|
137
144
|
header: '',
|
|
138
145
|
cell: ({ row }) => (
|
|
@@ -144,29 +151,32 @@ export function TeamMembersPanel({
|
|
|
144
151
|
</Button>
|
|
145
152
|
</DropdownMenuTrigger>
|
|
146
153
|
<DropdownMenuContent align="end">
|
|
147
|
-
<DropdownMenuItem className="text-destructive" onClick={() => onRemove
|
|
154
|
+
<DropdownMenuItem className="text-destructive" onClick={() => onRemove(row.original.id)}>
|
|
148
155
|
Remove member
|
|
149
156
|
</DropdownMenuItem>
|
|
150
157
|
</DropdownMenuContent>
|
|
151
158
|
</DropdownMenu>
|
|
152
159
|
),
|
|
153
160
|
enableSorting: false,
|
|
154
|
-
},
|
|
161
|
+
} satisfies ColumnDef<TeamMemberRecord>] : []),
|
|
155
162
|
], [roles, onRoleChange, onRemove]);
|
|
156
163
|
|
|
157
164
|
return (
|
|
158
165
|
<div className="space-y-4">
|
|
159
|
-
<div className="flex items-center justify-between gap-4">
|
|
166
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
160
167
|
<div>
|
|
161
168
|
<h2 className="text-base font-semibold">{title}</h2>
|
|
162
169
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
163
170
|
</div>
|
|
164
|
-
|
|
165
|
-
<
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
{onInvite && (
|
|
172
|
+
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
|
173
|
+
<DialogTrigger asChild>
|
|
174
|
+
<Button><UserPlus className="size-4" /> Invite member</Button>
|
|
175
|
+
</DialogTrigger>
|
|
176
|
+
{/* key resets the form fields each time the dialog opens */}
|
|
177
|
+
<InviteDialog key={String(inviteOpen)} roles={roles} onInvite={onInvite} onClose={() => setInviteOpen(false)} />
|
|
178
|
+
</Dialog>
|
|
179
|
+
)}
|
|
170
180
|
</div>
|
|
171
181
|
<DataTable columns={columns} data={members} searchKey="name" searchPlaceholder="Search members…" pageSize={0} />
|
|
172
182
|
</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Activity } from 'lucide-react';
|
|
5
|
+
import { Avatar, AvatarFallback, AvatarImage, cn } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
export interface ActivityFeedItem {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Main line — pass rich nodes for emphasis, e.g. <><b>Ana</b> deployed to production</>. */
|
|
10
|
+
title: React.ReactNode;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Pre-formatted timestamp, e.g. "2h ago" or "Mar 4". */
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
/** Avatar image — takes precedence over `icon`. */
|
|
15
|
+
avatar?: string;
|
|
16
|
+
/** Fallback initials when `avatar` is set but fails to load. */
|
|
17
|
+
initials?: string;
|
|
18
|
+
/** Icon node rendered in the timeline marker when there is no avatar. */
|
|
19
|
+
icon?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ActivityFeedProps {
|
|
23
|
+
items: ActivityFeedItem[];
|
|
24
|
+
emptyMessage?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Vertical activity timeline — avatar or icon markers connected by a rail,
|
|
30
|
+
* one row per event. Presentation-only: pass pre-formatted timestamps and
|
|
31
|
+
* rich `title` nodes from your own data layer.
|
|
32
|
+
*/
|
|
33
|
+
export function ActivityFeed({
|
|
34
|
+
items,
|
|
35
|
+
emptyMessage = 'No activity yet.',
|
|
36
|
+
className,
|
|
37
|
+
}: ActivityFeedProps) {
|
|
38
|
+
if (items.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn('flex flex-col items-center gap-2 py-10 text-center', className)}>
|
|
41
|
+
<Activity className="size-6 text-muted-foreground" />
|
|
42
|
+
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<ol className={className}>
|
|
49
|
+
{items.map((item, i) => (
|
|
50
|
+
<li key={item.id} className="relative flex gap-3 pb-6 last:pb-0">
|
|
51
|
+
{/* Rail connecting this marker to the next */}
|
|
52
|
+
{i !== items.length - 1 && (
|
|
53
|
+
<span aria-hidden className="absolute left-4 top-9 bottom-0 w-px -translate-x-1/2 bg-border" />
|
|
54
|
+
)}
|
|
55
|
+
<span className="relative z-10 flex size-8 shrink-0 items-center justify-center">
|
|
56
|
+
{item.avatar ? (
|
|
57
|
+
<Avatar className="size-8">
|
|
58
|
+
<AvatarImage src={item.avatar} alt="" />
|
|
59
|
+
<AvatarFallback className="text-xs">{item.initials ?? '?'}</AvatarFallback>
|
|
60
|
+
</Avatar>
|
|
61
|
+
) : (
|
|
62
|
+
<span className="flex size-8 items-center justify-center rounded-full border bg-card text-muted-foreground [&>svg]:size-4">
|
|
63
|
+
{item.icon ?? <Activity />}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
<div className="min-w-0 flex-1 pt-1">
|
|
68
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
69
|
+
<p className="text-sm">{item.title}</p>
|
|
70
|
+
{item.timestamp && (
|
|
71
|
+
<span className="shrink-0 text-xs text-muted-foreground">{item.timestamp}</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
{item.description && (
|
|
75
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{item.description}</p>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ol>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Area,
|
|
6
|
+
AreaChart,
|
|
7
|
+
Bar,
|
|
8
|
+
BarChart,
|
|
9
|
+
CartesianGrid,
|
|
10
|
+
Cell,
|
|
11
|
+
Line,
|
|
12
|
+
LineChart,
|
|
13
|
+
Pie,
|
|
14
|
+
PieChart,
|
|
15
|
+
ResponsiveContainer,
|
|
16
|
+
Tooltip,
|
|
17
|
+
XAxis,
|
|
18
|
+
YAxis,
|
|
19
|
+
} from 'recharts';
|
|
20
|
+
import { cn } from '@olwiba/cn';
|
|
21
|
+
|
|
22
|
+
export interface ChartSeries {
|
|
23
|
+
/** Data key to plot. */
|
|
24
|
+
key: string;
|
|
25
|
+
/** Legend and tooltip label. @default key */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** CSS color. @default theme tokens --chart-1..5 in fixed order */
|
|
28
|
+
color?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChartProps {
|
|
32
|
+
/** Chart form. @default 'line' */
|
|
33
|
+
type?: 'line' | 'area' | 'bar' | 'donut';
|
|
34
|
+
data: Array<Record<string, string | number>>;
|
|
35
|
+
/** Key for x-axis categories (line/area/bar) or slice labels (donut). */
|
|
36
|
+
xKey: string;
|
|
37
|
+
/** Series to plot. Donut uses `series[0].key` as the slice value. */
|
|
38
|
+
series: ChartSeries[];
|
|
39
|
+
/** Chart height in px. @default 300 */
|
|
40
|
+
height?: number;
|
|
41
|
+
/** Horizontal grid lines. @default true (ignored for donut) */
|
|
42
|
+
grid?: boolean;
|
|
43
|
+
/** Legend. @default true for multiple series or donut, false for one series */
|
|
44
|
+
legend?: boolean;
|
|
45
|
+
/** Formats values in tooltips and the y-axis, e.g. `(v) => \`$${v}\``. */
|
|
46
|
+
valueFormatter?: (value: number) => string;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fixed categorical order — series N always gets token N, never cycled.
|
|
51
|
+
const TOKEN_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];
|
|
52
|
+
|
|
53
|
+
const seriesColor = (s: ChartSeries, i: number) => s.color ?? TOKEN_COLORS[i % TOKEN_COLORS.length];
|
|
54
|
+
|
|
55
|
+
function ChartTooltip({
|
|
56
|
+
active,
|
|
57
|
+
payload,
|
|
58
|
+
label,
|
|
59
|
+
valueFormatter,
|
|
60
|
+
}: {
|
|
61
|
+
active?: boolean;
|
|
62
|
+
payload?: Array<{ name?: string; value?: number | string; color?: string; payload?: { fill?: string } }>;
|
|
63
|
+
label?: string | number;
|
|
64
|
+
valueFormatter: (value: number) => string;
|
|
65
|
+
}) {
|
|
66
|
+
if (!active || !payload?.length) return null;
|
|
67
|
+
return (
|
|
68
|
+
<div className="rounded-lg border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md">
|
|
69
|
+
{label !== undefined && <p className="mb-1 font-medium">{label}</p>}
|
|
70
|
+
<div className="space-y-1">
|
|
71
|
+
{payload.map((entry, i) => (
|
|
72
|
+
<div key={i} className="flex items-center gap-2">
|
|
73
|
+
<span
|
|
74
|
+
aria-hidden
|
|
75
|
+
className="size-2 shrink-0 rounded-full"
|
|
76
|
+
style={{ background: entry.color ?? entry.payload?.fill }}
|
|
77
|
+
/>
|
|
78
|
+
<span className="text-muted-foreground">{entry.name}</span>
|
|
79
|
+
<span className="ml-auto pl-3 font-medium tabular-nums">
|
|
80
|
+
{typeof entry.value === 'number' ? valueFormatter(entry.value) : entry.value}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ChartLegend({ entries }: { entries: Array<{ label: string; color: string }> }) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="mt-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
|
92
|
+
{entries.map((entry) => (
|
|
93
|
+
<div key={entry.label} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
94
|
+
<span aria-hidden className="size-2 rounded-full" style={{ background: entry.color }} />
|
|
95
|
+
{entry.label}
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const axisProps = {
|
|
103
|
+
tickLine: false,
|
|
104
|
+
axisLine: false,
|
|
105
|
+
tick: { fill: 'var(--muted-foreground)', fontSize: 12 },
|
|
106
|
+
tickMargin: 8,
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Theme-aware chart — one component, `type` switches the form. Colors come
|
|
111
|
+
* from the `--chart-1..5` theme tokens in fixed order (override per series
|
|
112
|
+
* via `color`). Line and area for change-over-time, bar for magnitude
|
|
113
|
+
* comparison, donut for part-of-whole identity. More than 5 donut slices:
|
|
114
|
+
* fold the tail into an "Other" slice in your data instead of adding hues.
|
|
115
|
+
*/
|
|
116
|
+
export function Chart({
|
|
117
|
+
type = 'line',
|
|
118
|
+
data,
|
|
119
|
+
xKey,
|
|
120
|
+
series,
|
|
121
|
+
height = 300,
|
|
122
|
+
grid = true,
|
|
123
|
+
legend,
|
|
124
|
+
valueFormatter = (v) => String(v),
|
|
125
|
+
className,
|
|
126
|
+
}: ChartProps) {
|
|
127
|
+
const showLegend = legend ?? (type === 'donut' || series.length > 1);
|
|
128
|
+
const tooltip = (
|
|
129
|
+
<Tooltip
|
|
130
|
+
cursor={type === 'bar' ? { fill: 'var(--muted)', opacity: 0.4 } : { stroke: 'var(--border)' }}
|
|
131
|
+
content={<ChartTooltip valueFormatter={valueFormatter} />}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
const gridEl = grid ? (
|
|
135
|
+
<CartesianGrid vertical={false} stroke="var(--border)" strokeOpacity={0.6} />
|
|
136
|
+
) : null;
|
|
137
|
+
|
|
138
|
+
let chart: React.ReactElement;
|
|
139
|
+
let legendEntries: Array<{ label: string; color: string }>;
|
|
140
|
+
|
|
141
|
+
if (type === 'donut') {
|
|
142
|
+
const valueKey = series[0]?.key;
|
|
143
|
+
legendEntries = data.map((row, i) => ({
|
|
144
|
+
label: String(row[xKey]),
|
|
145
|
+
color: TOKEN_COLORS[i % TOKEN_COLORS.length],
|
|
146
|
+
}));
|
|
147
|
+
chart = (
|
|
148
|
+
<PieChart>
|
|
149
|
+
<Pie
|
|
150
|
+
data={data}
|
|
151
|
+
dataKey={valueKey}
|
|
152
|
+
nameKey={xKey}
|
|
153
|
+
innerRadius="60%"
|
|
154
|
+
outerRadius="85%"
|
|
155
|
+
paddingAngle={2}
|
|
156
|
+
stroke="var(--card)"
|
|
157
|
+
strokeWidth={2}
|
|
158
|
+
>
|
|
159
|
+
{data.map((row, i) => (
|
|
160
|
+
<Cell key={String(row[xKey])} fill={TOKEN_COLORS[i % TOKEN_COLORS.length]} />
|
|
161
|
+
))}
|
|
162
|
+
</Pie>
|
|
163
|
+
{tooltip}
|
|
164
|
+
</PieChart>
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
legendEntries = series.map((s, i) => ({ label: s.label ?? s.key, color: seriesColor(s, i) }));
|
|
168
|
+
|
|
169
|
+
if (type === 'bar') {
|
|
170
|
+
chart = (
|
|
171
|
+
<BarChart data={data} barCategoryGap="25%">
|
|
172
|
+
{gridEl}
|
|
173
|
+
<XAxis dataKey={xKey} {...axisProps} />
|
|
174
|
+
<YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
|
|
175
|
+
{tooltip}
|
|
176
|
+
{series.map((s, i) => (
|
|
177
|
+
<Bar
|
|
178
|
+
key={s.key}
|
|
179
|
+
dataKey={s.key}
|
|
180
|
+
name={s.label ?? s.key}
|
|
181
|
+
fill={seriesColor(s, i)}
|
|
182
|
+
radius={[4, 4, 0, 0]}
|
|
183
|
+
maxBarSize={40}
|
|
184
|
+
/>
|
|
185
|
+
))}
|
|
186
|
+
</BarChart>
|
|
187
|
+
);
|
|
188
|
+
} else if (type === 'area') {
|
|
189
|
+
chart = (
|
|
190
|
+
<AreaChart data={data}>
|
|
191
|
+
{gridEl}
|
|
192
|
+
<XAxis dataKey={xKey} {...axisProps} />
|
|
193
|
+
<YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
|
|
194
|
+
{tooltip}
|
|
195
|
+
{series.map((s, i) => (
|
|
196
|
+
<Area
|
|
197
|
+
key={s.key}
|
|
198
|
+
type="monotone"
|
|
199
|
+
dataKey={s.key}
|
|
200
|
+
name={s.label ?? s.key}
|
|
201
|
+
stroke={seriesColor(s, i)}
|
|
202
|
+
strokeWidth={2}
|
|
203
|
+
fill={seriesColor(s, i)}
|
|
204
|
+
fillOpacity={0.12}
|
|
205
|
+
dot={false}
|
|
206
|
+
activeDot={{ r: 4 }}
|
|
207
|
+
/>
|
|
208
|
+
))}
|
|
209
|
+
</AreaChart>
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
chart = (
|
|
213
|
+
<LineChart data={data}>
|
|
214
|
+
{gridEl}
|
|
215
|
+
<XAxis dataKey={xKey} {...axisProps} />
|
|
216
|
+
<YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
|
|
217
|
+
{tooltip}
|
|
218
|
+
{series.map((s, i) => (
|
|
219
|
+
<Line
|
|
220
|
+
key={s.key}
|
|
221
|
+
type="monotone"
|
|
222
|
+
dataKey={s.key}
|
|
223
|
+
name={s.label ?? s.key}
|
|
224
|
+
stroke={seriesColor(s, i)}
|
|
225
|
+
strokeWidth={2}
|
|
226
|
+
dot={false}
|
|
227
|
+
activeDot={{ r: 4 }}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
</LineChart>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className={cn('w-full', className)}>
|
|
237
|
+
<div style={{ height }}>
|
|
238
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
239
|
+
{chart}
|
|
240
|
+
</ResponsiveContainer>
|
|
241
|
+
</div>
|
|
242
|
+
{showLegend && <ChartLegend entries={legendEntries} />}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -54,7 +54,13 @@ export function CommandMenu({
|
|
|
54
54
|
}: CommandMenuProps) {
|
|
55
55
|
const internal = useControlledOpen(false);
|
|
56
56
|
const isOpen = openProp ?? internal.isOpen;
|
|
57
|
-
const setOpen =
|
|
57
|
+
const setOpen = React.useCallback(
|
|
58
|
+
(next: boolean) => {
|
|
59
|
+
if (openProp === undefined) internal.setIsOpen(next);
|
|
60
|
+
onOpenChange?.(next);
|
|
61
|
+
},
|
|
62
|
+
[openProp, onOpenChange, internal.setIsOpen],
|
|
63
|
+
);
|
|
58
64
|
|
|
59
65
|
const runItem = (item: CommandMenuItem) => {
|
|
60
66
|
setOpen(false);
|