feat: adopt ShadCN chart primitives

Add ChartContainer, ChartConfig, ChartTooltip, ChartTooltipContent
primitives ported from ShadCN's chart pattern. Refactor all 3 chart
locations (dashboard, funnels, uptime) to use CSS variable-driven
theming instead of duplicated CHART_COLORS_LIGHT/DARK objects.

- Add --chart-1 through --chart-5, --chart-grid, --chart-axis CSS vars
- Remove duplicated color objects from 3 files (-223 lines)
- Add accessibilityLayer to all charts
- Rounded bar corners on funnel chart
- Tooltips use Tailwind dark classes instead of imperative style props

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Usman Baig
2026-03-09 13:24:29 +01:00
parent 86c11dc16f
commit 3f81cb0e48
6 changed files with 497 additions and 223 deletions

325
components/charts/chart.tsx Normal file
View File

@@ -0,0 +1,325 @@
'use client'
import * as React from 'react'
import { Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { cn } from '@ciphera-net/ui'
// ─── ChartConfig ────────────────────────────────────────────────────
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
color?: string
theme?: { light: string; dark: string }
}
>
// ─── ChartContext ───────────────────────────────────────────────────
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
// ─── ChartContainer ────────────────────────────────────────────────
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<typeof ResponsiveContainer>['children']
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
// Build CSS variables from config
const colorVars = React.useMemo(() => {
const vars: Record<string, string> = {}
for (const [key, value] of Object.entries(config)) {
if (value.color) {
vars[`--color-${key}`] = value.color
}
}
return vars
}, [config])
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
'[&_.recharts-cartesian-grid_line[stroke=\'#ccc\']]:stroke-border/50',
'[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border',
'[&_.recharts-polar-grid_[stroke=\'#ccc\']]:stroke-border',
'[&_.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',
className,
)}
style={colorVars as React.CSSProperties}
{...props}
>
<ResponsiveContainer width="100%" height="100%">
{children}
</ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = 'ChartContainer'
// ─── ChartTooltip ──────────────────────────────────────────────────
const ChartTooltip = Tooltip
// ─── ChartTooltipContent ───────────────────────────────────────────
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
labelFormatter?: (value: string, payload: Record<string, unknown>[]) => React.ReactNode
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelKey,
nameKey,
},
ref,
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) return null
const item = payload[0]
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return labelFormatter(
value as string,
payload as Record<string, unknown>[],
)
}
return value
}, [label, labelFormatter, payload, hideLabel, config, labelKey])
if (!active || !payload?.length) return null
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel ? (
<div className="font-medium text-neutral-900 dark:text-neutral-50">
{tooltipLabel}
</div>
) : null : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = item.fill || item.color
return (
<div
key={item.dataKey || index}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
indicator === 'dot' && 'h-2.5 w-2.5 rounded-full',
indicator === 'line' && 'w-1',
indicator === 'dashed' &&
'w-0 border-[1.5px] border-dashed bg-transparent',
nestLabel && indicator === 'dashed'
? 'my-0.5'
: 'my-0.5',
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
{typeof item.value === 'number'
? item.value.toLocaleString()
: item.value}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
},
)
ChartTooltipContent.displayName = 'ChartTooltipContent'
// ─── ChartLegend ───────────────────────────────────────────────────
const ChartLegend = Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<React.ComponentProps<typeof Legend>, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart()
if (!payload?.length) return null
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className="text-xs text-muted-foreground">
{itemConfig?.label}
</span>
</div>
)
})}
</div>
)
},
)
ChartLegendContent.displayName = 'ChartLegendContent'
// ─── Helpers ───────────────────────────────────────────────────────
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) return undefined
const payloadPayload =
'payload' in payload &&
typeof (payload as Record<string, unknown>).payload === 'object' &&
(payload as Record<string, unknown>).payload !== null
? ((payload as Record<string, unknown>).payload as Record<string, unknown>)
: undefined
let configLabelKey = key
if (
key in config
) {
configLabelKey = key
} else if (payloadPayload) {
const payloadKey = Object.keys(payloadPayload).find(
(k) => payloadPayload[k] === key && k in config,
)
if (payloadKey) configLabelKey = payloadKey
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartContext,
useChart,
}

View File

@@ -0,0 +1,8 @@
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from './chart'

View File

@@ -8,40 +8,29 @@ import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import type { TooltipProps } from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui'
const COLORS = {
brand: 'var(--color-brand-orange)',
brand: 'var(--chart-1)',
success: 'var(--color-success)',
danger: 'var(--color-error)',
}
const CHART_COLORS_LIGHT = {
border: 'var(--color-neutral-200)',
grid: 'var(--color-neutral-100)',
text: 'var(--color-neutral-900)',
textMuted: 'var(--color-neutral-500)',
axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff',
tooltipBorder: 'var(--color-neutral-200)',
}
const CHART_COLORS_DARK = {
border: 'var(--color-neutral-700)',
grid: 'var(--color-neutral-800)',
text: 'var(--color-neutral-50)',
textMuted: 'var(--color-neutral-400)',
axis: 'var(--color-neutral-500)',
tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: 'var(--color-neutral-700)',
}
const dashboardChartConfig = {
visitors: { label: 'Visitors', color: 'var(--chart-1)' },
pageviews: { label: 'Pageviews', color: 'var(--chart-1)' },
bounce_rate: { label: 'Bounce Rate', color: 'var(--chart-1)' },
avg_duration: { label: 'Visit Duration', color: 'var(--chart-1)' },
prevVisitors: { label: 'Previous', color: 'var(--chart-axis)' },
prevPageviews: { label: 'Previous', color: 'var(--chart-axis)' },
prevBounceRate: { label: 'Previous', color: 'var(--chart-axis)' },
prevAvgDuration: { label: 'Previous', color: 'var(--chart-axis)' },
} satisfies ChartConfig
const ANNOTATION_COLORS: Record<string, string> = {
deploy: '#3b82f6',
@@ -160,7 +149,7 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
// ─── Tooltip ─────────────────────────────────────────────────────────
function ChartTooltip({
function DashboardTooltipContent({
active,
payload,
label,
@@ -169,7 +158,6 @@ function ChartTooltip({
formatNumberFn,
showComparison,
prevPeriodLabel,
colors,
}: {
active?: boolean
payload?: Array<{ payload: Record<string, number>; value: number; dataKey?: string }>
@@ -179,7 +167,6 @@ function ChartTooltip({
formatNumberFn: (n: number) => string
showComparison: boolean
prevPeriodLabel?: string
colors: typeof CHART_COLORS_LIGHT
}) {
if (!active || !payload?.length || !label) return null
@@ -199,29 +186,26 @@ function ChartTooltip({
}
return (
<div
className="rounded-lg border px-3.5 py-2.5 shadow-lg"
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
>
<div className="text-[11px] font-medium mb-1" style={{ color: colors.textMuted }}>
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3.5 py-2.5 shadow-xl">
<div className="text-[11px] font-medium mb-1 text-neutral-500 dark:text-neutral-400">
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-bold" style={{ color: colors.text }}>
<span className="text-sm font-bold text-neutral-900 dark:text-neutral-50">
{formatValue(value)}
</span>
<span className="text-[11px]" style={{ color: colors.textMuted }}>
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1 flex items-center gap-1.5 text-[11px]" style={{ color: colors.textMuted }}>
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-neutral-500 dark:text-neutral-400">
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : undefined,
}}
>
{delta > 0 ? '+' : ''}{delta}%
@@ -297,11 +281,6 @@ export default function Chart({
} catch { /* noop */ }
}, [onExportChart, dateRange, resolvedTheme])
const colors = useMemo(
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
[resolvedTheme]
)
// ─── Data ──────────────────────────────────────────────────────────
const chartData = data.map((item, i) => {
@@ -515,7 +494,7 @@ export default function Chart({
Current
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: colors.axis }} />
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: 'var(--chart-axis)' }} />
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
</span>
</div>
@@ -587,8 +566,8 @@ export default function Chart({
</div>
) : (
<div className="h-[320px] w-full" onContextMenu={handleChartContextMenu}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
<ChartContainer config={dashboardChartConfig} className="h-full w-full">
<AreaChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
<defs>
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLORS.brand} stopOpacity={0.25} />
@@ -598,11 +577,12 @@ export default function Chart({
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={colors.grid}
stroke="var(--chart-grid)"
strokeOpacity={0.5}
/>
<XAxis
dataKey="date"
stroke={colors.axis}
stroke="var(--chart-axis)"
fontSize={11}
tickLine={false}
axisLine={false}
@@ -611,7 +591,7 @@ export default function Chart({
dy={8}
/>
<YAxis
stroke={colors.axis}
stroke="var(--chart-axis)"
fontSize={11}
tickLine={false}
axisLine={false}
@@ -624,29 +604,24 @@ export default function Chart({
return formatAxisValue(val)
}}
/>
<Tooltip
content={(p: TooltipProps<number, string>) => (
<ChartTooltip
active={p.active}
payload={p.payload as Array<{ payload: Record<string, number>; value: number; dataKey?: string }>}
label={p.label as string}
<ChartTooltip
content={
<DashboardTooltipContent
metric={metric}
metricLabel={metricLabel}
formatNumberFn={formatNumber}
showComparison={hasPrev}
prevPeriodLabel={prevPeriodLabel}
colors={colors}
/>
)}
cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }}
}
cursor={{ stroke: 'var(--chart-axis)', strokeOpacity: 0.3, strokeWidth: 1 }}
/>
{hasPrev && (
<Area
type="monotone"
dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
stroke={colors.axis}
stroke="var(--chart-axis)"
strokeWidth={1.5}
strokeDasharray="4 4"
strokeLinecap="round"
@@ -696,7 +671,7 @@ export default function Chart({
)
})}
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</div>
)}
</div>