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.
Files changed (58) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.github/workflows/release.yml +53 -0
  3. package/.github/workflows/storybook.yml +34 -0
  4. package/.storybook/main.ts +17 -0
  5. package/.storybook/preview.ts +35 -0
  6. package/CHANGELOG.md +65 -0
  7. package/CONTRIBUTING.md +106 -0
  8. package/README.md +206 -0
  9. package/package.json +30 -0
  10. package/packages/tokens/build.js +357 -0
  11. package/packages/tokens/package.json +44 -0
  12. package/packages/tokens/src/tokens.json +1538 -0
  13. package/packages/ui/.storybook/main.ts +17 -0
  14. package/packages/ui/.storybook/preview.tsx +37 -0
  15. package/packages/ui/package.json +109 -0
  16. package/packages/ui/postcss.config.js +6 -0
  17. package/packages/ui/src/components/atoms/Avatar.tsx +139 -0
  18. package/packages/ui/src/components/atoms/Badge.tsx +62 -0
  19. package/packages/ui/src/components/atoms/Button.tsx +125 -0
  20. package/packages/ui/src/components/atoms/Input.tsx +116 -0
  21. package/packages/ui/src/components/atoms/Misc.tsx +128 -0
  22. package/packages/ui/src/components/atoms/Toggle.tsx +191 -0
  23. package/packages/ui/src/components/atoms/Typography.tsx +178 -0
  24. package/packages/ui/src/components/atoms/index.ts +7 -0
  25. package/packages/ui/src/components/charts/Charts.tsx +380 -0
  26. package/packages/ui/src/components/charts/DataTable.tsx +222 -0
  27. package/packages/ui/src/components/charts/index.ts +19 -0
  28. package/packages/ui/src/components/molecules/Accordion.tsx +93 -0
  29. package/packages/ui/src/components/molecules/Card.tsx +100 -0
  30. package/packages/ui/src/components/molecules/PricingCard.tsx +196 -0
  31. package/packages/ui/src/components/molecules/TestimonialCard.tsx +85 -0
  32. package/packages/ui/src/components/molecules/Tooltip.tsx +71 -0
  33. package/packages/ui/src/components/molecules/index.ts +5 -0
  34. package/packages/ui/src/components/organisms/FeatureTabs.tsx +196 -0
  35. package/packages/ui/src/components/organisms/LogoMarquee.tsx +119 -0
  36. package/packages/ui/src/components/organisms/Navbar.tsx +194 -0
  37. package/packages/ui/src/components/organisms/index.ts +3 -0
  38. package/packages/ui/src/index.ts +15 -0
  39. package/packages/ui/src/lib/utils.ts +12 -0
  40. package/packages/ui/src/stories/atoms/Avatar.stories.tsx +49 -0
  41. package/packages/ui/src/stories/atoms/Badge.stories.tsx +68 -0
  42. package/packages/ui/src/stories/atoms/Button.stories.tsx +98 -0
  43. package/packages/ui/src/stories/atoms/Input.stories.tsx +66 -0
  44. package/packages/ui/src/stories/atoms/Toggle.stories.tsx +36 -0
  45. package/packages/ui/src/stories/molecules/Accordion.stories.tsx +47 -0
  46. package/packages/ui/src/stories/molecules/Card.stories.tsx +84 -0
  47. package/packages/ui/src/stories/molecules/PricingCard.stories.tsx +62 -0
  48. package/packages/ui/src/stories/molecules/TestimonialCard.stories.tsx +52 -0
  49. package/packages/ui/src/stories/molecules/Tooltip.stories.tsx +66 -0
  50. package/packages/ui/src/stories/organisms/LogoMarquee.stories.tsx +33 -0
  51. package/packages/ui/src/stories/organisms/Navbar.stories.tsx +37 -0
  52. package/packages/ui/src/styles/globals.css +220 -0
  53. package/packages/ui/tailwind.config.ts +68 -0
  54. package/packages/ui/tsconfig.json +23 -0
  55. package/packages/ui/tsup.config.ts +24 -0
  56. package/packages/ui/vite.config.ts +17 -0
  57. package/pnpm-workspace.yaml +2 -0
  58. 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";