Skip to content

Commit 5a15979

Browse files
authored
feat: add shadcn/ui charts and use it for the sales status widget (#383)
* feat: add charts from `shadcn/ui` * feat: add the chart css variables * fix: using the formatter to render the values in the existing `<span>` ui * feat: use the shadcn line chart for the sales status chart
1 parent 523ad5b commit 5a15979

File tree

3 files changed

+445
-69
lines changed

3 files changed

+445
-69
lines changed

src/components/ui/chart.tsx

+368
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import * as React from "react";
2+
import * as RechartsPrimitive from "recharts";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
// Format: { THEME_NAME: CSS_SELECTOR }
7+
const THEMES = { light: "", dark: ".dark" } as const;
8+
9+
export type ChartConfig = {
10+
[k in string]: {
11+
label?: React.ReactNode;
12+
icon?: React.ComponentType;
13+
} & (
14+
| { color?: string; theme?: never }
15+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
16+
);
17+
};
18+
19+
type ChartContextProps = {
20+
config: ChartConfig;
21+
};
22+
23+
const ChartContext = React.createContext<ChartContextProps | null>(null);
24+
25+
function useChart() {
26+
const context = React.useContext(ChartContext);
27+
28+
if (!context) {
29+
throw new Error("useChart must be used within a <ChartContainer />");
30+
}
31+
32+
return context;
33+
}
34+
35+
const ChartContainer = React.forwardRef<
36+
HTMLDivElement,
37+
React.ComponentProps<"div"> & {
38+
config: ChartConfig;
39+
children: React.ComponentProps<
40+
typeof RechartsPrimitive.ResponsiveContainer
41+
>["children"];
42+
}
43+
>(({ id, className, children, config, ...props }, ref) => {
44+
const uniqueId = React.useId();
45+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
46+
47+
return (
48+
<ChartContext.Provider value={{ config }}>
49+
<div
50+
data-chart={chartId}
51+
ref={ref}
52+
className={cn(
53+
"flex justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
54+
className
55+
)}
56+
{...props}
57+
>
58+
<ChartStyle id={chartId} config={config} />
59+
<RechartsPrimitive.ResponsiveContainer>
60+
{children}
61+
</RechartsPrimitive.ResponsiveContainer>
62+
</div>
63+
</ChartContext.Provider>
64+
);
65+
});
66+
ChartContainer.displayName = "Chart";
67+
68+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
69+
const colorConfig = Object.entries(config).filter(
70+
([_, config]) => config.theme || config.color
71+
);
72+
73+
if (!colorConfig.length) {
74+
return null;
75+
}
76+
77+
return (
78+
<style
79+
dangerouslySetInnerHTML={{
80+
__html: Object.entries(THEMES).map(
81+
([theme, prefix]) => `
82+
${prefix} [data-chart=${id}] {
83+
${colorConfig
84+
.map(([key, itemConfig]) => {
85+
const color =
86+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
87+
itemConfig.color;
88+
return color ? ` --color-${key}: ${color};` : null;
89+
})
90+
.join("\n")}
91+
}
92+
`
93+
),
94+
}}
95+
/>
96+
);
97+
};
98+
99+
const ChartTooltip = RechartsPrimitive.Tooltip;
100+
101+
const ChartTooltipContent = React.forwardRef<
102+
HTMLDivElement,
103+
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
104+
React.ComponentProps<"div"> & {
105+
hideLabel?: boolean;
106+
hideIndicator?: boolean;
107+
indicator?: "line" | "dot" | "dashed";
108+
nameKey?: string;
109+
labelKey?: string;
110+
}
111+
>(
112+
(
113+
{
114+
active,
115+
payload,
116+
className,
117+
indicator = "dot",
118+
hideLabel = false,
119+
hideIndicator = false,
120+
label,
121+
labelFormatter,
122+
labelClassName,
123+
formatter,
124+
color,
125+
nameKey,
126+
labelKey,
127+
},
128+
ref
129+
) => {
130+
const { config } = useChart();
131+
132+
const tooltipLabel = React.useMemo(() => {
133+
if (hideLabel || !payload?.length) {
134+
return null;
135+
}
136+
137+
const [item] = payload;
138+
if (!item) {
139+
throw new Error("Payload is empty");
140+
}
141+
const key = `${labelKey || item.dataKey || item.name || "value"}`;
142+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
143+
const value =
144+
!labelKey && typeof label === "string"
145+
? config[label as keyof typeof config]?.label || label
146+
: itemConfig?.label;
147+
148+
if (labelFormatter) {
149+
return (
150+
<div className={cn("font-medium", labelClassName)}>
151+
{labelFormatter(value, payload)}
152+
</div>
153+
);
154+
}
155+
156+
if (!value) {
157+
return null;
158+
}
159+
160+
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
161+
}, [
162+
label,
163+
labelFormatter,
164+
payload,
165+
hideLabel,
166+
labelClassName,
167+
config,
168+
labelKey,
169+
]);
170+
171+
if (!active || !payload?.length) {
172+
return null;
173+
}
174+
175+
const nestLabel = payload.length === 1 && indicator !== "dot";
176+
177+
return (
178+
<div
179+
ref={ref}
180+
className={cn(
181+
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
182+
className
183+
)}
184+
>
185+
{!nestLabel ? tooltipLabel : null}
186+
<div className="grid gap-1.5">
187+
{payload.map((item, index) => {
188+
const key = `${nameKey || item.name || item.dataKey || "value"}`;
189+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
190+
const indicatorColor = color || item.payload.fill || item.color;
191+
192+
return (
193+
<div
194+
key={item.dataKey}
195+
className={cn(
196+
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
197+
indicator === "dot" && "items-center"
198+
)}
199+
>
200+
<React.Fragment>
201+
{itemConfig?.icon ? (
202+
<itemConfig.icon />
203+
) : (
204+
!hideIndicator && (
205+
<div
206+
className={cn(
207+
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
208+
{
209+
"h-2.5 w-2.5": indicator === "dot",
210+
"w-1": indicator === "line",
211+
"w-0 border-[1.5px] border-dashed bg-transparent":
212+
indicator === "dashed",
213+
"my-0.5": nestLabel && indicator === "dashed",
214+
}
215+
)}
216+
style={
217+
{
218+
"--color-bg": indicatorColor,
219+
"--color-border": indicatorColor,
220+
} as React.CSSProperties
221+
}
222+
/>
223+
)
224+
)}
225+
<div
226+
className={cn(
227+
"flex flex-1 justify-between gap-1.5 leading-none",
228+
nestLabel ? "items-end" : "items-center"
229+
)}
230+
>
231+
<div className="grid gap-1.5">
232+
{nestLabel ? tooltipLabel : null}
233+
<span className="text-muted-foreground">
234+
{itemConfig?.label || item.name}
235+
</span>
236+
</div>
237+
{typeof item.value !== "undefined" ? (
238+
<span className="font-mono font-medium tabular-nums text-foreground">
239+
{typeof formatter !== "undefined" && item.name
240+
? formatter(
241+
item.value,
242+
item.name,
243+
item,
244+
index,
245+
item.payload
246+
)
247+
: item.value.toLocaleString()}
248+
</span>
249+
) : null}
250+
</div>
251+
</React.Fragment>
252+
</div>
253+
);
254+
})}
255+
</div>
256+
</div>
257+
);
258+
}
259+
);
260+
ChartTooltipContent.displayName = "ChartTooltip";
261+
262+
const ChartLegend = RechartsPrimitive.Legend;
263+
264+
const ChartLegendContent = React.forwardRef<
265+
HTMLDivElement,
266+
React.ComponentProps<"div"> &
267+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
268+
hideIcon?: boolean;
269+
nameKey?: string;
270+
}
271+
>(
272+
(
273+
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
274+
ref
275+
) => {
276+
const { config } = useChart();
277+
278+
if (!payload?.length) {
279+
return null;
280+
}
281+
282+
return (
283+
<div
284+
ref={ref}
285+
className={cn(
286+
"flex items-center justify-center gap-4",
287+
verticalAlign === "top" ? "pb-3" : "pt-3",
288+
className
289+
)}
290+
>
291+
{payload.map((item) => {
292+
const key = `${nameKey || item.dataKey || "value"}`;
293+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
294+
295+
return (
296+
<div
297+
key={item.value}
298+
className={cn(
299+
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
300+
)}
301+
>
302+
{itemConfig?.icon && !hideIcon ? (
303+
<itemConfig.icon />
304+
) : (
305+
<div
306+
className="h-2 w-2 shrink-0 rounded-[2px]"
307+
style={{
308+
backgroundColor: item.color,
309+
}}
310+
/>
311+
)}
312+
{itemConfig?.label}
313+
</div>
314+
);
315+
})}
316+
</div>
317+
);
318+
}
319+
);
320+
ChartLegendContent.displayName = "ChartLegend";
321+
322+
// Helper to extract item config from a payload.
323+
function getPayloadConfigFromPayload(
324+
config: ChartConfig,
325+
payload: unknown,
326+
key: string
327+
) {
328+
if (typeof payload !== "object" || payload === null) {
329+
return undefined;
330+
}
331+
332+
const payloadPayload =
333+
"payload" in payload &&
334+
typeof payload.payload === "object" &&
335+
payload.payload !== null
336+
? payload.payload
337+
: undefined;
338+
339+
let configLabelKey: string = key;
340+
341+
if (
342+
key in payload &&
343+
typeof payload[key as keyof typeof payload] === "string"
344+
) {
345+
configLabelKey = payload[key as keyof typeof payload] as string;
346+
} else if (
347+
payloadPayload &&
348+
key in payloadPayload &&
349+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
350+
) {
351+
configLabelKey = payloadPayload[
352+
key as keyof typeof payloadPayload
353+
] as string;
354+
}
355+
356+
return configLabelKey in config
357+
? config[configLabelKey]
358+
: config[key as keyof typeof config];
359+
}
360+
361+
export {
362+
ChartContainer,
363+
ChartTooltip,
364+
ChartTooltipContent,
365+
ChartLegend,
366+
ChartLegendContent,
367+
ChartStyle,
368+
};

0 commit comments

Comments
 (0)