index.tsx617 lines · main
1'use client'
2
3import dayjs from 'dayjs'
4import { HelpCircle, Loader2 } from 'lucide-react'
5import Link from 'next/link'
6import { Slot } from 'radix-ui'
7import * as React from 'react'
8import { useContext, useMemo, useRef, useState } from 'react'
9import {
10 Area,
11 AreaChart,
12 Bar,
13 BarChart,
14 Tooltip as RechartsTooltip,
15 TooltipProps as RechartsTooltipProps,
16 ResponsiveContainer,
17 XAxis,
18 YAxis,
19} from 'recharts'
20import {
21 Button,
22 Card,
23 ChartContainer,
24 cn,
25 Skeleton,
26 Tooltip,
27 TooltipContent,
28 TooltipTrigger,
29} from 'ui'
30
31/* Chart Config */
32export type ChartConfig = {
33 [key: string]: {
34 label?: React.ReactNode
35 icon?: React.ComponentType
36 } & (
37 | { color?: string; theme?: never }
38 | { color?: never; theme: Record<'light' | 'dark', string> } /* come back to this */
39 )
40}
41
42/* Chart Context */
43interface ChartContextValue {
44 isLoading?: boolean
45 isDisabled?: boolean
46}
47
48const ChartContext = React.createContext<ChartContextValue>({
49 isLoading: false,
50 isDisabled: false,
51})
52
53export const useChart = () => {
54 return useContext(ChartContext)
55}
56
57/* Chart Base */
58interface ChartProps extends React.HTMLAttributes<HTMLDivElement> {
59 children: React.ReactNode
60 isLoading?: boolean
61 isDisabled?: boolean
62 className?: string
63}
64
65const chartTableClasses = `[&_tr]:border-b [&_tr]:border-border [&_thead_tr]:bg-transparent! [&_thead_th]:py-2! [&_thead_th]:!px-card [&_thead_th]:h-auto [&_tbody_td]:py-2.5 [&_tbody_td]:px-card [&_tbody_td]:text-xs [&_table]:mb-1 [&_table]:border-b [&_table]:border-border`
66
67const Chart = React.forwardRef<HTMLDivElement, ChartProps>(
68 ({ children, isLoading = false, isDisabled = false, className, ...props }, ref) => {
69 return (
70 <ChartContext.Provider value={{ isLoading, isDisabled }}>
71 <div ref={ref} className={cn('relative w-full', className)} {...props}>
72 {children}
73 </div>
74 </ChartContext.Provider>
75 )
76 }
77)
78Chart.displayName = 'Chart'
79
80/* Chart Card */
81interface ChartCardProps extends React.HTMLAttributes<HTMLDivElement> {
82 asChild?: boolean
83 children: React.ReactNode
84 className?: string
85}
86
87const ChartCard = React.forwardRef<HTMLDivElement, ChartCardProps>(
88 ({ children, className, asChild, ...props }, ref) => {
89 const Comp = asChild ? Slot.Slot : Card
90 return (
91 <Comp ref={ref} className={cn('relative w-full', className)} {...props}>
92 {children}
93 </Comp>
94 )
95 }
96)
97ChartCard.displayName = 'ChartCard'
98
99/* Chart Header Components */
100interface ChartHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
101 align?: 'start' | 'center'
102 children: React.ReactNode
103}
104
105const ChartHeader = React.forwardRef<HTMLDivElement, ChartHeaderProps>(
106 ({ children, className, align = 'center', ...props }, ref) => {
107 return (
108 <div
109 ref={ref}
110 className={cn(
111 'p-card flex flex-row justify-between gap-2 space-y-0 pb-0 border-b-0 relative',
112 align === 'center' ? 'items-center' : 'items-start',
113 className
114 )}
115 {...props}
116 >
117 {children}
118 </div>
119 )
120 }
121)
122ChartHeader.displayName = 'ChartHeader'
123
124interface ChartTitleProps extends React.HTMLAttributes<HTMLDivElement> {
125 children: React.ReactNode
126 tooltip?: string
127}
128
129const ChartTitle = React.forwardRef<HTMLDivElement, ChartTitleProps>(
130 ({ children, className, tooltip, ...props }, ref) => {
131 return (
132 <h3 ref={ref} className={cn('h3 flex items-center gap-2', className)} {...props}>
133 <span>{children}</span>
134 {tooltip && (
135 <Tooltip>
136 <TooltipTrigger asChild>
137 <HelpCircle
138 size={14}
139 strokeWidth={1.5}
140 className="text-foreground-lighter hover:text-foreground-light transition-colors cursor-help"
141 />
142 </TooltipTrigger>
143 <TooltipContent className="max-w-72">{tooltip}</TooltipContent>
144 </Tooltip>
145 )}
146 </h3>
147 )
148 }
149)
150ChartTitle.displayName = 'ChartTitle'
151
152export type ChartAction = {
153 label: string
154 icon?: React.ReactNode
155 onClick?: () => void
156 href?: string
157 type?: 'button' | 'link'
158 className?: string
159}
160
161interface ChartActionsProps extends React.HTMLAttributes<HTMLDivElement> {
162 actions?: ChartAction[]
163 children?: React.ReactNode
164}
165
166const ChartActions = React.forwardRef<HTMLDivElement, ChartActionsProps>(
167 ({ actions, children, className, ...props }, ref) => {
168 if (children) {
169 return (
170 <div ref={ref} className={cn('flex items-center gap-2', className)} {...props}>
171 {children}
172 </div>
173 )
174 }
175
176 if (actions && actions.length > 0) {
177 return (
178 <div ref={ref} className={cn('flex items-center gap-2', className)} {...props}>
179 {actions.map((action, index) => {
180 const isLink = !!action.href
181
182 return (
183 <Tooltip key={index}>
184 <TooltipTrigger asChild>
185 <Button
186 type="default"
187 size="tiny"
188 className={cn('px-1.5 text-foreground-lighter', action.className)}
189 onClick={action.onClick}
190 asChild={isLink}
191 >
192 {isLink ? (
193 <Link href={action.href!}>
194 {action.icon}
195 <span className="sr-only">{action.label}</span>
196 </Link>
197 ) : (
198 <>
199 {action.icon}
200 <span className="sr-only">{action.label}</span>
201 </>
202 )}
203 </Button>
204 </TooltipTrigger>
205 <TooltipContent side="top">{action.label}</TooltipContent>
206 </Tooltip>
207 )
208 })}
209 </div>
210 )
211 }
212
213 return null
214 }
215)
216ChartActions.displayName = 'ChartActions'
217
218/* Chart Metric */
219interface ChartMetricProps extends React.HTMLAttributes<HTMLDivElement> {
220 label: string
221 value: string | number | null | undefined
222 diffValue?: string | number | null | undefined
223 status?: 'positive' | 'negative' | 'warning' | 'default'
224 align?: 'start' | 'end'
225 tooltip?: string
226 className?: string
227}
228
229const ChartMetric = React.forwardRef<HTMLDivElement, ChartMetricProps>(
230 ({ label, value, diffValue, className, status, align = 'start', tooltip, ...props }, ref) => {
231 const { isLoading } = useChart()
232
233 const { variant, formattedDiffValue } = useMemo(() => {
234 if (diffValue === null || diffValue === undefined) {
235 return { variant: 'default' as const, formattedDiffValue: null }
236 }
237
238 const numValue = typeof diffValue === 'string' ? parseFloat(diffValue) : diffValue
239
240 if (isNaN(numValue)) {
241 return { variant: 'default' as const, formattedDiffValue: String(diffValue) }
242 }
243
244 const variant: 'positive' | 'negative' | 'default' =
245 numValue > 0 ? 'positive' : numValue < 0 ? 'negative' : 'default'
246
247 const formattedDiffValue = numValue > 0 ? `+${diffValue}` : String(diffValue)
248
249 return { variant, formattedDiffValue }
250 }, [diffValue])
251
252 return (
253 <div
254 ref={ref}
255 className={cn(
256 'flex gap-0.5 flex-col',
257 align === 'start' ? 'items-start' : 'items-end',
258 className
259 )}
260 {...props}
261 >
262 <div className="flex items-center gap-2">
263 {status && (
264 <span
265 className={cn(
266 'shrink-0 w-1.5 h-1.5 rounded-full flex',
267 status === 'positive' && 'bg-brand',
268 status === 'negative' && 'bg-destructive',
269 status === 'warning' && 'bg-warning',
270 status === 'default' && 'bg-foreground-lighter'
271 )}
272 />
273 )}
274 <h3 className="text-xs font-mono uppercase flex items-center gap-1.5 text-foreground-light my-0">
275 <span>{label}</span>
276 {tooltip && (
277 <Tooltip>
278 <TooltipTrigger asChild>
279 <HelpCircle
280 size={12}
281 strokeWidth={1.5}
282 className="text-foreground-lighter hover:text-foreground-light transition-colors cursor-help"
283 />
284 </TooltipTrigger>
285 <TooltipContent className="max-w-72">{tooltip}</TooltipContent>
286 </Tooltip>
287 )}
288 </h3>
289 </div>
290 <span className="text-foreground text-xl tabular-nums">
291 {isLoading ? <Skeleton className="w-12 h-6" /> : value}
292 </span>
293 {diffValue !== null && diffValue !== undefined && !isLoading && (
294 <ChartValueDifferential variant={variant}>{formattedDiffValue}</ChartValueDifferential>
295 )}
296 </div>
297 )
298 }
299)
300ChartMetric.displayName = 'ChartMetric'
301
302/* Chart Content */
303interface ChartContentProps extends React.HTMLAttributes<HTMLDivElement> {
304 children: React.ReactNode
305 className?: string
306 isEmpty?: boolean
307 emptyState?: React.ReactNode
308 loadingState?: React.ReactNode
309 disabledState?: React.ReactNode
310 disabledActions?: ChartAction[]
311}
312
313const ChartContent = React.forwardRef<HTMLDivElement, ChartContentProps>(
314 (
315 {
316 children,
317 className,
318 isEmpty = false,
319 emptyState,
320 loadingState,
321 disabledState,
322 disabledActions,
323 ...props
324 },
325 ref
326 ) => {
327 const { isLoading, isDisabled } = useChart()
328 let content: React.ReactNode
329
330 if (isDisabled) {
331 content = disabledState
332 } else if (isLoading) {
333 content = loadingState
334 } else if (isEmpty) {
335 content = emptyState
336 } else {
337 content = children
338 }
339
340 return (
341 <div ref={ref} className={cn('p-card', chartTableClasses, className)} {...props}>
342 {content}
343 </div>
344 )
345 }
346)
347ChartContent.displayName = 'ChartContent'
348
349/* Chart Empty State */
350interface ChartEmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
351 className?: string
352 icon?: React.ReactNode
353 title: string
354 description: string
355}
356
357const ChartEmptyState = React.forwardRef<HTMLDivElement, ChartEmptyStateProps>(
358 ({ className, icon, title, description, ...props }, ref) => {
359 return (
360 <div
361 ref={ref}
362 className={cn(
363 'h-40 border border-dashed border-control items-center justify-center flex flex-col',
364 className
365 )}
366 {...props}
367 >
368 {icon && (
369 <div className="flex items-center justify-center w-6 h-6 text-foreground-lighter mb-1">
370 {icon}
371 </div>
372 )}
373 <h3 className="text-sm font-medium text-foreground-light">{title}</h3>
374 <p className="text-sm text-foreground-lighter">{description}</p>
375 </div>
376 )
377 }
378)
379ChartEmptyState.displayName = 'ChartEmptyState'
380
381/* Chart Loading State */
382const ChartLoadingState = ({ className }: { className?: string }) => {
383 return (
384 <div
385 className={cn(
386 'h-40 border border-dashed border-control items-center justify-center flex flex-col',
387 className
388 )}
389 >
390 <Loader2 size={20} className="animate-spin text-foreground-muted" />
391 </div>
392 )
393}
394ChartLoadingState.displayName = 'ChartLoadingState'
395
396/* Chart Disabled State */
397type ChartDisabledStateActions = {
398 icon?: React.ReactNode
399 label: string
400 href?: string
401 onClick?: () => void
402 type?: 'button' | 'link'
403 className?: string
404}
405interface ChartDisabledStateProps extends React.HTMLAttributes<HTMLDivElement> {
406 icon?: React.ReactNode
407 label: string
408 description: string
409 actions?: ChartDisabledStateActions[]
410}
411
412const ChartDisabledState = ({ icon, label, description, actions }: ChartDisabledStateProps) => {
413 const [isHoveringButton, setIsHoveringButton] = useState(false)
414 const startDate = '2025-01-01'
415
416 const getExpDemoChartData = useMemo(() => {
417 return new Array(20).fill(0).map((_, index) => ({
418 period_start: new Date(startDate).getTime() + index * 1000,
419 demo: Math.floor(Math.pow(1.25, index) * 10),
420 max_demo: 1000,
421 }))
422 }, [])
423
424 const getDemoChartData = useRef(
425 new Array(20).fill(0).map((_, index) => ({
426 period_start: new Date(startDate).getTime() + index * 1000,
427 demo: Math.floor(Math.random() * 10) + 1,
428 max_demo: 1000,
429 }))
430 )
431
432 const demoData = isHoveringButton ? getExpDemoChartData : getDemoChartData.current
433
434 return (
435 <>
436 <div className="relative h-40">
437 <ChartContainer
438 config={
439 {
440 demo: {
441 label: 'Demo',
442 color: 'hsl(var(--brand-default))',
443 },
444 } satisfies ChartConfig
445 }
446 className="h-full w-full"
447 >
448 <BarChart data={demoData}>
449 <XAxis dataKey="period_start" hide />
450 <YAxis hide />
451 <Bar dataKey="demo" fill="hsl(var(--brand-default))" />
452 </BarChart>
453 </ChartContainer>
454 </div>
455 <div className="absolute inset-0 bg-surface-100/80 backdrop-blur-md w-full h-full flex flex-col items-center justify-center">
456 {icon && (
457 <div className="flex items-center justify-center w-6 h-6 text-foreground-lighter mb-1">
458 {icon}
459 </div>
460 )}
461 <h3 className="text-sm font-medium text-foreground">{label}</h3>
462 <p className="text-sm text-foreground-light">{description}</p>
463 {actions && actions.length > 0 && (
464 <div className="flex items-center gap-2 mt-3">
465 {actions.map((action, index) => (
466 <Button
467 key={index}
468 type="primary"
469 size="tiny"
470 className={cn(action.className)}
471 onClick={action.onClick}
472 asChild={!!action.href}
473 onMouseEnter={() => setIsHoveringButton(true)}
474 onMouseLeave={() => setIsHoveringButton(false)}
475 >
476 {action.href ? <Link href={action.href}>{action.label}</Link> : <>{action.label}</>}
477 </Button>
478 ))}
479 </div>
480 )}
481 </div>
482 </>
483 )
484}
485ChartDisabledState.displayName = 'ChartDisabledState'
486
487/* Chart Footer */
488
489interface ChartFooterProps extends React.HTMLAttributes<HTMLDivElement> {
490 children: React.ReactNode
491}
492
493const ChartFooter = React.forwardRef<HTMLDivElement, ChartFooterProps>(
494 ({ children, className, ...props }, ref) => {
495 return (
496 <div ref={ref} className={cn('border-t', className, chartTableClasses)} {...props}>
497 {children}
498 </div>
499 )
500 }
501)
502ChartFooter.displayName = 'ChartFooter'
503
504/* Metric Chart Components */
505interface ChartValueDifferentialProps extends React.HTMLAttributes<HTMLDivElement> {
506 variant?: 'positive' | 'negative' | 'default'
507}
508
509const ChartValueDifferential = React.forwardRef<HTMLDivElement, ChartValueDifferentialProps>(
510 ({ className, variant = 'default', ...props }, ref) => {
511 const { isLoading } = useChart()
512
513 if (isLoading) {
514 return <Skeleton className="w-16 h-5" />
515 }
516
517 return (
518 <span
519 ref={ref}
520 className={cn(
521 variant === 'positive'
522 ? 'text-brand'
523 : variant === 'negative'
524 ? 'text-destructive'
525 : 'text-foreground-light',
526 'tabular-nums text-sm',
527 className
528 )}
529 {...props}
530 />
531 )
532 }
533)
534ChartValueDifferential.displayName = 'ChartValueDifferential'
535
536const ChartSparklineTooltip = ({ active, payload, label }: RechartsTooltipProps<any, any>) => {
537 if (!active || !payload || !payload.length) return null
538
539 const formatTimestamp = (timestamp: string) => {
540 const date = dayjs(timestamp)
541 const hour = date.hour()
542 const period = hour >= 12 ? 'pm' : 'am'
543 const displayHour = hour % 12 || 12
544
545 return `${date.format('MMM D')}, ${displayHour}${period}`
546 }
547
548 return (
549 <div className="bg-black/90 text-white p-2 rounded-sm text-xs">
550 {label && (
551 <div className="dark:text-foreground-light text-white/60">
552 {formatTimestamp(payload[0].payload.timestamp)}
553 </div>
554 )}
555 <div>{payload[0].value.toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
556 </div>
557 )
558}
559
560interface ChartSparklineProps extends React.HTMLAttributes<HTMLDivElement> {
561 data?: Array<{ value: number; [key: string]: any }>
562 dataKey?: string
563 className?: string
564}
565
566const ChartSparkline = React.forwardRef<HTMLDivElement, ChartSparklineProps>(
567 ({ className, data, dataKey, ...props }, ref) => {
568 if (!data || data.length === 0) {
569 return null
570 }
571
572 return (
573 <div ref={ref} className={cn('w-full h-24 pt-4 relative', className)} {...props}>
574 <ResponsiveContainer width="100%" height="100%" className="relative">
575 <AreaChart data={data} margin={{ top: 5, left: 0, right: 0, bottom: 0 }}>
576 <defs>
577 <linearGradient id="sparklineGradient" x1="0" y1="0" x2="0" y2="1">
578 <stop offset="5%" stopColor="hsl(var(--brand-default))" stopOpacity={0.8} />
579 <stop offset="95%" stopColor="hsl(var(--brand-default))" stopOpacity={0} />
580 </linearGradient>
581 </defs>
582 <RechartsTooltip content={<ChartSparklineTooltip />} />
583 <Area
584 type="step"
585 dataKey={dataKey || 'value'}
586 fill="url(#sparklineGradient)"
587 fillOpacity={0.1}
588 stroke="hsl(var(--brand-default))"
589 strokeWidth={1.5}
590 />
591 </AreaChart>
592 </ResponsiveContainer>
593 </div>
594 )
595 }
596)
597ChartSparkline.displayName = 'ChartSparkline'
598
599/* Exports */
600export { ChartBar, type ChartBarProps, type ChartBarTick } from './charts/chart-bar'
601export { ChartLine, type ChartLineProps, type ChartLineTick } from './charts/chart-line'
602export {
603 Chart,
604 ChartActions,
605 ChartCard,
606 ChartContent,
607 ChartDisabledState,
608 ChartEmptyState,
609 ChartFooter,
610 ChartHeader,
611 ChartLoadingState,
612 ChartMetric,
613 ChartSparkline,
614 ChartSparklineTooltip,
615 ChartTitle,
616 ChartValueDifferential,
617}