@alpic-ai/ui 0.0.0-dev.g14de318 → 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.
Files changed (34) hide show
  1. package/dist/components/area-chart.d.mts +2 -0
  2. package/dist/components/area-chart.mjs +9 -3
  3. package/dist/components/bar-chart.d.mts +2 -0
  4. package/dist/components/bar-chart.mjs +9 -3
  5. package/dist/components/bar-list.d.mts +3 -0
  6. package/dist/components/bar-list.mjs +19 -7
  7. package/dist/components/chart-card.d.mts +1 -1
  8. package/dist/components/chart-card.mjs +1 -1
  9. package/dist/components/chart-container.d.mts +1 -1
  10. package/dist/components/chart-legend.d.mts +5 -0
  11. package/dist/components/chart-legend.mjs +11 -2
  12. package/dist/components/donut-chart.mjs +4 -0
  13. package/dist/components/form.mjs +1 -1
  14. package/dist/components/heatmap-chart.d.mts +8 -0
  15. package/dist/components/heatmap-chart.mjs +39 -8
  16. package/dist/components/line-chart.d.mts +2 -0
  17. package/dist/components/line-chart.mjs +10 -3
  18. package/dist/components/stat.d.mts +3 -1
  19. package/dist/components/stat.mjs +14 -4
  20. package/dist/lib/chart.mjs +16 -1
  21. package/package.json +23 -23
  22. package/src/components/area-chart.tsx +12 -4
  23. package/src/components/bar-chart.tsx +12 -4
  24. package/src/components/bar-list.tsx +21 -6
  25. package/src/components/chart-card.tsx +8 -6
  26. package/src/components/chart-container.tsx +2 -0
  27. package/src/components/chart-legend.tsx +10 -2
  28. package/src/components/donut-chart.tsx +1 -1
  29. package/src/components/form.tsx +1 -1
  30. package/src/components/heatmap-chart.tsx +62 -18
  31. package/src/components/line-chart.tsx +18 -5
  32. package/src/components/stat.tsx +10 -6
  33. package/src/lib/chart.ts +34 -0
  34. package/src/stories/wizard.stories.tsx +1 -1
@@ -21,6 +21,7 @@ interface AreaChartProps {
21
21
  variant?: "stacked" | "grouped" | "expand";
22
22
  curve?: keyof typeof CURVE_TYPE;
23
23
  legend?: boolean;
24
+ legendAlign?: "left" | "center" | "right";
24
25
  valueFlags?: boolean;
25
26
  height?: number;
26
27
  yAxisWidth?: number;
@@ -45,6 +46,7 @@ declare function AreaChart({
45
46
  variant,
46
47
  curve,
47
48
  legend,
49
+ legendAlign,
48
50
  valueFlags,
49
51
  height,
50
52
  yAxisWidth,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.mjs";
3
3
  import { useReducedMotion } from "../hooks/use-reduced-motion.mjs";
4
- import { orderByLuminance, resolveSeries } from "../lib/chart.mjs";
4
+ import { makeXAxisTick, orderByLuminance, resolveSeries } from "../lib/chart.mjs";
5
5
  import { useChartContext } from "./chart-container.mjs";
6
6
  import { ChartLegend } from "./chart-legend.mjs";
7
7
  import { ChartTooltipContent } from "./chart-tooltip.mjs";
@@ -14,7 +14,7 @@ const CURVE_TYPE = {
14
14
  linear: "linear",
15
15
  step: "stepAfter"
16
16
  };
17
- function AreaChart({ data, index, series, variant = "stacked", curve = "monotone", legend = false, valueFlags = false, height = 200, yAxisWidth = 48, palette, referenceLine, markers, lastValueLabel = false, texture = false, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
17
+ function AreaChart({ data, index, series, variant = "stacked", curve = "monotone", legend = false, legendAlign = "left", valueFlags = false, height = 200, yAxisWidth = 48, palette, referenceLine, markers, lastValueLabel = false, texture = false, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
18
18
  const { palette: paletteColors, theme } = useChartContext(palette);
19
19
  const reactId = React$1.useId().replace(/:/g, "");
20
20
  const animated = !useReducedMotion();
@@ -128,6 +128,10 @@ function AreaChart({ data, index, series, variant = "stacked", curve = "monotone
128
128
  }) : /* @__PURE__ */ jsx(ResponsiveContainer, {
129
129
  width: "100%",
130
130
  height: "100%",
131
+ initialDimension: {
132
+ width: 0,
133
+ height
134
+ },
131
135
  children: /* @__PURE__ */ jsxs(AreaChart$1, {
132
136
  data,
133
137
  stackOffset: variant === "expand" ? "expand" : "none",
@@ -177,6 +181,7 @@ function AreaChart({ data, index, series, variant = "stacked", curve = "monotone
177
181
  /* @__PURE__ */ jsx(XAxis, {
178
182
  dataKey: index,
179
183
  ...axis,
184
+ tick: makeXAxisTick(theme),
180
185
  interval: "preserveStartEnd",
181
186
  minTickGap: 44
182
187
  }),
@@ -261,7 +266,8 @@ function AreaChart({ data, index, series, variant = "stacked", curve = "monotone
261
266
  })
262
267
  }), legend && !isEmpty && /* @__PURE__ */ jsx(ChartLegend, {
263
268
  items: legendItems,
264
- style: { paddingLeft: yAxisWidth }
269
+ align: legendAlign,
270
+ insetLeft: yAxisWidth
265
271
  })]
266
272
  });
267
273
  }
@@ -10,6 +10,7 @@ interface BarChartProps {
10
10
  series: ChartSeries[];
11
11
  variant?: "stacked" | "grouped" | "expand";
12
12
  legend?: boolean;
13
+ legendAlign?: "left" | "center" | "right";
13
14
  valueLabels?: boolean;
14
15
  height?: number;
15
16
  yAxisWidth?: number;
@@ -32,6 +33,7 @@ declare function BarChart({
32
33
  series,
33
34
  variant,
34
35
  legend,
36
+ legendAlign,
35
37
  valueLabels,
36
38
  height,
37
39
  yAxisWidth,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.mjs";
3
3
  import { useReducedMotion } from "../hooks/use-reduced-motion.mjs";
4
- import { orderByLuminance, resolveSeries } from "../lib/chart.mjs";
4
+ import { makeXAxisTick, orderByLuminance, resolveSeries } from "../lib/chart.mjs";
5
5
  import { useChartContext } from "./chart-container.mjs";
6
6
  import { ChartLegend } from "./chart-legend.mjs";
7
7
  import { ChartTooltipContent } from "./chart-tooltip.mjs";
@@ -11,7 +11,7 @@ import { Bar, BarChart as BarChart$1, CartesianGrid, LabelList, ReferenceArea, R
11
11
  //#region src/components/bar-chart.tsx
12
12
  const BAR_RADIUS = 4;
13
13
  const MAX_BAR_SIZE = 48;
14
- function BarChart({ data, index, series, variant = "stacked", legend = false, valueLabels = false, height = 200, yAxisWidth = 48, palette, referenceLine, markers, texture = false, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
14
+ function BarChart({ data, index, series, variant = "stacked", legend = false, legendAlign = "left", valueLabels = false, height = 200, yAxisWidth = 48, palette, referenceLine, markers, texture = false, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
15
15
  const { palette: paletteColors, theme } = useChartContext(palette);
16
16
  const reactId = React$1.useId().replace(/:/g, "");
17
17
  const animated = !useReducedMotion();
@@ -111,6 +111,10 @@ function BarChart({ data, index, series, variant = "stacked", legend = false, va
111
111
  }) : /* @__PURE__ */ jsx(ResponsiveContainer, {
112
112
  width: "100%",
113
113
  height: "100%",
114
+ initialDimension: {
115
+ width: 0,
116
+ height
117
+ },
114
118
  children: /* @__PURE__ */ jsxs(BarChart$1, {
115
119
  data,
116
120
  stackOffset: variant === "expand" ? "expand" : "none",
@@ -161,6 +165,7 @@ function BarChart({ data, index, series, variant = "stacked", legend = false, va
161
165
  /* @__PURE__ */ jsx(XAxis, {
162
166
  dataKey: index,
163
167
  ...axis,
168
+ tick: makeXAxisTick(theme),
164
169
  interval: "preserveStartEnd",
165
170
  minTickGap: 44
166
171
  }),
@@ -248,7 +253,8 @@ function BarChart({ data, index, series, variant = "stacked", legend = false, va
248
253
  })
249
254
  }), legend && !isEmpty && /* @__PURE__ */ jsx(ChartLegend, {
250
255
  items: legendItems,
251
- style: { paddingLeft: yAxisWidth }
256
+ align: legendAlign,
257
+ insetLeft: yAxisWidth
252
258
  })]
253
259
  });
254
260
  }
@@ -8,6 +8,8 @@ interface BarListProps {
8
8
  dataKey?: string;
9
9
  maxItems?: number;
10
10
  palette?: ChartPaletteName;
11
+ /** Renders bars in a single semantic hue (e.g. red for errors) rather than the palette ramp. */
12
+ semantic?: "error" | "warning" | "success";
11
13
  loading?: boolean;
12
14
  valueFormatter?: (value: number) => string;
13
15
  labelFormatter?: (label: string | number) => string;
@@ -19,6 +21,7 @@ declare function BarList({
19
21
  dataKey,
20
22
  maxItems,
21
23
  palette,
24
+ semantic,
22
25
  loading,
23
26
  valueFormatter,
24
27
  labelFormatter,
@@ -7,11 +7,18 @@ import { useChartContext } from "./chart-container.mjs";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import * as React$1 from "react";
9
9
  //#region src/components/bar-list.tsx
10
+ const SEMANTIC_KEY = {
11
+ error: "destructive",
12
+ warning: "warning",
13
+ success: "success"
14
+ };
10
15
  const PLACEHOLDER_HEIGHT = 168;
11
16
  const RAMP_CEILING = .8;
17
+ const SEMANTIC_FLOOR = .62;
12
18
  const IN_FILL_SHADOW = "0 1px 2px rgb(0 0 0 / 0.28)";
13
- function BarList({ data, index, dataKey = "value", maxItems, palette, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
14
- const { paletteName } = useChartContext(palette);
19
+ function BarList({ data, index, dataKey = "value", maxItems, palette, semantic, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
20
+ const { paletteName, theme } = useChartContext(palette);
21
+ const accent = semantic ? theme[SEMANTIC_KEY[semantic]] : null;
15
22
  const reducedMotion = useReducedMotion();
16
23
  const [mounted, setMounted] = React$1.useState(false);
17
24
  const [active, setActive] = React$1.useState(null);
@@ -26,16 +33,21 @@ function BarList({ data, index, dataKey = "value", maxItems, palette, loading =
26
33
  }));
27
34
  mapped.sort((lower, upper) => upper.value - lower.value);
28
35
  const capped = maxItems ? mapped.slice(0, maxItems) : mapped;
29
- return capped.map((row, rank) => ({
30
- ...row,
31
- color: rampColor(paletteName, (capped.length > 1 ? rank / (capped.length - 1) : 0) * RAMP_CEILING)
32
- }));
36
+ return capped.map((row, rank) => {
37
+ const rankFraction = capped.length > 1 ? rank / (capped.length - 1) : 0;
38
+ const accentWeight = Math.round((1 - rankFraction * (1 - SEMANTIC_FLOOR)) * 100);
39
+ return {
40
+ ...row,
41
+ color: accent ? `color-mix(in oklab, ${accent} ${accentWeight}%, white)` : rampColor(paletteName, rankFraction * RAMP_CEILING)
42
+ };
43
+ });
33
44
  }, [
34
45
  data,
35
46
  index,
36
47
  dataKey,
37
48
  maxItems,
38
- paletteName
49
+ paletteName,
50
+ accent
39
51
  ]);
40
52
  const maxValue = rows.reduce((max, row) => row.value > max ? row.value : max, 0);
41
53
  const isEmpty = rows.length === 0;
@@ -8,7 +8,7 @@ interface ChartCardProps extends Omit<React$1.ComponentProps<"section">, "title"
8
8
  title?: React$1.ReactNode;
9
9
  description?: React$1.ReactNode;
10
10
  action?: React$1.ReactNode;
11
- accent?: "top" | "left";
11
+ accent?: "top" | "left" | "none";
12
12
  }
13
13
  declare function ChartCard({
14
14
  palette,
@@ -12,7 +12,7 @@ function ChartCard({ palette = "magenta", kicker, title, description, action, ac
12
12
  className: cn("chart-rise relative overflow-hidden rounded-xl border bg-card p-5 text-card-foreground shadow-shadow", isLeft && "pl-6", className),
13
13
  ...props,
14
14
  children: [
15
- /* @__PURE__ */ jsx("span", {
15
+ accent !== "none" && /* @__PURE__ */ jsx("span", {
16
16
  "aria-hidden": true,
17
17
  className: cn("absolute", isLeft ? "inset-y-0 left-0 w-[3px]" : "inset-x-0 top-0 h-[3px]"),
18
18
  style: { background: lead }
@@ -17,4 +17,4 @@ declare function ChartContainer({
17
17
  }): React$1.JSX.Element;
18
18
  declare function useChartContext(paletteOverride?: ChartPaletteName): ChartContextValue;
19
19
  //#endregion
20
- export { ChartContainer, type ChartContextValue, useChartContext };
20
+ export { ChartContainer, type ChartContextValue, type ChartPaletteName, useChartContext };
@@ -6,10 +6,15 @@ interface ChartLegendItem {
6
6
  }
7
7
  interface ChartLegendProps extends React.HTMLAttributes<HTMLDivElement> {
8
8
  items: ChartLegendItem[];
9
+ align?: "left" | "center" | "right";
10
+ insetLeft?: number;
9
11
  }
10
12
  declare function ChartLegend({
11
13
  items,
14
+ align,
15
+ insetLeft,
12
16
  className,
17
+ style,
13
18
  ...props
14
19
  }: ChartLegendProps): import("react").JSX.Element;
15
20
  //#endregion
@@ -2,6 +2,11 @@
2
2
  import { cn } from "../lib/cn.mjs";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/components/chart-legend.tsx
5
+ const ALIGN_CLASS = {
6
+ left: "justify-start",
7
+ center: "justify-center",
8
+ right: "justify-end"
9
+ };
5
10
  function Swatch({ color, dashed }) {
6
11
  return /* @__PURE__ */ jsx("span", {
7
12
  "aria-hidden": true,
@@ -9,9 +14,13 @@ function Swatch({ color, dashed }) {
9
14
  style: dashed ? { border: `1.5px solid ${color}` } : { background: color }
10
15
  });
11
16
  }
12
- function ChartLegend({ items, className, ...props }) {
17
+ function ChartLegend({ items, align = "left", insetLeft, className, style, ...props }) {
13
18
  return /* @__PURE__ */ jsx("div", {
14
- className: cn("flex flex-wrap gap-x-4 gap-y-1.5", className),
19
+ className: cn("flex flex-wrap gap-x-4 gap-y-1.5", ALIGN_CLASS[align], className),
20
+ style: {
21
+ paddingLeft: align === "left" ? insetLeft : void 0,
22
+ ...style
23
+ },
15
24
  ...props,
16
25
  children: items.map((item) => /* @__PURE__ */ jsxs("span", {
17
26
  className: "inline-flex items-center gap-1.5 font-mono text-[10px] text-muted-foreground uppercase tracking-[0.12em]",
@@ -73,6 +73,10 @@ function DonutChart({ data, index, dataKey = "value", variant = "donut", legend
73
73
  children: [/* @__PURE__ */ jsx(ResponsiveContainer, {
74
74
  width: "100%",
75
75
  height: "100%",
76
+ initialDimension: {
77
+ width: 0,
78
+ height
79
+ },
76
80
  children: /* @__PURE__ */ jsxs(PieChart, { children: [/* @__PURE__ */ jsx("defs", { children: slices.map((slice, slot) => /* @__PURE__ */ jsxs("linearGradient", {
77
81
  id: `donut-${reactId}-${slot}`,
78
82
  x1: "0",
@@ -65,7 +65,7 @@ function FormLabel({ className, required, tooltip, children, ...props }) {
65
65
  }),
66
66
  required && /* @__PURE__ */ jsx("span", {
67
67
  "aria-hidden": true,
68
- className: "type-text-sm font-medium text-required",
68
+ className: "type-text-sm font-medium text-required leading-none",
69
69
  children: "*"
70
70
  }),
71
71
  tooltip && /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
@@ -11,11 +11,17 @@ interface HeatmapChartProps {
11
11
  palette?: ChartPaletteName;
12
12
  xLabels?: readonly string[];
13
13
  yLabels?: readonly string[];
14
+ showAllXLabels?: boolean;
14
15
  highlightPeak?: boolean;
15
16
  loading?: boolean;
16
17
  valueFormatter?: (value: number) => string;
17
18
  xTickFormatter?: (label: string) => string;
18
19
  yTickFormatter?: (label: string) => string;
20
+ tooltipMetrics?: ReadonlyArray<{
21
+ key: string;
22
+ label: string;
23
+ format?: (value: number) => string;
24
+ }>;
19
25
  ariaLabel?: string;
20
26
  className?: string;
21
27
  }
@@ -28,11 +34,13 @@ declare function HeatmapChart({
28
34
  palette,
29
35
  xLabels,
30
36
  yLabels,
37
+ showAllXLabels,
31
38
  highlightPeak,
32
39
  loading,
33
40
  valueFormatter,
34
41
  xTickFormatter,
35
42
  yTickFormatter,
43
+ tooltipMetrics,
36
44
  ariaLabel,
37
45
  className
38
46
  }: HeatmapChartProps): React$1.JSX.Element;
@@ -31,7 +31,7 @@ const uniqueInOrder = (values) => {
31
31
  }
32
32
  return ordered;
33
33
  };
34
- function HeatmapChart({ data, xKey, yKey, dataKey = "value", variant = "square", palette, xLabels, yLabels, highlightPeak = true, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), xTickFormatter, yTickFormatter, ariaLabel = "Activity heatmap", className }) {
34
+ function HeatmapChart({ data, xKey, yKey, dataKey = "value", variant = "square", palette, xLabels, yLabels, showAllXLabels = false, highlightPeak = true, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), xTickFormatter, yTickFormatter, tooltipMetrics, ariaLabel = "Activity heatmap", className }) {
35
35
  const { paletteName, theme } = useChartContext(palette);
36
36
  const [hovered, setHovered] = React$1.useState(null);
37
37
  const [mounted, setMounted] = React$1.useState(false);
@@ -42,24 +42,31 @@ function HeatmapChart({ data, xKey, yKey, dataKey = "value", variant = "square",
42
42
  const columns = xLabels ? [...xLabels] : uniqueInOrder(data.map((row) => String(row[xKey] ?? "")));
43
43
  const rows = yLabels ? [...yLabels] : uniqueInOrder(data.map((row) => String(row[yKey] ?? "")));
44
44
  const valueAt = /* @__PURE__ */ new Map();
45
- for (const row of data) valueAt.set(pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? "")), Number(row[dataKey]) || 0);
45
+ const recordAt = /* @__PURE__ */ new Map();
46
+ for (const row of data) {
47
+ const key = pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? ""));
48
+ valueAt.set(key, Number(row[dataKey]) || 0);
49
+ recordAt.set(key, row);
50
+ }
46
51
  let maxValue = 0;
47
52
  let peakKey = "";
48
53
  return {
49
54
  columns,
50
55
  rows,
51
56
  cells: rows.flatMap((rowLabel, rowIndex) => columns.map((colLabel, colIndex) => {
52
- const value = valueAt.get(pairKey(rowLabel, colLabel)) ?? 0;
57
+ const key = pairKey(rowLabel, colLabel);
58
+ const value = valueAt.get(key) ?? 0;
53
59
  if (value > maxValue) {
54
60
  maxValue = value;
55
- peakKey = pairKey(rowLabel, colLabel);
61
+ peakKey = key;
56
62
  }
57
63
  return {
58
64
  rowLabel,
59
65
  colLabel,
60
66
  rowIndex,
61
67
  colIndex,
62
- value
68
+ value,
69
+ record: recordAt.get(key) ?? null
63
70
  };
64
71
  })),
65
72
  maxValue,
@@ -87,7 +94,7 @@ function HeatmapChart({ data, xKey, yKey, dataKey = "value", variant = "square",
87
94
  const ramp = heatRamp(paletteName, theme.isDark ? HEAT_EMPTY.dark : HEAT_EMPTY.light);
88
95
  const totalWidth = PAD.left + columns.length * CELL + (columns.length - 1) * GAP + PAD.right;
89
96
  const totalHeight = PAD.top + rows.length * CELL + (rows.length - 1) * GAP + PAD.bottom;
90
- const xStride = Math.ceil(columns.length / MAX_X_TICKS);
97
+ const xStride = showAllXLabels ? 1 : Math.ceil(columns.length / MAX_X_TICKS);
91
98
  const cellX = (col) => PAD.left + col * 25;
92
99
  const cellY = (row) => PAD.top + row * 25;
93
100
  const formatX = (label) => xTickFormatter ? xTickFormatter(label) : label;
@@ -159,7 +166,8 @@ function HeatmapChart({ data, xKey, yKey, dataKey = "value", variant = "square",
159
166
  y: event.clientY,
160
167
  rowLabel: cell.rowLabel,
161
168
  colLabel: cell.colLabel,
162
- value: cell.value
169
+ value: cell.value,
170
+ record: cell.record
163
171
  })
164
172
  }, `hit-${pairKey(cell.rowLabel, cell.colLabel)}`))
165
173
  ]
@@ -177,7 +185,30 @@ function HeatmapChart({ data, xKey, yKey, dataKey = "value", variant = "square",
177
185
  " · ",
178
186
  formatX(hovered.colLabel)
179
187
  ]
180
- }), /* @__PURE__ */ jsxs("div", {
188
+ }), tooltipMetrics && tooltipMetrics.length > 0 ? /* @__PURE__ */ jsx("div", {
189
+ className: "flex flex-col gap-1",
190
+ children: tooltipMetrics.map((metric) => {
191
+ const raw = hovered.record ? Number(hovered.record[metric.key]) || 0 : 0;
192
+ const isActive = metric.key === dataKey;
193
+ return /* @__PURE__ */ jsxs("div", {
194
+ className: "flex items-center justify-between gap-4 text-text-xs",
195
+ children: [/* @__PURE__ */ jsxs("span", {
196
+ className: "inline-flex items-center gap-2 text-muted-foreground",
197
+ children: [/* @__PURE__ */ jsx("span", {
198
+ "aria-hidden": true,
199
+ className: "size-2 shrink-0 rounded-[2px]",
200
+ style: {
201
+ background: isActive ? heatColor(ramp, hovered.value / maxValue) : theme.mutedForeground,
202
+ opacity: isActive ? 1 : .35
203
+ }
204
+ }), metric.label]
205
+ }), /* @__PURE__ */ jsx("span", {
206
+ className: cn("font-mono tabular-nums", isActive ? "font-semibold text-foreground" : "text-muted-foreground"),
207
+ children: (metric.format ?? valueFormatter)(raw)
208
+ })]
209
+ }, metric.key);
210
+ })
211
+ }) : /* @__PURE__ */ jsxs("div", {
181
212
  className: "flex items-center justify-between gap-4 text-text-xs",
182
213
  children: [/* @__PURE__ */ jsxs("span", {
183
214
  className: "inline-flex items-center gap-2 text-muted-foreground",
@@ -15,6 +15,7 @@ interface LineChartProps {
15
15
  series: ChartSeries[];
16
16
  curve?: keyof typeof CURVE_TYPE;
17
17
  legend?: boolean;
18
+ legendAlign?: "left" | "center" | "right";
18
19
  valueFlags?: boolean;
19
20
  dots?: boolean;
20
21
  height?: number;
@@ -38,6 +39,7 @@ declare function LineChart({
38
39
  series,
39
40
  curve,
40
41
  legend,
42
+ legendAlign,
41
43
  valueFlags,
42
44
  dots,
43
45
  height,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/cn.mjs";
3
3
  import { useReducedMotion } from "../hooks/use-reduced-motion.mjs";
4
- import { resolveSeries } from "../lib/chart.mjs";
4
+ import { makeXAxisTick, resolveSeries } from "../lib/chart.mjs";
5
5
  import { useChartContext } from "./chart-container.mjs";
6
6
  import { ChartLegend } from "./chart-legend.mjs";
7
7
  import { ChartTooltipContent } from "./chart-tooltip.mjs";
@@ -14,7 +14,7 @@ const CURVE_TYPE = {
14
14
  linear: "linear",
15
15
  step: "stepAfter"
16
16
  };
17
- function LineChart({ data, index, series, curve = "monotone", legend = false, valueFlags = false, dots = false, height = 200, yAxisWidth = 48, palette, referenceLine, markers, lastValueLabel = false, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
17
+ function LineChart({ data, index, series, curve = "monotone", legend = false, legendAlign = "left", valueFlags = false, dots = false, height = 200, yAxisWidth = 48, palette, referenceLine, markers, lastValueLabel = false, loading = false, valueFormatter = (value) => value.toLocaleString("en-US"), labelFormatter, className }) {
18
18
  const { palette: paletteColors, theme } = useChartContext(palette);
19
19
  const animated = !useReducedMotion();
20
20
  const resolved = resolveSeries(series, paletteColors, theme);
@@ -106,6 +106,10 @@ function LineChart({ data, index, series, curve = "monotone", legend = false, va
106
106
  }) : /* @__PURE__ */ jsx(ResponsiveContainer, {
107
107
  width: "100%",
108
108
  height: "100%",
109
+ initialDimension: {
110
+ width: 0,
111
+ height
112
+ },
109
113
  children: /* @__PURE__ */ jsxs(LineChart$1, {
110
114
  data,
111
115
  margin,
@@ -118,12 +122,14 @@ function LineChart({ data, index, series, curve = "monotone", legend = false, va
118
122
  /* @__PURE__ */ jsx(XAxis, {
119
123
  dataKey: index,
120
124
  ...axis,
125
+ tick: makeXAxisTick(theme),
121
126
  interval: "preserveStartEnd",
122
127
  minTickGap: 44
123
128
  }),
124
129
  /* @__PURE__ */ jsx(YAxis, {
125
130
  ...axis,
126
131
  width: yAxisWidth,
132
+ domain: ["auto", "auto"],
127
133
  tickFormatter: (value) => valueFormatter(value)
128
134
  }),
129
135
  /* @__PURE__ */ jsx(Tooltip, {
@@ -203,7 +209,8 @@ function LineChart({ data, index, series, curve = "monotone", legend = false, va
203
209
  })
204
210
  }), legend && !isEmpty && /* @__PURE__ */ jsx(ChartLegend, {
205
211
  items: legendItems,
206
- style: { paddingLeft: yAxisWidth }
212
+ align: legendAlign,
213
+ insetLeft: yAxisWidth
207
214
  })]
208
215
  });
209
216
  }
@@ -13,16 +13,18 @@ interface StatDelta {
13
13
  interface StatProps extends React$1.ComponentProps<"div"> {
14
14
  value: React$1.ReactNode;
15
15
  unit?: string;
16
- delta?: StatDelta;
16
+ delta?: StatDelta | null;
17
17
  sparkline?: number[] | Array<{
18
18
  value: number;
19
19
  }>;
20
+ semantic?: "error" | "warning" | "success";
20
21
  }
21
22
  declare function Stat({
22
23
  value,
23
24
  unit,
24
25
  delta,
25
26
  sparkline,
27
+ semantic,
26
28
  className,
27
29
  ...props
28
30
  }: StatProps): React$1.JSX.Element;
@@ -21,11 +21,17 @@ const toSparkData = (sparkline) => (sparkline ?? []).map((point, index) => typeo
21
21
  index,
22
22
  value: point.value
23
23
  });
24
- function Stat({ value, unit, delta, sparkline, className, ...props }) {
25
- const { palette } = useChartContext();
24
+ const SEMANTIC_KEY = {
25
+ error: "destructive",
26
+ warning: "warning",
27
+ success: "success"
28
+ };
29
+ function Stat({ value, unit, delta, sparkline, semantic, className, ...props }) {
30
+ const { palette, theme } = useChartContext();
26
31
  const gradientId = React$1.useId().replace(/:/g, "");
27
32
  const sparkData = React$1.useMemo(() => toSparkData(sparkline), [sparkline]);
28
- const sparkColor = palette[0];
33
+ const hasSpark = sparkData.some((point) => point.value > 0);
34
+ const sparkColor = semantic ? theme[SEMANTIC_KEY[semantic]] : palette[0];
29
35
  const sentiment = delta && (delta.invert ? delta.direction === "down" : delta.direction === "up") ? "positive" : "negative";
30
36
  return /* @__PURE__ */ jsxs("div", {
31
37
  "data-slot": "stat",
@@ -48,11 +54,15 @@ function Stat({ value, unit, delta, sparkline, className, ...props }) {
48
54
  children: [delta.direction === "up" ? /* @__PURE__ */ jsx(ArrowUp, { className: "size-3" }) : /* @__PURE__ */ jsx(ArrowDown, { className: "size-3" }), delta.label ?? `${delta.value}%`]
49
55
  })
50
56
  ]
51
- }), sparkData.length > 0 && /* @__PURE__ */ jsx("div", {
57
+ }), hasSpark && /* @__PURE__ */ jsx("div", {
52
58
  className: "h-9 w-full",
53
59
  children: /* @__PURE__ */ jsx(ResponsiveContainer, {
54
60
  width: "100%",
55
61
  height: "100%",
62
+ initialDimension: {
63
+ width: 0,
64
+ height: 36
65
+ },
56
66
  children: /* @__PURE__ */ jsxs(AreaChart, {
57
67
  data: sparkData,
58
68
  margin: {
@@ -1,4 +1,5 @@
1
1
  import { luminance, paletteColor } from "./chart-palette.mjs";
2
+ import * as React$1 from "react";
2
3
  //#region src/lib/chart.ts
3
4
  const formatShare = (fraction) => `${(fraction * 100).toFixed(fraction >= .1 ? 0 : 1)}%`;
4
5
  const semanticColor = (theme, semantic) => {
@@ -23,5 +24,19 @@ const resolveSeries = (series, palette, theme) => series.map((entry, index) => (
23
24
  * stack order in Recharts — first entry sits at the bottom.
24
25
  */
25
26
  const orderByLuminance = (series) => [...series].sort((lower, upper) => luminance(lower.color) - luminance(upper.color));
27
+ const makeXAxisTick = (theme) => ({ x, y, payload, index, visibleTicksCount }) => {
28
+ const isFirst = index === 0;
29
+ const isLast = visibleTicksCount != null && index === visibleTicksCount - 1;
30
+ const anchor = isFirst ? "start" : isLast ? "end" : "middle";
31
+ return React$1.createElement("text", {
32
+ x: Number(x ?? 0),
33
+ y: Number(y ?? 0),
34
+ dy: 12,
35
+ textAnchor: anchor,
36
+ fill: theme.axisForeground,
37
+ fontFamily: theme.fontMono,
38
+ fontSize: 10
39
+ }, String(payload?.value ?? ""));
40
+ };
26
41
  //#endregion
27
- export { formatShare, orderByLuminance, resolveSeries };
42
+ export { formatShare, makeXAxisTick, orderByLuminance, resolveSeries };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alpic-ai/ui",
3
- "version": "0.0.0-dev.g14de318",
3
+ "version": "0.0.0-dev.g162d798",
4
4
  "description": "Alpic design system — shared UI components",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,32 +23,32 @@
23
23
  "src"
24
24
  ],
25
25
  "peerDependencies": {
26
- "lucide-react": "^1.18.0",
26
+ "lucide-react": "^1.21.0",
27
27
  "react": "^19.2.7",
28
28
  "react-dom": "^19.2.7",
29
- "react-hook-form": "^7.79.0",
29
+ "react-hook-form": "^7.80.0",
30
30
  "sonner": "^2.0.7",
31
31
  "tailwindcss": "^4.3.1",
32
32
  "tw-animate-css": "^1.4.0"
33
33
  },
34
34
  "dependencies": {
35
- "@radix-ui/react-accordion": "^1.2.13",
36
- "@radix-ui/react-avatar": "^1.1.12",
37
- "@radix-ui/react-checkbox": "^1.3.4",
38
- "@radix-ui/react-collapsible": "^1.1.13",
39
- "@radix-ui/react-dialog": "^1.1.16",
40
- "@radix-ui/react-dropdown-menu": "^2.1.17",
41
- "@radix-ui/react-label": "^2.1.9",
42
- "@radix-ui/react-popover": "^1.1.16",
43
- "@radix-ui/react-radio-group": "^1.4.0",
44
- "@radix-ui/react-scroll-area": "^1.2.11",
45
- "@radix-ui/react-select": "^2.3.0",
46
- "@radix-ui/react-separator": "^1.1.9",
47
- "@radix-ui/react-slot": "^1.2.5",
48
- "@radix-ui/react-switch": "^1.3.0",
49
- "@radix-ui/react-tabs": "^1.1.14",
50
- "@radix-ui/react-toggle-group": "^1.1.12",
51
- "@radix-ui/react-tooltip": "^1.2.9",
35
+ "@radix-ui/react-accordion": "^1.2.14",
36
+ "@radix-ui/react-avatar": "^1.2.0",
37
+ "@radix-ui/react-checkbox": "^1.3.5",
38
+ "@radix-ui/react-collapsible": "^1.1.14",
39
+ "@radix-ui/react-dialog": "^1.1.17",
40
+ "@radix-ui/react-dropdown-menu": "^2.1.18",
41
+ "@radix-ui/react-label": "^2.1.10",
42
+ "@radix-ui/react-popover": "^1.1.17",
43
+ "@radix-ui/react-radio-group": "^1.4.1",
44
+ "@radix-ui/react-scroll-area": "^1.2.12",
45
+ "@radix-ui/react-select": "^2.3.1",
46
+ "@radix-ui/react-separator": "^1.1.10",
47
+ "@radix-ui/react-slot": "^1.3.0",
48
+ "@radix-ui/react-switch": "^1.3.1",
49
+ "@radix-ui/react-tabs": "^1.1.15",
50
+ "@radix-ui/react-toggle-group": "^1.1.13",
51
+ "@radix-ui/react-tooltip": "^1.2.10",
52
52
  "class-variance-authority": "^0.7.1",
53
53
  "clsx": "^2.1.1",
54
54
  "cmdk": "^1.1.1",
@@ -60,12 +60,12 @@
60
60
  "@tailwindcss/postcss": "^4.3.1",
61
61
  "@types/react": "19.2.17",
62
62
  "@types/react-dom": "19.2.3",
63
- "lucide-react": "^1.18.0",
64
- "react-hook-form": "^7.79.0",
63
+ "lucide-react": "^1.21.0",
64
+ "react-hook-form": "^7.80.0",
65
65
  "shx": "^0.4.0",
66
66
  "sonner": "^2.0.7",
67
67
  "tailwindcss": "^4.3.1",
68
- "tsdown": "^0.22.2",
68
+ "tsdown": "^0.22.3",
69
69
  "tw-animate-css": "^1.4.0",
70
70
  "typescript": "^6.0.3"
71
71
  },
@@ -16,7 +16,7 @@ import {
16
16
  } from "recharts";
17
17
 
18
18
  import { useReducedMotion } from "../hooks/use-reduced-motion";
19
- import { type ChartSeries, orderByLuminance, resolveSeries } from "../lib/chart";
19
+ import { type ChartSeries, makeXAxisTick, orderByLuminance, resolveSeries } from "../lib/chart";
20
20
  import type { ChartPaletteName } from "../lib/chart-palette";
21
21
  import { cn } from "../lib/cn";
22
22
  import { useChartContext } from "./chart-container";
@@ -39,6 +39,7 @@ export interface AreaChartProps {
39
39
  variant?: "stacked" | "grouped" | "expand";
40
40
  curve?: keyof typeof CURVE_TYPE;
41
41
  legend?: boolean;
42
+ legendAlign?: "left" | "center" | "right";
42
43
  valueFlags?: boolean;
43
44
  height?: number;
44
45
  yAxisWidth?: number;
@@ -60,6 +61,7 @@ function AreaChart({
60
61
  variant = "stacked",
61
62
  curve = "monotone",
62
63
  legend = false,
64
+ legendAlign = "left",
63
65
  valueFlags = false,
64
66
  height = 200,
65
67
  yAxisWidth = 48,
@@ -205,7 +207,7 @@ function AreaChart({
205
207
  no data in range
206
208
  </div>
207
209
  ) : (
208
- <ResponsiveContainer width="100%" height="100%">
210
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
209
211
  <RechartsAreaChart
210
212
  data={data as Record<string, string | number>[]}
211
213
  stackOffset={variant === "expand" ? "expand" : "none"}
@@ -234,7 +236,13 @@ function AreaChart({
234
236
  </defs>
235
237
 
236
238
  <CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
237
- <XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
239
+ <XAxis
240
+ dataKey={index}
241
+ {...axis}
242
+ tick={makeXAxisTick(theme)}
243
+ interval="preserveStartEnd"
244
+ minTickGap={44}
245
+ />
238
246
  <YAxis
239
247
  {...axis}
240
248
  width={yAxisWidth}
@@ -331,7 +339,7 @@ function AreaChart({
331
339
  )}
332
340
  </div>
333
341
 
334
- {legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
342
+ {legend && !isEmpty && <ChartLegend items={legendItems} align={legendAlign} insetLeft={yAxisWidth} />}
335
343
  </div>
336
344
  );
337
345
  }
@@ -16,7 +16,7 @@ import {
16
16
  } from "recharts";
17
17
 
18
18
  import { useReducedMotion } from "../hooks/use-reduced-motion";
19
- import { type ChartSeries, orderByLuminance, resolveSeries } from "../lib/chart";
19
+ import { type ChartSeries, makeXAxisTick, orderByLuminance, resolveSeries } from "../lib/chart";
20
20
  import type { ChartPaletteName } from "../lib/chart-palette";
21
21
  import { cn } from "../lib/cn";
22
22
  import type { ChartMarker } from "./area-chart";
@@ -33,6 +33,7 @@ export interface BarChartProps {
33
33
  series: ChartSeries[];
34
34
  variant?: "stacked" | "grouped" | "expand";
35
35
  legend?: boolean;
36
+ legendAlign?: "left" | "center" | "right";
36
37
  valueLabels?: boolean;
37
38
  height?: number;
38
39
  yAxisWidth?: number;
@@ -52,6 +53,7 @@ function BarChart({
52
53
  series,
53
54
  variant = "stacked",
54
55
  legend = false,
56
+ legendAlign = "left",
55
57
  valueLabels = false,
56
58
  height = 200,
57
59
  yAxisWidth = 48,
@@ -168,7 +170,7 @@ function BarChart({
168
170
  no data in range
169
171
  </div>
170
172
  ) : (
171
- <ResponsiveContainer width="100%" height="100%">
173
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
172
174
  <RechartsBarChart
173
175
  data={data as Record<string, string | number>[]}
174
176
  stackOffset={variant === "expand" ? "expand" : "none"}
@@ -198,7 +200,13 @@ function BarChart({
198
200
  </defs>
199
201
 
200
202
  <CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
201
- <XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
203
+ <XAxis
204
+ dataKey={index}
205
+ {...axis}
206
+ tick={makeXAxisTick(theme)}
207
+ interval="preserveStartEnd"
208
+ minTickGap={44}
209
+ />
202
210
  <YAxis
203
211
  {...axis}
204
212
  width={yAxisWidth}
@@ -301,7 +309,7 @@ function BarChart({
301
309
  )}
302
310
  </div>
303
311
 
304
- {legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
312
+ {legend && !isEmpty && <ChartLegend items={legendItems} align={legendAlign} insetLeft={yAxisWidth} />}
305
313
  </div>
306
314
  );
307
315
  }
@@ -15,15 +15,22 @@ export interface BarListProps {
15
15
  dataKey?: string;
16
16
  maxItems?: number;
17
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";
18
20
  loading?: boolean;
19
21
  valueFormatter?: (value: number) => string;
20
22
  labelFormatter?: (label: string | number) => string;
21
23
  className?: string;
22
24
  }
23
25
 
26
+ const SEMANTIC_KEY = { error: "destructive", warning: "warning", success: "success" } as const;
27
+
24
28
  const PLACEHOLDER_HEIGHT = 168;
25
29
  // Cap the ramp off its palest end so low-rank bars stay legible on a light card.
26
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;
27
34
  const IN_FILL_SHADOW = "0 1px 2px rgb(0 0 0 / 0.28)";
28
35
 
29
36
  function BarList({
@@ -32,12 +39,14 @@ function BarList({
32
39
  dataKey = "value",
33
40
  maxItems,
34
41
  palette,
42
+ semantic,
35
43
  loading = false,
36
44
  valueFormatter = (value) => value.toLocaleString("en-US"),
37
45
  labelFormatter,
38
46
  className,
39
47
  }: BarListProps) {
40
- const { paletteName } = useChartContext(palette);
48
+ const { paletteName, theme } = useChartContext(palette);
49
+ const accent = semantic ? theme[SEMANTIC_KEY[semantic]] : null;
41
50
  const reducedMotion = useReducedMotion();
42
51
  const [mounted, setMounted] = React.useState(false);
43
52
  const [active, setActive] = React.useState<number | null>(null);
@@ -55,11 +64,17 @@ function BarList({
55
64
  }));
56
65
  mapped.sort((lower, upper) => upper.value - lower.value);
57
66
  const capped = maxItems ? mapped.slice(0, maxItems) : mapped;
58
- return capped.map((row, rank) => ({
59
- ...row,
60
- color: rampColor(paletteName, (capped.length > 1 ? rank / (capped.length - 1) : 0) * RAMP_CEILING),
61
- }));
62
- }, [data, index, dataKey, maxItems, paletteName]);
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]);
63
78
 
64
79
  const maxValue = rows.reduce((max, row) => (row.value > max ? row.value : max), 0);
65
80
  const isEmpty = rows.length === 0;
@@ -12,7 +12,7 @@ export interface ChartCardProps extends Omit<React.ComponentProps<"section">, "t
12
12
  title?: React.ReactNode;
13
13
  description?: React.ReactNode;
14
14
  action?: React.ReactNode;
15
- accent?: "top" | "left";
15
+ accent?: "top" | "left" | "none";
16
16
  }
17
17
 
18
18
  function ChartCard({
@@ -40,11 +40,13 @@ function ChartCard({
40
40
  )}
41
41
  {...props}
42
42
  >
43
- <span
44
- aria-hidden
45
- className={cn("absolute", isLeft ? "inset-y-0 left-0 w-[3px]" : "inset-x-0 top-0 h-[3px]")}
46
- style={{ background: lead }}
47
- />
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
+ )}
48
50
  {(kicker || title || action) && (
49
51
  <header className="mb-4 flex items-start justify-between gap-4">
50
52
  <div className="flex flex-col gap-1">
@@ -6,6 +6,8 @@ import { type ChartTheme, useChartTheme } from "../hooks/use-chart-theme";
6
6
  import { CHART_PALETTES, type ChartPaletteName } from "../lib/chart-palette";
7
7
  import { cn } from "../lib/cn";
8
8
 
9
+ export type { ChartPaletteName } from "../lib/chart-palette";
10
+
9
11
  interface ChartContextValue {
10
12
  palette: readonly string[];
11
13
  paletteName: ChartPaletteName;
@@ -10,8 +10,12 @@ export interface ChartLegendItem {
10
10
 
11
11
  export interface ChartLegendProps extends React.HTMLAttributes<HTMLDivElement> {
12
12
  items: ChartLegendItem[];
13
+ align?: "left" | "center" | "right";
14
+ insetLeft?: number;
13
15
  }
14
16
 
17
+ const ALIGN_CLASS = { left: "justify-start", center: "justify-center", right: "justify-end" } as const;
18
+
15
19
  function Swatch({ color, dashed }: { color: string; dashed?: boolean }) {
16
20
  return (
17
21
  <span
@@ -22,9 +26,13 @@ function Swatch({ color, dashed }: { color: string; dashed?: boolean }) {
22
26
  );
23
27
  }
24
28
 
25
- function ChartLegend({ items, className, ...props }: ChartLegendProps) {
29
+ function ChartLegend({ items, align = "left", insetLeft, className, style, ...props }: ChartLegendProps) {
26
30
  return (
27
- <div className={cn("flex flex-wrap gap-x-4 gap-y-1.5", className)} {...props}>
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
+ >
28
36
  {items.map((item) => (
29
37
  <span
30
38
  key={item.name}
@@ -99,7 +99,7 @@ function DonutChart({
99
99
  ) : (
100
100
  <div className="flex flex-col items-center gap-5 @md:flex-row">
101
101
  <div className="relative shrink-0" style={{ width: height, height }}>
102
- <ResponsiveContainer width="100%" height="100%">
102
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
103
103
  <RechartsPieChart>
104
104
  <defs>
105
105
  {slices.map((slice, slot) => (
@@ -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
  )}
@@ -17,21 +17,26 @@ export interface HeatmapChartProps {
17
17
  palette?: ChartPaletteName;
18
18
  xLabels?: readonly string[];
19
19
  yLabels?: readonly string[];
20
+ showAllXLabels?: boolean;
20
21
  highlightPeak?: boolean;
21
22
  loading?: boolean;
22
23
  valueFormatter?: (value: number) => string;
23
24
  xTickFormatter?: (label: string) => string;
24
25
  yTickFormatter?: (label: string) => string;
26
+ tooltipMetrics?: ReadonlyArray<{ key: string; label: string; format?: (value: number) => string }>;
25
27
  ariaLabel?: string;
26
28
  className?: string;
27
29
  }
28
30
 
31
+ type CellRecord = Record<string, string | number | null | undefined>;
32
+
29
33
  interface HoverState {
30
34
  x: number;
31
35
  y: number;
32
36
  rowLabel: string;
33
37
  colLabel: string;
34
38
  value: number;
39
+ record: CellRecord | null;
35
40
  }
36
41
 
37
42
  const PLACEHOLDER_HEIGHT = 168;
@@ -71,11 +76,13 @@ function HeatmapChart({
71
76
  palette,
72
77
  xLabels,
73
78
  yLabels,
79
+ showAllXLabels = false,
74
80
  highlightPeak = true,
75
81
  loading = false,
76
82
  valueFormatter = (value) => value.toLocaleString("en-US"),
77
83
  xTickFormatter,
78
84
  yTickFormatter,
85
+ tooltipMetrics,
79
86
  ariaLabel = "Activity heatmap",
80
87
  className,
81
88
  }: HeatmapChartProps) {
@@ -91,19 +98,23 @@ function HeatmapChart({
91
98
  const columns = xLabels ? [...xLabels] : uniqueInOrder(data.map((row) => String(row[xKey] ?? "")));
92
99
  const rows = yLabels ? [...yLabels] : uniqueInOrder(data.map((row) => String(row[yKey] ?? "")));
93
100
  const valueAt = new Map<string, number>();
101
+ const recordAt = new Map<string, CellRecord>();
94
102
  for (const row of data) {
95
- valueAt.set(pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? "")), Number(row[dataKey]) || 0);
103
+ const key = pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? ""));
104
+ valueAt.set(key, Number(row[dataKey]) || 0);
105
+ recordAt.set(key, row);
96
106
  }
97
107
  let maxValue = 0;
98
108
  let peakKey = "";
99
109
  const cells = rows.flatMap((rowLabel, rowIndex) =>
100
110
  columns.map((colLabel, colIndex) => {
101
- const value = valueAt.get(pairKey(rowLabel, colLabel)) ?? 0;
111
+ const key = pairKey(rowLabel, colLabel);
112
+ const value = valueAt.get(key) ?? 0;
102
113
  if (value > maxValue) {
103
114
  maxValue = value;
104
- peakKey = pairKey(rowLabel, colLabel);
115
+ peakKey = key;
105
116
  }
106
- return { rowLabel, colLabel, rowIndex, colIndex, value };
117
+ return { rowLabel, colLabel, rowIndex, colIndex, value, record: recordAt.get(key) ?? null };
107
118
  }),
108
119
  );
109
120
  return { columns, rows, cells, maxValue, peakKey };
@@ -144,7 +155,7 @@ function HeatmapChart({
144
155
  const ramp = heatRamp(paletteName, theme.isDark ? HEAT_EMPTY.dark : HEAT_EMPTY.light);
145
156
  const totalWidth = PAD.left + columns.length * CELL + (columns.length - 1) * GAP + PAD.right;
146
157
  const totalHeight = PAD.top + rows.length * CELL + (rows.length - 1) * GAP + PAD.bottom;
147
- const xStride = Math.ceil(columns.length / MAX_X_TICKS);
158
+ const xStride = showAllXLabels ? 1 : Math.ceil(columns.length / MAX_X_TICKS);
148
159
  const cellX = (col: number) => PAD.left + col * (CELL + GAP);
149
160
  const cellY = (row: number) => PAD.top + row * (CELL + GAP);
150
161
  const formatX = (label: string) => (xTickFormatter ? xTickFormatter(label) : label);
@@ -247,6 +258,7 @@ function HeatmapChart({
247
258
  rowLabel: cell.rowLabel,
248
259
  colLabel: cell.colLabel,
249
260
  value: cell.value,
261
+ record: cell.record,
250
262
  })
251
263
  }
252
264
  />
@@ -264,19 +276,51 @@ function HeatmapChart({
264
276
  <p className="mb-1.5 font-mono text-[10px] text-quaternary-foreground uppercase tracking-wider">
265
277
  {formatY(hovered.rowLabel)} · {formatX(hovered.colLabel)}
266
278
  </p>
267
- <div className="flex items-center justify-between gap-4 text-text-xs">
268
- <span className="inline-flex items-center gap-2 text-muted-foreground">
269
- <span
270
- aria-hidden
271
- className="size-2 shrink-0 rounded-[2px]"
272
- style={{ background: heatColor(ramp, hovered.value / maxValue) }}
273
- />
274
- activity
275
- </span>
276
- <span className="font-mono font-semibold tabular-nums text-foreground">
277
- {valueFormatter(hovered.value)}
278
- </span>
279
- </div>
279
+ {tooltipMetrics && tooltipMetrics.length > 0 ? (
280
+ <div className="flex flex-col gap-1">
281
+ {tooltipMetrics.map((metric) => {
282
+ const raw = hovered.record ? Number(hovered.record[metric.key]) || 0 : 0;
283
+ const isActive = metric.key === dataKey;
284
+ return (
285
+ <div key={metric.key} className="flex items-center justify-between gap-4 text-text-xs">
286
+ <span className="inline-flex items-center gap-2 text-muted-foreground">
287
+ <span
288
+ aria-hidden
289
+ className="size-2 shrink-0 rounded-[2px]"
290
+ style={{
291
+ background: isActive ? heatColor(ramp, hovered.value / maxValue) : theme.mutedForeground,
292
+ opacity: isActive ? 1 : 0.35,
293
+ }}
294
+ />
295
+ {metric.label}
296
+ </span>
297
+ <span
298
+ className={cn(
299
+ "font-mono tabular-nums",
300
+ isActive ? "font-semibold text-foreground" : "text-muted-foreground",
301
+ )}
302
+ >
303
+ {(metric.format ?? valueFormatter)(raw)}
304
+ </span>
305
+ </div>
306
+ );
307
+ })}
308
+ </div>
309
+ ) : (
310
+ <div className="flex items-center justify-between gap-4 text-text-xs">
311
+ <span className="inline-flex items-center gap-2 text-muted-foreground">
312
+ <span
313
+ aria-hidden
314
+ className="size-2 shrink-0 rounded-[2px]"
315
+ style={{ background: heatColor(ramp, hovered.value / maxValue) }}
316
+ />
317
+ activity
318
+ </span>
319
+ <span className="font-mono font-semibold tabular-nums text-foreground">
320
+ {valueFormatter(hovered.value)}
321
+ </span>
322
+ </div>
323
+ )}
280
324
  </div>,
281
325
  document.body,
282
326
  )}
@@ -16,7 +16,7 @@ import {
16
16
  } from "recharts";
17
17
 
18
18
  import { useReducedMotion } from "../hooks/use-reduced-motion";
19
- import { type ChartSeries, resolveSeries } from "../lib/chart";
19
+ import { type ChartSeries, makeXAxisTick, resolveSeries } from "../lib/chart";
20
20
  import type { ChartPaletteName } from "../lib/chart-palette";
21
21
  import { cn } from "../lib/cn";
22
22
  import type { ChartMarker } from "./area-chart";
@@ -32,6 +32,7 @@ export interface LineChartProps {
32
32
  series: ChartSeries[];
33
33
  curve?: keyof typeof CURVE_TYPE;
34
34
  legend?: boolean;
35
+ legendAlign?: "left" | "center" | "right";
35
36
  valueFlags?: boolean;
36
37
  dots?: boolean;
37
38
  height?: number;
@@ -52,6 +53,7 @@ function LineChart({
52
53
  series,
53
54
  curve = "monotone",
54
55
  legend = false,
56
+ legendAlign = "left",
55
57
  valueFlags = false,
56
58
  dots = false,
57
59
  height = 200,
@@ -170,11 +172,22 @@ function LineChart({
170
172
  no data in range
171
173
  </div>
172
174
  ) : (
173
- <ResponsiveContainer width="100%" height="100%">
175
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height }}>
174
176
  <RechartsLineChart data={data as Record<string, string | number>[]} margin={margin}>
175
177
  <CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
176
- <XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
177
- <YAxis {...axis} width={yAxisWidth} tickFormatter={(value: number) => valueFormatter(value)} />
178
+ <XAxis
179
+ dataKey={index}
180
+ {...axis}
181
+ tick={makeXAxisTick(theme)}
182
+ interval="preserveStartEnd"
183
+ minTickGap={44}
184
+ />
185
+ <YAxis
186
+ {...axis}
187
+ width={yAxisWidth}
188
+ domain={["auto", "auto"]}
189
+ tickFormatter={(value: number) => valueFormatter(value)}
190
+ />
178
191
  <Tooltip
179
192
  offset={12}
180
193
  allowEscapeViewBox={{ x: false, y: false }}
@@ -256,7 +269,7 @@ function LineChart({
256
269
  )}
257
270
  </div>
258
271
 
259
- {legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
272
+ {legend && !isEmpty && <ChartLegend items={legendItems} align={legendAlign} insetLeft={yAxisWidth} />}
260
273
  </div>
261
274
  );
262
275
  }
@@ -31,8 +31,9 @@ export interface StatDelta {
31
31
  export interface StatProps extends React.ComponentProps<"div"> {
32
32
  value: React.ReactNode;
33
33
  unit?: string;
34
- delta?: StatDelta;
34
+ delta?: StatDelta | null;
35
35
  sparkline?: number[] | Array<{ value: number }>;
36
+ semantic?: "error" | "warning" | "success";
36
37
  }
37
38
 
38
39
  const toSparkData = (sparkline: StatProps["sparkline"]) =>
@@ -40,12 +41,15 @@ const toSparkData = (sparkline: StatProps["sparkline"]) =>
40
41
  typeof point === "number" ? { index, value: point } : { index, value: point.value },
41
42
  );
42
43
 
43
- function Stat({ value, unit, delta, sparkline, className, ...props }: StatProps) {
44
- const { palette } = useChartContext();
44
+ const SEMANTIC_KEY = { error: "destructive", warning: "warning", success: "success" } as const;
45
+
46
+ function Stat({ value, unit, delta, sparkline, semantic, className, ...props }: StatProps) {
47
+ const { palette, theme } = useChartContext();
45
48
  const gradientId = React.useId().replace(/:/g, "");
46
49
  const sparkData = React.useMemo(() => toSparkData(sparkline), [sparkline]);
50
+ const hasSpark = sparkData.some((point) => point.value > 0);
47
51
  // biome-ignore lint/style/noNonNullAssertion: palettes are never empty
48
- const sparkColor = palette[0]!;
52
+ const sparkColor = semantic ? theme[SEMANTIC_KEY[semantic]] : palette[0]!;
49
53
 
50
54
  const sentiment =
51
55
  delta && (delta.invert ? delta.direction === "down" : delta.direction === "up") ? "positive" : "negative";
@@ -64,9 +68,9 @@ function Stat({ value, unit, delta, sparkline, className, ...props }: StatProps)
64
68
  </DeltaPill>
65
69
  )}
66
70
  </div>
67
- {sparkData.length > 0 && (
71
+ {hasSpark && (
68
72
  <div className="h-9 w-full">
69
- <ResponsiveContainer width="100%" height="100%">
73
+ <ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 0, height: 36 }}>
70
74
  <AreaChart data={sparkData} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
71
75
  <defs>
72
76
  <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
package/src/lib/chart.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as React from "react";
1
2
  import type { ChartTheme } from "../hooks/use-chart-theme";
2
3
  import { luminance, paletteColor } from "./chart-palette";
3
4
 
@@ -54,3 +55,36 @@ export const resolveSeries = (
54
55
  */
55
56
  export const orderByLuminance = (series: ResolvedSeries[]) =>
56
57
  [...series].sort((lower, upper) => luminance(lower.color) - luminance(upper.color));
58
+
59
+ export const makeXAxisTick =
60
+ (theme: ChartTheme) =>
61
+ ({
62
+ x,
63
+ y,
64
+ payload,
65
+ index,
66
+ visibleTicksCount,
67
+ }: {
68
+ x?: string | number;
69
+ y?: string | number;
70
+ payload?: { value?: string | number };
71
+ index?: number;
72
+ visibleTicksCount?: number;
73
+ }) => {
74
+ const isFirst = index === 0;
75
+ const isLast = visibleTicksCount != null && index === visibleTicksCount - 1;
76
+ const anchor = isFirst ? "start" : isLast ? "end" : "middle";
77
+ return React.createElement(
78
+ "text",
79
+ {
80
+ x: Number(x ?? 0),
81
+ y: Number(y ?? 0),
82
+ dy: 12,
83
+ textAnchor: anchor,
84
+ fill: theme.axisForeground,
85
+ fontFamily: theme.fontMono,
86
+ fontSize: 10,
87
+ },
88
+ String(payload?.value ?? ""),
89
+ );
90
+ };
@@ -11,7 +11,7 @@ const steps = [
11
11
  { id: "branding", label: "Branding & metadata" },
12
12
  { id: "auth", label: "Authentication" },
13
13
  { id: "tools", label: "Tools & test cases" },
14
- { id: "review", label: "Review & submit" },
14
+ { id: "review", label: "Review & export" },
15
15
  ];
16
16
 
17
17
  /** The step rail is a vertical `TabsNav` composed with the consumer's selection state. */