{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-heatmap",
  "type": "registry:ui",
  "title": "Activity heatmap",
  "description": "See a year of activity at a glance, one square per day.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/activity-heatmap/activity-heatmap.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ActivityDay {\n  /** Calendar day as YYYY-MM-DD. */\n  date: string;\n  count: number;\n}\n\n/**\n * A contribution style calendar: one square per day, weeks as columns, with four accent tints for how busy a day was.\n * Use it when rhythm, streaks, and quiet weeks matter more than exact comparisons; use a bar chart when the exact\n * comparison is the point. Cells wave in once on view, a tooltip glides between cells on hover or keyboard focus, and a\n * new range recolors the grid in a sweep instead of redrawing it. Arrow keys move by day and week, Enter selects.\n */\nexport interface ActivityHeatmapProps {\n  /** One entry per day, oldest first. Days missing inside the range count as zero. */\n  days: ActivityDay[];\n  /** Accessible name for the grid, such as \"Contributions in 2025\". */\n  label: string;\n  /** Finishes the summary line: \"1,284 contributions in {period}\". */\n  period: string;\n  /** Nouns for the count. */\n  unit?: { one: string; other: string };\n  /** Upper bounds for levels one to three; anything above the last is level four. Defaults to quarters of the busiest day. */\n  thresholds?: [number, number, number];\n  weekStartsOn?: 0 | 1;\n  selectedDate?: string | null;\n  onSelectDate?: (date: string) => void;\n  /** Controls beside the summary, such as a range switch. */\n  actions?: ReactNode;\n  /** Formatting locale. Fixed by default so server and client render the same labels. */\n  locale?: string;\n  className?: string;\n}\n\ntype Model = {\n  start: number;\n  length: number;\n  lead: number;\n  weeks: number;\n  total: number;\n  counts: number[];\n  levels: number[];\n  thresholds: [number, number, number];\n  months: { month: number; col: number; label: string }[];\n  /** How many days in the range sit at each level. */\n  perLevel: number[];\n};\ntype Tip = { key: string; primary: string; secondary: string; value: number; anchor: HTMLElement };\n/** How the tooltip text changes: rolls up or down with the count, crossfades on a tie, or appears at once when the bubble opens. */\ntype Change = 1 | -1 | 0 | \"instant\";\n\nconst DAY = 86_400_000;\nconst LEVELS = [0, 1, 2, 3, 4] as const;\n/** Splits text into user-perceived characters, so a digit or letter never splits into code points. */\nconst graphemes = (text: string) => Array.from(new Intl.Segmenter().segment(text), (part) => part.segment);\n/** Levels are tints of the accent over a neutral empty square, so the grid follows the selected accent and both themes. */\nconst levelColors =\n  \"[--level-0:color-mix(in_oklab,var(--ink)_6.5%,var(--surface))] [--level-1:color-mix(in_oklab,var(--accent)_26%,var(--level-0))] [--level-2:color-mix(in_oklab,var(--accent)_50%,var(--level-0))] [--level-3:color-mix(in_oklab,var(--accent)_76%,var(--level-0))] [--level-4:var(--accent)]\";\n/**\n * One square per day. The wave delay rides a custom property so a reveal and a recolor sweep the same diagonal.\n * Before the reveal the squares wait small and transparent; a highlighted level keeps full opacity and the rest step back.\n */\nconst cellBase =\n  \"block size-(--cell) flex-none cursor-default rounded-[calc(var(--cell)*.28)] outline-[1.5px] outline-offset-1 outline-transparent outline-solid [-webkit-tap-highlight-color:transparent] [--delay:var(--wave)] group-data-[direction=back]/grid:[--delay:var(--wave-back)] [transition:background-color_320ms_var(--ease-standard)_var(--delay),transform_var(--duration-spring)_var(--ease-spring)_var(--delay),opacity_var(--duration-fast)_var(--ease-standard)] group-data-[reveal=revealing]/grid:[transition:background-color_320ms_var(--ease-standard)_var(--delay),transform_var(--duration-spring)_var(--ease-spring)_var(--delay),opacity_360ms_var(--ease-enter)_var(--delay)] motion-reduce:not-group-data-[reveal=hidden]/grid:[transition:background-color_var(--duration-fast)_var(--ease-standard),opacity_var(--duration-fast)_var(--ease-standard)] group-data-[reveal=hidden]/grid:[transition:none]\";\n/** Days outside the range shrink away in the sweep and grow back when a range needs them. */\nconst emptyCell = `${cellBase} bg-(--level-0) [transform:scale(0)] group-data-[reveal=hidden]/grid:opacity-0 motion-reduce:[transform:none] motion-reduce:opacity-0`;\nconst dayCell = [\n  \"bg-(--level-0) group-data-highlight/grid:not-group-data-[highlight=0]/grid:opacity-20\",\n  \"bg-(--level-1) group-data-highlight/grid:not-group-data-[highlight=1]/grid:opacity-20\",\n  \"bg-(--level-2) group-data-highlight/grid:not-group-data-[highlight=2]/grid:opacity-20\",\n  \"bg-(--level-3) group-data-highlight/grid:not-group-data-[highlight=3]/grid:opacity-20\",\n  \"bg-(--level-4) group-data-highlight/grid:not-group-data-[highlight=4]/grid:opacity-20\",\n].map(\n  (level) =>\n    `${cellBase} ${level} hover:outline-[color-mix(in_oklab,var(--ink)_45%,transparent)] group-data-[reveal=hidden]/grid:[transform:scale(.5)] not-group-data-highlight/grid:group-data-[reveal=hidden]/grid:opacity-0 motion-reduce:[transform:none]`,\n);\n/** The visible square stays the size of a day while the button keeps a comfortable target around it. */\nconst swatchBase =\n  \"relative grid h-5 w-[15px] cursor-pointer place-items-center rounded-[4px] border-0 bg-transparent p-0 [-webkit-tap-highlight-color:transparent] before:size-[11px] before:rounded-[3px] before:content-[''] aria-pressed:shadow-[inset_0_0_0_1.5px_var(--ink)]\";\nconst swatchLevel = [\n  \"before:bg-(--level-0)\",\n  \"before:bg-(--level-1)\",\n  \"before:bg-(--level-2)\",\n  \"before:bg-(--level-3)\",\n  \"before:bg-(--level-4)\",\n];\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n/** Total wave travel in ms, kept under the system's stagger budget however many weeks the range spans. */\nconst WAVE = 420;\nconst toUtc = (iso: string) => Date.parse(`${iso}T00:00:00Z`);\nconst toIso = (time: number) => new Date(time).toISOString().slice(0, 10);\n\nfunction buildModel(\n  days: ActivityDay[],\n  weekStartsOn: 0 | 1,\n  thresholds: [number, number, number] | undefined,\n  locale: string,\n): Model {\n  const dates = days.map((day) => toUtc(day.date)).filter(Number.isFinite);\n  const start = dates.length ? Math.min(...dates) : toUtc(\"2025-01-01\");\n  const length = dates.length ? Math.round((Math.max(...dates) - start) / DAY) + 1 : 0;\n  const counts = Array.from({ length }, () => 0);\n  days.forEach((day) => {\n    const index = Math.round((toUtc(day.date) - start) / DAY);\n    if (index >= 0 && index < length) counts[index]! += Math.max(0, day.count);\n  });\n  const max = counts.reduce((a, b) => Math.max(a, b), 0);\n  const bounds =\n    thresholds ??\n    ([Math.max(1, Math.ceil(max * 0.25)), Math.max(2, Math.ceil(max * 0.5)), Math.max(3, Math.ceil(max * 0.75))] as [\n      number,\n      number,\n      number,\n    ]);\n  const levels = counts.map((count) =>\n    count <= 0 ? 0 : count <= bounds[0] ? 1 : count <= bounds[1] ? 2 : count <= bounds[2] ? 3 : 4,\n  );\n  const lead = (new Date(start).getUTCDay() - weekStartsOn + 7) % 7;\n  const weeks = Math.ceil((lead + length) / 7);\n  const monthName = new Intl.DateTimeFormat(locale, { month: \"short\", timeZone: \"UTC\" });\n  const months: Model[\"months\"] = [];\n  for (let index = 0; index < length; index++) {\n    const date = new Date(start + index * DAY);\n    if (index === 0 || date.getUTCDate() === 1)\n      months.push({\n        month: date.getUTCFullYear() * 12 + date.getUTCMonth(),\n        col: Math.floor((lead + index) / 7),\n        label: monthName.format(date),\n      });\n  }\n  // A partial first month keeps its label only when there is room before the next one.\n  if (months.length > 1 && months[1]!.col - months[0]!.col < 3) months.shift();\n  const perLevel = [0, 0, 0, 0, 0];\n  levels.forEach((level) => perLevel[level]!++);\n  return {\n    start,\n    length,\n    lead,\n    weeks,\n    total: counts.reduce((a, b) => a + b, 0),\n    counts,\n    levels,\n    thresholds: bounds,\n    months,\n    perLevel,\n  };\n}\n\nconst CONTRIBUTIONS = { one: \"contribution\", other: \"contributions\" };\nconst noun = (count: number, unit: { one: string; other: string }) => (count === 1 ? unit.one : unit.other);\nconst noopSubscribe = () => () => {};\n/** Reduced motion only after hydration, so the server and the first client render agree. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false,\n  );\n  const reduced = useReducedMotion();\n  return hydrated && !!reduced;\n}\n\nconst tipText = (reduced: boolean): Variants => ({\n  from: (change: Change) =>\n    change === \"instant\"\n      ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" }\n      : reduced\n        ? { opacity: 0 }\n        : {\n            opacity: 0,\n            y: `${0.3 * (change || 1)}em`,\n            filter: `blur(${change ? motionTokens.blur.soft : motionTokens.blur.subtle}px)`,\n          },\n  to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n  gone: (change: Change) =>\n    change === \"instant\" || reduced\n      ? { opacity: 0, transition: { duration: 0 } }\n      : {\n          opacity: 0,\n          y: `${-0.3 * (change || 1)}em`,\n          filter: `blur(${motionTokens.blur.subtle}px)`,\n          transition: { duration: motionTokens.duration.instant, ease: standard },\n        },\n});\n\n/** A total that rolls digit by digit in the direction it moved. Places keep their identity, so only changed digits turn,\n *  and when the number gains or loses a digit the width follows on a spring instead of shifting the sentence in one frame. */\nfunction RollingNumber({ value, locale, reduced }: { value: number; locale: string; reduced: boolean }) {\n  const [state, setState] = useState({ value, direction: 1 });\n  if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });\n  const inner = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const armedUntil = useRef(0),\n    lastValue = useRef(value);\n  useLayoutEffect(() => {\n    if (lastValue.current === value) return;\n    lastValue.current = value;\n    armedUntil.current = performance.now() + 600;\n  }, [value]);\n  useEffect(() => {\n    const node = inner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = node.getBoundingClientRect().width;\n      if (reduced || width.get() === \"auto\" || performance.now() > armedUntil.current) width.jump(next);\n      else animate(width, next, motionTokens.spring.morph);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  const chars = graphemes(new Intl.NumberFormat(locale).format(value));\n  const variants: Variants = {\n    from: (direction: number) =>\n      reduced ? { opacity: 0 } : { opacity: 0, y: `${0.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` },\n    to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n    gone: (direction: number) =>\n      reduced\n        ? { opacity: 0, transition: { duration: 0 } }\n        : {\n            opacity: 0,\n            y: `${-0.3 * direction}em`,\n            filter: `blur(${motionTokens.blur.subtle}px)`,\n            transition: { duration: motionTokens.duration.instant, ease: standard },\n          },\n  };\n  return (\n    <motion.span\n      className=\"inline-flex justify-end overflow-clip [overflow-clip-margin:.2em]\"\n      style={{ width }}\n      aria-hidden=\"true\"\n    >\n      <span ref={inner} className=\"inline-flex flex-none\">\n        {chars.map((char, index) => {\n          const place = chars.length - index;\n          // Each place is its own window, so a turning digit clips at the line instead of brushing the next line of text.\n          return (\n            <span key={place} className=\"relative inline-flex overflow-clip [overflow-clip-margin:.15em]\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={state.direction}>\n                <motion.span\n                  key={char}\n                  className=\"inline-block\"\n                  custom={state.direction}\n                  variants={variants}\n                  initial=\"from\"\n                  animate=\"to\"\n                  exit=\"gone\"\n                  transition={\n                    reduced\n                      ? { duration: motionTokens.duration.fast }\n                      : { duration: motionTokens.duration.standard, ease: enter, delay: Math.min(place * 0.018, 0.09) }\n                  }\n                >\n                  {char}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          );\n        })}\n      </span>\n    </motion.span>\n  );\n}\n\n/** A short word that changes rises in from a soft blur while the old one lifts away a little faster. */\nfunction RiseText({ text, reduced, children }: { text: string; reduced: boolean; children?: ReactNode }) {\n  return (\n    <span className=\"relative inline-flex\" aria-hidden=\"true\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={text}\n          className=\"inline-block\"\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n          animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n          exit={\n            reduced\n              ? { opacity: 0, transition: { duration: 0 } }\n              : {\n                  opacity: 0,\n                  y: \"-0.3em\",\n                  filter: `blur(${motionTokens.blur.subtle}px)`,\n                  transition: { duration: motionTokens.duration.instant, ease: standard },\n                }\n          }\n          transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}\n        >\n          {children ?? text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActivityHeatmap({\n  days,\n  label,\n  period,\n  unit: unitProp = CONTRIBUTIONS,\n  thresholds,\n  weekStartsOn = 0,\n  selectedDate = null,\n  onSelectDate,\n  actions,\n  locale = \"en-US\",\n  className,\n}: ActivityHeatmapProps) {\n  const reduced = useReducedMotionSafe();\n  const id = useId();\n  // Keyed by its words, so an inline unit object does not rebuild every cell on each render.\n  const unit = useMemo(() => ({ one: unitProp.one, other: unitProp.other }), [unitProp.one, unitProp.other]);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const plotRef = useRef<HTMLDivElement>(null);\n  const gridRef = useRef<HTMLDivElement>(null);\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<HTMLSpanElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(plotRef, { once: true, amount: 0.35 });\n\n  const model = useMemo(\n    () => buildModel(days, weekStartsOn, thresholds, locale),\n    [days, weekStartsOn, thresholds, locale],\n  );\n  const formats = useMemo(\n    () => ({\n      long: new Intl.DateTimeFormat(locale, {\n        weekday: \"long\",\n        month: \"long\",\n        day: \"numeric\",\n        year: \"numeric\",\n        timeZone: \"UTC\",\n      }),\n      short: new Intl.DateTimeFormat(locale, {\n        weekday: \"short\",\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n        timeZone: \"UTC\",\n      }),\n      weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }),\n      number: new Intl.NumberFormat(locale),\n    }),\n    [locale],\n  );\n\n  // A new range sweeps forward when it is later than the last one and back when it is earlier.\n  const [range, setRange] = useState({ start: model.start, direction: 1 });\n  if (range.start !== model.start) setRange({ start: model.start, direction: model.start > range.start ? 1 : -1 });\n\n  const [reveal, setReveal] = useState<\"hidden\" | \"revealing\" | \"done\">(\"hidden\");\n  useEffect(() => {\n    if (reveal !== \"hidden\" || !(inView || reduced)) return;\n    const frame = requestAnimationFrame(() => setReveal(reduced ? \"done\" : \"revealing\"));\n    return () => cancelAnimationFrame(frame);\n  }, [inView, reduced, reveal]);\n  useEffect(() => {\n    if (reveal !== \"revealing\") return;\n    const timer = window.setTimeout(() => setReveal(\"done\"), WAVE + 700);\n    return () => window.clearTimeout(timer);\n  }, [reveal]);\n\n  const selectedIndex = selectedDate ? Math.round((toUtc(selectedDate) - model.start) / DAY) : -1;\n  const hasSelection = selectedIndex >= 0 && selectedIndex < model.length;\n  const [focusIndex, setFocusIndex] = useState<number | null>(null);\n  const tabIndexDay =\n    focusIndex !== null && focusIndex < model.length ? focusIndex : hasSelection ? selectedIndex : model.length - 1;\n\n  // Legend: hovering or focusing a level previews it on the grid, clicking pins it. The caption says how many days sit at that level.\n  const [preview, setPreview] = useState<number | null>(null);\n  const [pinned, setPinned] = useState<number | null>(null);\n  const [legendFocus, setLegendFocus] = useState(0);\n  const previewTimer = useRef(0);\n  const highlight = preview ?? pinned;\n  const previewLevel = (level: number | null) => {\n    window.clearTimeout(previewTimer.current);\n    if (level !== null) setPreview(level);\n    else previewTimer.current = window.setTimeout(() => setPreview(null), 90);\n  };\n  useEffect(() => () => window.clearTimeout(previewTimer.current), []);\n\n  // Tooltip: one bubble for the whole grid. It jumps into place when it opens and glides on a spring between cells after that.\n  const [tip, setTip] = useState<Tip | null>(null);\n  const [open, setOpen] = useState(false);\n  const [change, setChange] = useState<Change>(\"instant\");\n  const openRef = useRef(false);\n  const hideTimer = useRef(0);\n  const tipX = useMotionValue(0),\n    tipY = useMotionValue(0),\n    tipWidth = useMotionValue<number | \"auto\">(\"auto\");\n\n  function contentFor(index: number, anchor: HTMLElement): Tip {\n    const count = model.counts[index]!;\n    const date = new Date(model.start + index * DAY);\n    return {\n      key: `day-${toIso(date.getTime())}`,\n      primary: count ? `${formats.number.format(count)} ${noun(count, unit)}` : `No ${unit.other}`,\n      secondary: formats.short.format(date),\n      value: count,\n      anchor,\n    };\n  }\n  const [a, b, c] = model.thresholds;\n  const ranges = [\n    `no ${unit.other}`,\n    a === 1 ? `1 ${unit.one}` : `1 to ${a} ${unit.other}`,\n    `${a + 1} to ${b} ${unit.other}`,\n    `${b + 1} to ${c} ${unit.other}`,\n    `${c + 1} or more ${unit.other}`,\n  ];\n  const shortRanges = [\n    `no ${unit.other}`,\n    a === 1 ? `1 ${unit.one}` : `1–${a} ${unit.other}`,\n    `${a + 1}–${b} ${unit.other}`,\n    `${b + 1}–${c} ${unit.other}`,\n    `${c + 1}+ ${unit.other}`,\n  ];\n  const dayCount =\n    highlight === null\n      ? \"\"\n      : `${formats.number.format(model.perLevel[highlight]!)} ${model.perLevel[highlight] === 1 ? \"day\" : \"days\"}`;\n  const caption = highlight === null ? \"\" : `${dayCount} with ${shortRanges[highlight]}`;\n  function show(next: Tip) {\n    window.clearTimeout(hideTimer.current);\n    setChange(tip && openRef.current ? (Math.sign(next.value - tip.value) as Change) : \"instant\");\n    setTip(next);\n    setOpen(true);\n  }\n  function hideSoon(delay = 110) {\n    window.clearTimeout(hideTimer.current);\n    hideTimer.current = window.setTimeout(() => setOpen(false), delay);\n  }\n  useEffect(() => () => window.clearTimeout(hideTimer.current), []);\n\n  useLayoutEffect(() => {\n    const root = rootRef.current,\n      measure = measureRef.current;\n    if (!tip || !root || !measure) return;\n    const place = (glide: boolean) => {\n      const box = root.getBoundingClientRect(),\n        cell = tip.anchor.getBoundingClientRect();\n      const width = Math.ceil(measure.getBoundingClientRect().width);\n      const half = width / 2 + 13;\n      const x = Math.min(Math.max(cell.left - box.left + cell.width / 2, half + 2), box.width - half - 2);\n      const y = cell.top - box.top;\n      if (glide) {\n        animate(tipX, x, motionTokens.spring.snappy);\n        animate(tipY, y, motionTokens.spring.snappy);\n        animate(tipWidth, width, motionTokens.spring.morph);\n      } else {\n        tipX.jump(x);\n        tipY.jump(y);\n        tipWidth.jump(width);\n      }\n    };\n    place(open && openRef.current && !reduced);\n    openRef.current = open;\n    if (!open) return;\n    // The grid scrolls sideways on narrow screens; the bubble stays pinned to its cell while it does.\n    const scroller = scrollerRef.current;\n    const follow = () => place(false);\n    scroller?.addEventListener(\"scroll\", follow, { passive: true });\n    return () => scroller?.removeEventListener(\"scroll\", follow);\n  }, [tip, open, reduced, tipX, tipY, tipWidth]);\n\n  const indexOf = (element: Element | null) => {\n    const cell = element?.closest<HTMLElement>(\"[data-index]\");\n    return cell && gridRef.current?.contains(cell) ? { cell, index: Number(cell.dataset.index) } : null;\n  };\n  const focusDay = (index: number) => gridRef.current?.querySelector<HTMLElement>(`[data-index=\"${index}\"]`)?.focus();\n\n  function onGridPointerOver(event: PointerEvent<HTMLDivElement>) {\n    const hit = indexOf(event.target as Element);\n    if (hit) show(contentFor(hit.index, hit.cell));\n  }\n  function onGridPointerLeave(event: PointerEvent<HTMLDivElement>) {\n    // A keyboard user keeps their bubble on the focused day. A tap focuses its day too, so the bubble stays until focus moves on;\n    // where a tap does not focus, it lingers long enough to read.\n    const focused = indexOf(document.activeElement);\n    if (focused?.cell.matches(\":focus-visible\")) show(contentFor(focused.index, focused.cell));\n    else hideSoon(event.pointerType === \"touch\" ? 2400 : 110);\n  }\n  function onGridFocus(event: FocusEvent<HTMLDivElement>) {\n    const hit = indexOf(event.target);\n    if (!hit) return;\n    setFocusIndex(hit.index);\n    show(contentFor(hit.index, hit.cell));\n  }\n  function onGridBlur(event: FocusEvent<HTMLDivElement>) {\n    if (!gridRef.current?.contains(event.relatedTarget as Node | null)) hideSoon(0);\n  }\n  function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const hit = indexOf(event.target as Element);\n    if (!hit) return;\n    const moves: Record<string, number> = {\n      ArrowUp: hit.index - 1,\n      ArrowDown: hit.index + 1,\n      ArrowLeft: hit.index - 7,\n      ArrowRight: hit.index + 7,\n      Home: 0,\n      End: model.length - 1,\n    };\n    if (event.key in moves) {\n      event.preventDefault();\n      focusDay(Math.min(Math.max(moves[event.key]!, 0), model.length - 1));\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      onSelectDate?.(toIso(model.start + hit.index * DAY));\n    } else if (event.key === \"Escape\" && open) {\n      event.preventDefault();\n      setOpen(false);\n    }\n  }\n\n  function onLegendKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const moves: Record<string, number> = { ArrowLeft: legendFocus - 1, ArrowRight: legendFocus + 1, Home: 0, End: 4 };\n    if (event.key === \"Escape\") {\n      previewLevel(null);\n      return;\n    }\n    if (!(event.key in moves)) return;\n    event.preventDefault();\n    const next = Math.min(Math.max(moves[event.key]!, 0), 4);\n    rootRef.current?.querySelector<HTMLElement>(`[data-level-key=\"${next}\"]`)?.focus();\n  }\n\n  // The selection ring glides between cells on CSS. When it first appears it should fade in where it lands, not fly in from its last spot.\n  const ringShown = hasSelection && reveal !== \"hidden\";\n  const ringWasShown = useRef(ringShown);\n  useLayoutEffect(() => {\n    const ring = ringRef.current;\n    if (ring && ringShown && !ringWasShown.current) {\n      ring.style.transitionProperty = \"opacity\";\n      void ring.offsetWidth;\n      ring.style.transitionProperty = \"\";\n    }\n    ringWasShown.current = ringShown;\n  }, [ringShown]);\n\n  const maxDiagonal = Math.max(1, model.weeks - 1 + 6);\n  const step = Math.min(12, WAVE / maxDiagonal);\n  const cells = useMemo(\n    () =>\n      Array.from({ length: 7 }, (_, row) => (\n        <div key={row} role=\"row\" className=\"flex gap-(--gap)\">\n          {Array.from({ length: model.weeks }, (_, col) => {\n            const index = col * 7 + row - model.lead;\n            const inRange = index >= 0 && index < model.length;\n            const wave = {\n              \"--wave\": `${Math.round((col + row) * step)}ms`,\n              \"--wave-back\": `${Math.round((maxDiagonal - col - row) * step)}ms`,\n            } as CSSProperties;\n            if (!inRange) return <span key={col} className={emptyCell} data-empty=\"\" style={wave} aria-hidden=\"true\" />;\n            const count = model.counts[index]!;\n            const date = new Date(model.start + index * DAY);\n            return (\n              // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- the day is the focusable gridcell of the ARIA grid pattern; the grid handles its keys\n              <span\n                key={col}\n                role=\"gridcell\"\n                className={dayCell[model.levels[index]!]}\n                style={wave}\n                data-index={index}\n                data-level={model.levels[index]}\n                tabIndex={index === tabIndexDay ? 0 : -1}\n                aria-selected={onSelectDate ? index === selectedIndex : undefined}\n                aria-label={`${count ? formats.number.format(count) : \"No\"} ${noun(count, unit)}, ${formats.long.format(date)}`}\n                onClick={() => onSelectDate?.(toIso(date.getTime()))}\n              />\n            );\n          })}\n        </div>\n      )),\n    [model, step, maxDiagonal, tabIndexDay, selectedIndex, formats, unit, onSelectDate],\n  );\n\n  const weekdayLabels = useMemo(\n    () =>\n      Array.from({ length: 7 }, (_, row) => {\n        const weekday = (row + weekStartsOn) % 7;\n        // January 4, 1970 was a Sunday.\n        return weekday % 2 === 1 ? formats.weekday.format(new Date((3 + weekday) * DAY)) : \"\";\n      }),\n    [formats, weekStartsOn],\n  );\n\n  const selectedCol = hasSelection ? Math.floor((selectedIndex + model.lead) / 7) : 0;\n  const selectedRow = hasSelection ? (selectedIndex + model.lead) % 7 : 0;\n  const summary = `${formats.number.format(model.total)} ${noun(model.total, unit)} in ${period}`;\n\n  return (\n    <div ref={rootRef} className={cn(levelColors, \"@container relative grid min-w-0 gap-4 text-ink\", className)}>\n      <div className=\"flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-3\">\n        <p className=\"m-0 flex min-w-0 flex-wrap items-baseline gap-x-[.3em] text-sm leading-body text-ink-2 tabular-nums\">\n          <span className=\"font-medium whitespace-nowrap text-ink\">\n            <RollingNumber value={model.total} locale={locale} reduced={reduced} /> {noun(model.total, unit)}\n          </span>{\" \"}\n          <span className=\"whitespace-nowrap\">\n            in <RiseText text={period} reduced={reduced} />\n          </span>\n          <span className=\"sr-only\" role=\"status\">\n            {summary}\n          </span>\n        </p>\n        {actions && <div className=\"flex flex-none items-center\">{actions}</div>}\n      </div>\n\n      {/* The scroller starts at its right edge on narrow screens (the most recent weeks) without script, through its writing direction. */}\n      <div\n        ref={scrollerRef}\n        className=\"-m-[6px] overflow-x-auto overflow-y-hidden overscroll-x-contain p-[6px] [direction:rtl] [scrollbar-color:var(--line-strong)_transparent] [scrollbar-width:thin]\"\n      >\n        <div\n          className=\"grid w-max grid-cols-[var(--label)_auto] grid-rows-[auto_auto] gap-x-(--gap) gap-y-2 [--cell:clamp(9px,calc((100cqi_-_var(--label)_-_var(--gap)_*_53)_/_53),15px)] [--gap:3px] [--label:30px] [--step:calc(var(--cell)_+_var(--gap))] [direction:ltr]\"\n          style={{ \"--weeks\": model.weeks } as CSSProperties}\n        >\n          <div className=\"relative h-4 text-xs leading-4 text-ink-3 [grid-column:2] [grid-row:1]\" aria-hidden=\"true\">\n            {model.months.map((month, order) => (\n              <span\n                key={`${month.month % 12}-${model.months.findIndex((item) => item.month % 12 === month.month % 12) === order ? 0 : 1}`}\n                className=\"absolute top-0 left-0 whitespace-nowrap [transform:translateX(calc(var(--col)*var(--step)))] [transition:transform_var(--duration-spring)_var(--ease-spring)] motion-reduce:[transition:opacity_var(--duration-fast)_var(--ease-standard)]\"\n                style={{ \"--col\": month.col } as CSSProperties}\n              >\n                {month.label}\n              </span>\n            ))}\n          </div>\n          {/* Weekday labels stay pinned while a narrow grid scrolls under them. Set --heatmap-surface when the heatmap sits on another surface. */}\n          <div\n            className=\"sticky -left-[6px] z-1 my-0 mr-[calc(var(--gap)*-1)] -ml-[6px] grid grid-rows-[repeat(7,var(--cell))] content-end gap-y-(--gap) bg-[var(--heatmap-surface,var(--surface))] py-0 pr-(--gap) pl-[6px] text-(length:--text-xs) text-ink-3 [grid-column:1] [grid-row:1/3]\"\n            aria-hidden=\"true\"\n          >\n            {weekdayLabels.map((text, row) => (\n              <span key={row} className=\"flex h-(--cell) items-center leading-none whitespace-nowrap\">\n                {text}\n              </span>\n            ))}\n          </div>\n          <div ref={plotRef} className=\"relative [grid-column:2] [grid-row:2]\">\n            {/* oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- focus moves to the day cells through a roving tabindex, per the ARIA grid pattern */}\n            <div\n              ref={gridRef}\n              role=\"grid\"\n              aria-label={label}\n              aria-readonly=\"true\"\n              aria-describedby={`${id}-legend`}\n              className=\"group/grid grid gap-(--gap) outline-none\"\n              data-reveal={reveal}\n              data-direction={range.direction < 0 ? \"back\" : undefined}\n              data-highlight={highlight ?? undefined}\n              onPointerOver={onGridPointerOver}\n              onPointerLeave={onGridPointerLeave}\n              onFocus={onGridFocus}\n              onBlur={onGridBlur}\n              onKeyDown={onGridKeyDown}\n            >\n              {cells}\n            </div>\n            {/* The selection ring glides between days, and follows its day when the range changes. */}\n            <span\n              ref={ringRef}\n              className=\"pointer-events-none absolute -top-[3px] -left-[3px] size-[calc(var(--cell)+6px)] rounded-[calc(var(--cell)*.28+3px)] border-[1.5px] border-ink opacity-0 [transform:translate(calc(var(--col)*var(--step)),calc(var(--row)*var(--step)))] [transition:transform_var(--duration-spring)_var(--ease-spring),opacity_var(--duration-fast)_var(--ease-standard)] data-shown:opacity-100 motion-reduce:[transition:opacity_var(--duration-fast)_var(--ease-standard)]\"\n              data-shown={ringShown || undefined}\n              style={{ \"--col\": selectedCol, \"--row\": selectedRow } as CSSProperties}\n              aria-hidden=\"true\"\n            />\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex min-w-0 items-center justify-between gap-3 text-(length:--text-xs) text-ink-3\">\n        <span id={`${id}-legend`} className=\"sr-only\">\n          Darker squares mean more {unit.other}. Levels: {ranges.join(\", \")}.\n        </span>\n        {/* The caption names the previewed level; it sits apart from the scale so its changing width never nudges the swatches. Narrow cards keep the day count. */}\n        <span className=\"relative min-w-0 truncate text-ink-2 tabular-nums\" aria-live=\"polite\">\n          <RiseText text={caption} reduced={reduced}>\n            {caption && (\n              <>\n                <span className=\"[@container(max-width:440px)]:hidden\">{caption}</span>\n                <span className=\"hidden [@container(max-width:440px)]:inline\">{dayCount}</span>\n              </>\n            )}\n          </RiseText>\n          <span className=\"sr-only\">{caption}</span>\n        </span>\n        <span className=\"flex flex-none items-center gap-2\">\n          <span className=\"leading-none\" aria-hidden=\"true\">\n            Less\n          </span>\n          {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a group of swatch buttons with delegated arrow-key navigation, not a form */}\n          <div className=\"flex\" role=\"group\" aria-label=\"Highlight days by level\" onKeyDown={onLegendKeyDown}>\n            {LEVELS.map((level) => (\n              <button\n                key={level}\n                type=\"button\"\n                className={cn(swatchBase, swatchLevel[level])}\n                data-level-key={level}\n                data-level={level}\n                tabIndex={level === legendFocus ? 0 : -1}\n                aria-pressed={pinned === level}\n                aria-label={`Highlight days with ${ranges[level]}`}\n                onClick={() => setPinned((current) => (current === level ? null : level))}\n                onPointerEnter={() => previewLevel(level)}\n                onPointerLeave={() => previewLevel(null)}\n                onFocus={() => {\n                  setLegendFocus(level);\n                  previewLevel(level);\n                }}\n                onBlur={() => previewLevel(null)}\n              />\n            ))}\n          </div>\n          <span className=\"leading-none\" aria-hidden=\"true\">\n            More\n          </span>\n        </span>\n      </div>\n\n      {/* One bubble for the whole grid. The outer layer glides between cells; the bubble fades and scales from its tail. */}\n      <motion.div\n        className=\"pointer-events-none absolute top-0 left-0 z-5 size-0\"\n        style={{ x: tipX, y: tipY }}\n        aria-hidden=\"true\"\n      >\n        <motion.div\n          className=\"absolute bottom-2 left-0 block origin-bottom rounded-control border border-[color-mix(in_oklab,var(--page)_14%,var(--ink))] bg-ink px-3 pt-[7px] pb-2 text-page shadow-raised will-change-[transform,opacity]\"\n          style={{ x: \"-50%\" }}\n          initial={false}\n          animate={open && tip ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduced ? 1 : 0.96 }}\n          transition={\n            reduced\n              ? { duration: open ? motionTokens.duration.fast : motionTokens.duration.instant }\n              : open\n                ? { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } }\n                : { duration: motionTokens.duration.instant, ease: standard }\n          }\n        >\n          <motion.span className=\"relative block overflow-clip [overflow-clip-margin:8px]\" style={{ width: tipWidth }}>\n            <span ref={measureRef} className=\"invisible absolute top-0 left-0 grid w-max\">\n              <span className=\"text-sm leading-[1.3] font-medium whitespace-nowrap tabular-nums\">{tip?.primary}</span>\n              <span className=\"text-xs leading-[1.35] whitespace-nowrap text-[color-mix(in_oklab,var(--page)_68%,var(--ink))]\">\n                {tip?.secondary}\n              </span>\n            </span>\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={change}>\n              {tip && (\n                <motion.span\n                  key={tip.key}\n                  className=\"grid w-max\"\n                  custom={change}\n                  variants={tipText(reduced)}\n                  initial=\"from\"\n                  animate=\"to\"\n                  exit=\"gone\"\n                  transition={{\n                    duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard,\n                    ease: enter,\n                  }}\n                >\n                  <span className=\"text-sm leading-[1.3] font-medium whitespace-nowrap tabular-nums\">\n                    {tip.primary}\n                  </span>\n                  <span className=\"text-xs leading-[1.35] whitespace-nowrap text-[color-mix(in_oklab,var(--page)_68%,var(--ink))]\">\n                    {tip.secondary}\n                  </span>\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </motion.span>\n        </motion.div>\n      </motion.div>\n    </div>\n  );\n}\n\nexport default ActivityHeatmap;\n",
      "type": "registry:component",
      "target": "@components/iris/activity-heatmap/activity-heatmap.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/activity-heatmap",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "ActivityHeatmap",
    "tags": [
      "data",
      "chart",
      "calendar"
    ],
    "docs": "https://irisui.dev/components/activity-heatmap"
  }
}
