rechta-ds 0.0.0
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/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +53 -0
- package/.github/workflows/storybook.yml +34 -0
- package/.storybook/main.ts +17 -0
- package/.storybook/preview.ts +35 -0
- package/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +106 -0
- package/README.md +206 -0
- package/package.json +30 -0
- package/packages/tokens/build.js +357 -0
- package/packages/tokens/package.json +44 -0
- package/packages/tokens/src/tokens.json +1538 -0
- package/packages/ui/.storybook/main.ts +17 -0
- package/packages/ui/.storybook/preview.tsx +37 -0
- package/packages/ui/package.json +109 -0
- package/packages/ui/postcss.config.js +6 -0
- package/packages/ui/src/components/atoms/Avatar.tsx +139 -0
- package/packages/ui/src/components/atoms/Badge.tsx +62 -0
- package/packages/ui/src/components/atoms/Button.tsx +125 -0
- package/packages/ui/src/components/atoms/Input.tsx +116 -0
- package/packages/ui/src/components/atoms/Misc.tsx +128 -0
- package/packages/ui/src/components/atoms/Toggle.tsx +191 -0
- package/packages/ui/src/components/atoms/Typography.tsx +178 -0
- package/packages/ui/src/components/atoms/index.ts +7 -0
- package/packages/ui/src/components/charts/Charts.tsx +380 -0
- package/packages/ui/src/components/charts/DataTable.tsx +222 -0
- package/packages/ui/src/components/charts/index.ts +19 -0
- package/packages/ui/src/components/molecules/Accordion.tsx +93 -0
- package/packages/ui/src/components/molecules/Card.tsx +100 -0
- package/packages/ui/src/components/molecules/PricingCard.tsx +196 -0
- package/packages/ui/src/components/molecules/TestimonialCard.tsx +85 -0
- package/packages/ui/src/components/molecules/Tooltip.tsx +71 -0
- package/packages/ui/src/components/molecules/index.ts +5 -0
- package/packages/ui/src/components/organisms/FeatureTabs.tsx +196 -0
- package/packages/ui/src/components/organisms/LogoMarquee.tsx +119 -0
- package/packages/ui/src/components/organisms/Navbar.tsx +194 -0
- package/packages/ui/src/components/organisms/index.ts +3 -0
- package/packages/ui/src/index.ts +15 -0
- package/packages/ui/src/lib/utils.ts +12 -0
- package/packages/ui/src/stories/atoms/Avatar.stories.tsx +49 -0
- package/packages/ui/src/stories/atoms/Badge.stories.tsx +68 -0
- package/packages/ui/src/stories/atoms/Button.stories.tsx +98 -0
- package/packages/ui/src/stories/atoms/Input.stories.tsx +66 -0
- package/packages/ui/src/stories/atoms/Toggle.stories.tsx +36 -0
- package/packages/ui/src/stories/molecules/Accordion.stories.tsx +47 -0
- package/packages/ui/src/stories/molecules/Card.stories.tsx +84 -0
- package/packages/ui/src/stories/molecules/PricingCard.stories.tsx +62 -0
- package/packages/ui/src/stories/molecules/TestimonialCard.stories.tsx +52 -0
- package/packages/ui/src/stories/molecules/Tooltip.stories.tsx +66 -0
- package/packages/ui/src/stories/organisms/LogoMarquee.stories.tsx +33 -0
- package/packages/ui/src/stories/organisms/Navbar.stories.tsx +37 -0
- package/packages/ui/src/styles/globals.css +220 -0
- package/packages/ui/tailwind.config.ts +68 -0
- package/packages/ui/tsconfig.json +23 -0
- package/packages/ui/tsup.config.ts +24 -0
- package/packages/ui/vite.config.ts +17 -0
- package/pnpm-workspace.yaml +2 -0
- package/turbo.json +33 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Charts — @rechta/ui v2.1.0
|
|
3
|
+
*
|
|
4
|
+
* Stripe/Linear visual style — Sessions 6-7 overhaul:
|
|
5
|
+
* - Ultra-thin 1.5px strokes
|
|
6
|
+
* - Gradient fills capped at 0.2–0.3 opacity, fade to transparent
|
|
7
|
+
* - Dashed comparison/benchmark line (Stripe "prior period" pattern)
|
|
8
|
+
* - Slim bars (maxBarSize 18-22), generous breathing room
|
|
9
|
+
* - Chart palette: violet #5E6AD2 primary, blue #60A5FA, indigo #818CF8
|
|
10
|
+
* - KPI strip: mono label → Ruda 900 number → delta badge
|
|
11
|
+
*
|
|
12
|
+
* Charts:
|
|
13
|
+
* 1. AreaChartStripe — area with dashed prior-period comparison
|
|
14
|
+
* 2. BarChartStripe — slim grouped bars
|
|
15
|
+
* 3. BarChartHorizontal— platform/creator ranking
|
|
16
|
+
* 4. BarChartStacked — campaign post status
|
|
17
|
+
* 5. AreaChartStacked — authenticity stacked area
|
|
18
|
+
* 6. DonutChart — audience demographics
|
|
19
|
+
* 7. KpiStrip — 4-cell Stripe-style KPI row
|
|
20
|
+
* 8. Sparkline — inline SVG micro-chart
|
|
21
|
+
* 9. ChartCard — wrapper with badge + subtitle
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as React from "react";
|
|
25
|
+
import {
|
|
26
|
+
AreaChart, Area, BarChart, Bar, LineChart, Line,
|
|
27
|
+
PieChart, Pie, Cell,
|
|
28
|
+
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
|
29
|
+
ResponsiveContainer, ReferenceLine,
|
|
30
|
+
} from "recharts";
|
|
31
|
+
|
|
32
|
+
// ─── Palette ───────────────────────────────────────────────────────────────────
|
|
33
|
+
export const CHART = {
|
|
34
|
+
brand: "var(--c-chart-1)", // violet
|
|
35
|
+
blue: "var(--c-chart-2)", // blue
|
|
36
|
+
indigo: "var(--c-chart-3)", // indigo
|
|
37
|
+
emerald: "var(--c-chart-4)",
|
|
38
|
+
amber: "var(--c-chart-5)",
|
|
39
|
+
axis: "var(--c-text-muted)",
|
|
40
|
+
grid: "var(--c-border-subtle)",
|
|
41
|
+
tooltip: { bg: "var(--c-bg-elevated)", border: "var(--c-border)" },
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
// ─── Tooltip ───────────────────────────────────────────────────────────────────
|
|
45
|
+
interface TooltipPayloadItem {
|
|
46
|
+
name: string;
|
|
47
|
+
value: number;
|
|
48
|
+
color?: string;
|
|
49
|
+
fill?: string;
|
|
50
|
+
}
|
|
51
|
+
interface TooltipProps {
|
|
52
|
+
active?: boolean;
|
|
53
|
+
payload?: TooltipPayloadItem[];
|
|
54
|
+
label?: string;
|
|
55
|
+
formatter?: (v: number, name: string) => string;
|
|
56
|
+
labelFormatter?: (label: string) => string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const RechtaTooltip: React.FC<TooltipProps> = ({
|
|
60
|
+
active, payload, label, formatter, labelFormatter,
|
|
61
|
+
}) => {
|
|
62
|
+
if (!active || !payload?.length) return null;
|
|
63
|
+
const filtered = payload.filter(p => p.value !== undefined && p.value !== null);
|
|
64
|
+
return (
|
|
65
|
+
<div style={{
|
|
66
|
+
background: "var(--c-bg-elevated)",
|
|
67
|
+
border: "1px solid var(--c-border)",
|
|
68
|
+
borderRadius: 10,
|
|
69
|
+
padding: "10px 13px",
|
|
70
|
+
boxShadow: "0 8px 30px rgba(0,0,0,.3)",
|
|
71
|
+
minWidth: 130,
|
|
72
|
+
backdropFilter: "blur(8px)",
|
|
73
|
+
fontFamily: "var(--font-sans)",
|
|
74
|
+
}}>
|
|
75
|
+
{label && (
|
|
76
|
+
<div style={{ fontSize: 11, color: "var(--c-text-muted)", marginBottom: 7, fontWeight: 500 }}>
|
|
77
|
+
{labelFormatter ? labelFormatter(label) : label}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
{filtered.map((p, i) => (
|
|
81
|
+
<div key={i} style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: i < filtered.length - 1 ? 5 : 0 }}>
|
|
82
|
+
<span style={{ width: 6, height: 6, borderRadius: "50%", background: p.color || p.fill, flexShrink: 0, display: "inline-block" }} />
|
|
83
|
+
<span style={{ fontSize: 12, color: "var(--c-text-muted)", flex: 1 }}>{p.name}</span>
|
|
84
|
+
<span style={{ fontSize: 12, color: "var(--c-text)", fontWeight: 600, marginLeft: 8 }}>
|
|
85
|
+
{formatter ? formatter(p.value, p.name) : p.value?.toLocaleString()}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ─── ChartCard ─────────────────────────────────────────────────────────────────
|
|
94
|
+
interface BadgeInfo { label: string; variant?: "success" | "default"; }
|
|
95
|
+
interface ChartCardProps {
|
|
96
|
+
title: string;
|
|
97
|
+
subtitle?: string;
|
|
98
|
+
badge?: BadgeInfo;
|
|
99
|
+
children: React.ReactNode;
|
|
100
|
+
span2?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const ChartCard: React.FC<ChartCardProps> = ({ title, subtitle, badge, children, span2 }) => (
|
|
104
|
+
<div style={{
|
|
105
|
+
background: "var(--c-bg-surface)",
|
|
106
|
+
border: "1px solid var(--c-border)",
|
|
107
|
+
borderRadius: 14,
|
|
108
|
+
padding: "20px 22px 18px",
|
|
109
|
+
gridColumn: span2 ? "span 2" : undefined,
|
|
110
|
+
position: "relative",
|
|
111
|
+
overflow: "hidden",
|
|
112
|
+
}}>
|
|
113
|
+
<div style={{ marginBottom: 16 }}>
|
|
114
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 3 }}>
|
|
115
|
+
<div style={{ fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 600, color: "var(--c-text)", letterSpacing: "-.01em" }}>
|
|
116
|
+
{title}
|
|
117
|
+
</div>
|
|
118
|
+
{badge && (
|
|
119
|
+
<span style={{
|
|
120
|
+
fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 500,
|
|
121
|
+
padding: "2px 7px", borderRadius: 999,
|
|
122
|
+
background: badge.variant === "success" ? "rgba(34,197,94,.1)" : "rgba(100,116,139,.1)",
|
|
123
|
+
color: badge.variant === "success" ? "#4ade80" : "var(--c-text-muted)",
|
|
124
|
+
border: `1px solid ${badge.variant === "success" ? "rgba(74,222,128,.2)" : "rgba(100,116,139,.15)"}`,
|
|
125
|
+
}}>{badge.label}</span>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
{subtitle && (
|
|
129
|
+
<div style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--c-text-muted)" }}>{subtitle}</div>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
{children}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// ─── KpiStrip ──────────────────────────────────────────────────────────────────
|
|
137
|
+
// Stripe-style 4-cell row: mono label → Ruda 900 number → delta badge
|
|
138
|
+
interface KpiCell {
|
|
139
|
+
label: string;
|
|
140
|
+
value: string;
|
|
141
|
+
delta?: string;
|
|
142
|
+
up?: boolean;
|
|
143
|
+
accent?: "brand" | "blue" | "emerald";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const KpiStrip: React.FC<{ cells: KpiCell[] }> = ({ cells }) => (
|
|
147
|
+
<div className="chart-kpi-strip">
|
|
148
|
+
{cells.map((k, i) => (
|
|
149
|
+
<div key={i} className={`chart-kpi-cell ${k.accent ?? "accent"}`}>
|
|
150
|
+
<div className="chart-kpi-label">{k.label}</div>
|
|
151
|
+
<div className="chart-kpi-value">{k.value}</div>
|
|
152
|
+
{k.delta && (
|
|
153
|
+
<span className={`chart-delta ${k.up ? "up" : "down"}`}>
|
|
154
|
+
{k.up ? "↑" : "↓"} {k.delta}
|
|
155
|
+
</span>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// ─── 1. AreaChartStripe ────────────────────────────────────────────────────────
|
|
163
|
+
// Stripe "Today" pattern: thin stroke, low-opacity fill, dashed prior-period line
|
|
164
|
+
interface AreaStripeProps {
|
|
165
|
+
data: Array<Record<string, string | number>>;
|
|
166
|
+
xKey: string;
|
|
167
|
+
series: Array<{ key: string; name: string; color: string; dashed?: boolean }>;
|
|
168
|
+
referenceY?: number;
|
|
169
|
+
referenceLabel?: string;
|
|
170
|
+
height?: number;
|
|
171
|
+
formatter?: (v: number) => string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const AreaChartStripe: React.FC<AreaStripeProps> = ({
|
|
175
|
+
data, xKey, series, referenceY, referenceLabel, height = 200, formatter,
|
|
176
|
+
}) => (
|
|
177
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
178
|
+
<AreaChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
|
|
179
|
+
<defs>
|
|
180
|
+
{series.filter(s => !s.dashed).map(s => (
|
|
181
|
+
<linearGradient key={s.key} id={`ag-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
|
182
|
+
<stop offset="5%" stopColor={s.color} stopOpacity={0.22} />
|
|
183
|
+
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
|
|
184
|
+
</linearGradient>
|
|
185
|
+
))}
|
|
186
|
+
</defs>
|
|
187
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border-subtle)" vertical={false} />
|
|
188
|
+
<XAxis dataKey={xKey} tick={{ fill: "var(--c-text-muted)", fontSize: 10, fontFamily: "var(--font-mono)" }} axisLine={false} tickLine={false} />
|
|
189
|
+
<YAxis tickFormatter={formatter} tick={{ fill: "var(--c-text-muted)", fontSize: 10, fontFamily: "var(--font-mono)" }} axisLine={false} tickLine={false} width={44} />
|
|
190
|
+
<Tooltip content={<RechtaTooltip formatter={formatter} />} />
|
|
191
|
+
{referenceY !== undefined && (
|
|
192
|
+
<ReferenceLine y={referenceY} stroke="var(--c-border-mid)" strokeDasharray="4 4"
|
|
193
|
+
label={{ value: referenceLabel ?? "", fill: "var(--c-text-muted)", fontSize: 9, fontFamily: "var(--font-mono)" }} />
|
|
194
|
+
)}
|
|
195
|
+
{series.map(s => (
|
|
196
|
+
<Area
|
|
197
|
+
key={s.key}
|
|
198
|
+
type="monotone"
|
|
199
|
+
dataKey={s.key}
|
|
200
|
+
name={s.name}
|
|
201
|
+
stroke={s.color}
|
|
202
|
+
strokeWidth={s.dashed ? 1.5 : 1.5}
|
|
203
|
+
strokeDasharray={s.dashed ? "5 4" : undefined}
|
|
204
|
+
strokeOpacity={s.dashed ? 0.5 : 1}
|
|
205
|
+
fill={s.dashed ? "none" : `url(#ag-${s.key})`}
|
|
206
|
+
dot={false}
|
|
207
|
+
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--c-bg)" }}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
</AreaChart>
|
|
211
|
+
</ResponsiveContainer>
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// ─── 2. BarChartStripe ─────────────────────────────────────────────────────────
|
|
215
|
+
// Slim bars (maxBarSize 20), lots of breathing room
|
|
216
|
+
interface BarStripeProps {
|
|
217
|
+
data: Array<Record<string, string | number>>;
|
|
218
|
+
xKey: string;
|
|
219
|
+
series: Array<{ key: string; name: string; color: string }>;
|
|
220
|
+
stacked?: boolean;
|
|
221
|
+
height?: number;
|
|
222
|
+
formatter?: (v: number) => string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const BarChartStripe: React.FC<BarStripeProps> = ({
|
|
226
|
+
data, xKey, series, stacked, height = 200, formatter,
|
|
227
|
+
}) => (
|
|
228
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
229
|
+
<BarChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }} barGap={3} barCategoryGap="30%">
|
|
230
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border-subtle)" vertical={false} />
|
|
231
|
+
<XAxis dataKey={xKey} tick={{ fill: "var(--c-text-muted)", fontSize: 10, fontFamily: "var(--font-mono)" }} axisLine={false} tickLine={false} />
|
|
232
|
+
<YAxis tickFormatter={formatter} tick={{ fill: "var(--c-text-muted)", fontSize: 10, fontFamily: "var(--font-mono)" }} axisLine={false} tickLine={false} width={44} />
|
|
233
|
+
<Tooltip content={<RechtaTooltip formatter={formatter} />} />
|
|
234
|
+
{series.map((s, i) => (
|
|
235
|
+
<Bar
|
|
236
|
+
key={s.key}
|
|
237
|
+
dataKey={s.key}
|
|
238
|
+
name={s.name}
|
|
239
|
+
fill={s.color}
|
|
240
|
+
stackId={stacked ? "a" : undefined}
|
|
241
|
+
radius={!stacked || i === series.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
|
|
242
|
+
maxBarSize={stacked ? 32 : 20}
|
|
243
|
+
/>
|
|
244
|
+
))}
|
|
245
|
+
</BarChart>
|
|
246
|
+
</ResponsiveContainer>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// ─── 3. BarChartHorizontal ─────────────────────────────────────────────────────
|
|
250
|
+
interface BarHorizProps {
|
|
251
|
+
data: Array<Record<string, string | number>>;
|
|
252
|
+
yKey: string;
|
|
253
|
+
valueKey: string;
|
|
254
|
+
valueName: string;
|
|
255
|
+
height?: number;
|
|
256
|
+
formatter?: (v: number) => string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const BarChartHorizontal: React.FC<BarHorizProps> = ({
|
|
260
|
+
data, yKey, valueKey, valueName, height = 200, formatter,
|
|
261
|
+
}) => (
|
|
262
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
263
|
+
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 40, left: 0, bottom: 0 }}>
|
|
264
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--c-border-subtle)" horizontal={false} />
|
|
265
|
+
<XAxis type="number" tickFormatter={formatter} tick={{ fill: "var(--c-text-muted)", fontSize: 9, fontFamily: "var(--font-mono)" }} axisLine={false} tickLine={false} />
|
|
266
|
+
<YAxis type="category" dataKey={yKey} tick={{ fill: "var(--c-text-muted)", fontSize: 10, fontFamily: "var(--font-sans)" }} axisLine={false} tickLine={false} width={80} />
|
|
267
|
+
<Tooltip content={<RechtaTooltip formatter={formatter} />} />
|
|
268
|
+
<Bar dataKey={valueKey} name={valueName} radius={[0, 3, 3, 0]} maxBarSize={18}>
|
|
269
|
+
{data.map((_, i) => (
|
|
270
|
+
<Cell key={i} fill={i === 0 ? "var(--c-chart-1)" : i < 3 ? "var(--c-chart-2)" : "var(--c-bg-surface-hover)"} />
|
|
271
|
+
))}
|
|
272
|
+
</Bar>
|
|
273
|
+
</BarChart>
|
|
274
|
+
</ResponsiveContainer>
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// ─── 4. DonutChart ─────────────────────────────────────────────────────────────
|
|
278
|
+
interface DonutSlice { name: string; value: number; color: string; }
|
|
279
|
+
interface DonutProps {
|
|
280
|
+
data: DonutSlice[];
|
|
281
|
+
centerLabel?: string;
|
|
282
|
+
centerSublabel?: string;
|
|
283
|
+
height?: number;
|
|
284
|
+
showLegend?: boolean;
|
|
285
|
+
showProgressBars?: boolean;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const DonutChart: React.FC<DonutProps> = ({
|
|
289
|
+
data, centerLabel, centerSublabel, height = 180, showLegend = true, showProgressBars = false,
|
|
290
|
+
}) => {
|
|
291
|
+
const [active, setActive] = React.useState<number | null>(null);
|
|
292
|
+
return (
|
|
293
|
+
<div>
|
|
294
|
+
<div style={{ position: "relative" }}>
|
|
295
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
296
|
+
<PieChart>
|
|
297
|
+
<Pie
|
|
298
|
+
data={data} cx="50%" cy="50%"
|
|
299
|
+
innerRadius={52} outerRadius={76}
|
|
300
|
+
paddingAngle={3} dataKey="value" stroke="none"
|
|
301
|
+
onMouseEnter={(_, i) => setActive(i)}
|
|
302
|
+
onMouseLeave={() => setActive(null)}
|
|
303
|
+
>
|
|
304
|
+
{data.map((entry, i) => (
|
|
305
|
+
<Cell key={i} fill={entry.color} opacity={active === null || active === i ? 1 : 0.35} />
|
|
306
|
+
))}
|
|
307
|
+
</Pie>
|
|
308
|
+
<Tooltip formatter={(v: number, n: string) => [`${v}%`, n]} />
|
|
309
|
+
</PieChart>
|
|
310
|
+
</ResponsiveContainer>
|
|
311
|
+
{(centerLabel || centerSublabel) && (
|
|
312
|
+
<div style={{
|
|
313
|
+
position: "absolute", top: "50%", left: "50%",
|
|
314
|
+
transform: "translate(-50%,-50%)", textAlign: "center", pointerEvents: "none",
|
|
315
|
+
}}>
|
|
316
|
+
{centerLabel && <div style={{ fontFamily: "var(--font-display)", fontSize: 18, fontWeight: 900, color: "var(--c-text)" }}>{centerLabel}</div>}
|
|
317
|
+
{centerSublabel && <div style={{ fontFamily: "var(--font-mono)", fontSize: 9, color: "var(--c-text-muted)", letterSpacing: ".08em" }}>{centerSublabel}</div>}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
{showLegend && (
|
|
322
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
|
|
323
|
+
{data.map((item, i) => (
|
|
324
|
+
<div key={i} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", opacity: active === null || active === i ? 1 : 0.4, transition: "opacity .15s" }}>
|
|
325
|
+
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
|
326
|
+
<div style={{ width: 8, height: 8, borderRadius: 2, background: item.color, flexShrink: 0 }} />
|
|
327
|
+
<span style={{ fontSize: 12, color: "var(--c-text-2)", fontFamily: "var(--font-sans)" }}>{item.name}</span>
|
|
328
|
+
</div>
|
|
329
|
+
{showProgressBars ? (
|
|
330
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
331
|
+
<div style={{ width: 60, height: 4, borderRadius: 2, background: "var(--c-bg-surface-hover)", overflow: "hidden" }}>
|
|
332
|
+
<div style={{ height: "100%", width: `${item.value}%`, background: item.color, borderRadius: 2 }} />
|
|
333
|
+
</div>
|
|
334
|
+
<span style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--c-text-3)", minWidth: 28, textAlign: "right" }}>{item.value}%</span>
|
|
335
|
+
</div>
|
|
336
|
+
) : (
|
|
337
|
+
<span style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--c-text-3)", fontWeight: 500 }}>{item.value}%</span>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ─── 5. Sparkline ──────────────────────────────────────────────────────────────
|
|
348
|
+
interface SparklineProps {
|
|
349
|
+
data: number[];
|
|
350
|
+
color?: string;
|
|
351
|
+
width?: number;
|
|
352
|
+
height?: number;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const Sparkline: React.FC<SparklineProps> = ({
|
|
356
|
+
data, color = "var(--c-chart-1)", width = 72, height = 24,
|
|
357
|
+
}) => {
|
|
358
|
+
const min = Math.min(...data);
|
|
359
|
+
const max = Math.max(...data);
|
|
360
|
+
const range = max - min || 1;
|
|
361
|
+
const pts = data.map((v, i) => {
|
|
362
|
+
const x = (i / (data.length - 1)) * width;
|
|
363
|
+
const y = height - ((v - min) / range) * (height - 4) - 2;
|
|
364
|
+
return `${x},${y}`;
|
|
365
|
+
}).join(" ");
|
|
366
|
+
const last = pts.split(" ").at(-1)!.split(",");
|
|
367
|
+
return (
|
|
368
|
+
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
|
369
|
+
<defs>
|
|
370
|
+
<linearGradient id={`sp-g`} x1="0" y1="0" x2="0" y2="1">
|
|
371
|
+
<stop offset="0%" stopColor={color} stopOpacity={0.22} />
|
|
372
|
+
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
373
|
+
</linearGradient>
|
|
374
|
+
</defs>
|
|
375
|
+
<polygon points={`0,${height} ${pts} ${width},${height}`} fill="url(#sp-g)" />
|
|
376
|
+
<polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
377
|
+
<circle cx={parseFloat(last[0])} cy={parseFloat(last[1])} r={2.5} fill={color} />
|
|
378
|
+
</svg>
|
|
379
|
+
);
|
|
380
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTable — @rechta/ui
|
|
3
|
+
*
|
|
4
|
+
* Sortable, filterable creator roster table with:
|
|
5
|
+
* - Column header sort buttons (WCAG AA: aria-label with sort direction)
|
|
6
|
+
* - Status badge with dot indicator
|
|
7
|
+
* - Inline Sparkline for trend visualization
|
|
8
|
+
* - Platform colour-coded indicator
|
|
9
|
+
* - Row hover states
|
|
10
|
+
* - Filter tabs + pagination footer
|
|
11
|
+
*
|
|
12
|
+
* Use case: Creator roster, campaign list, component usage scan (like Componly screenshot)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
import { Sparkline } from "./Charts";
|
|
17
|
+
|
|
18
|
+
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
export interface DataTableColumn<T> {
|
|
20
|
+
key: keyof T | null;
|
|
21
|
+
label: string;
|
|
22
|
+
width?: number | string;
|
|
23
|
+
sortable?: boolean;
|
|
24
|
+
render?: (row: T, idx: number) => React.ReactNode;
|
|
25
|
+
align?: "left" | "right" | "center";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DataTableProps<T extends Record<string, unknown>> {
|
|
29
|
+
data: T[];
|
|
30
|
+
columns: DataTableColumn<T>[];
|
|
31
|
+
filterKey?: keyof T;
|
|
32
|
+
filterOptions?: string[];
|
|
33
|
+
defaultSortKey?: keyof T;
|
|
34
|
+
defaultSortDir?: "asc" | "desc";
|
|
35
|
+
pageSize?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── SortButton ────────────────────────────────────────────────────────────────
|
|
39
|
+
const SortBtn: React.FC<{
|
|
40
|
+
col: string;
|
|
41
|
+
label: string;
|
|
42
|
+
active: boolean;
|
|
43
|
+
dir: "asc" | "desc";
|
|
44
|
+
onClick: () => void;
|
|
45
|
+
}> = ({ label, active, dir, onClick }) => (
|
|
46
|
+
<button
|
|
47
|
+
onClick={onClick}
|
|
48
|
+
style={{
|
|
49
|
+
display: "flex", alignItems: "center", gap: 4,
|
|
50
|
+
fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
|
|
51
|
+
letterSpacing: ".08em", textTransform: "uppercase" as const,
|
|
52
|
+
color: active ? "var(--text-brand)" : "var(--text-3)",
|
|
53
|
+
cursor: "pointer", background: "none", border: "none", padding: 0,
|
|
54
|
+
}}
|
|
55
|
+
aria-label={`Sort by ${label} ${dir === "desc" ? "ascending" : "descending"}`}
|
|
56
|
+
>
|
|
57
|
+
{label}
|
|
58
|
+
<span style={{ fontSize: 9, opacity: active ? 1 : 0.4 }}>
|
|
59
|
+
{active ? (dir === "desc" ? "↓" : "↑") : "↕"}
|
|
60
|
+
</span>
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// ─── DataTable ─────────────────────────────────────────────────────────────────
|
|
65
|
+
export function DataTable<T extends Record<string, unknown>>({
|
|
66
|
+
data,
|
|
67
|
+
columns,
|
|
68
|
+
filterKey,
|
|
69
|
+
filterOptions = [],
|
|
70
|
+
defaultSortKey,
|
|
71
|
+
defaultSortDir = "desc",
|
|
72
|
+
pageSize = 10,
|
|
73
|
+
}: DataTableProps<T>) {
|
|
74
|
+
const [sortCol, setSortCol] = React.useState<keyof T | null>(defaultSortKey ?? null);
|
|
75
|
+
const [sortDir, setSortDir] = React.useState<"asc" | "desc">(defaultSortDir);
|
|
76
|
+
const [filter, setFilter] = React.useState<string>("all");
|
|
77
|
+
const [page, setPage] = React.useState(1);
|
|
78
|
+
|
|
79
|
+
const toggleSort = (col: keyof T) => {
|
|
80
|
+
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
|
81
|
+
else { setSortCol(col); setSortDir("desc"); }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const filtered = React.useMemo(() => {
|
|
85
|
+
let d = filter === "all" || !filterKey ? data : data.filter((r) => r[filterKey] === filter);
|
|
86
|
+
if (sortCol) {
|
|
87
|
+
d = [...d].sort((a, b) => {
|
|
88
|
+
const av = a[sortCol] as number;
|
|
89
|
+
const bv = b[sortCol] as number;
|
|
90
|
+
return sortDir === "desc" ? bv - av : av - bv;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return d;
|
|
94
|
+
}, [data, filter, sortCol, sortDir, filterKey]);
|
|
95
|
+
|
|
96
|
+
const totalPages = Math.ceil(filtered.length / pageSize);
|
|
97
|
+
const paged = filtered.slice((page - 1) * pageSize, page * pageSize);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div style={{ background: "var(--surface)", border: "1px solid var(--border-subtle)", borderRadius: 14, overflow: "hidden" }}>
|
|
101
|
+
{/* Toolbar */}
|
|
102
|
+
{(filterKey && filterOptions.length > 0) && (
|
|
103
|
+
<div style={{
|
|
104
|
+
padding: "12px 20px", borderBottom: "1px solid var(--border-subtle)",
|
|
105
|
+
display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" as const,
|
|
106
|
+
}}>
|
|
107
|
+
{["all", ...filterOptions].map((f) => (
|
|
108
|
+
<button
|
|
109
|
+
key={f}
|
|
110
|
+
onClick={() => { setFilter(f); setPage(1); }}
|
|
111
|
+
style={{
|
|
112
|
+
padding: "4px 10px", borderRadius: 6, fontSize: 11, fontWeight: 500,
|
|
113
|
+
fontFamily: "var(--font-body)", cursor: "pointer",
|
|
114
|
+
border: `1px solid ${filter === f ? "var(--border-brand)" : "var(--border-subtle)"}`,
|
|
115
|
+
background: filter === f ? "var(--brand-muted)" : "var(--surface)",
|
|
116
|
+
color: filter === f ? "var(--text-brand)" : "var(--text-muted)",
|
|
117
|
+
transition: "all .15s", textTransform: "capitalize" as const,
|
|
118
|
+
}}
|
|
119
|
+
>{f}</button>
|
|
120
|
+
))}
|
|
121
|
+
<span style={{
|
|
122
|
+
marginLeft: "auto", fontFamily: "var(--font-mono)", fontSize: 10,
|
|
123
|
+
color: "var(--text-muted)",
|
|
124
|
+
}}>{filtered.length} rows</span>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Table */}
|
|
129
|
+
<div style={{ overflowX: "auto" }}>
|
|
130
|
+
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-body)" }}>
|
|
131
|
+
<thead>
|
|
132
|
+
<tr style={{ borderBottom: "1px solid var(--border-subtle)" }}>
|
|
133
|
+
{columns.map(({ key, label, width, sortable, align }, i) => (
|
|
134
|
+
<th
|
|
135
|
+
key={i}
|
|
136
|
+
style={{
|
|
137
|
+
padding: "10px 16px", textAlign: align ?? "left",
|
|
138
|
+
width, whiteSpace: "nowrap" as const,
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{sortable && key ? (
|
|
142
|
+
<SortBtn
|
|
143
|
+
col={String(key)}
|
|
144
|
+
label={label}
|
|
145
|
+
active={sortCol === key}
|
|
146
|
+
dir={sortDir}
|
|
147
|
+
onClick={() => toggleSort(key)}
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
<span style={{
|
|
151
|
+
fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
|
|
152
|
+
letterSpacing: ".08em", textTransform: "uppercase" as const,
|
|
153
|
+
color: "var(--text-3)",
|
|
154
|
+
}}>{label}</span>
|
|
155
|
+
)}
|
|
156
|
+
</th>
|
|
157
|
+
))}
|
|
158
|
+
</tr>
|
|
159
|
+
</thead>
|
|
160
|
+
<tbody>
|
|
161
|
+
{paged.map((row, i) => (
|
|
162
|
+
<tr
|
|
163
|
+
key={i}
|
|
164
|
+
style={{ borderBottom: "1px solid var(--border-subtle)", transition: "background .12s", cursor: "pointer" }}
|
|
165
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--surface-hover)")}
|
|
166
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
|
167
|
+
>
|
|
168
|
+
{columns.map(({ key, render, align }, j) => (
|
|
169
|
+
<td key={j} style={{ padding: "12px 16px", textAlign: align ?? "left" }}>
|
|
170
|
+
{render ? render(row, i) : (
|
|
171
|
+
<span style={{ fontSize: 13, color: "var(--text-2)" }}>
|
|
172
|
+
{key ? String(row[key] ?? "") : ""}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</td>
|
|
176
|
+
))}
|
|
177
|
+
</tr>
|
|
178
|
+
))}
|
|
179
|
+
</tbody>
|
|
180
|
+
</table>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Footer */}
|
|
184
|
+
<div style={{
|
|
185
|
+
padding: "12px 20px", borderTop: "1px solid var(--border-subtle)",
|
|
186
|
+
display: "flex", justifyContent: "space-between", alignItems: "center",
|
|
187
|
+
}}>
|
|
188
|
+
<span style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--text-muted)" }}>
|
|
189
|
+
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filtered.length)} of {filtered.length}
|
|
190
|
+
{sortCol ? ` · sorted by ${String(sortCol)} ${sortDir}` : ""}
|
|
191
|
+
</span>
|
|
192
|
+
<div style={{ display: "flex", gap: 4 }}>
|
|
193
|
+
{["←", ...Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1), "→"].map((p, i) => {
|
|
194
|
+
const isActive = typeof p === "number" && p === page;
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
key={i}
|
|
198
|
+
onClick={() => {
|
|
199
|
+
if (p === "←") setPage((n) => Math.max(1, n - 1));
|
|
200
|
+
else if (p === "→") setPage((n) => Math.min(totalPages, n + 1));
|
|
201
|
+
else setPage(p as number);
|
|
202
|
+
}}
|
|
203
|
+
style={{
|
|
204
|
+
width: 28, height: 28, borderRadius: 6, fontSize: 11,
|
|
205
|
+
fontFamily: "var(--font-mono)", cursor: "pointer",
|
|
206
|
+
display: "flex", alignItems: "center", justifyContent: "center",
|
|
207
|
+
border: `1px solid ${isActive ? "var(--border-brand)" : "var(--border-subtle)"}`,
|
|
208
|
+
background: isActive ? "var(--brand-muted)" : "var(--surface)",
|
|
209
|
+
color: isActive ? "var(--text-brand)" : "var(--text-muted)",
|
|
210
|
+
transition: "all .15s",
|
|
211
|
+
}}
|
|
212
|
+
>{p}</button>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Re-export Sparkline for convenience
|
|
222
|
+
export { Sparkline };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rechta/ui/charts — chart component index
|
|
3
|
+
* Requires: recharts >= 2.8
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
CHART,
|
|
8
|
+
RechtaTooltip,
|
|
9
|
+
ChartCard,
|
|
10
|
+
KpiStrip,
|
|
11
|
+
AreaChartStripe,
|
|
12
|
+
BarChartStripe,
|
|
13
|
+
BarChartHorizontal,
|
|
14
|
+
DonutChart,
|
|
15
|
+
Sparkline,
|
|
16
|
+
} from "./Charts";
|
|
17
|
+
|
|
18
|
+
export { DataTable } from "./DataTable";
|
|
19
|
+
export type { DataTableColumn, DataTableProps } from "./DataTable";
|