{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "title": "Sparkline",
  "description": "A tiny trend line without axes for beside a figure or in a table cell, with a dot on the latest point, a tooltip and a 44 px keyboard and touch target however thin it is.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/sparkline/sparkline.tsx",
      "content": "// Sparkline: a tiny trend line with no axes, meant to sit beside a number or inside a table cell. The line is drawn\n// in ink and the latest point (or any `current` index) is marked with a small dot in --primary, the only signal.\n//\n// How it works: the values are scaled into the measured box (its own min to max, or a shared `domain` so several\n// sparklines can be compared) and drawn as one smooth line that never overshoots its points. It fills its\n// container's width unless `width` is given; the height is 30 px (24 in a compact tile) unless `height` is given.\n// Pointer hover and a finger scrubbing sideways pick the nearest point and show the shared tooltip; the page still\n// scrolls under a finger. The plot is one Tab stop, with a hit area of at least 44 px however thin the line is.\n// Arrow keys, Home and End step through the points and Escape lets go.\n//\n// Screen readers: the sparkline is one image named \"<label>, up 11.2 percent, from €4,752.22 to €5,470.58\", with a\n// hint; each step by keyboard is read once (\"Thu 8 Oct, latest: €5,470.58\"), and the points are in a hidden table\n// unless `table` is off (as inside a table row, where the row already says what the line is about).\n//\n// Motion: on first view the line is wiped in from the left by scaling its clip shape (300 ms, after an optional\n// `delay` of at most 300 ms, so all is done within 600 ms). New data redraws at once. Under reduced motion the line\n// is there at once.\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent,\n  type PointerEvent,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChartTooltip, SrTable } from '@/registry/manniche/chart-kit/chart-kit'\nimport { EASE_CSS, GRID, INK, formatValue, smoothPath, stepIndex, valueParts, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce, useChartFrame, useSvgId } from '@/registry/manniche/chart-kit/use-chart'\n\nexport type SparkPoint = {\n  /** The point's value. */\n  value: number\n  /** The point's name for the tooltip, the table and screen readers: \"Thu 8 Oct\". Default \"3 of 30\". */\n  label?: string\n}\n\nexport type SparklineSummary = {\n  /** The first and last values, already formatted. */\n  first: string\n  last: string\n  /** The change from the first to the last value in percent, or null when the first value is 0. */\n  change: number | null\n}\n\nexport type SparklineLabels = {\n  /** Added to the current point's name in the tooltip and when read: \"Thu 8 Oct · latest\". Default \"latest\". */\n  current?: string\n  /** The first column of the hidden table. Default \"Point\". */\n  point?: string\n  /** The second column of the hidden table. Default \"Value\". */\n  value?: string\n  /** Names a point that has no label. Default \"3 of 30\". */\n  position?: (index: number, count: number) => string\n  /** The trend read after the chart's name. Default \"up 11.2 percent, from €4,752.22 to €5,470.58\". */\n  summary?: (s: SparklineSummary) => string\n  /** Read after the name. Default \"Use the arrow keys to read each point.\" */\n  hint?: string\n  /** Read while loading. Default \"Loading\". */\n  loading?: string\n  /** Read when there is nothing to draw. Default \"No data yet\". */\n  empty?: string\n}\n\nconst LABELS: Required<SparklineLabels> = {\n  current: 'latest',\n  point: 'Point',\n  value: 'Value',\n  position: (i, n) => `${i + 1} of ${n}`,\n  summary: ({ first, last, change }) => {\n    const trend =\n      change === null ? '' : Math.abs(change) < 0.05 ? 'unchanged, ' : `${change > 0 ? 'up' : 'down'} ${Math.abs(change).toFixed(1)} percent, `\n    return `${trend}from ${first} to ${last}`\n  },\n  hint: 'Use the arrow keys to read each point.',\n  loading: 'Loading',\n  empty: 'No data yet',\n}\n\nexport type SparklineProps = Omit<ComponentProps<'div'>, 'children'> & {\n  /** The values, oldest first: plain numbers or `{ value, label? }`. */\n  data: readonly (number | SparkPoint)[]\n  /** The chart's accessible name, such as \"Savings, last 30 days\". The trend is read after it. */\n  label: string\n  /** How values are written in the tooltip, the summary and the table. */\n  format?: ValueFormat\n  /** A fixed width in px. Default the container's full width. */\n  width?: number\n  /** The height in px. Default 30, and 24 in a compact tile. */\n  height?: number\n  /** The index of the point marked with the signal dot. Default the last point; null for none. */\n  current?: number | null\n  /** The values at the bottom and top of the box, to put several sparklines on one scale. Default each line's own min and max. */\n  domain?: [number, number]\n  /** Show a tooltip and read points by hover, touch and keyboard. Off draws a still image. Default true. */\n  interactive?: boolean\n  /** Put the points in a hidden table for screen readers. Turn it off inside a table row. Default true. */\n  table?: boolean\n  /** Wait this long (ms, at most 300) before the first-view wipe, to stagger several lines. Default 0. */\n  delay?: number\n  /** Show a breathing placeholder line instead of data. */\n  loading?: boolean\n  /** The highlighted point (an index into `data`), or null. */\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?: SparklineLabels\n}\n\n// Room around the line: the stroke's round caps at the left, the signal dot at the right, and a little at the top\n// and bottom so the dot and the hover ring are never cut.\nconst PAD_X = 2\nconst PAD_DOT = 6\nconst PAD_Y = 4\n// A gentle placeholder shape, in 0…1 of the box.\nconst SKELETON = [0.62, 0.5, 0.56, 0.38, 0.44, 0.3, 0.36]\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/** A tiny trend line with the latest point marked, for beside a number or inside a table cell. */\nexport function Sparkline({\n  data,\n  label,\n  format,\n  width: widthProp,\n  height,\n  current,\n  domain,\n  interactive = true,\n  table = true,\n  delay = 0,\n  loading = false,\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveIndexChange,\n  labels: labelsProp,\n  className,\n  style,\n  ...rest\n}: SparklineProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()\n  const { say, region } = useAnnounce()\n  const hintId = useId()\n  const clipId = useSvgId('spark')\n  const [rawActive, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)\n\n  const points = data.map((d, i) => {\n    const p = typeof d === 'number' ? { value: d } : d\n    return { value: p.value, title: p.label ?? labels.position(i, data.length) }\n  })\n  const n = points.length\n  const nowIndex = current === undefined ? n - 1 : current !== null && current >= 0 && current < n ? current : -1\n  const at = rawActive !== null && rawActive >= 0 && rawActive < n ? rawActive : -1\n  const empty = !loading && n === 0\n  const live = interactive && !loading && !empty\n\n  const W = frame.width\n  const H = frame.height\n  const x0 = PAD_X\n  const x1 = Math.max(x0, W - (nowIndex >= 0 ? PAD_DOT : PAD_X))\n  const values = points.map((p) => p.value)\n  const lo = domain ? domain[0] : Math.min(...values)\n  const hi = domain ? domain[1] : Math.max(...values)\n  const xOf = (i: number) => (n < 2 ? (x0 + x1) / 2 : x0 + (i / (n - 1)) * (x1 - x0))\n  const yOf = (v: number) => (hi === lo ? H / 2 : PAD_Y + (1 - (v - lo) / (hi - lo)) * Math.max(0, H - PAD_Y * 2))\n  const pts = points.map((p, i) => [xOf(i), yOf(p.value)] as [number, number])\n\n  // The first view wipes the line in once; later data just redraws.\n  const ready = frame.drawn && W > 0 && H > 0\n  const [played, setPlayed] = useState(false)\n  const shown = frame.reduced ? ready : played\n  useEffect(() => {\n    if (!ready || played || frame.reduced) return\n    let b = 0\n    const a = requestAnimationFrame(() => {\n      b = requestAnimationFrame(() => setPlayed(true))\n    })\n    return () => {\n      cancelAnimationFrame(a)\n      cancelAnimationFrame(b)\n    }\n  }, [ready, played, frame.reduced])\n\n  // A point that no longer exists lets go.\n  useEffect(() => {\n    if (rawActive !== null && (rawActive < 0 || rawActive >= n)) setActive(null)\n  }, [rawActive, n, setActive])\n\n  const name = (i: number) => (i === nowIndex ? `${points[i].title}, ${labels.current}` : points[i].title)\n  const describe = (i: number) => `${name(i)}: ${formatValue(points[i].value, format)}`\n  const pick = (i: number, announce: boolean) => {\n    setActive(i)\n    if (announce) say(describe(i))\n  }\n\n  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {\n    if (!live || n < 1) return\n    const r = e.currentTarget.getBoundingClientRect()\n    const t = n < 2 ? 0 : (e.clientX - r.left - x0) / Math.max(1, x1 - x0)\n    const i = Math.max(0, Math.min(n - 1, Math.round(t * (n - 1))))\n    if (i !== at) pick(i, false)\n  }\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!live) 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 ? (nowIndex >= 0 ? nowIndex : n - 1) : at, n)\n    if (to === null) return\n    e.preventDefault()\n    pick(to, true)\n  }\n\n  const first = n ? points[0].value : 0\n  const last = n ? points[n - 1].value : 0\n  const summary = n\n    ? labels.summary({\n        first: formatValue(first, format),\n        last: formatValue(last, format),\n        change: first === 0 || n < 2 ? null : ((last - first) / Math.abs(first)) * 100,\n      })\n    : ''\n  const name0 = loading ? `${label}, ${labels.loading}` : empty ? `${label}, ${labels.empty}` : `${label}, ${summary}`\n\n  const wipe = Math.max(0, Math.min(300, delay))\n  const tip = live && at >= 0 && W > 0 ? points[at] : null\n  const dotNow = nowIndex >= 0 ? pts[nowIndex] : null\n\n  return (\n    <div\n      className={cn('relative min-w-0', widthProp === undefined ? 'block w-full' : 'inline-block align-middle', className)}\n      style={{ width: widthProp, ...style }}\n      aria-busy={loading || undefined}\n      {...rest}\n    >\n      <div\n        ref={frameRef}\n        role=\"img\"\n        aria-label={name0}\n        aria-describedby={live ? hintId : undefined}\n        tabIndex={live ? 0 : undefined}\n        onPointerDown={live ? fromPointer : undefined}\n        onPointerMove={live ? fromPointer : undefined}\n        onPointerLeave={\n          live\n            ? (e) => {\n                if (e.pointerType !== 'mouse') return\n                if (!e.currentTarget.matches(':focus-visible')) setActive(null)\n              }\n            : undefined\n        }\n        onFocus={\n          live\n            ? (e) => {\n                // A press focuses the line 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                pick(nowIndex >= 0 ? nowIndex : n - 1, true)\n              }\n            : undefined\n        }\n        onBlur={live ? () => setActive(null) : undefined}\n        onKeyDown={live ? onKeyDown : undefined}\n        className={cn(\n          'relative h-[30px] rounded-lg outline-offset-4 select-none group-data-[density=compact]/tile:h-6',\n          live &&\n            // However thin the line, the hit area is at least 44 px tall.\n            'cursor-crosshair [touch-action:pan-y] before:absolute before:inset-x-0 before:top-[min(0px,calc(50%-22px))] before:bottom-[min(0px,calc(50%-22px))] before:content-[\"\"]',\n        )}\n        style={{ height }}\n      >\n        {W > 0 && H > 0 && (\n          <svg aria-hidden width={W} height={H} viewBox={`0 0 ${W} ${H}`} className=\"pointer-events-none absolute inset-0 block overflow-visible\">\n            {loading ? (\n              <path\n                d={smoothPath(SKELETON.map((v, i) => [x0 + (i / (SKELETON.length - 1)) * (W - 2 * PAD_X), PAD_Y + v * (H - 2 * PAD_Y)]))}\n                fill=\"none\"\n                stroke=\"var(--muted)\"\n                strokeWidth={3}\n                strokeLinecap=\"round\"\n                className=\"motion-safe:animate-pulse\"\n              />\n            ) : empty ? (\n              <line x1={x0} x2={W - PAD_X} y1={H / 2} y2={H / 2} stroke={GRID} strokeWidth={1.5} strokeDasharray=\"2 4\" strokeLinecap=\"round\" />\n            ) : (\n              <>\n                <defs>\n                  <clipPath id={clipId}>\n                    <rect\n                      x={0}\n                      y={-PAD_Y * 2}\n                      width={W + PAD_X}\n                      height={H + PAD_Y * 4}\n                      style={{\n                        transformBox: 'view-box',\n                        transformOrigin: '0px 0px',\n                        transform: `scaleX(${shown ? 1 : 0})`,\n                        transition:\n                          shown && !frame.reduced ? `transform 300ms ${EASE_CSS} ${wipe}ms` : 'none',\n                      }}\n                    />\n                  </clipPath>\n                </defs>\n                <g clipPath={`url(#${clipId})`}>\n                  {n > 1 && (\n                    <path d={smoothPath(pts)} fill=\"none\" stroke={INK} strokeWidth={1.75} strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n                  )}\n                  {n === 1 && <circle cx={pts[0][0]} cy={pts[0][1]} r={2.5} fill={INK} />}\n                  {dotNow && <circle cx={dotNow[0]} cy={dotNow[1]} r={3.5} fill=\"var(--primary)\" stroke=\"var(--card)\" strokeWidth={2.5} />}\n                </g>\n                {tip && (\n                  <g>\n                    <line\n                      x1={pts[at][0]}\n                      x2={pts[at][0]}\n                      y1={Math.min(0, pts[at][1] - 8)}\n                      y2={Math.max(H, pts[at][1] + 8)}\n                      stroke=\"var(--foreground)\"\n                      strokeOpacity={0.35}\n                      strokeWidth={1}\n                      shapeRendering=\"crispEdges\"\n                    />\n                    {at === nowIndex ? (\n                      <circle cx={pts[at][0]} cy={pts[at][1]} r={3.5} fill=\"var(--primary)\" stroke=\"var(--card)\" strokeWidth={2.5} />\n                    ) : (\n                      <circle cx={pts[at][0]} cy={pts[at][1]} r={3.5} fill=\"var(--foreground)\" stroke=\"var(--card)\" strokeWidth={2.5} />\n                    )}\n                  </g>\n                )}\n              </>\n            )}\n          </svg>\n        )}\n      </div>\n      {live && (\n        <>\n          <span id={hintId} hidden>\n            {labels.hint}\n          </span>\n          <ChartTooltip\n            open={!!tip}\n            x={tip ? pts[at][0] : 0}\n            y={-4}\n            bounds={W}\n            title={tip ? (at === nowIndex ? `${tip.title} · ${labels.current}` : tip.title) : ''}\n            value={tip ? <TipValue value={tip.value} format={format} /> : ''}\n          />\n          {region}\n        </>\n      )}\n      {table && !loading && !empty && (\n        <SrTable\n          caption={label}\n          head={[labels.point, labels.value]}\n          rows={points.map((p, i) => [i === nowIndex ? `${p.title} (${labels.current})` : p.title, formatValue(p.value, format)])}\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"
}