Skip to content

Commit 7c3be0f

Browse files
committedDec 15, 2024·
fix(): Add shadcn chart with bad types.
1 parent ddb15f7 commit 7c3be0f

File tree

4 files changed

+663
-11
lines changed

4 files changed

+663
-11
lines changed
 

‎package-lock.json

+285-11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"react-day-picker": "^8.10.0",
130130
"react-icons": "^5.3.0",
131131
"react-resizable-panels": "^1.0.5",
132+
"recharts": "^2.15.0",
132133
"sonner": "^1.3.1",
133134
"tailwind-merge": "^2.1.0",
134135
"vaul": "^0.8.0",

‎src/shadcn/ui/chart.tsx

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

‎src/tokens.css

+12
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
--sidebar-accent-foreground: 240 5.9% 10%;
4040
--sidebar-border: 220 13% 91%;
4141
--sidebar-ring: 217.2 91.2% 59.8%;
42+
43+
--chart-1: 12 76% 61%;
44+
--chart-2: 173 58% 39%;
45+
--chart-3: 197 37% 24%;
46+
--chart-4: 43 74% 66%;
47+
--chart-5: 27 87% 67%;
4248
}
4349

4450
:root [data-theme="dark"] {
@@ -80,4 +86,10 @@
8086
--sidebar-accent-foreground: 240 4.8% 95.9%;
8187
--sidebar-border: 240 3.7% 15.9%;
8288
--sidebar-ring: 217.2 91.2% 59.8%;
89+
90+
--chart-1: 220 70% 50%;
91+
--chart-2: 160 60% 45%;
92+
--chart-3: 30 80% 55%;
93+
--chart-4: 280 65% 60%;
94+
--chart-5: 340 75% 55%;
8395
}

0 commit comments

Comments
 (0)
Please sign in to comment.