{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-frame",
  "title": "Chart Frame",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/chart.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/toggle.json",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/chart-frame.tsx",
      "content": "// Ballmac UI: Chart Frame. https://ui.ballmac.com/components/chart-frame\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChartColumn, Download, Table2 } from \"lucide-react\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { ChartContainer, type ChartConfig } from \"@/components/ballmac/chart\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Toggle } from \"@/components/ballmac/toggle\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useLocale, useMessages } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\n/** One row of data: a category plus a number for each series. */\ntype ChartRow = Record<string, unknown>\n\ntype ChartSeries = {\n  /** The key of this series' number in every row. */\n  key: string\n  /** Name shown in the legend, the tooltip and the table. */\n  label: string\n  /** A CSS color. Defaults to the theme's chart colors in order. */\n  color?: string\n}\n\n/** What the frame tells the chart it draws. Put `chart.defs` inside the chart as `<defs>` and fill series with `chart.fill(key)`. */\ntype ChartFrameContext = {\n  /** The series that are switched on. */\n  visible: string[]\n  isHidden: (key: string) => boolean\n  /** Whether series are also told apart by a pattern, not only by color. */\n  patterns: boolean\n  /** The fill for a series: its pattern when patterns are on, otherwise its color variable. */\n  fill: (key: string) => string\n  /** The solid color of a series, for strokes and dots. */\n  color: (key: string) => string\n  /** The `<pattern>` definitions. Render `<defs>{chart.defs}</defs>` inside the chart's svg. */\n  defs: React.ReactNode\n  /** False under reduced motion: pass it to Recharts' `isAnimationActive`. */\n  animate: boolean\n  /** Whether the page reads right to left: reverse the x axis and move the y axis to the right. */\n  rtl: boolean\n  /** Recharts margins are physical. Give them as they read left to right and this swaps `left` and `right` when the page reads right to left. */\n  margin: (m: { top?: number; right?: number; bottom?: number; left?: number }) => { top: number; right: number; bottom: number; left: number }\n  locale: string\n  /** Writes a number the way the frame was told to. */\n  format: (value: number, key: string) => string\n  /** Writes a category (an x value). */\n  formatCategory: (value: unknown) => string\n  config: ChartConfig\n}\n\ntype TableColumn = { key: string; label: string; numeric?: boolean; format?: (value: unknown, row: ChartRow) => string }\n\ntype ChartFrameProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> &\n  StateProps & {\n    /** The chart's name. It heads the frame and names the figure for screen readers. */\n    title: string\n    description?: string\n    /** The rows the chart draws. The table, the CSV and the written summary are made from them. */\n    data: ChartRow[]\n    /** The key of each row's category (the x axis). */\n    xKey: string\n    /** What the legend lists and what colors the chart: one entry per number. */\n    series: ChartSeries[]\n    /** Draws the chart. Return a Recharts chart (`AreaChart`, `BarChart`…) and use what `chart` gives you. */\n    children: (chart: ChartFrameContext) => React.ReactElement\n    /** \"toggle\": a legend whose items switch series on and off. \"static\": a plain legend. \"none\". Defaults to \"toggle\" with two or more series. */\n    legend?: \"toggle\" | \"static\" | \"none\"\n    /** Show the Chart and Table switch. Defaults to true. */\n    tableView?: boolean\n    /** `true` always draws patterns as well as colors. \"toggle\" adds a switch for it. */\n    patterns?: boolean | \"toggle\"\n    /** The sentence screen readers get. It is written from the data when you leave it out. */\n    summary?: string\n    /** How the summary reads the data: as a trend over `xKey`, as a ranking of categories, or as shares of a whole. */\n    summaryMode?: \"trend\" | \"ranking\"\n    /** Writes a number in the tooltip, the table and the summary. */\n    valueFormatter?: (value: number, seriesKey: string) => string\n    /** Writes a category in the table and the summary. */\n    categoryFormatter?: (value: unknown) => string\n    /** The shape of the plot. */\n    aspect?: \"video\" | \"wide\" | \"panorama\" | \"square\" | \"tall\"\n    /** Controls beside the view switch, for example a range picker. */\n    actions?: React.ReactNode\n    /** Content under the chart. */\n    footer?: React.ReactNode\n    /** What shows while loading: a shape that matches the chart, or your own node. */\n    skeleton?: \"area\" | \"bars\" | \"donut\" | \"lines\" | \"radar\" | React.ReactNode\n    /** Replace the table with your own columns and rows, for charts whose data is not a simple series per column. */\n    table?: { columns: TableColumn[]; rows: ChartRow[] }\n    /** Names the downloaded file (without .csv). Pass `false` to hide the download button. */\n    downloadName?: string | false\n    /** The first column's header in the table. */\n    categoryLabel?: string\n    locale?: string\n    /** Extra classes for the chart area. */\n    chartClassName?: string\n  }\n\nconst seriesColor = (index: number) => `var(--chart-${(index % 5) + 1})`\n\nconst ASPECT = { video: \"aspect-video\", wide: \"aspect-[2/1]\", panorama: \"aspect-[5/2]\", square: \"mx-auto aspect-square w-full max-w-sm\", tall: \"mx-auto aspect-[4/5] max-h-[28rem] w-full max-w-md\" } as const\n\n/** Six ways to fill a series that do not rely on color: each is a tile drawn in the series' color. */\nconst PATTERNS: ((color: string) => React.ReactNode)[] = [\n  (c) => (\n    <>\n      <rect width=\"8\" height=\"8\" fill={c} fillOpacity=\"0.16\" />\n      <path d=\"M-1 1 L1 -1 M0 8 L8 0 M7 9 L9 7\" stroke={c} strokeWidth=\"1.6\" />\n    </>\n  ),\n  (c) => (\n    <>\n      <rect width=\"8\" height=\"8\" fill={c} fillOpacity=\"0.16\" />\n      <circle cx=\"2\" cy=\"2\" r=\"1.5\" fill={c} />\n      <circle cx=\"6\" cy=\"6\" r=\"1.5\" fill={c} />\n    </>\n  ),\n  (c) => (\n    <>\n      <rect width=\"8\" height=\"8\" fill={c} fillOpacity=\"0.16\" />\n      <path d=\"M0 0 L8 8 M8 0 L0 8\" stroke={c} strokeWidth=\"1.3\" />\n    </>\n  ),\n  (c) => (\n    <>\n      <rect width=\"8\" height=\"8\" fill={c} fillOpacity=\"0.16\" />\n      <path d=\"M0 2 H8 M0 6 H8\" stroke={c} strokeWidth=\"1.5\" />\n    </>\n  ),\n  (c) => (\n    <>\n      <rect width=\"8\" height=\"8\" fill={c} fillOpacity=\"0.16\" />\n      <path d=\"M2 0 V8 M6 0 V8\" stroke={c} strokeWidth=\"1.5\" />\n    </>\n  ),\n  (c) => <rect width=\"8\" height=\"8\" fill={c} fillOpacity=\"0.85\" />,\n]\n\nconst csvCell = (value: string) => (/[\",\\n\\r]/.test(value) ? `\"${value.replace(/\"/g, '\"\"')}\"` : value)\n\n/** Rows of text as a CSV file's contents. */\nfunction toCsv(header: string[], rows: string[][]) {\n  return [header, ...rows].map((r) => r.map(csvCell).join(\",\")).join(\"\\r\\n\")\n}\n\nfunction numberOf(value: unknown): number | null {\n  return typeof value === \"number\" && Number.isFinite(value) ? value : null\n}\n\n/**\n * The sentence a screen reader gets for a chart, written from its data: for a trend each series is read from its first point to its last\n * with its high and low; for a ranking the categories come in order of size. Pass your own `summary` to say something about what it means.\n */\nfunction describeChart({\n  data,\n  xKey,\n  series,\n  mode,\n  format,\n  formatCategory,\n  msg,\n}: {\n  data: ChartRow[]\n  xKey: string\n  series: ChartSeries[]\n  mode: \"trend\" | \"ranking\"\n  format: (value: number, key: string) => string\n  formatCategory: (value: unknown) => string\n  msg: ReturnType<typeof useMessages>\n}): string {\n  if (!data.length) return \"\"\n  const shown = series.slice(0, 4)\n  const parts = shown.map((s) => {\n    const points = data.flatMap((row) => {\n      const v = numberOf(row[s.key])\n      return v === null ? [] : [{ v, at: formatCategory(row[xKey]) }]\n    })\n    if (!points.length) return \"\"\n    if (mode === \"ranking\") {\n      const ranked = [...points].sort((a, b) => b.v - a.v).slice(0, 3)\n      return msg(\"chart-frame.ranking\", \"{series}: {list}.\", { series: s.label, list: ranked.map((p) => `${p.at} ${format(p.v, s.key)}`).join(\", \") })\n    }\n    const first = points[0]!\n    const last = points.at(-1)!\n    const max = points.reduce((a, b) => (b.v > a.v ? b : a))\n    const min = points.reduce((a, b) => (b.v < a.v ? b : a))\n    if (points.length === 1) return msg(\"chart-frame.single\", \"{series}: {value} in {at}.\", { series: s.label, value: format(first.v, s.key), at: first.at })\n    return msg(\"chart-frame.trend\", \"{series}: from {first} in {firstAt} to {last} in {lastAt}; highest {max} in {maxAt}, lowest {min} in {minAt}.\", {\n      series: s.label,\n      first: format(first.v, s.key),\n      firstAt: first.at,\n      last: format(last.v, s.key),\n      lastAt: last.at,\n      max: format(max.v, s.key),\n      maxAt: max.at,\n      min: format(min.v, s.key),\n      minAt: min.at,\n    })\n  })\n  const more = series.length - shown.length\n  return [\n    msg(\"chart-frame.points\", { one: \"{count} point.\", other: \"{count} points.\" }, { count: data.length }),\n    ...parts.filter(Boolean),\n    more > 0 ? msg(\"chart-frame.more\", { one: \"And {count} more series, in the table.\", other: \"And {count} more series, in the table.\" }, { count: more }) : \"\",\n  ]\n    .filter(Boolean)\n    .join(\" \")\n}\n\n/** A round step for a value axis: 1, 2, 2.5, 5 or 10 times a power of ten. */\nfunction niceStep(range: number, ticks: number) {\n  if (!(range > 0)) return 1\n  const raw = range / Math.max(1, ticks - 1)\n  const magnitude = 10 ** Math.floor(Math.log10(raw))\n  const normal = raw / magnitude\n  return (normal <= 1 ? 1 : normal <= 2 ? 2 : normal <= 2.5 ? 2.5 : normal <= 5 ? 5 : 10) * magnitude\n}\n\n/** Round ticks inside a domain someone fixed, for example [3.5, 5] gives 3.5, 4, 4.5, 5. Returns undefined when the domain is not two numbers. */\nfunction domainTicks(domain: readonly [number | string, number | string] | undefined, count = 5): number[] | undefined {\n  if (!domain || typeof domain[0] !== \"number\" || typeof domain[1] !== \"number\" || !(domain[1] > domain[0])) return undefined\n  const [lo, hi] = domain\n  const step = niceStep(hi - lo, count)\n  const ticks: number[] = []\n  for (let v = Math.ceil(lo / step - 1e-9) * step; v <= hi + 1e-9; v += step) ticks.push(Number(v.toFixed(10)))\n  return ticks.length >= 2 ? ticks : undefined\n}\n\n/**\n * Round ticks for a value axis (0, 500, 1K…) instead of whatever the data happens to end on (0, 350, 700, 1050). Pass the keys that are drawn;\n * `stacked` adds them up. `zero` keeps the baseline at 0, as bars and areas need.\n */\nfunction niceScale({ data, keys, stacked = false, zero = true, count = 5 }: { data: ChartRow[]; keys: string[]; stacked?: boolean; zero?: boolean; count?: number }): { domain: [number, number]; ticks: number[] } | undefined {\n  let min = Infinity\n  let max = -Infinity\n  for (const row of data) {\n    const values = keys.map((k) => numberOf(row[k])).filter((v): v is number => v !== null)\n    if (!values.length) continue\n    if (stacked) {\n      max = Math.max(max, values.filter((v) => v > 0).reduce((a, b) => a + b, 0))\n      min = Math.min(min, values.filter((v) => v < 0).reduce((a, b) => a + b, 0))\n    } else {\n      max = Math.max(max, ...values)\n      min = Math.min(min, ...values)\n    }\n  }\n  if (!Number.isFinite(min) || !Number.isFinite(max)) return undefined\n  if (zero) {\n    min = Math.min(min, 0)\n    max = Math.max(max, 0)\n  }\n  if (max === min) max = min + 1\n  const step = niceStep(max - min, count)\n  const lo = Math.floor(min / step + 1e-9) * step\n  const hi = Math.ceil(max / step - 1e-9) * step\n  const ticks: number[] = []\n  for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toFixed(10)))\n  return { domain: [lo, hi], ticks }\n}\n\n/** The loading shape: roughly what the chart will look like, so nothing jumps when it arrives. */\nfunction ChartSkeleton({ kind }: { kind: \"area\" | \"bars\" | \"donut\" | \"lines\" | \"radar\" }) {\n  if (kind === \"donut\" || kind === \"radar\") {\n    return (\n      <div aria-hidden=\"true\" className=\"grid place-items-center py-2\">\n        <Skeleton className={cn(\"aspect-square w-full max-w-60 rounded-full\", kind === \"donut\" && \"[mask:radial-gradient(circle,transparent_52%,rgb(0_0_0)_53%)]\")} />\n      </div>\n    )\n  }\n  const heights = [46, 62, 54, 78, 66, 90, 72, 84, 58, 70]\n  return (\n    <div aria-hidden=\"true\" className=\"relative aspect-video w-full\">\n      <div className=\"absolute inset-0 grid grid-rows-4 border-b\">\n        {[0, 1, 2, 3].map((i) => (\n          <div key={i} className=\"border-t border-dashed border-border/60\" />\n        ))}\n      </div>\n      {kind === \"bars\" ? (\n        <div className=\"absolute inset-x-2 bottom-0 flex h-full items-end gap-2\">\n          {heights.map((h, i) => (\n            <Skeleton key={i} className=\"flex-1 rounded-b-none rounded-t-md\" style={{ height: `${h}%` }} />\n          ))}\n        </div>\n      ) : (\n        <svg viewBox=\"0 0 100 50\" preserveAspectRatio=\"none\" className=\"absolute inset-0 size-full\">\n          <path d=\"M0 40 C10 34 16 20 26 24 S42 38 52 28 S70 8 80 16 S94 22 100 12 V50 H0 Z\" className={cn(\"animate-pulse fill-muted motion-reduce:animate-none\", kind === \"lines\" && \"fill-transparent\")} />\n          <path d=\"M0 40 C10 34 16 20 26 24 S42 38 52 28 S70 8 80 16 S94 22 100 12\" className=\"animate-pulse fill-none stroke-muted-foreground/25 motion-reduce:animate-none\" strokeWidth=\"1.2\" vectorEffect=\"non-scaling-stroke\" />\n        </svg>\n      )}\n    </div>\n  )\n}\n\n/** The data as an accessible table, with the same numbers the chart draws. Used by ChartFrame's Table view, and fine on its own. */\nfunction ChartTable({\n  title,\n  columns,\n  rows,\n  className,\n}: {\n  title: string\n  columns: TableColumn[]\n  rows: ChartRow[]\n  className?: string\n}) {\n  const msg = useMessages()\n  return (\n    <div role=\"region\" aria-label={msg(\"chart-frame.tableLabel\", \"{title}, data table\", { title })} tabIndex={0} className={cn(\"relative max-h-96 overflow-auto rounded-lg border focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none\", className)}>\n      <table className=\"w-full min-w-max border-collapse text-sm\">\n        <caption className=\"sr-only\">{title}</caption>\n        <thead className=\"sticky top-0 z-10 bg-muted text-xs\">\n          <tr>\n            {columns.map((c, i) => (\n              <th key={c.key} scope=\"col\" className={cn(\"px-3 py-2 font-medium text-muted-foreground\", c.numeric ? \"text-end\" : \"text-start\", i === 0 && \"sticky start-0 bg-muted\")}>\n                {c.label}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody className=\"divide-y\">\n          {rows.map((row, r) => (\n            <tr key={r} className=\"hover:bg-muted/40\">\n              {columns.map((c, i) =>\n                i === 0 ? (\n                  <th key={c.key} scope=\"row\" className=\"bg-card sticky start-0 px-3 py-2 text-start font-medium whitespace-nowrap\">\n                    {c.format ? c.format(row[c.key], row) : String(row[c.key] ?? \"\")}\n                  </th>\n                ) : (\n                  <td key={c.key} className={cn(\"px-3 py-2 tabular-nums whitespace-nowrap\", c.numeric ? \"text-end\" : \"text-start\")}>\n                    {c.format ? c.format(row[c.key], row) : String(row[c.key] ?? \"\")}\n                  </td>\n                )\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n\n/**\n * The card around a chart, with what a chart needs to be usable by everyone: a Table view of the same data (and a CSV download), a legend that\n * switches series on and off, optional patterns so series differ by more than color, a summary written for screen readers, reduced-motion-aware\n * animation, mirroring in right-to-left, and loading, empty and error states through `state`.\n */\nfunction ChartFrame({\n  title,\n  description,\n  data,\n  xKey,\n  series,\n  children,\n  legend,\n  tableView = true,\n  patterns: patternsProp = false,\n  summary,\n  summaryMode = \"trend\",\n  valueFormatter,\n  categoryFormatter,\n  aspect = \"video\",\n  actions,\n  footer,\n  skeleton = \"area\",\n  table,\n  downloadName,\n  categoryLabel,\n  locale: localeProp,\n  chartClassName,\n  state,\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: ChartFrameProps) {\n  const msg = useMessages()\n  const locale = useLocale(localeProp)\n  const dir = useDirection(props.dir as \"ltr\" | \"rtl\" | undefined)\n  const reduced = useReducedMotion()\n  const uid = React.useId().replace(/:/g, \"\")\n  const headingId = `${uid}-title`\n  const [view, setView] = React.useState<\"chart\" | \"table\">(\"chart\")\n  const [patternsOn, setPatternsOn] = React.useState(false)\n  const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set())\n\n  const patterns = patternsProp === true || (patternsProp === \"toggle\" && patternsOn)\n  const legendMode = legend ?? (series.length > 1 ? \"toggle\" : \"none\")\n  const config = React.useMemo<ChartConfig>(() => Object.fromEntries(series.map((s, i) => [s.key, { label: s.label, color: s.color ?? seriesColor(i) }])), [series])\n  const index = (key: string) => Math.max(0, series.findIndex((s) => s.key === key))\n  const patternId = (key: string) => `${uid}-p${index(key)}`\n  const number = React.useMemo(() => new Intl.NumberFormat(locale), [locale])\n  const format = React.useCallback((value: number, key: string) => (valueFormatter ? valueFormatter(value, key) : number.format(value)), [valueFormatter, number])\n  const formatCategory = React.useCallback((value: unknown) => (categoryFormatter ? categoryFormatter(value) : String(value ?? \"\")), [categoryFormatter])\n\n  const toggleSeries = (key: string) =>\n    setHidden((prev) => {\n      const next = new Set(prev)\n      if (next.has(key)) next.delete(key)\n      else if (series.length - prev.size > 1) next.add(key)\n      return next\n    })\n\n  const chart: ChartFrameContext = {\n    visible: series.filter((s) => !hidden.has(s.key)).map((s) => s.key),\n    isHidden: (key) => hidden.has(key),\n    patterns,\n    fill: (key) => (patterns ? `url(#${patternId(key)})` : `var(--color-${key})`),\n    color: (key) => `var(--color-${key})`,\n    defs: patterns\n      ? series.map((s) => (\n          <pattern key={s.key} id={patternId(s.key)} width=\"8\" height=\"8\" patternUnits=\"userSpaceOnUse\">\n            {PATTERNS[index(s.key) % PATTERNS.length]!(`var(--color-${s.key})`)}\n          </pattern>\n        ))\n      : null,\n    animate: !reduced,\n    rtl: dir === \"rtl\",\n    margin: (m) => {\n      const [left, right] = [m.left ?? 0, m.right ?? 0]\n      return { top: m.top ?? 0, bottom: m.bottom ?? 0, left: dir === \"rtl\" ? right : left, right: dir === \"rtl\" ? left : right }\n    },\n    locale,\n    format,\n    formatCategory,\n    config,\n  }\n\n  const columns: TableColumn[] = table?.columns ?? [\n    { key: xKey, label: categoryLabel ?? msg(\"chart-frame.category\", \"Category\"), format: (v) => formatCategory(v) },\n    ...series.map((s) => ({ key: s.key, label: s.label, numeric: true, format: (v: unknown) => (numberOf(v) === null ? \"–\" : format(v as number, s.key)) })),\n  ]\n  const rows = table?.rows ?? data\n  const written = summary ?? describeChart({ data, xKey, series, mode: summaryMode, format, formatCategory, msg })\n\n  const effective = state ?? (data.length ? \"ready\" : \"empty\")\n  const ready = effective !== \"loading\" && effective !== \"empty\" && effective !== \"error\"\n  const showPatternSwitch = patternsProp === \"toggle\"\n\n  const download = () => {\n    const text = toCsv(columns.map((c) => c.label), rows.map((r) => columns.map((c) => (c.format ? c.format(r[c.key], r) : String(r[c.key] ?? \"\")))))\n    const url = URL.createObjectURL(new Blob([text], { type: \"text/csv;charset=utf-8\" }))\n    const a = document.createElement(\"a\")\n    a.href = url\n    a.download = `${(downloadName || title).toString().toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"chart\"}.csv`\n    a.click()\n    URL.revokeObjectURL(url)\n  }\n\n  return (\n    <div data-slot=\"chart-frame\" data-state={effective} className={cn(\"bg-card text-card-foreground flex min-w-0 flex-col rounded-xl border shadow-xs\", className)} {...props}>\n      <div className=\"flex flex-wrap items-start justify-between gap-x-4 gap-y-3 px-5 pt-5\">\n        <div className=\"min-w-0\">\n          <h3 id={headingId} className=\"text-base leading-snug font-semibold tracking-tight\">\n            {title}\n          </h3>\n          {description ? <p className=\"text-muted-foreground mt-0.5 text-sm\">{description}</p> : null}\n        </div>\n        {ready || actions ? (\n          <div className=\"flex flex-wrap items-center gap-2\">\n            {actions}\n            {ready && showPatternSwitch && view === \"chart\" ? (\n              <Toggle size=\"sm\" variant=\"outline\" pressed={patternsOn} onPressedChange={setPatternsOn} aria-label={msg(\"chart-frame.patternsHint\", \"Fill with patterns as well as color\")}>\n                {msg(\"chart-frame.patterns\", \"Patterns\")}\n              </Toggle>\n            ) : null}\n            {ready && tableView ? (\n              <SegmentedControl size=\"sm\" aria-label={msg(\"chart-frame.view\", \"View\")} value={view} onValueChange={(v) => setView(v as \"chart\" | \"table\")}>\n                <SegmentedControlItem value=\"chart\" aria-label={msg(\"chart-frame.chart\", \"Chart\")}>\n                  <ChartColumn aria-hidden=\"true\" />\n                  <span className=\"max-sm:sr-only\">{msg(\"chart-frame.chart\", \"Chart\")}</span>\n                </SegmentedControlItem>\n                <SegmentedControlItem value=\"table\" aria-label={msg(\"chart-frame.table\", \"Table\")}>\n                  <Table2 aria-hidden=\"true\" />\n                  <span className=\"max-sm:sr-only\">{msg(\"chart-frame.table\", \"Table\")}</span>\n                </SegmentedControlItem>\n              </SegmentedControl>\n            ) : null}\n          </div>\n        ) : null}\n      </div>\n\n      <StateView\n        bare\n        state={effective}\n        onRetry={onRetry}\n        label={msg(\"chart-frame.loading\", \"Loading {title}\", { title })}\n        loading={React.isValidElement(skeleton) ? skeleton : <ChartSkeleton kind={(typeof skeleton === \"string\" ? skeleton : \"area\") as \"area\" | \"bars\" | \"donut\" | \"lines\" | \"radar\"} />}\n        empty={{ title: msg(\"chart-frame.emptyTitle\", \"No data yet\"), description: msg(\"chart-frame.emptyDescription\", \"The chart appears when there is something to plot.\"), ...emptyState }}\n        error={{ title: msg(\"chart-frame.errorTitle\", \"The chart could not be loaded\"), description: msg(\"chart-frame.errorDescription\", \"Check your connection and try again.\"), ...errorState }}\n        className=\"flex-1 px-5 pt-4 pb-5\"\n      >\n        {view === \"chart\" || !tableView ? (\n          <div className=\"grid gap-3\">\n            <ChartContainer config={config} label={title} summary={written} format={format} className={cn(ASPECT[aspect], chartClassName)} dir={dir}>\n              {children(chart)}\n            </ChartContainer>\n            {legendMode !== \"none\" ? (\n              <div role={legendMode === \"toggle\" ? \"group\" : undefined} aria-label={legendMode === \"toggle\" ? msg(\"chart-frame.series\", \"Series\") : undefined} className=\"flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1\">\n                {series.map((s) => {\n                  const off = hidden.has(s.key)\n                  const swatch = (\n                    <svg aria-hidden=\"true\" width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" className=\"shrink-0 rounded-[3px]\" style={{ color: config[s.key]?.color }}>\n                      <rect width=\"12\" height=\"12\" rx=\"3\" fill={patterns && !off ? `url(#${patternId(s.key)})` : off ? \"none\" : \"currentColor\"} stroke=\"currentColor\" strokeWidth={off ? 1.5 : 0} />\n                    </svg>\n                  )\n                  return legendMode === \"toggle\" ? (\n                    <button\n                      key={s.key}\n                      type=\"button\"\n                      aria-pressed={!off}\n                      onClick={() => toggleSeries(s.key)}\n                      className={cn(\"inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\", !off && \"text-foreground\", off && \"opacity-70\")}\n                    >\n                      {swatch}\n                      {s.label}\n                    </button>\n                  ) : (\n                    <span key={s.key} className=\"inline-flex h-7 items-center gap-1.5 px-2 text-xs text-muted-foreground\">\n                      {swatch}\n                      {s.label}\n                    </span>\n                  )\n                })}\n              </div>\n            ) : null}\n          </div>\n        ) : (\n          <div className=\"grid gap-3\">\n            <ChartTable title={title} columns={columns} rows={rows} />\n            {downloadName !== false ? (\n              <div className=\"flex justify-end\">\n                <button type=\"button\" onClick={download} className=\"inline-flex h-8 items-center gap-2 rounded-md border px-3 text-[13px] font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4\">\n                  <Download aria-hidden=\"true\" />\n                  {msg(\"chart-frame.download\", \"Download CSV\")}\n                </button>\n              </div>\n            ) : null}\n          </div>\n        )}\n      </StateView>\n      {ready && footer ? <div className=\"text-muted-foreground border-t px-5 py-3 text-sm\">{footer}</div> : null}\n    </div>\n  )\n}\n\nexport { ChartFrame, ChartTable, describeChart, niceScale, domainTicks, type ChartFrameProps, type ChartFrameContext, type ChartSeries, type ChartRow, type TableColumn }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/chart-frame.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "chart",
      "recharts",
      "table",
      "accessibility",
      "legend",
      "patterns",
      "csv",
      "states",
      "graph"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "Wrap any Recharts chart: <ChartFrame title description data xKey series={[{ key, label }]}>{(chart) => <LineChart>…</LineChart>}</ChartFrame>. Use chart.color(key) for strokes, chart.fill(key) for fills, chart.isHidden(key) for `hide`, chart.defs in <defs>, chart.animate for isAnimationActive and chart.rtl to reverse the x axis. The frame adds a Chart/Table switch, a CSV download, a toggling legend, optional patterns, a written summary and state handling. Prefer the ready-made ChartArea, ChartBar, ChartLine, ChartPie, ChartRadar, ChartRadial or ChartScatter when one fits.",
      "whenToUse": [
        "Any chart that people need to read, compare or export",
        "A custom Recharts chart that should be accessible and handle loading and errors"
      ],
      "whenNotToUse": [
        "A tiny inline trend (use sparkline)",
        "A single ring value (use progress-ring)"
      ],
      "composesWith": [
        "chart",
        "segmented-control",
        "stat-card"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Each legend item is a button that switches its series off or on (at least one stays on)"
        },
        {
          "keys": "Chart / Table switch",
          "action": "Shows the same numbers as an accessible table with row and column headers, and a CSV download"
        },
        {
          "keys": "Screen readers",
          "action": "The figure has a name and a summary written from the data (first to last, highest and lowest, or a ranking); pass summary to say what it means"
        },
        {
          "keys": "Patterns",
          "action": "Series are filled with stripes, dots and hatching as well as color, so color is never the only signal"
        }
      ],
      "customization": [
        "legend: toggle | static | none",
        "patterns: true | 'toggle'",
        "aspect: video | wide | panorama | square | tall",
        "valueFormatter and categoryFormatter write numbers and categories everywhere",
        "table: your own columns for charts whose data is not one column per series"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "chart-frame-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton in the shape of the chart (area, bars, donut, lines or radar), under the real title."
      },
      {
        "id": "empty",
        "title": "No data yet",
        "example": "chart-frame-empty",
        "trigger": "state=\"empty\" (or data={[]})",
        "note": "Says when the chart will appear; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "chart-frame-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "chart-frame-demo",
      "chart-frame-loading",
      "chart-frame-empty",
      "chart-frame-error"
    ],
    "url": "https://ui.ballmac.com/components/chart-frame"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}