{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lollipop",
  "title": "Lollipop",
  "description": "A ranked list with thin stems and round heads at each value: sort by value or name and the rows slide into place; one Tab stop with arrow keys, a tooltip per row, and the list itself is the data for screen readers.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/lollipop/lollipop.tsx",
      "content": "// Lollipop: a ranked list where each row is a name, a thin stem and a round head at its value, with the figure at\n// the right. Sort it by value or by name and every row slides to its new place; the rank (01, 02 …) always counts by\n// value, so it stays with its row. Point at a row, tap it or land on it with the keyboard and its head turns to the\n// signal colour and a tooltip opens over the head with the rank and any extra lines the row carries.\n//\n// How it works: the rows are a real list of buttons in the order they are shown, so the reading order, the arrow\n// keys and the eye agree. One Tab stop; arrow up and down, Home and End move between rows, Escape lets go. A new\n// order is animated FLIP-style: each row is measured before and after and slides the difference by transform. Below\n// 400 px of width the name and figure share a line and the stem runs full width under them.\n//\n// Screen readers: the list is named by `label` and is the data itself. Each row reads \"1. Harbour Market: €289.20,\n// Visits 6\" as focus lands on it, so nothing else is announced and nothing is read twice. The axis and the tooltip\n// are hidden from them.\n//\n// Motion: on first view the stems grow from the left by transform and the heads slide out with them, one row after\n// another (all done in 600 ms). New values glide in 300 ms, and a new order slides the rows. Under reduced motion\n// every stem stands at its length at once and rows jump to their places.\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChartTooltip } from '@/registry/manniche/chart-kit/chart-kit'\nimport { BASE, EASE_CSS, INK, formatTick, formatValue, niceScale, stepIndex, valueParts, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\n\nexport type LollipopDatum = {\n  /** The row's name, shown and read aloud. Also its key, so keep it unique. */\n  label: string\n  /** The amount, 0 or more. Negative values count as zero. */\n  value: number\n  /** Extra lines for the tooltip and the spoken row, such as `{ label: 'Visits', value: '6' }`. */\n  details?: { label: string; value: string }[]\n}\n\nexport type LollipopLabels = {\n  /** After the name in the tooltip: \"rank 2 of 6\". */\n  rank?: (rank: number, count: number) => 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}\n\nconst LABELS: Required<LollipopLabels> = {\n  rank: (rank, count) => `rank ${rank} of ${count}`,\n  loading: 'Loading',\n  empty: 'No data yet',\n}\n\nexport type LollipopProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onSelect'> & {\n  /** One entry per row, in any order: `{ label, value, details? }`. */\n  data: LollipopDatum[]\n  /** What the list shows, such as \"Top merchants by spend, last 30 days\". Names the list. */\n  label: string\n  /** How values are written in the rows, the tooltip and on the axis. */\n  format?: ValueFormat\n  /** \"value\" puts the largest first, \"label\" sorts by name, \"none\" keeps the order of `data`. Default \"value\". */\n  sort?: 'value' | 'label' | 'none'\n  /** Show only the largest n rows. Default all. */\n  limit?: number\n  /** Show the value ticks under the list. Default true. */\n  axis?: boolean\n  /** The highlighted row (an index into `data`), or null for none. Pass it to control the highlight. */\n  activeIndex?: number | null\n  /** The highlighted row on first render when uncontrolled. Default null. */\n  defaultActiveIndex?: number | null\n  /** Called when the highlight moves by pointer, focus or keys, with null when it clears. */\n  onActiveIndexChange?: (index: number | null) => void\n  /** Called when a row is clicked or pressed with Enter or Space, with its index into `data`. */\n  onSelect?: (index: number) => void\n  /** Show breathing skeleton rows instead of data. */\n  loading?: boolean\n  /** Shown in place of the rows when there is no data or every value is 0, such as a message and a next step. */\n  placeholder?: ReactNode\n  /** Words for other languages. */\n  labels?: LollipopLabels\n  /** \"compact\" tightens the rows and columns. Inside a compact DataTile this happens on its own. */\n  density?: 'comfortable' | 'compact'\n}\n\n/** Where an element sits inside `root`, by layout (transforms ignored), so a sliding row reports where it lands. */\nfunction offsetIn(el: HTMLElement, root: HTMLElement) {\n  let x = 0\n  let y = 0\n  let n: HTMLElement | null = el\n  while (n && n !== root) {\n    x += n.offsetLeft\n    y += n.offsetTop\n    n = n.offsetParent as HTMLElement | null\n  }\n  return n === root ? { x, y } : null\n}\n\nconst SKELETON = [86, 62, 48, 40, 34, 26]\n\nexport function Lollipop({\n  data,\n  label,\n  format,\n  sort = 'value',\n  limit,\n  axis = true,\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveIndexChange,\n  onSelect,\n  loading = false,\n  placeholder,\n  labels: labelsProp,\n  density,\n  className,\n  style,\n  ...rest\n}: LollipopProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const { ref, width, drawn, reduced } = useChartFrame<HTMLDivElement>()\n  const rootRef = useRef<HTMLDivElement | null>(null)\n  const [own, setOwn] = useState<number | null>(defaultActiveIndex)\n  const [cursor, setCursor] = useState(0)\n  const [settled, setSettled] = useState(false)\n  const [tipAt, setTipAt] = useState<{ x: number; y: number } | null>(null)\n  const items = useRef(new Map<string, HTMLLIElement>())\n  const tracks = useRef(new Map<string, HTMLSpanElement>())\n  const buttons = useRef<(HTMLButtonElement | null)[]>([])\n\n  const values = data.map((d) => Math.max(0, d.value))\n  // Rank by value (ties keep their order in `data`), then keep the top `limit`.\n  const byValue = data.map((_, i) => i).sort((a, b) => values[b] - values[a] || a - b)\n  const shown = limit !== undefined ? byValue.slice(0, Math.max(0, limit)) : byValue\n  const rankOf = new Map(shown.map((i, k) => [i, k + 1]))\n  const order =\n    sort === 'value' ? shown : sort === 'label' ? [...shown].sort((a, b) => data[a].label.localeCompare(data[b].label)) : [...shown].sort((a, b) => a - b)\n  const n = order.length\n  const max = shown.length ? values[shown[0]] : 0\n  const empty = !loading && (n === 0 || max <= 0)\n  const scale = niceScale(max > 0 ? max : 1, 3)\n  const top = scale.max\n  const orderKey = order.map((i) => data[i].label).join('\\u0000')\n\n  const raw = activeIndex !== undefined ? activeIndex : own\n  const active = raw !== null && rankOf.has(raw) && !empty ? raw : null\n  const set = (i: number | null) => {\n    if (i === active) return\n    if (activeIndex === undefined) setOwn(i)\n    onActiveIndexChange?.(i)\n  }\n  const latest = useRef(set)\n  useEffect(() => {\n    latest.current = set\n  })\n\n  // After the entrance, value changes drop the stagger delay.\n  useEffect(() => {\n    if (!drawn || settled) return\n    const t = window.setTimeout(() => setSettled(true), reduced ? 0 : 620)\n    return () => window.clearTimeout(t)\n  }, [drawn, settled, reduced])\n\n  // A tap that left a row lit on a touch screen clears when the next tap lands outside the list.\n  const sticky = active !== null\n  useEffect(() => {\n    if (!sticky) return\n    const off = (e: globalThis.PointerEvent) => {\n      const root = rootRef.current\n      if (root && !root.contains(e.target as Node)) latest.current(null)\n    }\n    document.addEventListener('pointerdown', off)\n    return () => document.removeEventListener('pointerdown', off)\n  }, [sticky])\n\n  // FLIP: when the order changes, each row starts where it was drawn (mid-slide included) and slides to its place.\n  const tops = useRef(new Map<string, number>())\n  const lastOrder = useRef(orderKey)\n  useLayoutEffect(() => {\n    const moved = lastOrder.current !== orderKey\n    lastOrder.current = orderKey\n    const next = new Map<string, number>()\n    items.current.forEach((el, key) => {\n      const now = el.offsetTop\n      next.set(key, now)\n      const was = tops.current.get(key)\n      if (!moved || reduced || was === undefined) return\n      const t = getComputedStyle(el).transform\n      const inFlight = t && t !== 'none' ? new DOMMatrixReadOnly(t).m42 : 0\n      const dy = was + inFlight - now\n      if (Math.abs(dy) < 0.5) return\n      el.style.transition = 'none'\n      el.style.transform = `translateY(${dy}px)`\n      void el.offsetWidth\n      el.style.transition = `transform 300ms ${EASE_CSS}`\n      el.style.transform = ''\n    })\n    tops.current = next\n  }, [orderKey, width, reduced])\n\n  // The tooltip points at the active head, measured by layout so it goes straight to where a sliding row lands.\n  const activeKey = active === null ? null : data[active].label\n  const activeShare = active === null ? 0 : values[active] / top\n  useLayoutEffect(() => {\n    const root = rootRef.current\n    const track = activeKey === null ? undefined : tracks.current.get(activeKey)\n    const at = root && track ? offsetIn(track, root) : null\n    // The head is 26 px tall on a 20 px track, so its top sits 3 px above the track.\n    setTipAt(at ? { x: at.x + activeShare * track!.offsetWidth, y: at.y - 3 } : null)\n  }, [activeKey, activeShare, orderKey, width])\n\n  const focusedRow = () => {\n    const k = buttons.current.findIndex((b) => b && b === document.activeElement)\n    return k < 0 ? null : order[k]\n  }\n\n  const onKey = (e: KeyboardEvent<HTMLButtonElement>, k: number) => {\n    if (e.key === 'Escape') {\n      if (active === null) return\n      e.preventDefault()\n      set(null)\n      return\n    }\n    const to = stepIndex(e.key, k, n, { vertical: true })\n    if (to === null) return\n    e.preventDefault()\n    buttons.current[to]?.focus()\n  }\n\n  const compact = density === 'compact'\n  const stagger = n > 1 ? Math.min(50, 300 / (n - 1)) : 0\n  const tabStop = Math.max(0, Math.min(active !== null ? order.indexOf(active) : cursor, n - 1))\n  const longest = Math.max(4, ...order.map((i) => formatValue(values[i], format).length))\n  const cols = { ['--lp-v' as string]: `max(${compact ? 4.5 : 5.2}rem, ${longest + 0.5}ch)` } as CSSProperties\n\n  // One grid for the rows and the axis, so the stems and the ticks line up. Under 400 px: name and figure on one\n  // line, the stem under them.\n  const grid = cn(\n    'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-1.5',\n    '@min-[400px]:grid-cols-[minmax(0,8.5rem)_minmax(0,1fr)_var(--lp-v)] @min-[400px]:gap-x-3.5',\n    'group-data-[density=compact]/tile:@min-[400px]:grid-cols-[minmax(0,8rem)_minmax(0,1fr)_var(--lp-v)] group-data-[density=compact]/tile:@min-[400px]:gap-x-3',\n    compact && '@min-[400px]:grid-cols-[minmax(0,8rem)_minmax(0,1fr)_var(--lp-v)] @min-[400px]:gap-x-3',\n  )\n  const trackPlace = 'col-span-2 row-start-2 mx-2.5 @min-[400px]:col-span-1 @min-[400px]:col-start-2 @min-[400px]:row-start-1 @min-[400px]:mx-0'\n  const rowHeight = cn(\n    'min-h-[58px] gap-y-1 py-2 @min-[400px]:min-h-[46px] @min-[400px]:py-0',\n    'group-data-[density=compact]/tile:min-h-[54px] group-data-[density=compact]/tile:py-1.5 group-data-[density=compact]/tile:@min-[400px]:min-h-11 group-data-[density=compact]/tile:@min-[400px]:py-0',\n    compact && 'min-h-[54px] py-1.5 @min-[400px]:min-h-11 @min-[400px]:py-0',\n  )\n  const nameText = cn('text-sm group-data-[density=compact]/tile:text-[13.5px]', compact && 'text-[13.5px]')\n\n  return (\n    <div\n      ref={(el) => {\n        ref(el)\n        rootRef.current = el\n      }}\n      className={cn('@container relative w-full min-w-0', className)}\n      style={{ ...cols, ...style }}\n      aria-busy={loading || undefined}\n      {...rest}\n    >\n      {loading ? (\n        <div aria-hidden className=\"grid motion-safe:animate-pulse\">\n          {(data.length ? data.slice(0, limit ?? data.length) : SKELETON).map((_, k) => (\n            <div key={k} className={cn(grid, rowHeight)}>\n              <i className=\"block h-3 w-[70%] rounded-full bg-muted\" />\n              <i className=\"col-start-2 row-start-1 block h-3 w-12 justify-self-end rounded-full bg-muted @min-[400px]:col-start-3\" />\n              <span className={cn(trackPlace, 'relative h-5')}>\n                <i className=\"absolute top-[9px] left-0 block h-0.5 rounded-full bg-muted\" style={{ width: `${SKELETON[k % SKELETON.length]}%` }} />\n                <i\n                  className=\"absolute top-0 block size-5 -translate-x-1/2 rounded-full bg-muted\"\n                  style={{ left: `${SKELETON[k % SKELETON.length]}%` }}\n                />\n              </span>\n            </div>\n          ))}\n        </div>\n      ) : empty ? (\n        <div className=\"grid min-h-[188px] place-content-center justify-items-center gap-1.5 px-4 text-center group-data-[density=compact]/tile:min-h-[140px]\">\n          {placeholder ?? <span className=\"text-[13.5px] text-muted-foreground\">{labels.empty}</span>}\n        </div>\n      ) : (\n        <ul\n          aria-label={label}\n          className=\"grid\"\n          onPointerLeave={(e) => {\n            if (e.pointerType !== 'touch') set(focusedRow())\n          }}\n          onBlur={(e) => {\n            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) set(null)\n          }}\n        >\n          {order.map((i, k) => {\n            const d = data[i]\n            const on = i === active\n            const rank = rankOf.get(i) ?? k + 1\n            const v = values[i] / top\n            const p = valueParts(values[i], format)\n            const unit = p.unit && <span className=\"font-normal text-muted-foreground\">{p.unit}</span>\n            const extra = d.details?.length ? `, ${d.details.map((x) => `${x.label} ${x.value}`).join(', ')}` : ''\n            const motion = reduced\n              ? 'none'\n              : settled\n                ? `transform 300ms ${EASE_CSS}`\n                : `transform 300ms ${EASE_CSS} ${Math.round(k * stagger)}ms`\n            return (\n              <li\n                key={d.label}\n                ref={(el) => {\n                  if (el) items.current.set(d.label, el)\n                  else items.current.delete(d.label)\n                }}\n                className=\"relative min-w-0\"\n              >\n                <button\n                  ref={(el) => {\n                    buttons.current[k] = el\n                  }}\n                  type=\"button\"\n                  tabIndex={k === tabStop ? 0 : -1}\n                  aria-label={`${rank}. ${d.label}: ${formatValue(values[i], format)}${extra}`}\n                  onPointerEnter={() => set(i)}\n                  onPointerDown={() => set(i)}\n                  onFocus={() => {\n                    setCursor(k)\n                    set(i)\n                  }}\n                  onClick={(e) => {\n                    // Safari does not focus a button on click; focusing it keeps the row lit like everywhere else.\n                    e.currentTarget.focus()\n                    set(i)\n                    onSelect?.(i)\n                  }}\n                  onKeyDown={(e) => onKey(e, k)}\n                  className={cn(\n                    grid,\n                    rowHeight,\n                    'relative isolate w-full cursor-pointer touch-pan-y rounded-xl text-left focus-visible:outline-offset-0',\n                    'before:absolute before:inset-0 before:-z-10 before:rounded-xl before:bg-muted before:opacity-0 before:transition-opacity before:duration-150 before:ease-out-quint before:content-[\"\"] hover:before:opacity-100 motion-reduce:before:transition-none',\n                    on && 'before:opacity-100',\n                  )}\n                >\n                  <span aria-hidden className={cn('col-start-1 row-start-1 truncate', nameText)}>\n                    <small className=\"mr-2 font-mono text-[11px] leading-none text-muted-foreground tabular-nums\">\n                      {String(rank).padStart(2, '0')}\n                    </small>\n                    {d.label}\n                  </span>\n                  <span\n                    ref={(el) => {\n                      if (el) tracks.current.set(d.label, el)\n                      else tracks.current.delete(d.label)\n                    }}\n                    aria-hidden\n                    className={cn(trackPlace, 'relative h-5')}\n                  >\n                    <span\n                      className=\"absolute inset-x-0 top-[9px] h-0.5 origin-left rounded-full\"\n                      style={{ background: BASE, transform: `scaleX(${drawn ? v : 0})`, transition: motion }}\n                    />\n                    <span\n                      className=\"pointer-events-none absolute inset-0\"\n                      style={{ transform: `translateX(${((drawn ? v : 0) - 1) * 100}%)`, transition: motion }}\n                    >\n                      <span\n                        className=\"absolute top-[-3px] right-[-13px] size-[26px] rounded-full border-[3px] border-card\"\n                        style={{ background: INK }}\n                      >\n                        <span\n                          className=\"absolute inset-0 rounded-full bg-primary transition-opacity duration-150 ease-out-quint motion-reduce:transition-none\"\n                          style={{ opacity: on ? 1 : 0 }}\n                        />\n                      </span>\n                    </span>\n                  </span>\n                  <span\n                    aria-hidden\n                    className={cn(\n                      'col-start-2 row-start-1 text-right font-medium whitespace-nowrap tabular-nums @min-[400px]:col-start-3',\n                      nameText,\n                    )}\n                  >\n                    {p.sign}\n                    {!p.unitAfter && unit}\n                    {p.whole}\n                    {p.fraction}\n                    {p.unitAfter && <> {unit}</>}\n                    {p.suffix}\n                  </span>\n                </button>\n              </li>\n            )\n          })}\n        </ul>\n      )}\n      {axis && !loading && !empty && (\n        <div aria-hidden className={cn(grid, 'pt-1.5')}>\n          <span className=\"hidden @min-[400px]:block\" />\n          <span className={cn('relative col-span-2 mx-2.5 h-[11px] @min-[400px]:col-span-1 @min-[400px]:mx-0')}>\n            {scale.ticks.map((t) => (\n              <span\n                key={t}\n                className=\"absolute top-0 -translate-x-1/2 font-mono text-[11px] leading-none whitespace-nowrap text-muted-foreground tabular-nums\"\n                style={{ left: `${(t / top) * 100}%` }}\n              >\n                {formatTick(t, format)}\n              </span>\n            ))}\n          </span>\n        </div>\n      )}\n      {loading && <span className=\"sr-only\">{labels.loading}</span>}\n      {!loading && !empty && (\n        <ChartTooltip\n          open={!!tipAt && active !== null}\n          x={tipAt?.x ?? 0}\n          y={tipAt?.y ?? 0}\n          bounds={width}\n          title={active === null ? '' : `${data[active].label} · ${labels.rank(rankOf.get(active) ?? 0, n)}`}\n          value={active === null ? '' : <TipValue value={values[active]} format={format} />}\n          rows={active === null ? [] : (data[active].details ?? [])}\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\nexport default Lollipop\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}