v1.0

ChartCard

Preview

Basic

Loading…

Preview

Code

ts
import ChartCard from '@/components/data/ChartCard';

src/components/data/ChartCard.tsx

AI prompt

text
Build a chart card component in React + TypeScript + Tailwind CSS: the frame a chart sits in, with a title, an info hint, a legend and a one-click table view of the same numbers.

## Look
- Card: panel surface, `p-5`. Header row (`flex flex-wrap items-center gap-x-3 gap-y-2 mb-3`): the title as a section title (10px semibold uppercase, wide tracking, slate-500), followed by a 14px lucide `Info` icon button (slate-400, hover slate-600) that shows the hint in a wide tooltip on hover AND focus.
- Legend: shown only for two or more entries, and only in chart view (a single series needs none, the title names it). An inline wrapping list, 11px slate-600 / dark slate-300, `gap-x-3`. Each entry is a swatch then the label. Swatch shapes: `rect` a 10px `rounded-sm` square (default), `line` a 14×3px `rounded-full` stroke, `dot` an 8px circle.
- Right end (`ml-auto`): an optional actions slot, then a two-button segmented toggle — lucide `BarChart3` and `Table2` at 14px, `p-1.5`, inside a `rounded-lg` bordered group (slate-200 / dark slate-700, overflow hidden). Active: `bg-slate-100 text-slate-700` (dark `bg-slate-700 text-slate-100`). Inactive: slate-400, hover slate-600 (dark hover slate-200). The toggle is two small icons so the chart stays the first thing seen.
- Table view replaces the plot: a `max-h-[320px]` scroll box with a `rounded-lg` border. Sticky header row (10px semibold uppercase, `bg-slate-50/95` / dark `bg-slate-900/95`, bottom border), body text-xs with row dividers slate-100 / dark slate-800, `px-3` cells. Right-aligned columns get `tabular-nums`; a missing value prints "—".
- Empty: a 240px-tall box with "No data for the selected period." centred, text-xs slate-400 / dark slate-500.

## Companion tooltip
Also export a `ChartTooltip` for Recharts' `<Tooltip content>`: an opaque floating card, `min-w-[8rem] px-3 py-2 text-xs shadow-lg`. An optional label line on top (11px slate-500). Then one row per series: a 14×3px rounded stroke in the series colour (a stroke, not a filled box — at tooltip size a box is ink doing a label's job), the VALUE first in semibold tabular-nums slate-900 / dark slate-50, then the series name in slate-500. Props: `active`, `label`, `payload`, `format(v: number)`, `labelFormat(l)`. Renders nothing when inactive or the payload is empty.

## Behaviour
- Local state `'chart' | 'table'`, starting on chart. The toggle is hidden when no `table` is passed.
- The table is not optional polish: it is the view that works for a screen reader, for a colour-blind reader, and for anyone who wants the exact number — so a hover tooltip is never the only way to read a value.

## API
`title: string`, `hint?: string`, `legend?: { label: string; color: string; shape?: 'rect' | 'line' | 'dot' }[]`, `table?: { columns: { key: string; label: string; align?: 'left' | 'right'; format?: (v: unknown) => string }[]; rows: Record<string, unknown>[] }`, `actions?: ReactNode`, `empty = false`, `className?`, `children` (the plot).

## Accessibility
`<section aria-label={title}>` with the title as an `<h2>`. The info button is labelled "About {title}". The toggle is `role="group"` `aria-label="Chart or table"`; its buttons carry `aria-pressed` and `aria-label` "Show chart" / "Show table". Legend swatches are `aria-hidden`.

## Demo
"Seats by plan" with a one-sentence hint, a legend of Used (#2a78d6) and Free (#eb6834), a table of Pro 412 / 88 and Enterprise 1,904 / 96, and a placeholder child "Any chart goes here."

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useState, type ReactNode } from 'react';
import { BarChart3, Table2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { InfoTooltip } from '@/components/overlay/Tooltip';

export type ChartTableColumn = { key: string; label: string; align?: 'left' | 'right'; format?: (v: unknown) => string };

/**
 * The frame every chart here sits in: a title (with its explanation behind an
 * ⓘ), a legend, the plot, and a TABLE VIEW one click away.
 *
 * The table is not optional polish. It is the view that works for a screen
 * reader, for a colour-blind reader where two hues still sit close, and for
 * anyone who wants the exact number rather than a bar's length — so a
 * tooltip is never the only way to read a value. The toggle is two icons, so
 * the chart stays the first thing seen.
 */
export default function ChartCard({
  title,
  hint,
  legend,
  table,
  actions,
  empty = false,
  className,
  children,
}: {
  title: string;
  /** What the chart shows and how to read it — behind an ⓘ beside the title. */
  hint?: string;
  /** Series keys, for two or more series. A single series needs none: the title names it. */
  legend?: { label: string; color: string; shape?: 'rect' | 'line' | 'dot' }[];
  /** Rows and columns for the table view. Absent, the toggle is hidden. */
  table?: { columns: ChartTableColumn[]; rows: Record<string, unknown>[] };
  actions?: ReactNode;
  empty?: boolean;
  className?: string;
  children: ReactNode;
}) {
  const [view, setView] = useState<'chart' | 'table'>('chart');

  return (
    <section className={cn('panel p-5', className)} aria-label={title}>
      <header className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-2">
        <h2 className="panel-title flex items-center gap-1.5">
          {title}
          {hint && <InfoTooltip content={hint} label={`About ${title}`} wide />}
        </h2>
        {legend && legend.length > 1 && view === 'chart' && (
          <ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600 dark:text-slate-300">
            {legend.map((l) => (
              <li key={l.label} className="flex items-center gap-1.5">
                <span
                  aria-hidden
                  style={{ background: l.color }}
                  className={cn(
                    'shrink-0',
                    l.shape === 'line' ? 'h-[3px] w-3.5 rounded-full' : l.shape === 'dot' ? 'h-2 w-2 rounded-full' : 'h-2.5 w-2.5 rounded-sm',
                  )}
                />
                {l.label}
              </li>
            ))}
          </ul>
        )}
        <div className="ml-auto flex items-center gap-1">
          {actions}
          {table && (
            <div role="group" aria-label="Chart or table" className="flex overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
              {(['chart', 'table'] as const).map((v) => {
                const Icon = v === 'chart' ? BarChart3 : Table2;
                return (
                  <button
                    key={v}
                    type="button"
                    aria-pressed={view === v}
                    aria-label={v === 'chart' ? 'Show chart' : 'Show table'}
                    title={v === 'chart' ? 'Chart' : 'Table'}
                    onClick={() => setView(v)}
                    className={cn(
                      'p-1.5 transition-colors',
                      view === v
                        ? 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-100'
                        : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200',
                    )}
                  >
                    <Icon className="h-3.5 w-3.5" aria-hidden />
                  </button>
                );
              })}
            </div>
          )}
        </div>
      </header>

      {empty ? (
        <div className="flex h-[240px] items-center justify-center text-xs text-slate-400 dark:text-slate-500">No data for the selected period.</div>
      ) : view === 'table' && table ? (
        <div className="max-h-[320px] overflow-auto rounded-lg border border-slate-200 dark:border-slate-700">
          <table className="data-table">
            <thead>
              <tr>
                {table.columns.map((c) => (
                  <th key={c.key} className={cn('px-3', c.align === 'right' && 'text-right')}>{c.label}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {table.rows.map((r, i) => (
                <tr key={i}>
                  {table.columns.map((c) => (
                    <td key={c.key} className={cn('px-3', c.align === 'right' && 'text-right tabular-nums')}>
                      {c.format ? c.format(r[c.key]) : String(r[c.key] ?? '—')}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        children
      )}
    </section>
  );
}

/**
 * The hover readout the Recharts charts share: VALUE first and strong, the
 * series name after it in secondary ink, keyed by a short stroke of the series
 * colour rather than a filled box — at tooltip density a box is data-weight
 * ink doing a label's job. One tooltip lists every series at that point.
 */
export function ChartTooltip({
  active,
  label,
  payload,
  format = (v) => String(v),
  labelFormat = (l) => String(l),
}: {
  active?: boolean;
  label?: unknown;
  payload?: readonly { name?: unknown; value?: unknown; color?: string; payload?: unknown }[];
  format?: (v: number) => string;
  labelFormat?: (l: unknown) => string;
}) {
  if (!active || !payload?.length) return null;
  return (
    <div className="panel panel-solid min-w-[8rem] px-3 py-2 text-xs shadow-lg">
      {label !== undefined && label !== '' && <p className="mb-1 text-[11px] text-slate-500 dark:text-slate-400">{labelFormat(label)}</p>}
      <ul className="space-y-0.5">
        {payload.map((p, i) => (
          <li key={i} className="flex items-center gap-2">
            <span aria-hidden className="h-[3px] w-3.5 shrink-0 rounded-full" style={{ background: p.color }} />
            <span className="font-semibold tabular-nums text-slate-900 dark:text-slate-50">{format(Number(p.value))}</span>
            <span className="text-slate-500 dark:text-slate-400">{String(p.name ?? '')}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

Props

PropTypeDefaultDescription
title*string—
children*ReactNode—
hintstring—What the chart shows and how to read it — behind an ⓘ beside the title.
legend{ label: string; color: string; shape?: 'rect' | 'line' | 'dot' }[]—Series keys, for two or more series. A single series needs none: the title names it.
table{ columns: ChartTableColumn[]; rows: Record<string, unknown>[] }—Rows and columns for the table view. Absent, the toggle is hidden.
actionsReactNode—
emptybooleanfalse
classNamestring—