The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| import { cn } from "../lib/utils"; |
| 6 |
| 7 | /** |
| 8 | * Charts for apps: line, area, bar, sparkline, and donut charts drawn |
| 9 | * as SVG. Strict input (one category key, ≤ 6 numeric series), host-owned |
| 10 | * styling, animated entry, a hover readout, and an accessible data table |
| 11 | * for screen readers. |
| 12 | */ |
| 13 |
| 14 | type Tone = "primary" | "positive" | "warning" | "danger" | "muted" | "accent"; |
| 15 |
| 16 | export interface ChartProps { |
| 17 | kind: "line" | "area" | "bar" | "sparkline" | "donut"; |
| 18 | data: Array<Record<string, number | string | null>>; |
| 19 | xKey: string; |
| 20 | series: Array<{ key: string; label?: string | undefined; tone?: Tone | undefined }>; |
| 21 | title?: string | undefined; |
| 22 | height?: number | undefined; |
| 23 | valueFormat?: "number" | "percent" | "currency" | "compact" | undefined; |
| 24 | currency?: string | undefined; |
| 25 | stacked?: boolean | undefined; |
| 26 | compact?: boolean | undefined; |
| 27 | } |
| 28 |
| 29 | // Host theme tokens, so charts follow the workspace's palette and dark mode. |
| 30 | const TONE_COLORS: Record<Tone, string> = { |
| 31 | primary: "var(--action)", |
| 32 | positive: "var(--status-positive)", |
| 33 | warning: "var(--status-attention)", |
| 34 | danger: "var(--status-risk)", |
| 35 | muted: "var(--muted-foreground)", |
| 36 | accent: "var(--chart-4)", |
| 37 | }; |
| 38 | const DEFAULT_ORDER: Tone[] = ["primary", "accent", "positive", "warning", "danger", "muted"]; |
| 39 |
| 40 | function formatter(format: ChartProps["valueFormat"], currency?: string) { |
| 41 | switch (format) { |
| 42 | case "percent": |
| 43 | return new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 1 }); |
| 44 | case "currency": |
| 45 | return new Intl.NumberFormat(undefined, { |
| 46 | style: "currency", |
| 47 | currency: currency ?? "USD", |
| 48 | maximumFractionDigits: 0, |
| 49 | }); |
| 50 | case "compact": |
| 51 | return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }); |
| 52 | default: |
| 53 | return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }); |
| 54 | } |
| 55 | } |
| 56 |
| 57 | function niceMax(value: number): number { |
| 58 | if (value <= 0) return 1; |
| 59 | const magnitude = 10 ** Math.floor(Math.log10(value)); |
| 60 | const normalized = value / magnitude; |
| 61 | const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; |
| 62 | return nice * magnitude; |
| 63 | } |
| 64 |
| 65 | function useWidth<T extends HTMLElement>() { |
| 66 | const ref = useRef<T | null>(null); |
| 67 | const [width, setWidth] = useState(0); |
| 68 | useLayoutEffect(() => { |
| 69 | const element = ref.current; |
| 70 | if (!element) return; |
| 71 | setWidth(element.clientWidth); |
| 72 | const observer = new ResizeObserver(([entry]) => { |
| 73 | if (entry) setWidth(entry.contentRect.width); |
| 74 | }); |
| 75 | observer.observe(element); |
| 76 | return () => observer.disconnect(); |
| 77 | }, []); |
| 78 | return [ref, width] as const; |
| 79 | } |
| 80 |
| 81 | export function Chart(props: ChartProps) { |
| 82 | const reduceMotion = useReducedMotion(); |
| 83 | const gradientId = useId(); |
| 84 | const [containerRef, width] = useWidth<HTMLDivElement>(); |
| 85 | const [hover, setHover] = useState<number | null>(null); |
| 86 | const format = useMemo(() => formatter(props.valueFormat, props.currency), [props.valueFormat, props.currency]); |
| 87 |
| 88 | const series = props.series.map((entry, index) => ({ |
| 89 | ...entry, |
| 90 | color: TONE_COLORS[entry.tone ?? DEFAULT_ORDER[index % DEFAULT_ORDER.length]!], |
| 91 | label: entry.label ?? entry.key, |
| 92 | })); |
| 93 | const numeric = (row: Record<string, number | string | null>, key: string) => { |
| 94 | const value = row[key]; |
| 95 | return typeof value === "number" && Number.isFinite(value) ? value : 0; |
| 96 | }; |
| 97 |
| 98 | const isSpark = props.kind === "sparkline"; |
| 99 | const height = props.height ?? (isSpark ? 40 : props.kind === "donut" ? 200 : props.compact ? 160 : 240); |
| 100 | const padding = isSpark |
| 101 | ? { top: 4, right: 2, bottom: 4, left: 2 } |
| 102 | : { top: 12, right: 12, bottom: 26, left: 44 }; |
| 103 |
| 104 | if (props.data.length === 0) { |
| 105 | return ( |
| 106 | <div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-border/70 text-sm text-muted-foreground"> |
| 107 | No data yet |
| 108 | </div> |
| 109 | ); |
| 110 | } |
| 111 |
| 112 | const table = ( |
| 113 | <table className="sr-only"> |
| 114 | {props.title ? <caption>{props.title}</caption> : null} |
| 115 | <thead> |
| 116 | <tr> |
| 117 | <th>{props.xKey}</th> |
| 118 | {series.map((entry) => ( |
| 119 | <th key={entry.key}>{entry.label}</th> |
| 120 | ))} |
| 121 | </tr> |
| 122 | </thead> |
| 123 | <tbody> |
| 124 | {props.data.map((row, index) => ( |
| 125 | <tr key={index}> |
| 126 | <td>{String(row[props.xKey] ?? "")}</td> |
| 127 | {series.map((entry) => ( |
| 128 | <td key={entry.key}>{format.format(numeric(row, entry.key))}</td> |
| 129 | ))} |
| 130 | </tr> |
| 131 | ))} |
| 132 | </tbody> |
| 133 | </table> |
| 134 | ); |
| 135 |
| 136 | if (props.kind === "donut") { |
| 137 | const first = series[0]!; |
| 138 | const total = props.data.reduce((sum, row) => sum + Math.max(0, numeric(row, first.key)), 0) || 1; |
| 139 | const radius = 70; |
| 140 | const circumference = 2 * Math.PI * radius; |
| 141 | let offset = 0; |
| 142 | const slices = props.data.map((row, index) => { |
| 143 | const value = Math.max(0, numeric(row, first.key)); |
| 144 | const length = (value / total) * circumference; |
| 145 | const slice = { |
| 146 | label: String(row[props.xKey] ?? ""), |
| 147 | value, |
| 148 | dash: length, |
| 149 | offset, |
| 150 | color: TONE_COLORS[DEFAULT_ORDER[index % DEFAULT_ORDER.length]!], |
| 151 | }; |
| 152 | offset += length; |
| 153 | return slice; |
| 154 | }); |
| 155 | const active = hover !== null ? slices[hover] : null; |
| 156 | return ( |
| 157 | <figure className="flex flex-col gap-3"> |
| 158 | {props.title ? <figcaption className="text-sm font-medium text-foreground">{props.title}</figcaption> : null} |
| 159 | <div className="flex flex-wrap items-center gap-6"> |
| 160 | <svg viewBox="0 0 200 200" className="size-44 shrink-0 -rotate-90" aria-hidden> |
| 161 | <circle cx="100" cy="100" r={radius} fill="none" stroke="currentColor" strokeWidth="22" className="text-muted/60" /> |
| 162 | {slices.map((slice, index) => ( |
| 163 | <motion.circle |
| 164 | key={index} |
| 165 | cx="100" |
| 166 | cy="100" |
| 167 | r={radius} |
| 168 | fill="none" |
| 169 | stroke={slice.color} |
| 170 | strokeWidth={hover === index ? 26 : 22} |
| 171 | strokeDasharray={`${slice.dash} ${circumference}`} |
| 172 | initial={reduceMotion ? false : { strokeDashoffset: -slice.offset + slice.dash, opacity: 0 }} |
| 173 | animate={{ strokeDashoffset: -slice.offset, opacity: hover === null || hover === index ? 1 : 0.35 }} |
| 174 | transition={{ duration: 0.7, delay: index * 0.06, ease: [0.22, 1, 0.36, 1] }} |
| 175 | onMouseEnter={() => setHover(index)} |
| 176 | onMouseLeave={() => setHover(null)} |
| 177 | className="cursor-default transition-[stroke-width]" |
| 178 | /> |
| 179 | ))} |
| 180 | </svg> |
| 181 | <div className="flex min-w-0 flex-col gap-1.5"> |
| 182 | <p className="text-2xl font-semibold tabular-nums tracking-tight text-foreground"> |
| 183 | {format.format(active ? active.value : total)} |
| 184 | </p> |
| 185 | <p className="text-xs text-muted-foreground">{active ? active.label : `Total ${first.label}`}</p> |
| 186 | <ul className="mt-2 flex flex-col gap-1"> |
| 187 | {slices.map((slice, index) => ( |
| 188 | <li |
| 189 | key={index} |
| 190 | onMouseEnter={() => setHover(index)} |
| 191 | onMouseLeave={() => setHover(null)} |
| 192 | className="flex items-center gap-2 text-xs text-muted-foreground" |
| 193 | > |
| 194 | <span className="size-2 shrink-0 rounded-full" style={{ background: slice.color }} /> |
| 195 | <span className="truncate">{slice.label}</span> |
| 196 | <span className="ml-auto tabular-nums text-foreground"> |
| 197 | {Math.round((slice.value / total) * 100)}% |
| 198 | </span> |
| 199 | </li> |
| 200 | ))} |
| 201 | </ul> |
| 202 | </div> |
| 203 | </div> |
| 204 | {table} |
| 205 | </figure> |
| 206 | ); |
| 207 | } |
| 208 |
| 209 | const innerWidth = Math.max(0, width - padding.left - padding.right); |
| 210 | const innerHeight = height - padding.top - padding.bottom; |
| 211 | const count = props.data.length; |
| 212 | const stackedTotals = props.data.map((row) => series.reduce((sum, entry) => sum + numeric(row, entry.key), 0)); |
| 213 | const rawMax = props.stacked |
| 214 | ? Math.max(...stackedTotals, 0) |
| 215 | : Math.max(...props.data.flatMap((row) => series.map((entry) => numeric(row, entry.key))), 0); |
| 216 | const rawMin = props.stacked ? 0 : Math.min(0, ...props.data.flatMap((row) => series.map((entry) => numeric(row, entry.key)))); |
| 217 | const max = isSpark ? rawMax || 1 : niceMax(rawMax); |
| 218 | const min = rawMin; |
| 219 | const y = (value: number) => padding.top + innerHeight - ((value - min) / (max - min || 1)) * innerHeight; |
| 220 | const bandWidth = innerWidth / count; |
| 221 | const x = (index: number) => |
| 222 | props.kind === "bar" |
| 223 | ? padding.left + bandWidth * index + bandWidth / 2 |
| 224 | : padding.left + (count === 1 ? innerWidth / 2 : (innerWidth / (count - 1)) * index); |
| 225 | const ticks = isSpark ? [] : [0, 0.25, 0.5, 0.75, 1].map((ratio) => min + (max - min) * ratio); |
| 226 | const labelEvery = Math.max(1, Math.ceil(count / Math.max(1, Math.floor(innerWidth / 72)))); |
| 227 |
| 228 | function pathFor(key: string, offsets: number[] | null) { |
| 229 | return props.data |
| 230 | .map((row, index) => { |
| 231 | const base = offsets ? offsets[index]! : 0; |
| 232 | return `${index === 0 ? "M" : "L"}${x(index).toFixed(2)},${y(base + numeric(row, key)).toFixed(2)}`; |
| 233 | }) |
| 234 | .join(" "); |
| 235 | } |
| 236 |
| 237 | const cumulative = props.data.map(() => 0); |
| 238 | const layers = series.map((entry) => { |
| 239 | const offsets = props.stacked ? [...cumulative] : null; |
| 240 | const line = pathFor(entry.key, offsets); |
| 241 | if (props.stacked) props.data.forEach((row, index) => (cumulative[index]! += numeric(row, entry.key))); |
| 242 | return { entry, offsets, line }; |
| 243 | }); |
| 244 |
| 245 | const hovered = hover !== null ? props.data[hover] : null; |
| 246 |
| 247 | return ( |
| 248 | <figure className="flex min-w-0 flex-col gap-2"> |
| 249 | {props.title || (!isSpark && series.length > 1) ? ( |
| 250 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 251 | {props.title ? <figcaption className="text-sm font-medium text-foreground">{props.title}</figcaption> : <span />} |
| 252 | {series.length > 1 && !isSpark ? ( |
| 253 | <ul className="flex flex-wrap items-center gap-3"> |
| 254 | {series.map((entry) => ( |
| 255 | <li key={entry.key} className="flex items-center gap-1.5 text-xs text-muted-foreground"> |
| 256 | <span className="size-2 rounded-full" style={{ background: entry.color }} /> |
| 257 | {entry.label} |
| 258 | </li> |
| 259 | ))} |
| 260 | </ul> |
| 261 | ) : null} |
| 262 | </div> |
| 263 | ) : null} |
| 264 | <div ref={containerRef} className="relative w-full" style={{ height }}> |
| 265 | {width > 0 ? ( |
| 266 | <svg |
| 267 | width={width} |
| 268 | height={height} |
| 269 | className="overflow-visible" |
| 270 | role="img" |
| 271 | aria-label={props.title ?? `${props.kind} chart`} |
| 272 | onMouseLeave={() => setHover(null)} |
| 273 | onMouseMove={(event) => { |
| 274 | const box = event.currentTarget.getBoundingClientRect(); |
| 275 | const offset = event.clientX - box.left - padding.left; |
| 276 | const index = |
| 277 | props.kind === "bar" |
| 278 | ? Math.floor(offset / bandWidth) |
| 279 | : Math.round(offset / (innerWidth / Math.max(1, count - 1))); |
| 280 | setHover(index >= 0 && index < count ? index : null); |
| 281 | }} |
| 282 | > |
| 283 | <defs> |
| 284 | {series.map((entry, index) => ( |
| 285 | <linearGradient key={entry.key} id={`${gradientId}-${index}`} x1="0" x2="0" y1="0" y2="1"> |
| 286 | <stop offset="0%" stopColor={entry.color} stopOpacity={0.28} /> |
| 287 | <stop offset="100%" stopColor={entry.color} stopOpacity={0} /> |
| 288 | </linearGradient> |
| 289 | ))} |
| 290 | </defs> |
| 291 | {ticks.map((tick, index) => ( |
| 292 | <g key={index}> |
| 293 | <line |
| 294 | x1={padding.left} |
| 295 | x2={width - padding.right} |
| 296 | y1={y(tick)} |
| 297 | y2={y(tick)} |
| 298 | className="stroke-border" |
| 299 | strokeDasharray={tick === 0 ? undefined : "2 4"} |
| 300 | /> |
| 301 | <text |
| 302 | x={padding.left - 8} |
| 303 | y={y(tick)} |
| 304 | dy="0.32em" |
| 305 | textAnchor="end" |
| 306 | className="fill-muted-foreground text-xs tabular-nums" |
| 307 | > |
| 308 | {formatter(props.valueFormat === "currency" ? "compact" : props.valueFormat, props.currency).format(tick)} |
| 309 | </text> |
| 310 | </g> |
| 311 | ))} |
| 312 | {!isSpark |
| 313 | ? props.data.map((row, index) => |
| 314 | index % labelEvery === 0 ? ( |
| 315 | <text |
| 316 | key={index} |
| 317 | x={x(index)} |
| 318 | y={height - 8} |
| 319 | textAnchor="middle" |
| 320 | className="fill-muted-foreground text-xs" |
| 321 | > |
| 322 | {String(row[props.xKey] ?? "").slice(0, 12)} |
| 323 | </text> |
| 324 | ) : null, |
| 325 | ) |
| 326 | : null} |
| 327 |
| 328 | {props.kind === "bar" |
| 329 | ? layers.map(({ entry, offsets }, seriesIndex) => { |
| 330 | const groupWidth = Math.min(bandWidth * 0.72, 56); |
| 331 | const barWidth = props.stacked ? groupWidth : groupWidth / series.length; |
| 332 | return props.data.map((row, index) => { |
| 333 | const value = numeric(row, entry.key); |
| 334 | const base = offsets ? offsets[index]! : 0; |
| 335 | const top = y(base + value); |
| 336 | const bottom = y(base); |
| 337 | const left = |
| 338 | x(index) - groupWidth / 2 + (props.stacked ? 0 : barWidth * seriesIndex); |
| 339 | return ( |
| 340 | <motion.rect |
| 341 | key={`${entry.key}-${index}`} |
| 342 | x={left + 1} |
| 343 | width={Math.max(1, barWidth - 2)} |
| 344 | rx={Math.min(4, barWidth / 3)} |
| 345 | fill={entry.color} |
| 346 | initial={reduceMotion ? false : { y: bottom, height: 0 }} |
| 347 | animate={{ |
| 348 | y: Math.min(top, bottom), |
| 349 | height: Math.abs(bottom - top), |
| 350 | opacity: hover === null || hover === index ? 1 : 0.45, |
| 351 | }} |
| 352 | transition={{ duration: 0.55, delay: index * 0.025 + seriesIndex * 0.05, ease: [0.22, 1, 0.36, 1] }} |
| 353 | /> |
| 354 | ); |
| 355 | }); |
| 356 | }) |
| 357 | : layers.map(({ entry, offsets, line }, seriesIndex) => { |
| 358 | const areaBase = props.data |
| 359 | .map((_, index) => `L${x(count - 1 - index).toFixed(2)},${y(offsets ? offsets[count - 1 - index]! : Math.max(min, 0)).toFixed(2)}`) |
| 360 | .join(" "); |
| 361 | return ( |
| 362 | <g key={entry.key}> |
| 363 | {props.kind === "area" || isSpark ? ( |
| 364 | <motion.path |
| 365 | d={`${line} ${areaBase} Z`} |
| 366 | fill={`url(#${gradientId}-${seriesIndex})`} |
| 367 | initial={reduceMotion ? false : { opacity: 0 }} |
| 368 | animate={{ opacity: 1 }} |
| 369 | transition={{ duration: 0.6, delay: 0.25 }} |
| 370 | /> |
| 371 | ) : null} |
| 372 | <motion.path |
| 373 | d={line} |
| 374 | fill="none" |
| 375 | stroke={entry.color} |
| 376 | strokeWidth={isSpark ? 1.75 : 2} |
| 377 | strokeLinecap="round" |
| 378 | strokeLinejoin="round" |
| 379 | initial={reduceMotion ? false : { pathLength: 0 }} |
| 380 | animate={{ pathLength: 1 }} |
| 381 | transition={{ duration: 0.9, delay: seriesIndex * 0.08, ease: [0.22, 1, 0.36, 1] }} |
| 382 | /> |
| 383 | </g> |
| 384 | ); |
| 385 | })} |
| 386 |
| 387 | {hover !== null && props.kind !== "bar" && !isSpark ? ( |
| 388 | <g> |
| 389 | <line |
| 390 | x1={x(hover)} |
| 391 | x2={x(hover)} |
| 392 | y1={padding.top} |
| 393 | y2={padding.top + innerHeight} |
| 394 | className="stroke-foreground/20" |
| 395 | /> |
| 396 | {layers.map(({ entry }) => { |
| 397 | const row = props.data[hover]!; |
| 398 | const stackedValue = props.stacked |
| 399 | ? series |
| 400 | .slice(0, series.findIndex((candidate) => candidate.key === entry.key) + 1) |
| 401 | .reduce((sum, candidate) => sum + numeric(row, candidate.key), 0) |
| 402 | : numeric(row, entry.key); |
| 403 | return ( |
| 404 | <circle |
| 405 | key={entry.key} |
| 406 | cx={x(hover)} |
| 407 | cy={y(stackedValue)} |
| 408 | r={4} |
| 409 | fill={entry.color} |
| 410 | className="stroke-background" |
| 411 | strokeWidth={2} |
| 412 | /> |
| 413 | ); |
| 414 | })} |
| 415 | </g> |
| 416 | ) : null} |
| 417 | </svg> |
| 418 | ) : null} |
| 419 | {hovered && !isSpark ? ( |
| 420 | <div |
| 421 | className={cn( |
| 422 | "pointer-events-none absolute top-1 z-10 min-w-32 rounded-xl border border-border/70 bg-popover/95 px-3 py-2 text-xs shadow-lg backdrop-blur", |
| 423 | )} |
| 424 | style={{ |
| 425 | left: Math.min(Math.max(x(hover!) - 64, 0), Math.max(0, width - 140)), |
| 426 | }} |
| 427 | > |
| 428 | <p className="mb-1 font-medium text-foreground">{String(hovered[props.xKey] ?? "")}</p> |
| 429 | {series.map((entry) => ( |
| 430 | <p key={entry.key} className="flex items-center gap-2 text-muted-foreground"> |
| 431 | <span className="size-1.5 rounded-full" style={{ background: entry.color }} /> |
| 432 | <span className="truncate">{entry.label}</span> |
| 433 | <span className="ml-auto pl-3 font-medium tabular-nums text-foreground"> |
| 434 | {format.format(numeric(hovered, entry.key))} |
| 435 | </span> |
| 436 | </p> |
| 437 | ))} |
| 438 | </div> |
| 439 | ) : null} |
| 440 | </div> |
| 441 | {table} |
| 442 | </figure> |
| 443 | ); |
| 444 | } |
| 445 |
| 446 | type SeriesInput = ChartProps["series"]; |
| 447 |
| 448 | /** Line chart over a category key. */ |
| 449 | export function LineChart(props: Omit<ChartProps, "kind">) { |
| 450 | return <Chart {...props} kind="line" />; |
| 451 | } |
| 452 |
| 453 | /** Filled area chart; pass `stacked` to stack series. */ |
| 454 | export function AreaChart(props: Omit<ChartProps, "kind">) { |
| 455 | return <Chart {...props} kind="area" />; |
| 456 | } |
| 457 |
| 458 | /** Grouped or stacked bars. */ |
| 459 | export function BarChart(props: Omit<ChartProps, "kind">) { |
| 460 | return <Chart {...props} kind="bar" />; |
| 461 | } |
| 462 |
| 463 | /** One proportion per category; uses the first series. */ |
| 464 | export function DonutChart(props: Omit<ChartProps, "kind">) { |
| 465 | return <Chart {...props} kind="donut" />; |
| 466 | } |
| 467 |
| 468 | /** A tiny trend line for stats and table cells. */ |
| 469 | export function Sparkline({ |
| 470 | values, |
| 471 | tone = "primary", |
| 472 | height = 32, |
| 473 | className, |
| 474 | }: { |
| 475 | values: number[]; |
| 476 | tone?: NonNullable<SeriesInput[number]["tone"]>; |
| 477 | height?: number; |
| 478 | className?: string; |
| 479 | }) { |
| 480 | return ( |
| 481 | <div className={className}> |
| 482 | <Chart |
| 483 | kind="sparkline" |
| 484 | data={values.map((value, index) => ({ x: index, value }))} |
| 485 | xKey="x" |
| 486 | series={[{ key: "value", tone }]} |
| 487 | height={height} |
| 488 | /> |
| 489 | </div> |
| 490 | ); |
| 491 | } |
| 492 |