{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waffle-chart",
  "type": "registry:ui",
  "title": "Waffle chart",
  "description": "A ten by ten unit chart where every cell is one percent, and cells fly to their new group when the data changes.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/waffle-chart/waffle-chart.tsx",
      "type": "registry:component",
      "target": "@components/xzero/waffle-chart/waffle-chart.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./waffle-chart.module.css\";\n\nexport interface WaffleCategory {\n  /** Stable identity. Cells keep following their category when the data changes, so a new dataset flows instead of repainting. */\n  key: string;\n  /** Name in the legend, the tooltip, and the data table. */\n  label: string;\n  /** Raw amount. Shares are computed from the total; negatives count as zero. */\n  value: number;\n  /** Any CSS color. Defaults to the first series for accentKey, the supporting series for the next three, then neutral steps. */\n  color?: string;\n}\n\n/** A unit chart: every cell is one share of the whole, so parts of a total read as countable squares. */\nexport interface WaffleChartProps {\n  /** Categories in fill order. The first fills from the bottom-left corner, column by column. */\n  data: WaffleCategory[];\n  /** What the whole is, such as \"Electricity generation, 2023\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after each raw value in the tooltip and table, such as \"TWh\". */\n  unit?: string;\n  formatValue?: (value: number, category: WaffleCategory) => string;\n  /** Grid rows. rows × columns cells make the whole; 10 × 10 means one cell per percent. */\n  rows?: number;\n  columns?: number;\n  /** The category painted in the accent. Pass null to keep every category neutral. Defaults to the first category. */\n  accentKey?: string | null;\n  /** Controlled focused category. Others dim while one is focused. */\n  activeKey?: string | null;\n  defaultActiveKey?: string | null;\n  onActiveChange?: (key: string | null) => void;\n  /** Category list with shares that roll to their new value. */\n  legend?: boolean;\n  /** Decimal places for shares. */\n  decimals?: number;\n  emptyLabel?: string;\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Cell = { id: number; key: string | null; slot: number };\ntype Pointer = { key: string; slot: number } | null;\n\nconst { spring } = motionTokens;\nconst GAP = 3;\n/** accentKey takes the first series and the next three categories the supporting series. Anything past that falls back to neutral steps that alternate in lightness. */\nconst SHADES = [40, 20, 30, 14];\n/** A second, colour-free channel: neutral categories cycle through square, round, and framed cells, so neighbours differ in shape as well as lightness. */\nconst SHAPES = [\"square\", \"round\", \"framed\"] as const;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\n/** Whole cells per category by largest remainder, so the counts always add up to the grid. */\nfunction allocate(data: WaffleCategory[], total: number, cells: number): number[] {\n  if (total <= 0) return data.map(() => 0);\n  const exact = data.map(item => Math.max(0, item.value) / total * cells);\n  const counts = exact.map(Math.floor);\n  let left = cells - counts.reduce((sum, count) => sum + count, 0);\n  const order = exact.map((value, index) => ({ index, rest: value - Math.floor(value) })).sort((a, b) => b.rest - a.rest);\n  for (let k = 0; left > 0 && k < order.length; k++, left--) counts[order[k]!.index]!++;\n  return counts;\n}\n\n/**\n * Hands cells to categories while moving as few as possible: each category keeps the cells it already had,\n * surplus cells join the categories that grew, and every block takes its cells in their previous order so paths never cross.\n */\nfunction reassign(previous: Cell[], data: WaffleCategory[], counts: number[]): Cell[] {\n  const bySlot = [...previous].sort((a, b) => a.slot - b.slot);\n  const kept = new Map<string, Cell[]>();\n  const pool: Cell[] = [];\n  const want = new Map(data.map((item, index) => [item.key, counts[index]]));\n  for (const cell of bySlot) {\n    const list = cell.key !== null ? kept.get(cell.key) ?? [] : [];\n    if (cell.key !== null && list.length < (want.get(cell.key) ?? 0)) { list.push(cell); kept.set(cell.key, list); } else pool.push(cell);\n  }\n  const next: Cell[] = [];\n  let slot = 0;\n  data.forEach((item, index) => {\n    const own = kept.get(item.key) ?? [];\n    while (own.length < counts[index]! && pool.length) own.push(pool.shift()!);\n    own.sort((a, b) => a.slot - b.slot).forEach(cell => next.push({ id: cell.id, key: item.key, slot: slot++ }));\n  });\n  pool.forEach(cell => next.push({ id: cell.id, key: null, slot: slot++ }));\n  return next.sort((a, b) => a.id - b.id);\n}\n\n/** A share that rolls to its new value on a spring and holds its width with tabular figures. */\nfunction Rolling({ value, decimals, reduced }: { value: number; decimals: number; reduced: boolean }) {\n  const shown = useMotionValue(value);\n  const text = useTransform(shown, current => `${current.toFixed(decimals)}%`);\n  useEffect(() => {\n    if (reduced) { shown.jump(value); return; }\n    const controls = animate(shown, value, spring.smooth);\n    return () => controls.stop();\n  }, [reduced, shown, value]);\n  return <motion.span>{text}</motion.span>;\n}\n\nexport function WaffleChart({ data, label, unit = \"\", formatValue, rows = 10, columns = 10, accentKey, activeKey, defaultActiveKey = null, onActiveChange, legend = true, decimals = 0, emptyLabel = \"No data\", ref, className }: WaffleChartProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const grid = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: .35 });\n  const count = Math.max(1, rows * columns);\n  const total = data.reduce((sum, item) => sum + Math.max(0, item.value), 0);\n  const empty = !data.length || total <= 0;\n  const accent = accentKey === undefined ? data[0]?.key ?? null : accentKey;\n\n  // Cells are persistent objects; each data change reassigns them so they travel instead of repainting.\n  const signature = `${count}|${data.map(item => `${item.key}:${item.value}`).join(\",\")}`;\n  const counts = useMemo(() => allocate(data, total, count), [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n  const [layout, setLayout] = useState(() => ({ cells: reassign(Array.from({ length: count }, (_, id) => ({ id, key: null, slot: id })), data, counts), moved: new Set<number>(), version: 0 }));\n  const [seen, setSeen] = useState(signature);\n  if (seen !== signature) {\n    setSeen(signature);\n    const base = Array.from({ length: count }, (_, id) => layout.cells[id] ?? { id, key: null, slot: id });\n    const next = reassign(base, data, counts);\n    setLayout({ cells: next, moved: new Set(next.filter(cell => base[cell.id]!.slot !== cell.slot || base[cell.id]!.key !== cell.key).map(cell => cell.id)), version: layout.version + 1 });\n  }\n  const { cells, moved, version } = layout;\n\n  const [ownActive, setOwnActive] = useState<string | null>(defaultActiveKey);\n  const pinned = activeKey !== undefined ? activeKey : ownActive;\n  const setPinned = (key: string | null) => { if (activeKey === undefined) setOwnActive(key); onActiveChange?.(key); };\n  const [pointer, setPointer] = useState<Pointer>(null);\n  const [preview, setPreview] = useState<string | null>(null);\n  const focusKey = pointer?.key ?? preview ?? pinned;\n  const active = focusKey && data.some(item => item.key === focusKey) ? focusKey : null;\n\n  const [side, setSide] = useState(0);\n  useLayoutEffect(() => {\n    const node = grid.current;\n    if (!node) return;\n    const read = () => setSide(node.clientWidth);\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  const size = side ? (side - GAP * (columns - 1)) / columns : 0;\n  const height = size ? size * rows + GAP * (rows - 1) : 0;\n  // Column by column from the bottom-left corner, like a glass filling up.\n  const place = (slot: number) => ({ x: Math.floor(slot / rows) * (size + GAP), y: (rows - 1 - (slot % rows)) * (size + GAP) });\n\n  const colorOf = (key: string | null) => {\n    if (key === null) return \"transparent\";\n    const index = data.findIndex(item => item.key === key), item = data[index];\n    if (!item) return \"transparent\";\n    if (item.color) return item.color;\n    if (key === accent) return \"var(--series-1)\";\n    const rank = data.filter(entry => entry.key !== accent).findIndex(entry => entry.key === key);\n    return rank < 3 ? `var(--series-${rank + 2})` : `color-mix(in oklab, var(--foreground) ${SHADES[(rank - 3) % SHADES.length]}%, var(--surface))`;\n  };\n\n  const shapeOf = (key: string | null) => {\n    if (key === null || key === accent) return \"square\";\n    const rank = data.filter(entry => entry.key !== accent).findIndex(entry => entry.key === key);\n    return rank < 0 ? \"square\" : SHAPES[rank % SHAPES.length];\n  };\n\n  // The tooltip glides between cells on its own spring and stays inside the figure.\n  const tipX = useMotionValue(0), tipY = useMotionValue(0);\n  const follow = { stiffness: 520, damping: 44, restDelta: .01 };\n  const glideX = useSpring(tipX, follow), glideY = useSpring(tipY, follow);\n  const wasShown = useRef(false);\n  useLayoutEffect(() => {\n    const bubble = tip.current, host = figure.current, board = grid.current;\n    if (!pointer || !bubble || !host || !board || !size) { wasShown.current = false; return; }\n    const hostBox = host.getBoundingClientRect(), boardBox = board.getBoundingClientRect();\n    const { x, y } = place(pointer.slot);\n    const cx = boardBox.left - hostBox.left + x + size / 2, cy = boardBox.top - hostBox.top + y;\n    const tw = bubble.offsetWidth, th = bubble.offsetHeight;\n    let top = cy - th - 10;\n    if (top < 0) top = cy + size + 10;\n    const left = clamp(cx - tw / 2, 0, Math.max(0, hostBox.width - tw));\n    tipX.set(left); tipY.set(clamp(top, 0, Math.max(0, hostBox.height - th)));\n    if (!wasShown.current || reduced) { glideX.jump(tipX.get()); glideY.jump(tipY.get()); }\n    wasShown.current = true;\n  });\n\n  const slotAt = (clientX: number, clientY: number) => {\n    const box = grid.current?.getBoundingClientRect();\n    if (!box || !size) return null;\n    const col = clamp(Math.floor((clientX - box.left) / (size + GAP)), 0, columns - 1);\n    const row = clamp(Math.floor((clientY - box.top) / (size + GAP)), 0, rows - 1);\n    return col * rows + (rows - 1 - row);\n  };\n  const pointAt = (slot: number | null): Pointer => {\n    if (slot === null) return null;\n    const cell = cells.find(item => item.slot === slot);\n    return cell?.key ? { key: cell.key, slot } : null;\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === \"mouse\" || event.buttons) setPointer(pointAt(slotAt(event.clientX, event.clientY))); };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId); setPointer(pointAt(slotAt(event.clientX, event.clientY))); };\n\n  // Arrows walk the grid cell by cell; Page keys jump between categories.\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (empty) return;\n    if (event.key === \"Escape\" && pointer) { event.preventDefault(); setPointer(null); return; }\n    const filled = cells.filter(cell => cell.key !== null).length;\n    const at = pointer?.slot ?? 0;\n    const col = Math.floor(at / rows), row = at % rows;\n    const blocks = counts.map((size, index) => ({ start: counts.slice(0, index).reduce((sum, value) => sum + value, 0), size })).filter(block => block.size > 0);\n    const block = Math.max(0, blocks.findIndex(item => at >= item.start && at < item.start + item.size));\n    const moves: Record<string, () => number> = {\n      ArrowUp: () => col * rows + Math.min(rows - 1, row + 1),\n      ArrowDown: () => col * rows + Math.max(0, row - 1),\n      ArrowRight: () => Math.min(columns - 1, col + 1) * rows + row,\n      ArrowLeft: () => Math.max(0, col - 1) * rows + row,\n      PageDown: () => blocks[Math.min(blocks.length - 1, block + 1)]!.start,\n      PageUp: () => at > blocks[block]!.start ? blocks[block]!.start : blocks[Math.max(0, block - 1)]!.start,\n      Home: () => 0,\n      End: () => filled - 1,\n    };\n    const move = moves[event.key];\n    if (!move) return;\n    event.preventDefault();\n    setPointer(pointAt(clamp(pointer ? move() : 0, 0, Math.max(0, filled - 1))));\n  };\n\n  const format = (value: number, item: WaffleCategory) => `${formatValue ? formatValue(value, item) : grouped.format(value)}${unit ? ` ${unit}` : \"\"}`;\n  const share = (item: WaffleCategory) => total > 0 ? Math.max(0, item.value) / total * 100 : 0;\n  const reading = pointer ? data.find(item => item.key === pointer.key) ?? null : null;\n  const announce = reading ? `${reading.label}, ${share(reading).toFixed(decimals)}%, ${format(reading.value, reading)}` : \"\";\n  const summary = empty ? `${label}. ${emptyLabel}.` : `${label}. ${data.map(item => `${item.label} ${share(item).toFixed(decimals)}%`).join(\", \")}.`;\n  const shownX = reduced ? tipX : glideX, shownY = reduced ? tipY : glideY;\n  const settle = reduced ? { duration: 0 } : spring.morph;\n\n  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(\" \")} aria-label={label} data-legend={legend || undefined}>\n    <div className={styles.body}>\n    <div className={styles.board}>\n      <div ref={grid} className={styles.grid} style={{ height: height || undefined, aspectRatio: height ? undefined : `${columns} / ${rows}` }} data-active={active ? true : undefined}\n        role=\"group\" tabIndex={empty ? -1 : 0} aria-roledescription=\"waffle chart\" aria-label={`${label}. ${count} cells. Use arrow keys to move between cells, Page Up and Page Down to jump between categories.`}\n        onPointerMove={onPointerMove} onPointerDown={onPointerDown} onPointerUp={event => { if (event.pointerType !== \"mouse\") setPointer(null); }} onPointerCancel={() => setPointer(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setPointer(null); }}\n        onKeyDown={onKeyDown} onBlur={() => setPointer(null)} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && !empty) setPointer(current => current ?? pointAt(0)); }}>\n        {size > 0 && cells.map(cell => {\n          const { x, y } = place(cell.slot);\n          const travel = moved.has(cell.id) && !reduced;\n          const delay = reduced ? 0 : (cell.slot / count) * .32;\n          const on = cell.key !== null && cell.key === active;\n          const shown = inView || reduced;\n          // A travelling cell dips while it flies, so the flow reads as cells lifting out of one block and landing in another.\n          const dip = travel ? [1, .62 + (version % 2) * .001, 1] : 1;\n          return <motion.span key={cell.id} className={styles.cell} aria-hidden=\"true\"\n            data-on={on || undefined} data-cursor={pointer?.slot === cell.slot || undefined} data-empty={cell.key === null || undefined} data-shape={shapeOf(cell.key)}\n            style={{ width: size, height: size, \"--cell\": colorOf(cell.key), \"--delay\": `${delay}s` } as CSSProperties}\n            initial={reduced ? false : { x, y, scale: .4, opacity: 0 }}\n            animate={shown ? { x, y, scale: dip, opacity: cell.key === null ? 0 : 1 } : { x, y, scale: .4, opacity: 0 }}\n            transition={reduced ? { duration: 0 } : {\n              x: { ...settle, delay }, y: { ...settle, delay },\n              scale: travel ? { duration: .62, times: [0, .42, 1], ease: motionTokens.ease.inOut, delay } : { ...spring.snappy, delay: delay * .6 },\n              opacity: { duration: motionTokens.duration.standard, ease: motionTokens.ease.standard, delay: delay * .6 },\n            }} />;\n        })}\n      </div>\n      {!empty && <motion.div ref={tip} className={styles.tooltip} data-shown={pointer ? true : undefined} style={{ x: shownX, y: shownY }} aria-hidden=\"true\">\n        <p className={styles.tipRow}>\n          <span className={styles.tipSwatch} data-shape={shapeOf(reading?.key ?? null)} style={{ \"--cell\": colorOf(reading?.key ?? null) } as CSSProperties} />\n          <span className={styles.tipName}>{reading?.label ?? \"\"}</span>\n          <span className={styles.tipValue}>{reading ? `${share(reading).toFixed(decimals)}%` : \"\"}</span>\n        </p>\n        <p className={styles.tipMeta}>{reading ? format(reading.value, reading) : \"\"}</p>\n      </motion.div>}\n      {empty && <p className={styles.message}>{emptyLabel}</p>}\n    </div>\n    {legend && !empty && <ul className={styles.legend} aria-label={`${label} categories`} onPointerLeave={() => setPreview(null)}>\n      {data.map(item => <li key={item.key}>\n        <button type=\"button\" className={styles.item} aria-pressed={pinned === item.key} data-on={active === item.key || undefined} data-dim={(active !== null && active !== item.key) || undefined}\n          onClick={() => setPinned(pinned === item.key ? null : item.key)}\n          onPointerEnter={event => { if (event.pointerType === \"mouse\") setPreview(item.key); }} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\")) setPreview(item.key); }} onBlur={() => setPreview(null)}>\n          <span className={styles.swatch} data-shape={shapeOf(item.key)} style={{ \"--cell\": colorOf(item.key) } as CSSProperties} aria-hidden=\"true\" />\n          <span className={styles.itemLabel}>{item.label}</span>\n          <span className={styles.itemShare}><Rolling value={share(item)} decimals={decimals} reduced={reduced} /></span>\n        </button>\n      </li>)}\n    </ul>}\n    </div>\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{announce}</p>\n    <p className={styles.srOnly}>{summary}</p>\n    {!empty && <div className={styles.srOnly}><table>\n      <caption>{label}</caption>\n      <thead><tr><th scope=\"col\">Category</th><th scope=\"col\">Value</th><th scope=\"col\">Share</th><th scope=\"col\">Cells</th></tr></thead>\n      <tbody>{data.map((item, index) => <tr key={item.key}><th scope=\"row\">{item.label}</th><td>{format(item.value, item)}</td><td>{share(item).toFixed(decimals)}%</td><td>{counts[index]}</td></tr>)}</tbody>\n    </table></div>}\n  </figure>;\n}\n\nexport default WaffleChart;\n"
    },
    {
      "path": "registry/components/waffle-chart/waffle-chart.module.css",
      "type": "registry:component",
      "target": "@components/xzero/waffle-chart/waffle-chart.module.css",
      "content": ".figure { position: relative; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); container-type: inline-size; }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n.body { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); align-items: center; gap: var(--space-6); }\n@container (min-width: 420px) {\n  .figure[data-legend] .body { grid-template-columns: minmax(0, 232px) minmax(0, 1fr); gap: var(--space-6); }\n}\n\n.board { min-width: 0; }\n.grid { position: relative; width: 100%; max-width: 300px; margin-inline: auto; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: default; }\n\n/* Each cell is one unit. Its colour changes mid-flight with the same stagger as its travel. */\n.cell { --inner: 0 0 0 0 transparent; --outer: 0 0 0 0 transparent; position: absolute; top: 0; left: 0; border-radius: 22%; background: var(--cell); box-shadow: var(--inner), var(--outer); will-change: transform; transition: background-color var(--duration-considered) var(--ease-standard) var(--delay, 0s), border-radius var(--duration-considered) var(--ease-standard) var(--delay, 0s), box-shadow var(--duration-considered) var(--ease-standard) var(--delay, 0s), opacity var(--duration-fast) var(--ease-standard); }\n.grid[data-active] .cell:not([data-on]) { opacity: .26 !important; transition: background-color var(--duration-considered) var(--ease-standard) var(--delay, 0s), border-radius var(--duration-considered) var(--ease-standard) var(--delay, 0s), opacity var(--duration-standard) var(--ease-standard); }\n.cell[data-cursor] { --outer: 0 0 0 2px var(--surface), 0 0 0 3.5px color-mix(in oklab, var(--foreground) 55%, transparent); }\n\n/* Shape is the colour-free second channel: a cell morphs between these as it changes category. */\n.cell[data-shape=\"round\"], .swatch[data-shape=\"round\"], .tipSwatch[data-shape=\"round\"] { border-radius: 50%; }\n.cell[data-shape=\"framed\"] { --inner: inset 0 0 0 2.5px var(--cell), inset 0 0 0 3.25px var(--surface); }\n.swatch[data-shape=\"framed\"], .tipSwatch[data-shape=\"framed\"] { box-shadow: inset 0 0 0 2px var(--cell), inset 0 0 0 3px var(--surface); }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 150px; max-width: min(240px, 100%); gap: 2px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.tooltip[data-shown] { opacity: 1; scale: 1; }\n.tipRow { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0; font-size: var(--text-sm); line-height: 1.3; }\n.tipSwatch { width: 10px; height: 10px; flex: none; border-radius: 3px; background: var(--cell); transition: background-color var(--duration-fast) var(--ease-standard); }\n.tipName { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }\n.tipValue { flex: none; margin-left: 8px; font-weight: 500; font-variant-numeric: tabular-nums; }\n.tipMeta { margin: 0 0 0 18px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; white-space: nowrap; }\n\n.message { display: grid; min-height: 160px; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); }\n\n/* The legend doubles as the reading: each share rolls to its new value. */\n.legend { display: grid; min-width: 0; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 2px var(--space-2); margin: 0; padding: 0; list-style: none; }\n@container (min-width: 420px) { .figure[data-legend] .legend { grid-template-columns: minmax(0, 1fr); } }\n/* Below the side-by-side layout two columns would clip names like \"Wind and solar\", so the legend reads as one list. */\n/* Stacked, the legend shares the grid's measure and its row padding hangs outside, so swatches and shares line up with the cell edges. */\n@container (max-width: 419px) { .legend { grid-template-columns: minmax(0, 1fr); margin-inline: max(-10px, calc((100% - 320px) / 2)); } }\n.item { display: grid; width: 100%; min-height: 36px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); text-align: left; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.item:active { transform: scale(.98); }\n.item[data-on] { color: var(--foreground); background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n.item[data-dim] { opacity: .5; }\n.item[aria-pressed=\"true\"] { color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) { .item:hover { color: var(--foreground); } }\n.swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--cell); }\n.itemLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.itemShare { color: var(--foreground); font-size: var(--text-base); font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; min-width: 3.2em; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .cell, .grid[data-active] .cell:not([data-on]), .tooltip, .tipSwatch, .item { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/waffle-chart",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/waffle-chart",
    "markdown": "https://ui.xzero.co.uk/components/waffle-chart/markdown"
  }
}