{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-chart",
  "title": "Bar chart",
  "description": "A column chart with round-capped bars, a dot on today, an optional pale comparison bar, and day points grouped into weeks when the bars would get too thin; one Tab stop with arrow keys.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit",
    "@manniche/data-tile"
  ],
  "files": [
    {
      "path": "registry/manniche/bar-chart/bar-chart.tsx",
      "content": "// A column chart for one value per period: chunky bars with round caps that rise from a solid baseline, dashed\n// gridlines with their values at the right, and the current point (today) marked by a small dot in --primary.\n// `compare` draws the period before as a pale bar beside each one. When the bars would get thinner than\n// `minBarWidth`, the points are grouped into buckets (seven days become a week), counted back from the latest\n// point, and the tooltip, the screen reader table and the announcements speak the buckets.\n// The plot is one Tab stop. Arrow keys, Home and End step through the bars, Escape lets go; pointer hover and a finger\n// scrubbing sideways show the same tooltip, and the page still scrolls up and down under a finger. Screen readers get\n// the chart's name, a hint, one short sentence per step, and the whole series in a hidden table.\n// Bars only move by transform and fade by opacity. On first view they rise in a short stagger (all done in 600 ms);\n// a new period or turning compare on replays it, and new values on the same bars glide. Under reduced motion every\n// bar stands at its height at once.\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChartTooltip, SrTable, type TooltipRow } from '@/registry/manniche/chart-kit/chart-kit'\nimport {\n  BASE,\n  EASE_CSS,\n  GRID,\n  INK,\n  INK_GHOST,\n  formatTick,\n  formatValue,\n  niceScale,\n  stepIndex,\n  valueParts,\n  type ValueFormat,\n} from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\nimport { TileLegend } from '@/registry/manniche/data-tile/data-tile'\n\nexport type BarPoint = {\n  /** The short name under the bar: \"Thu\", \"14\", \"Oct\". */\n  label: string\n  /** The full name for the tooltip, the table and screen readers: \"Thu 8 Oct\". Defaults to `label`. */\n  title?: string\n  /** The bar's value, 0 or more. */\n  value: number\n  /** The same slot in the period before, drawn beside the bar when `compare` is on. */\n  previous?: number\n}\n\nexport type BarChartLabels = {\n  /** Added to the current point's name in the tooltip and when read: \"Thu 8 Oct · today\". Default \"today\". */\n  current?: string\n  /** The current point in the legend. Default \"Today\". */\n  currentKey?: string\n  /** The main series in the legend and the table. Default \"This period\". */\n  value?: string\n  /** The comparison series. Default \"Period before\". */\n  previous?: string\n  /** The change row in the tooltip. Default \"Change\". */\n  change?: string\n  /** The first column of the screen reader table. Default \"Period\". */\n  point?: string\n  /** Read after the chart's name. Default \"Use the arrow keys to read each bar.\" */\n  hint?: string\n  /** Read while loading. Default \"Loading\". */\n  loading?: string\n  /** Shown when there is nothing to draw and no `placeholder` is given. Default \"No data yet\". */\n  empty?: string\n  /** Names a group of points from its first and last title. Default \"Wed 9 Sep – Tue 15 Sep\". */\n  range?: (first: string, last: string) => string\n}\n\nconst LABELS: Required<BarChartLabels> = {\n  current: 'today',\n  currentKey: 'Today',\n  value: 'This period',\n  previous: 'Period before',\n  change: 'Change',\n  point: 'Period',\n  hint: 'Use the arrow keys to read each bar.',\n  loading: 'Loading',\n  empty: 'No data yet',\n  range: (first, last) => `${first} – ${last}`,\n}\n\nexport type BarChartProps = Omit<ComponentProps<'div'>, 'children'> & {\n  /** One object per period, oldest first: `{ label, title?, value, previous? }`. */\n  data: BarPoint[]\n  /** The chart's accessible name, such as \"Spending, last 7 days\". */\n  label: string\n  /** How values are written in the tooltip, the table and on the axis. */\n  format?: ValueFormat\n  /** Draw each point's `previous` value as a pale bar beside it, with a legend. Default false. */\n  compare?: boolean\n  /** The index of the current point (today), marked with the signal dot. Default the last point; null for none. */\n  current?: number | null\n  /** The plot's height in px. Default 208, 170 when narrow, and lower in a compact tile. */\n  height?: number\n  /** The thinnest a bar may be drawn, in px. Thinner bars are grouped instead. Default 10. */\n  minBarWidth?: number\n  /** How many points make one group when bars get too thin, such as 7 for days into weeks. False never groups. Default 7. */\n  bucket?: number | false\n  /** Names a group for the tooltip, the table and screen readers: \"Week of 14 Sep\". Gets the points and the first one's index. */\n  bucketTitle?: (points: BarPoint[], start: number) => string\n  /** The short name under a group's bar. Default the first point's label. */\n  bucketLabel?: (points: BarPoint[], start: number) => string\n  /** Label every nth bar, counted back from the last. Default as many as fit, at most about ten. */\n  tickEvery?: number\n  /** Show breathing skeleton bars instead of data. */\n  loading?: boolean\n  /** Shown over the empty frame when there is no data or every value is 0, such as a message and a next step. */\n  placeholder?: ReactNode\n  /** Show the key under the chart: the series drawn and the current-point dot. Default on when there is a\n   * comparison or a current point to explain; a series that is not drawn is never listed. */\n  legend?: boolean\n  /** The highlighted point (an index into `data`), or null. When grouped, the first index of the group. */\n  activeIndex?: number | null\n  /** The highlighted point to start with, when `activeIndex` is not controlled. Default null. */\n  defaultActiveIndex?: number | null\n  /** Called when hover, a finger or the keyboard picks another point, and with null when it lets go. */\n  onActiveIndexChange?: (index: number | null) => void\n  /** Every visible and spoken string that is not data. */\n  labels?: BarChartLabels\n}\n\n/** The space at the right for the value labels, as in the mockup. */\nconst GUT = 46\nconst SKELETON = [46, 78, 58, 30, 50, 24, 66]\n\ntype Slot = { label: string; title: string; value: number; previous?: number; start: number; end: number }\n\nfunction useControllable(controlled: number | null | undefined, initial: number | null, onChange?: (v: number | null) => void) {\n  const [inner, setInner] = useState(initial)\n  const isControlled = controlled !== undefined\n  const value = isControlled ? controlled : inner\n  const set = useCallback(\n    (v: number | null) => {\n      if (v === value) return\n      if (!isControlled) setInner(v)\n      onChange?.(v)\n    },\n    [value, isControlled, onChange],\n  )\n  return [value, set] as const\n}\n\n/** Bar width and gap for n slots across the plot. */\nfunction measure(n: number, plot: number, compare: boolean) {\n  const slot = plot / Math.max(1, n)\n  const gap = Math.max(3, Math.min(10, Math.round(slot * 0.12)))\n  const inner = slot - gap\n  const bar = compare ? Math.min(26, inner * 0.44) : Math.min(48, inner * 0.64)\n  return { slot, bar }\n}\n\n/**\n * A column chart with round-capped bars, a signal dot on the current point and an optional comparison period.\n * Groups the points into buckets when the bars would be too thin to read.\n */\nexport function BarChart({\n  data,\n  label,\n  format,\n  compare = false,\n  current,\n  height,\n  minBarWidth = 10,\n  bucket = 7,\n  bucketTitle,\n  bucketLabel,\n  tickEvery,\n  loading = false,\n  placeholder,\n  legend,\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveIndexChange,\n  labels: labelsProp,\n  className,\n  style,\n  ...rest\n}: BarChartProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()\n  const { say, region } = useAnnounce()\n  const hintId = useId()\n  const [active, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)\n  const nowIndex = current === undefined ? data.length - 1 : current\n  const width = frame.width\n  const plotW = Math.max(0, width - GUT)\n  // The frame holds the plot and the 20 px row of labels under it.\n  const plotH = Math.max(0, frame.height - 20)\n\n  // Group the points when the bars would be thinner than promised. Groups are counted back from the last point,\n  // so the newest group is always whole and the oldest may be short.\n  const size = useMemo(() => {\n    if (!plotW || !bucket || bucket < 2) return 1\n    let s = 1\n    for (let k = 1; k < 64; k++) {\n      if (measure(Math.ceil(data.length / s), plotW, compare).bar >= minBarWidth || Math.ceil(data.length / s) <= 1) break\n      s = bucket * k\n    }\n    return s\n  }, [plotW, bucket, data.length, compare, minBarWidth])\n\n  const range = labelsProp?.range ?? LABELS.range\n  const slots: Slot[] = useMemo(() => {\n    const n = data.length\n    if (size === 1)\n      return data.map((d, i) => ({ label: d.label, title: d.title ?? d.label, value: d.value, previous: d.previous, start: i, end: i }))\n    const out: Slot[] = []\n    for (let end = n - 1; end >= 0; end -= size) {\n      const start = Math.max(0, end - size + 1)\n      const pts = data.slice(start, end + 1)\n      const hasPrev = pts.some((p) => p.previous !== undefined)\n      const first = pts[0].title ?? pts[0].label\n      const last = pts[pts.length - 1].title ?? pts[pts.length - 1].label\n      out.unshift({\n        label: bucketLabel ? bucketLabel(pts, start) : pts[0].label,\n        title: bucketTitle ? bucketTitle(pts, start) : pts.length === 1 ? first : range(first, last),\n        value: pts.reduce((s, p) => s + p.value, 0),\n        previous: hasPrev ? pts.reduce((s, p) => s + (p.previous ?? 0), 0) : undefined,\n        start,\n        end,\n      })\n    }\n    return out\n  }, [data, size, bucketTitle, bucketLabel, range])\n\n  const n = slots.length\n  const slotOf = (index: number | null) => (index === null || index < 0 ? -1 : slots.findIndex((s) => index >= s.start && index <= s.end))\n  const nowSlot = nowIndex === null ? -1 : slotOf(nowIndex)\n  const at = slotOf(active)\n  const empty = !loading && (n === 0 || slots.every((s) => !s.value && !(compare && s.previous)))\n\n  const max = Math.max(0, ...slots.map((s) => Math.max(s.value, compare ? (s.previous ?? 0) : 0)))\n  const scale = niceScale(max || 1, 2)\n  const top = scale.max\n  const ticks = empty ? [0, 0.5, 1] : scale.ticks.map((t) => t / top)\n  const { slot, bar } = measure(n, plotW, compare)\n\n  // First view and every new shape (period, grouping, compare) rise from the baseline; new values on the same bars glide.\n  const shape = `${n}|${compare}|${size}`\n  const [played, setPlayed] = useState<string | null>(null)\n  const ready = frame.drawn && width > 0\n  const risen = frame.reduced ? ready : played === shape\n  useEffect(() => {\n    if (!ready || played === shape || frame.reduced) return\n    let b = 0\n    const a = requestAnimationFrame(() => {\n      b = requestAnimationFrame(() => setPlayed(shape))\n    })\n    return () => {\n      cancelAnimationFrame(a)\n      cancelAnimationFrame(b)\n    }\n  }, [ready, played, shape, frame.reduced])\n\n  // A point that no longer exists lets go.\n  useEffect(() => {\n    if (active !== null && (active < 0 || active >= data.length)) setActive(null)\n  }, [active, data.length, setActive])\n\n  const describe = (i: number) => {\n    const s = slots[i]\n    const head = i === nowSlot ? `${s.title}, ${labels.current}` : s.title\n    const prev = compare && s.previous !== undefined ? `. ${labels.previous}: ${formatValue(s.previous, format)}` : ''\n    return `${head}: ${formatValue(s.value, format)}${prev}`\n  }\n  const pick = (i: number, announce: boolean) => {\n    setActive(slots[i].start)\n    if (announce) say(describe(i))\n  }\n\n  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {\n    if (!n || empty || loading) return\n    const r = e.currentTarget.getBoundingClientRect()\n    const i = Math.max(0, Math.min(n - 1, Math.floor((e.clientX - r.left) / Math.max(1, slot))))\n    if (i !== at) pick(i, false)\n  }\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!n || empty) return\n    if (e.key === 'Escape') {\n      if (at < 0) return\n      e.preventDefault()\n      setActive(null)\n      return\n    }\n    const to = stepIndex(e.key, at < 0 ? (nowSlot >= 0 ? nowSlot : n - 1) : at, n)\n    if (to === null) return\n    e.preventDefault()\n    pick(to, true)\n  }\n\n  // Ticks: as many labels as fit, counted back from the last bar so the current one is always named.\n  const longest = Math.max(1, ...slots.map((s) => s.label.length))\n  const fit = Math.ceil((longest * 6.8 + 10) / Math.max(1, slot))\n  const every = Math.max(1, fit, size === 1 && tickEvery ? tickEvery : size === 1 ? Math.ceil(n / 10) : 1)\n\n  const tip = at >= 0 && !empty && !loading && width > 0 ? slots[at] : null\n  const tipRows: TooltipRow[] = []\n  if (tip && compare && tip.previous !== undefined) {\n    tipRows.push({ label: labels.previous, value: formatValue(tip.previous, format), color: INK_GHOST })\n    if (tip.previous)\n      tipRows.push({\n        label: labels.change,\n        value: formatValue(((tip.value - tip.previous) / tip.previous) * 100, { locale: format?.locale, decimals: 1, sign: true, suffix: '%' }),\n      })\n  }\n  const tipY = tip\n    ? Math.min(\n        plotH * (1 - tip.value / top) - (at === nowSlot ? 18 : 0),\n        compare && tip.previous !== undefined ? plotH * (1 - tip.previous / top) : Infinity,\n      )\n    : 0\n\n  const showLegend = (legend ?? (compare || nowSlot >= 0)) && !loading && !empty\n  const stagger = Math.min(26, 300 / Math.max(1, n))\n  const glide = (k: number, extra = 0) =>\n    risen && !frame.reduced\n      ? {\n          transitionProperty: 'transform, opacity',\n          transitionDuration: '300ms, 160ms',\n          transitionTimingFunction: EASE_CSS,\n          transitionDelay: `${Math.round(k * stagger + extra)}ms, 0ms`,\n        }\n      : { transition: 'none' }\n\n  const heightClass =\n    'h-[170px] @min-[448px]:h-[208px] group-data-[density=compact]/tile:h-[132px] @min-[448px]:group-data-[density=compact]/tile:h-[150px]'\n\n  return (\n    <div className={cn('@container relative min-w-0', className)} style={style} aria-busy={loading || undefined} {...rest}>\n      {loading ? (\n        <div aria-hidden>\n          <div\n            className={cn('flex items-end gap-2 border-b motion-safe:animate-pulse', heightClass)}\n            style={{ marginRight: GUT, borderColor: GRID, height }}\n          >\n            {(data.length ? data.slice(-12) : SKELETON).map((_, i) => (\n              <span key={i} className=\"flex h-full flex-1 items-end justify-center\">\n                <i className=\"block w-[64%] max-w-12 rounded-[999px_999px_3px_3px] bg-muted\" style={{ height: `${SKELETON[i % 7]}%` }} />\n              </span>\n            ))}\n          </div>\n          <div className=\"mt-[9px] flex gap-2\" style={{ marginRight: GUT }}>\n            {(data.length ? data.slice(-12) : SKELETON).map((_, i) => (\n              <span key={i} className=\"flex flex-1 justify-center\">\n                <i className=\"block h-[9px] w-6 rounded-[10px] bg-muted motion-safe:animate-pulse\" />\n              </span>\n            ))}\n          </div>\n        </div>\n      ) : (\n        <div\n          ref={frameRef}\n          tabIndex={empty ? undefined : 0}\n          role={empty ? undefined : 'img'}\n          aria-label={empty ? undefined : label}\n          aria-describedby={empty ? undefined : hintId}\n          onPointerDown={fromPointer}\n          onPointerMove={fromPointer}\n          onPointerLeave={(e) => {\n            if (e.pointerType !== 'mouse') return\n            if (!e.currentTarget.matches(':focus-visible')) setActive(null)\n          }}\n          onFocus={(e) => {\n            // A press focuses the plot too; only keyboard focus picks the current point.\n            if (!e.currentTarget.matches(':focus-visible')) return\n            if (at >= 0) {\n              say(describe(at))\n              return\n            }\n            const i = nowSlot >= 0 ? nowSlot : n - 1\n            if (i >= 0) pick(i, true)\n          }}\n          onBlur={() => setActive(null)}\n          onKeyDown={onKeyDown}\n          className={cn('relative rounded-xl outline-offset-4 select-none [touch-action:pan-y]', !empty && 'cursor-crosshair')}\n        >\n          <div className={cn('relative', heightClass)} style={{ marginRight: GUT, height }}>\n            {/* Gridlines with their values at the right; the baseline is solid. */}\n            {ticks.map((t, i) => (\n              <div\n                key={`${i}-${t}`}\n                aria-hidden\n                className=\"absolute left-0 h-0 border-t\"\n                style={{\n                  bottom: `${t * 100}%`,\n                  right: 0,\n                  borderTopStyle: t === 0 ? 'solid' : 'dashed',\n                  borderTopColor: t === 0 ? BASE : GRID,\n                }}\n              >\n                {!empty && (\n                  <span\n                    className=\"absolute top-[-6px] font-mono text-[11px] leading-none whitespace-nowrap text-muted-foreground tabular-nums\"\n                    style={{ left: `calc(100% + 8px)` }}\n                  >\n                    {formatTick(t * top, format)}\n                  </span>\n                )}\n              </div>\n            ))}\n            {!empty &&\n              width > 0 &&\n              slots.map((s, i) => {\n                const isNow = i === nowSlot\n                const dim = at >= 0 && at !== i\n                const v = risen ? Math.max(0, s.value) / top : 0\n                const p = risen ? Math.max(0, s.previous ?? 0) / top : 0\n                const two = compare && s.previous !== undefined\n                const groupW = two ? bar * 2 + 3 : bar\n                return (\n                  <div\n                    key={i}\n                    aria-hidden\n                    className=\"absolute top-0 bottom-0 flex justify-center gap-[3px]\"\n                    style={{ left: i * slot + slot / 2 - groupW / 2, width: groupW }}\n                  >\n                    {two && (\n                      <span className=\"relative block h-full overflow-hidden rounded-b-[3px]\" style={{ width: bar }}>\n                        <i\n                          className=\"absolute inset-0 rounded-[999px_999px_3px_3px]\"\n                          style={{ background: INK_GHOST, transform: `translateY(${(1 - p) * 100}%)`, opacity: dim ? 0.42 : 1, ...glide(i) }}\n                        />\n                      </span>\n                    )}\n                    <span className=\"relative block h-full\" style={{ width: bar }}>\n                      <span className=\"absolute inset-0 overflow-hidden rounded-b-[3px]\">\n                        <i\n                          className=\"absolute inset-0 rounded-[999px_999px_3px_3px]\"\n                          style={{ background: INK, transform: `translateY(${(1 - v) * 100}%)`, opacity: dim ? 0.42 : 1, ...glide(i) }}\n                        />\n                      </span>\n                      {isNow && (\n                        <span\n                          className=\"absolute inset-0\"\n                          style={{ transform: `translateY(${(1 - v) * 100}%)`, ...glide(i) }}\n                        >\n                          <span\n                            className=\"absolute top-[-18px] left-1/2 -ml-[5px] block size-2.5 rounded-full bg-primary shadow-[0_0_0_3px_var(--card)]\"\n                            style={\n                              frame.reduced\n                                ? undefined\n                                : {\n                                    opacity: risen ? 1 : 0,\n                                    transform: risen ? 'none' : 'translateY(6px)',\n                                    transitionProperty: risen ? 'opacity, transform' : 'none',\n                                    transitionDuration: '300ms',\n                                    transitionTimingFunction: EASE_CSS,\n                                    transitionDelay: '300ms',\n                                  }\n                            }\n                          />\n                        </span>\n                      )}\n                    </span>\n                  </div>\n                )\n              })}\n            {empty && (\n              <div className=\"absolute inset-0 grid place-content-center justify-items-center gap-1.5 px-4 text-center\" style={{ marginRight: -GUT }}>\n                {placeholder ?? <span className=\"text-[13.5px] text-muted-foreground\">{labels.empty}</span>}\n              </div>\n            )}\n          </div>\n          <div aria-hidden className=\"relative mt-[9px] h-[11px]\" style={{ marginRight: GUT }}>\n            {width > 0 &&\n              slots.map((s, i) =>\n                (n - 1 - i) % every === 0 ? (\n                  <span\n                    key={i}\n                    className={cn(\n                      'absolute top-0 -translate-x-1/2 font-mono text-[11px] leading-none whitespace-nowrap tabular-nums',\n                      i === nowSlot ? 'font-medium text-foreground' : 'text-muted-foreground',\n                    )}\n                    style={{ left: i * slot + slot / 2 }}\n                  >\n                    {s.label}\n                  </span>\n                ) : null,\n              )}\n          </div>\n        </div>\n      )}\n      <span id={hintId} hidden>\n        {labels.hint}\n      </span>\n      {loading && <span className=\"sr-only\">{labels.loading}</span>}\n      {!loading && !empty && (\n        <>\n          <ChartTooltip\n            open={!!tip}\n            x={tip ? at * slot + slot / 2 : 0}\n            y={tipY}\n            bounds={width}\n            title={tip ? (at === nowSlot ? `${tip.title} · ${labels.current}` : tip.title) : ''}\n            value={tip ? <TipValue value={tip.value} format={format} /> : ''}\n            rows={tipRows}\n          />\n          {showLegend && (\n            <TileLegend\n              className=\"mt-3\"\n              items={[\n                { label: labels.value, color: INK },\n                ...(compare ? [{ label: labels.previous, color: INK_GHOST }] : []),\n                ...(nowSlot >= 0 ? [{ label: labels.currentKey, color: 'var(--primary)', shape: 'dot' as const }] : []),\n              ]}\n            />\n          )}\n          <SrTable\n            caption={label}\n            head={compare ? [labels.point, labels.value, labels.previous] : [labels.point, labels.value]}\n            rows={slots.map((s) => {\n              const name = slots.indexOf(s) === nowSlot ? `${s.title} (${labels.current})` : s.title\n              return compare ? [name, formatValue(s.value, format), s.previous === undefined ? '' : formatValue(s.previous, format)] : [name, formatValue(s.value, format)]\n            })}\n          />\n          {region}\n        </>\n      )}\n    </div>\n  )\n}\n\n/** The tooltip figure with its unit set muted, as on the big number. */\nfunction TipValue({ value, format }: { value: number; format?: ValueFormat }) {\n  const p = valueParts(value, format)\n  const unit = <span className=\"font-medium text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]\">{p.unit}</span>\n  return (\n    <>\n      {p.sign}\n      {!p.unitAfter && unit}\n      {p.whole}\n      {p.fraction}\n      {p.unitAfter && <> {unit}</>}\n      {p.suffix}\n    </>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}