{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tracker","type":"registry:ui","title":"Tracker","description":"A row of status squares for uptime, job runs or habits; hover a square (or use the arrow keys) to read its day and note. Where it's narrow, the oldest make room.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json","https://dashral.com/r/swap.json"],"files":[{"path":"registry/dashral/tracker.tsx","type":"registry:file","target":"components/dashral/tracker.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { riseMotion, stagger } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\nimport { Swap } from \"./swap\"\n\ntype TrackerColor = \"success\" | \"warning\" | \"destructive\" | \"info\" | \"primary\" | \"neutral\"\n\nexport type TrackerDatum = {\n  /** A stable key; the index is used without one. */\n  key?: string\n  /** What the square stands for, said when it's read: a day (\"Oct 18\"), a run (\"#1284\"), an hour. */\n  label: string\n  /** Its color, for what it means: success (fine), warning, destructive (down, failed), info, primary; neutral for no data. By default the tracker's color. */\n  color?: TrackerColor\n  /** What happened, after the label when it's read: \"Slow responses for 14 minutes\". */\n  note?: string\n}\n\ntype TrackerProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"color\"> & {\n  /** The squares, oldest first. */\n  data: TrackerDatum[]\n  /** The color of squares without their own. */\n  color?: TrackerColor\n  /** The squares' height in pixels. */\n  height?: number\n  /**\n   * Under the squares while none is read: at the start, in the middle (e.g. \"99.98% uptime\") and at the end (\"Today\").\n   * The start and end default to the first and last square showing (where it's narrow, the oldest make room, so the\n   * start follows); null leaves one out.\n   */\n  startLabel?: React.ReactNode\n  summary?: React.ReactNode\n  endLabel?: React.ReactNode\n  /** What the tracker shows, e.g. \"API uptime, last 90 days\". */\n  \"aria-label\"?: string\n  classNames?: { square?: string; caption?: string }\n}\n\nconst colors: Record<TrackerColor, string> = {\n  success: \"bg-success/80\",\n  warning: \"bg-warning\",\n  destructive: \"bg-destructive\",\n  info: \"bg-info\",\n  primary: \"bg-primary\",\n  neutral: \"bg-muted\",\n}\n\n/** The narrowest a square gets before the oldest ones are left out, and the gap between squares, in pixels. */\nconst MIN_SQUARE = 4\nconst GAP = 2\n\n/**\n * A row of status squares, in one tag: uptime per day, the last runs of a job, a habit kept. Hover a square, or focus\n * the row and use the arrow keys, to read it under the row (its label and note) while the others fade back. Where it's\n * narrow, the oldest squares make room, so the latest always show. The squares rise in one after another.\n */\nfunction Tracker({\n  data,\n  color = \"success\",\n  height = 28,\n  startLabel,\n  summary,\n  endLabel,\n  className,\n  classNames,\n  \"aria-label\": ariaLabel,\n  ...props\n}: TrackerProps) {\n  const { t, dir } = useLocale()\n  const box = React.useRef<HTMLDivElement>(null)\n  const [fits, setFits] = React.useState(data.length)\n  const [active, setActive] = React.useState<number | null>(null)\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  // As many of the latest squares as fit at their narrowest; followed as the row resizes.\n  React.useLayoutEffect(() => {\n    const el = box.current\n    if (!el) return\n    const measure = () => setFits(Math.max(1, Math.floor((el.clientWidth + GAP) / (MIN_SQUARE + GAP))))\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n  const first = Math.max(0, data.length - fits)\n  const shown = data.slice(first)\n  const n = shown.length\n  const read = active === null ? null : shown[active]\n  const describe = (d: TrackerDatum) => (d.note ? `${d.label} · ${d.note}` : d.label)\n\n  function pick(i: number | null, fromKeyboard = false) {\n    setActive(i)\n    if (fromKeyboard && i !== null) setAnnounce(describe(shown[i]!))\n  }\n\n  function onKeyDown(event: React.KeyboardEvent) {\n    if (!n) return\n    const current = active ?? n - 1\n    // The arrows move the way they point on screen: right to left, ← is the next square.\n    const next =\n      event.key === (dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\") ? Math.max(0, current - 1)\n      : event.key === (dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\") ? Math.min(n - 1, current + 1)\n      : event.key === \"Home\" ? 0\n      : event.key === \"End\" ? n - 1\n      : undefined\n    if (event.key === \"Escape\") return pick(null)\n    if (next === undefined) return\n    event.preventDefault()\n    pick(next, true)\n  }\n\n  return (\n    <div\n      data-slot=\"tracker\"\n      role=\"group\"\n      aria-roledescription={t(\"tracker\")}\n      aria-label={ariaLabel}\n      tabIndex={0}\n      onKeyDown={onKeyDown}\n      onBlur={() => pick(null)}\n      className={cn(\"grid gap-1.5 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-4 focus-visible:ring-offset-background\", className)}\n      {...props}\n    >\n      {/* Hover holds across the gaps: the row, not each square, lets go when the pointer leaves. */}\n      <div ref={box} aria-hidden className=\"flex\" style={{ height, gap: GAP }} onPointerLeave={() => pick(null)}>\n        {shown.map((d, i) => (\n          <span key={d.key ?? first + i} className=\"relative min-w-0 flex-1 overflow-hidden rounded-[2px]\" onPointerEnter={() => pick(i)}>\n            <span\n              className={cn(\n                riseMotion,\n                \"absolute inset-0 transition-opacity duration-(--duration-fast)\",\n                colors[d.color ?? color],\n                active !== null && active !== i && \"opacity-50\",\n                classNames?.square\n              )}\n              style={stagger(i, 8, 90)}\n            />\n          </span>\n        ))}\n      </div>\n\n      {n > 0 && (\n        <Swap value={active === null ? \"rest\" : `day-${active}`} as=\"div\" className={cn(\"flex min-h-4 justify-between gap-3 text-[11px] text-muted-foreground\", classNames?.caption)}>\n          {read ? (\n            <span className=\"truncate\">\n              <span className=\"text-foreground\">{read.label}</span>\n              {read.note && <> · {read.note}</>}\n            </span>\n          ) : (\n            <>\n              <span>{startLabel === undefined ? shown[0]!.label : startLabel}</span>\n              <span className=\"tabular-nums\">{summary}</span>\n              <span>{endLabel === undefined ? shown[n - 1]!.label : endLabel}</span>\n            </>\n          )}\n        </Swap>\n      )}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </div>\n  )\n}\n\nexport { Tracker, type TrackerProps, type TrackerColor }\n"}],"cssVars":{"theme":{"color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42"}}}