@alpic-ai/ui 0.0.0-dev.g1326fb9 → 0.0.0-dev.g162d798
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/components/accordion-card.d.mts +5 -5
- package/dist/components/accordion.d.mts +5 -5
- package/dist/components/alert.d.mts +8 -8
- package/dist/components/area-chart.d.mts +64 -0
- package/dist/components/area-chart.mjs +275 -0
- package/dist/components/attachment-tile.d.mts +1 -1
- package/dist/components/avatar.d.mts +7 -7
- package/dist/components/badge.d.mts +1 -1
- package/dist/components/bar-chart.d.mts +50 -0
- package/dist/components/bar-chart.mjs +262 -0
- package/dist/components/bar-list.d.mts +31 -0
- package/dist/components/bar-list.mjs +111 -0
- package/dist/components/breadcrumb.d.mts +10 -10
- package/dist/components/button.d.mts +5 -5
- package/dist/components/card.d.mts +9 -9
- package/dist/components/chart-card.d.mts +25 -0
- package/dist/components/chart-card.mjs +48 -0
- package/dist/components/chart-container.d.mts +20 -0
- package/dist/components/chart-container.mjs +37 -0
- package/dist/components/chart-legend.d.mts +21 -0
- package/dist/components/chart-legend.mjs +35 -0
- package/dist/components/chart-tooltip.d.mts +33 -0
- package/dist/components/chart-tooltip.mjs +52 -0
- package/dist/components/checkbox.d.mts +2 -2
- package/dist/components/collapsible.d.mts +4 -4
- package/dist/components/combobox.d.mts +10 -10
- package/dist/components/command.d.mts +9 -9
- package/dist/components/copyable.d.mts +2 -2
- package/dist/components/description-list.d.mts +5 -5
- package/dist/components/dialog.d.mts +13 -13
- package/dist/components/donut-chart.d.mts +46 -0
- package/dist/components/donut-chart.mjs +189 -0
- package/dist/components/dropdown-menu.d.mts +17 -17
- package/dist/components/form.d.mts +18 -18
- package/dist/components/form.mjs +7 -7
- package/dist/components/github-button.d.mts +1 -1
- package/dist/components/heatmap-chart.d.mts +48 -0
- package/dist/components/heatmap-chart.mjs +229 -0
- package/dist/components/input-group.d.mts +4 -4
- package/dist/components/input.d.mts +4 -4
- package/dist/components/input.mjs +2 -2
- package/dist/components/label.d.mts +2 -2
- package/dist/components/line-chart.d.mts +57 -0
- package/dist/components/line-chart.mjs +218 -0
- package/dist/components/page-loader.d.mts +1 -1
- package/dist/components/pagination.d.mts +3 -3
- package/dist/components/popover.d.mts +5 -5
- package/dist/components/radio-group.d.mts +3 -3
- package/dist/components/scroll-area.d.mts +3 -3
- package/dist/components/select.d.mts +9 -9
- package/dist/components/separator.d.mts +2 -2
- package/dist/components/sheet.d.mts +11 -11
- package/dist/components/shimmer-text.d.mts +3 -1
- package/dist/components/sidebar.d.mts +33 -33
- package/dist/components/sidebar.mjs +10 -10
- package/dist/components/skeleton.d.mts +1 -1
- package/dist/components/sonner.d.mts +5 -5
- package/dist/components/spinner.d.mts +2 -2
- package/dist/components/stat.d.mts +32 -0
- package/dist/components/stat.mjs +117 -0
- package/dist/components/status-dot.d.mts +1 -1
- package/dist/components/switch.d.mts +2 -2
- package/dist/components/table.d.mts +10 -10
- package/dist/components/tabs.d.mts +10 -10
- package/dist/components/tag.d.mts +3 -3
- package/dist/components/task-progress.d.mts +1 -1
- package/dist/components/textarea.d.mts +3 -3
- package/dist/components/textarea.mjs +2 -2
- package/dist/components/toggle-group.d.mts +3 -3
- package/dist/components/toggle-group.mjs +3 -3
- package/dist/components/tooltip.d.mts +5 -5
- package/dist/components/typography.d.mts +4 -4
- package/dist/components/wizard.d.mts +4 -22
- package/dist/components/wizard.mjs +1 -19
- package/dist/hooks/use-chart-theme.d.mts +18 -0
- package/dist/hooks/use-chart-theme.mjs +57 -0
- package/dist/hooks/use-mobile.mjs +3 -3
- package/dist/hooks/use-reduced-motion.d.mts +4 -0
- package/dist/hooks/use-reduced-motion.mjs +16 -0
- package/dist/lib/chart-palette.d.mts +4 -0
- package/dist/lib/chart-palette.mjs +95 -0
- package/dist/lib/chart.d.mts +14 -0
- package/dist/lib/chart.mjs +42 -0
- package/package.json +30 -29
- package/src/components/area-chart.tsx +347 -0
- package/src/components/bar-chart.tsx +317 -0
- package/src/components/bar-list.tsx +166 -0
- package/src/components/chart-card.tsx +65 -0
- package/src/components/chart-container.tsx +51 -0
- package/src/components/chart-legend.tsx +49 -0
- package/src/components/chart-tooltip.tsx +93 -0
- package/src/components/donut-chart.tsx +217 -0
- package/src/components/form.tsx +1 -1
- package/src/components/heatmap-chart.tsx +331 -0
- package/src/components/line-chart.tsx +277 -0
- package/src/components/stat.tsx +113 -0
- package/src/components/wizard.tsx +1 -35
- package/src/hooks/use-chart-theme.ts +75 -0
- package/src/hooks/use-reduced-motion.ts +17 -0
- package/src/lib/chart-palette.ts +110 -0
- package/src/lib/chart.ts +90 -0
- package/src/stories/area-chart.stories.tsx +198 -0
- package/src/stories/bar-chart.stories.tsx +167 -0
- package/src/stories/bar-list.stories.tsx +83 -0
- package/src/stories/donut-chart.stories.tsx +110 -0
- package/src/stories/heatmap-chart.stories.tsx +105 -0
- package/src/stories/line-chart.stories.tsx +144 -0
- package/src/stories/stat.stories.tsx +64 -0
- package/src/stories/wizard.stories.tsx +23 -5
- package/src/styles/tokens.css +18 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion";
|
|
6
|
+
import { formatShare } from "../lib/chart";
|
|
7
|
+
import type { ChartPaletteName } from "../lib/chart-palette";
|
|
8
|
+
import { rampColor } from "../lib/chart-palette";
|
|
9
|
+
import { cn } from "../lib/cn";
|
|
10
|
+
import { useChartContext } from "./chart-container";
|
|
11
|
+
|
|
12
|
+
export interface BarListProps {
|
|
13
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
14
|
+
index: string;
|
|
15
|
+
dataKey?: string;
|
|
16
|
+
maxItems?: number;
|
|
17
|
+
palette?: ChartPaletteName;
|
|
18
|
+
/** Renders bars in a single semantic hue (e.g. red for errors) rather than the palette ramp. */
|
|
19
|
+
semantic?: "error" | "warning" | "success";
|
|
20
|
+
loading?: boolean;
|
|
21
|
+
valueFormatter?: (value: number) => string;
|
|
22
|
+
labelFormatter?: (label: string | number) => string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEMANTIC_KEY = { error: "destructive", warning: "warning", success: "success" } as const;
|
|
27
|
+
|
|
28
|
+
const PLACEHOLDER_HEIGHT = 168;
|
|
29
|
+
// Cap the ramp off its palest end so low-rank bars stay legible on a light card.
|
|
30
|
+
const RAMP_CEILING = 0.8;
|
|
31
|
+
// Semantic bars keep a single hue: the lightest bar still holds this much colour
|
|
32
|
+
// (mixed toward white) so a long error list never fades to near-invisible.
|
|
33
|
+
const SEMANTIC_FLOOR = 0.62;
|
|
34
|
+
const IN_FILL_SHADOW = "0 1px 2px rgb(0 0 0 / 0.28)";
|
|
35
|
+
|
|
36
|
+
function BarList({
|
|
37
|
+
data,
|
|
38
|
+
index,
|
|
39
|
+
dataKey = "value",
|
|
40
|
+
maxItems,
|
|
41
|
+
palette,
|
|
42
|
+
semantic,
|
|
43
|
+
loading = false,
|
|
44
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
45
|
+
labelFormatter,
|
|
46
|
+
className,
|
|
47
|
+
}: BarListProps) {
|
|
48
|
+
const { paletteName, theme } = useChartContext(palette);
|
|
49
|
+
const accent = semantic ? theme[SEMANTIC_KEY[semantic]] : null;
|
|
50
|
+
const reducedMotion = useReducedMotion();
|
|
51
|
+
const [mounted, setMounted] = React.useState(false);
|
|
52
|
+
const [active, setActive] = React.useState<number | null>(null);
|
|
53
|
+
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
setMounted(true);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const total = React.useMemo(() => data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0), [data, dataKey]);
|
|
59
|
+
|
|
60
|
+
const rows = React.useMemo(() => {
|
|
61
|
+
const mapped = data.map((row) => ({
|
|
62
|
+
name: String(row[index] ?? ""),
|
|
63
|
+
value: Number(row[dataKey]) || 0,
|
|
64
|
+
}));
|
|
65
|
+
mapped.sort((lower, upper) => upper.value - lower.value);
|
|
66
|
+
const capped = maxItems ? mapped.slice(0, maxItems) : mapped;
|
|
67
|
+
return capped.map((row, rank) => {
|
|
68
|
+
const rankFraction = capped.length > 1 ? rank / (capped.length - 1) : 0;
|
|
69
|
+
const accentWeight = Math.round((1 - rankFraction * (1 - SEMANTIC_FLOOR)) * 100);
|
|
70
|
+
return {
|
|
71
|
+
...row,
|
|
72
|
+
color: accent
|
|
73
|
+
? `color-mix(in oklab, ${accent} ${accentWeight}%, white)`
|
|
74
|
+
: rampColor(paletteName, rankFraction * RAMP_CEILING),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}, [data, index, dataKey, maxItems, paletteName, accent]);
|
|
78
|
+
|
|
79
|
+
const maxValue = rows.reduce((max, row) => (row.value > max ? row.value : max), 0);
|
|
80
|
+
const isEmpty = rows.length === 0;
|
|
81
|
+
const formatName = (name: string) => (labelFormatter ? labelFormatter(name) : name);
|
|
82
|
+
|
|
83
|
+
if (loading) {
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs",
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
style={{ minHeight: PLACEHOLDER_HEIGHT }}
|
|
91
|
+
>
|
|
92
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
93
|
+
loading…
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isEmpty) {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
"flex items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs",
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
105
|
+
style={{ minHeight: PLACEHOLDER_HEIGHT }}
|
|
106
|
+
>
|
|
107
|
+
no data in range
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div data-slot="bar-list" className={cn("flex w-full flex-col", className)}>
|
|
114
|
+
{rows.map((row, slot) => {
|
|
115
|
+
const fraction = maxValue > 0 ? row.value / maxValue : 0;
|
|
116
|
+
const fillWidth = !reducedMotion && !mounted ? "0%" : `${fraction * 100}%`;
|
|
117
|
+
const dimmed = active !== null && active !== slot;
|
|
118
|
+
const isActive = active === slot;
|
|
119
|
+
return (
|
|
120
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: decorative hover-sync; all values are visible without it
|
|
121
|
+
<div
|
|
122
|
+
key={row.name}
|
|
123
|
+
onMouseEnter={() => setActive(slot)}
|
|
124
|
+
onMouseLeave={() => setActive(null)}
|
|
125
|
+
className="flex items-center gap-3 py-1"
|
|
126
|
+
>
|
|
127
|
+
<div className="relative h-[30px] flex-1 overflow-hidden rounded-md bg-muted">
|
|
128
|
+
<span className="pointer-events-none absolute inset-x-3 top-1/2 z-0 -translate-y-1/2 truncate type-text-xs font-medium text-foreground">
|
|
129
|
+
{formatName(row.name)}
|
|
130
|
+
</span>
|
|
131
|
+
<div
|
|
132
|
+
className="absolute inset-y-0 left-0 z-10 overflow-hidden rounded-md"
|
|
133
|
+
style={{
|
|
134
|
+
width: fillWidth,
|
|
135
|
+
background: `linear-gradient(90deg, ${row.color}, color-mix(in oklab, ${row.color} 82%, transparent))`,
|
|
136
|
+
boxShadow: `inset 0 0 0 1px ${row.color}`,
|
|
137
|
+
opacity: dimmed ? 0.45 : 1,
|
|
138
|
+
transition: reducedMotion ? undefined : "width 700ms ease-out",
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
<span
|
|
142
|
+
className="pointer-events-none absolute top-1/2 -translate-y-1/2 truncate type-text-xs font-medium text-white"
|
|
143
|
+
style={{
|
|
144
|
+
left: 12,
|
|
145
|
+
width: `calc(${100 / Math.max(fraction, 0.0001)}% - 24px)`,
|
|
146
|
+
textShadow: IN_FILL_SHADOW,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
{formatName(row.name)}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<span
|
|
154
|
+
className="min-w-[4rem] shrink-0 whitespace-nowrap text-right font-mono text-text-xs tabular-nums motion-safe:transition-colors"
|
|
155
|
+
style={{ color: isActive ? row.color : undefined }}
|
|
156
|
+
>
|
|
157
|
+
{isActive ? formatShare(total > 0 ? row.value / total : 0) : valueFormatter(row.value)}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { BarList };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { CHART_PALETTES, type ChartPaletteName } from "../lib/chart-palette";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { ChartContainer } from "./chart-container";
|
|
8
|
+
|
|
9
|
+
export interface ChartCardProps extends Omit<React.ComponentProps<"section">, "title"> {
|
|
10
|
+
palette?: ChartPaletteName;
|
|
11
|
+
kicker?: React.ReactNode;
|
|
12
|
+
title?: React.ReactNode;
|
|
13
|
+
description?: React.ReactNode;
|
|
14
|
+
action?: React.ReactNode;
|
|
15
|
+
accent?: "top" | "left" | "none";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ChartCard({
|
|
19
|
+
palette = "magenta",
|
|
20
|
+
kicker,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
action,
|
|
24
|
+
accent = "top",
|
|
25
|
+
className,
|
|
26
|
+
children,
|
|
27
|
+
...props
|
|
28
|
+
}: ChartCardProps) {
|
|
29
|
+
// biome-ignore lint/style/noNonNullAssertion: palettes are never empty
|
|
30
|
+
const lead = CHART_PALETTES[palette][0]!;
|
|
31
|
+
const isLeft = accent === "left";
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<section
|
|
35
|
+
data-slot="chart-card"
|
|
36
|
+
className={cn(
|
|
37
|
+
"chart-rise relative overflow-hidden rounded-xl border bg-card p-5 text-card-foreground shadow-shadow",
|
|
38
|
+
isLeft && "pl-6",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
{accent !== "none" && (
|
|
44
|
+
<span
|
|
45
|
+
aria-hidden
|
|
46
|
+
className={cn("absolute", isLeft ? "inset-y-0 left-0 w-[3px]" : "inset-x-0 top-0 h-[3px]")}
|
|
47
|
+
style={{ background: lead }}
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
{(kicker || title || action) && (
|
|
51
|
+
<header className="mb-4 flex items-start justify-between gap-4">
|
|
52
|
+
<div className="flex flex-col gap-1">
|
|
53
|
+
{kicker && <span className="font-mono text-[10px] text-primary uppercase tracking-[0.18em]">{kicker}</span>}
|
|
54
|
+
{title && <h3 className="type-text-md font-semibold leading-none tracking-tight">{title}</h3>}
|
|
55
|
+
{description && <p className="type-text-xs text-muted-foreground">{description}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
{action}
|
|
58
|
+
</header>
|
|
59
|
+
)}
|
|
60
|
+
<ChartContainer palette={palette}>{children}</ChartContainer>
|
|
61
|
+
</section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { ChartCard };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { type ChartTheme, useChartTheme } from "../hooks/use-chart-theme";
|
|
6
|
+
import { CHART_PALETTES, type ChartPaletteName } from "../lib/chart-palette";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
|
|
9
|
+
export type { ChartPaletteName } from "../lib/chart-palette";
|
|
10
|
+
|
|
11
|
+
interface ChartContextValue {
|
|
12
|
+
palette: readonly string[];
|
|
13
|
+
paletteName: ChartPaletteName;
|
|
14
|
+
theme: ChartTheme;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
function ChartContainer({
|
|
20
|
+
palette = "magenta",
|
|
21
|
+
className,
|
|
22
|
+
...props
|
|
23
|
+
}: React.ComponentProps<"div"> & { palette?: ChartPaletteName }) {
|
|
24
|
+
const theme = useChartTheme();
|
|
25
|
+
const value = React.useMemo<ChartContextValue>(
|
|
26
|
+
() => ({ palette: CHART_PALETTES[palette], paletteName: palette, theme }),
|
|
27
|
+
[palette, theme],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ChartContext.Provider value={value}>
|
|
32
|
+
<div data-slot="chart-container" className={cn("flex flex-col gap-4", className)} {...props} />
|
|
33
|
+
</ChartContext.Provider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function useChartContext(paletteOverride?: ChartPaletteName): ChartContextValue {
|
|
38
|
+
const provided = React.useContext(ChartContext);
|
|
39
|
+
const fallbackTheme = useChartTheme();
|
|
40
|
+
if (provided && !paletteOverride) {
|
|
41
|
+
return provided;
|
|
42
|
+
}
|
|
43
|
+
const paletteName = paletteOverride ?? provided?.paletteName ?? "magenta";
|
|
44
|
+
return {
|
|
45
|
+
palette: CHART_PALETTES[paletteName],
|
|
46
|
+
paletteName,
|
|
47
|
+
theme: provided?.theme ?? fallbackTheme,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { ChartContainer, type ChartContextValue, useChartContext };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
export interface ChartLegendItem {
|
|
6
|
+
name: string;
|
|
7
|
+
color: string;
|
|
8
|
+
dashed?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ChartLegendProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
12
|
+
items: ChartLegendItem[];
|
|
13
|
+
align?: "left" | "center" | "right";
|
|
14
|
+
insetLeft?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ALIGN_CLASS = { left: "justify-start", center: "justify-center", right: "justify-end" } as const;
|
|
18
|
+
|
|
19
|
+
function Swatch({ color, dashed }: { color: string; dashed?: boolean }) {
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
aria-hidden
|
|
23
|
+
className="size-2 shrink-0 rounded-full"
|
|
24
|
+
style={dashed ? { border: `1.5px solid ${color}` } : { background: color }}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ChartLegend({ items, align = "left", insetLeft, className, style, ...props }: ChartLegendProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={cn("flex flex-wrap gap-x-4 gap-y-1.5", ALIGN_CLASS[align], className)}
|
|
33
|
+
style={{ paddingLeft: align === "left" ? insetLeft : undefined, ...style }}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{items.map((item) => (
|
|
37
|
+
<span
|
|
38
|
+
key={item.name}
|
|
39
|
+
className="inline-flex items-center gap-1.5 font-mono text-[10px] text-muted-foreground uppercase tracking-[0.12em]"
|
|
40
|
+
>
|
|
41
|
+
<Swatch color={item.color} dashed={item.dashed} />
|
|
42
|
+
{item.name}
|
|
43
|
+
</span>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { ChartLegend };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useChartContext } from "./chart-container";
|
|
5
|
+
|
|
6
|
+
export interface ChartTooltipItem {
|
|
7
|
+
name?: string | number;
|
|
8
|
+
value?: number | string;
|
|
9
|
+
color?: string;
|
|
10
|
+
stroke?: string;
|
|
11
|
+
fill?: string;
|
|
12
|
+
dataKey?: string | number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ChartTooltipContentProps {
|
|
16
|
+
active?: boolean;
|
|
17
|
+
payload?: ChartTooltipItem[];
|
|
18
|
+
label?: string | number;
|
|
19
|
+
valueFormatter?: (value: number) => string;
|
|
20
|
+
labelFormatter?: (label: string | number) => string;
|
|
21
|
+
hideLabel?: boolean;
|
|
22
|
+
showTotal?: boolean;
|
|
23
|
+
totalLabel?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ChartTooltipContent({
|
|
28
|
+
active,
|
|
29
|
+
payload,
|
|
30
|
+
label,
|
|
31
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
32
|
+
labelFormatter,
|
|
33
|
+
hideLabel,
|
|
34
|
+
showTotal,
|
|
35
|
+
totalLabel = "Total",
|
|
36
|
+
className,
|
|
37
|
+
}: ChartTooltipContentProps) {
|
|
38
|
+
const { theme } = useChartContext();
|
|
39
|
+
|
|
40
|
+
if (!active || !payload?.length) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const total = payload.reduce(
|
|
45
|
+
(sum, item) => sum + (typeof item.value === "number" ? item.value : Number(item.value ?? 0)),
|
|
46
|
+
0,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"min-w-[130px] rounded-lg border px-3 py-2.5 shadow-lg",
|
|
53
|
+
"border-border bg-popover text-popover-foreground",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{!hideLabel && label !== undefined && (
|
|
58
|
+
<p className="font-mono text-[10px] uppercase tracking-wider text-quaternary-foreground mb-1.5">
|
|
59
|
+
{labelFormatter ? labelFormatter(label) : label}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
<div className="flex flex-col gap-1">
|
|
63
|
+
{payload.map((item, index) => {
|
|
64
|
+
const swatch =
|
|
65
|
+
[item.color, item.stroke, item.fill].find(
|
|
66
|
+
(candidate): candidate is string =>
|
|
67
|
+
typeof candidate === "string" && candidate.length > 0 && !candidate.startsWith("url("),
|
|
68
|
+
) ?? theme.mutedForeground;
|
|
69
|
+
const numeric = typeof item.value === "number" ? item.value : Number(item.value ?? 0);
|
|
70
|
+
return (
|
|
71
|
+
<div key={`${item.dataKey ?? index}`} className="flex items-center justify-between gap-4 text-text-xs">
|
|
72
|
+
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
|
73
|
+
<span aria-hidden className="size-2 shrink-0 rounded-[2px]" style={{ background: swatch }} />
|
|
74
|
+
{item.name}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="font-mono font-semibold tabular-nums text-foreground">{valueFormatter(numeric)}</span>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
{showTotal && (
|
|
81
|
+
<div className="mt-1 flex items-center justify-between gap-4 border-border border-t pt-1.5 text-text-xs">
|
|
82
|
+
<span className="font-mono text-quaternary-foreground uppercase tracking-wider text-[10px]">
|
|
83
|
+
{totalLabel}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="font-mono font-semibold tabular-nums text-foreground">{valueFormatter(total)}</span>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { ChartTooltipContent };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Cell, Pie, PieChart as RechartsPieChart, ResponsiveContainer } from "recharts";
|
|
5
|
+
|
|
6
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion";
|
|
7
|
+
import { formatShare } from "../lib/chart";
|
|
8
|
+
import type { ChartPaletteName } from "../lib/chart-palette";
|
|
9
|
+
import { paletteColor } from "../lib/chart-palette";
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
import { useChartContext } from "./chart-container";
|
|
12
|
+
|
|
13
|
+
const GEOMETRY = {
|
|
14
|
+
donut: { inner: "64%", outer: "92%" },
|
|
15
|
+
ring: { inner: "78%", outer: "92%" },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export interface DonutChartProps {
|
|
19
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
20
|
+
index: string;
|
|
21
|
+
dataKey?: string;
|
|
22
|
+
variant?: keyof typeof GEOMETRY;
|
|
23
|
+
legend?: boolean;
|
|
24
|
+
paddingAngle?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
palette?: ChartPaletteName;
|
|
27
|
+
centerLabel?: string;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
valueFormatter?: (value: number) => string;
|
|
30
|
+
labelFormatter?: (label: string | number) => string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function DonutChart({
|
|
35
|
+
data,
|
|
36
|
+
index,
|
|
37
|
+
dataKey = "value",
|
|
38
|
+
variant = "donut",
|
|
39
|
+
legend = false,
|
|
40
|
+
paddingAngle = 1,
|
|
41
|
+
height = 220,
|
|
42
|
+
palette,
|
|
43
|
+
centerLabel = "total",
|
|
44
|
+
loading = false,
|
|
45
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
46
|
+
labelFormatter,
|
|
47
|
+
className,
|
|
48
|
+
}: DonutChartProps) {
|
|
49
|
+
const { palette: paletteColors, theme } = useChartContext(palette);
|
|
50
|
+
const reactId = React.useId().replace(/:/g, "");
|
|
51
|
+
const reducedMotion = useReducedMotion();
|
|
52
|
+
const animated = !reducedMotion;
|
|
53
|
+
const [active, setActive] = React.useState<number | null>(null);
|
|
54
|
+
const rowRefs = React.useRef<Array<HTMLDivElement | null>>([]);
|
|
55
|
+
|
|
56
|
+
const slices = React.useMemo(() => {
|
|
57
|
+
const mapped = data.map((row) => ({
|
|
58
|
+
name: String(row[index] ?? ""),
|
|
59
|
+
value: Number(row[dataKey]) || 0,
|
|
60
|
+
}));
|
|
61
|
+
mapped.sort((lower, upper) => upper.value - lower.value);
|
|
62
|
+
return mapped.map((slice, rank) => ({ ...slice, color: paletteColor(paletteColors, rank) }));
|
|
63
|
+
}, [data, index, dataKey, paletteColors]);
|
|
64
|
+
|
|
65
|
+
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
|
66
|
+
const maxValue = slices.reduce((max, slice) => (slice.value > max ? slice.value : max), 0);
|
|
67
|
+
const geometry = GEOMETRY[variant] ?? GEOMETRY.donut;
|
|
68
|
+
const formatName = (name: string) => (labelFormatter ? labelFormatter(name) : name);
|
|
69
|
+
|
|
70
|
+
const isEmpty = slices.length === 0 || total <= 0;
|
|
71
|
+
|
|
72
|
+
const activeSlice = active !== null ? slices[active] : undefined;
|
|
73
|
+
const centerTitle = activeSlice ? formatName(activeSlice.name) : centerLabel;
|
|
74
|
+
const centerValue = valueFormatter(activeSlice ? activeSlice.value : total);
|
|
75
|
+
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (active !== null) {
|
|
78
|
+
rowRefs.current[active]?.scrollIntoView({ block: "nearest" });
|
|
79
|
+
}
|
|
80
|
+
}, [active]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div data-slot="donut-chart" className={cn("@container flex w-full flex-col gap-3", className)}>
|
|
84
|
+
{loading ? (
|
|
85
|
+
<div
|
|
86
|
+
className="flex items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs"
|
|
87
|
+
style={{ height }}
|
|
88
|
+
>
|
|
89
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
90
|
+
loading…
|
|
91
|
+
</div>
|
|
92
|
+
) : isEmpty ? (
|
|
93
|
+
<div
|
|
94
|
+
className="flex items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs"
|
|
95
|
+
style={{ height }}
|
|
96
|
+
>
|
|
97
|
+
no data in range
|
|
98
|
+
</div>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="flex flex-col items-center gap-5 @md:flex-row">
|
|
101
|
+
<div className="relative shrink-0" style={{ width: height, height }}>
|
|
102
|
+
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
|
|
103
|
+
<RechartsPieChart>
|
|
104
|
+
<defs>
|
|
105
|
+
{slices.map((slice, slot) => (
|
|
106
|
+
<linearGradient key={slice.name} id={`donut-${reactId}-${slot}`} x1="0" y1="0" x2="0" y2="1">
|
|
107
|
+
<stop offset="0%" stopColor={slice.color} stopOpacity={1} />
|
|
108
|
+
<stop offset="100%" stopColor={slice.color} stopOpacity={0.7} />
|
|
109
|
+
</linearGradient>
|
|
110
|
+
))}
|
|
111
|
+
</defs>
|
|
112
|
+
<Pie
|
|
113
|
+
data={slices}
|
|
114
|
+
dataKey="value"
|
|
115
|
+
nameKey="name"
|
|
116
|
+
innerRadius={geometry.inner}
|
|
117
|
+
outerRadius={geometry.outer}
|
|
118
|
+
paddingAngle={paddingAngle}
|
|
119
|
+
startAngle={90}
|
|
120
|
+
endAngle={-270}
|
|
121
|
+
cornerRadius={2}
|
|
122
|
+
stroke={theme.card}
|
|
123
|
+
strokeWidth={1.5}
|
|
124
|
+
isAnimationActive={animated}
|
|
125
|
+
animationDuration={650}
|
|
126
|
+
animationEasing="ease-out"
|
|
127
|
+
onMouseEnter={(_entry, sliceIndex) => setActive(sliceIndex)}
|
|
128
|
+
onMouseLeave={() => setActive(null)}
|
|
129
|
+
>
|
|
130
|
+
{slices.map((slice, slot) => (
|
|
131
|
+
<Cell
|
|
132
|
+
key={slice.name}
|
|
133
|
+
className="motion-safe:[transition:fill-opacity_180ms_ease-out]"
|
|
134
|
+
fill={`url(#donut-${reactId}-${slot})`}
|
|
135
|
+
fillOpacity={active === null || active === slot ? 1 : 0.55}
|
|
136
|
+
stroke={theme.card}
|
|
137
|
+
strokeWidth={1.5}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</Pie>
|
|
141
|
+
</RechartsPieChart>
|
|
142
|
+
</ResponsiveContainer>
|
|
143
|
+
|
|
144
|
+
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 text-center">
|
|
145
|
+
<span className="max-w-[52%] truncate font-mono text-[10px] text-quaternary-foreground uppercase tracking-[0.18em]">
|
|
146
|
+
{centerTitle}
|
|
147
|
+
</span>
|
|
148
|
+
<span className="font-mono font-semibold text-[28px] text-foreground leading-none tabular-nums">
|
|
149
|
+
{centerValue}
|
|
150
|
+
</span>
|
|
151
|
+
{activeSlice && (
|
|
152
|
+
<span className="font-mono font-medium text-[11px] tabular-nums" style={{ color: activeSlice.color }}>
|
|
153
|
+
{formatShare(activeSlice.value / total)}
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{legend && (
|
|
160
|
+
<div
|
|
161
|
+
data-slot="donut-readout"
|
|
162
|
+
className="flex min-w-0 flex-1 flex-col overflow-y-auto pr-1"
|
|
163
|
+
style={{ maxHeight: height }}
|
|
164
|
+
>
|
|
165
|
+
{slices.map((slice, slot) => (
|
|
166
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: decorative hover-sync; all values are visible without it
|
|
167
|
+
<div
|
|
168
|
+
key={slice.name}
|
|
169
|
+
ref={(node) => {
|
|
170
|
+
rowRefs.current[slot] = node;
|
|
171
|
+
}}
|
|
172
|
+
onMouseEnter={() => setActive(slot)}
|
|
173
|
+
onMouseLeave={() => setActive(null)}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex flex-col gap-1.5 border-border/40 border-b px-2 py-2 text-text-xs last:border-b-0 motion-safe:transition-colors",
|
|
176
|
+
active === slot ? "bg-muted/50" : "bg-transparent",
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<div className="flex items-center justify-between gap-2">
|
|
180
|
+
<span className="inline-flex min-w-0 items-center gap-2 text-muted-foreground">
|
|
181
|
+
<span
|
|
182
|
+
aria-hidden
|
|
183
|
+
className="h-2 w-2.5 shrink-0 rounded-[3px]"
|
|
184
|
+
style={{ background: slice.color }}
|
|
185
|
+
/>
|
|
186
|
+
<span className="truncate">{formatName(slice.name)}</span>
|
|
187
|
+
</span>
|
|
188
|
+
<span className="flex shrink-0 items-center gap-2 font-mono tabular-nums">
|
|
189
|
+
<span className="min-w-[2.25rem] text-right font-semibold text-foreground">
|
|
190
|
+
{valueFormatter(slice.value)}
|
|
191
|
+
</span>
|
|
192
|
+
<span className="w-9 text-right text-quaternary-foreground">
|
|
193
|
+
{formatShare(slice.value / total)}
|
|
194
|
+
</span>
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
<span aria-hidden className="relative block h-[2px] w-full overflow-hidden rounded-full bg-border/40">
|
|
198
|
+
<span
|
|
199
|
+
className="absolute inset-y-0 left-0 rounded-full motion-safe:transition-[width] motion-safe:duration-500"
|
|
200
|
+
style={{
|
|
201
|
+
width: `${maxValue > 0 ? (slice.value / maxValue) * 100 : 0}%`,
|
|
202
|
+
background: slice.color,
|
|
203
|
+
opacity: active === null || active === slot ? 1 : 0.45,
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { DonutChart };
|
package/src/components/form.tsx
CHANGED
|
@@ -106,7 +106,7 @@ function FormLabel({ className, required, tooltip, children, ...props }: FormLab
|
|
|
106
106
|
{children}
|
|
107
107
|
</Label>
|
|
108
108
|
{required && (
|
|
109
|
-
<span aria-hidden className="type-text-sm font-medium text-required">
|
|
109
|
+
<span aria-hidden className="type-text-sm font-medium text-required leading-none">
|
|
110
110
|
*
|
|
111
111
|
</span>
|
|
112
112
|
)}
|