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