{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"virtual-list","type":"registry:ui","title":"VirtualList","description":"Windowed list for tens of thousands of rows - measured variable heights, deep links by row id, tail-follow.","package":"react-fancy","dependencies":["tailwind-merge"],"registryDependencies":[],"files":[{"path":"components\/fancy\/virtual-list\/VirtualList.tsx","content":"import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, TouchEvent, WheelEvent } from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport type { VirtualListProps, VirtualListRow } from \".\/VirtualList.types\";\n\n\/**\n * A windowed list for ledgers that run to tens of thousands of rows \u2014 only the\n * visible slice is in the DOM, with **measured** variable row heights, deep\n * links by row id, and sticky-bottom tail-following.\n *\n * Built for an agent event stream, where every one of those three is load\n * bearing at once: message rows wrap and event rows do not, a link carries\n * `?at=<rowId>` to the exact row under discussion, and new rows arrive while\n * the human is reading older ones.\n *\n * ## Heights are measured, never predicted\n *\n * `estimateRowHeight` only seeds rows that have never been on screen. The\n * moment a row renders, its real height is measured and replaces the estimate,\n * and every offset after it moves. The alternative \u2014 a `rowHeight(item)`\n * callback \u2014 is a prediction about text wrapping, font loading and container\n * width, and when it is wrong the error accumulates down the list: rows draw in\n * the wrong place and `scrollToId` lands on a neighbour. Measuring is slower to\n * settle and correct when it does.\n *\n * ## Tail-follow breaks on INTENT, not on position\n *\n * Any upward gesture \u2014 wheel, drag, PageUp, Home \u2014 stops following\n * immediately, even when the list is still pinned to the bottom and\n * `scrollTop` has not changed yet. Position cannot tell \"the human scrolled\n * up\" from \"content grew downward\", so a position-only implementation keeps\n * following through a wheel-up and yanks the reader back on the next append.\n *\n * ## It is controlled\n *\n * `followTail` is the consumer's state. This component only ever *asks* via\n * `onFollowChange`, so the follow state is readable and writable by an agent\n * over a bridge, as the Human+ component contract requires. The `\u2193 n new` pill\n * is the consumer's to render, from `onUnseenChange`.\n *\n * @example\n * ```tsx\n * const [following, setFollowing] = useState(true);\n * const [unseen, setUnseen] = useState(0);\n *\n * <VirtualList\n *   items={events}\n *   renderRow={(e) => <StreamRow event={e} \/>}\n *   height=\"100%\"\n *   estimateRowHeight={28}\n *   followTail={following}\n *   onFollowChange={setFollowing}\n *   onUnseenChange={setUnseen}\n *   scrollToId={params.at}\n * \/>\n * {!following && unseen > 0 && (\n *   <Button onClick={() => setFollowing(true)}>\u2193 {unseen} new<\/Button>\n * )}\n * ```\n *\/\nexport function VirtualList<T extends VirtualListRow>({\n  items,\n  renderRow,\n  height = \"100%\",\n  estimateRowHeight = 32,\n  overscan = 6,\n  followTail = false,\n  onFollowChange,\n  onUnseenChange,\n  scrollToId = null,\n  onScrollToIdResolved,\n  className,\n  ref,\n  ...props\n}: VirtualListProps<T>) {\n  const viewportRef = useRef<HTMLDivElement | null>(null);\n  const windowRef = useRef<HTMLDivElement | null>(null);\n\n  \/** Measured heights by row id, so a height follows its row across a reindex. *\/\n  const heights = useRef(new Map<string, number>());\n  \/** Bumped when a measurement actually changed, to recompute offsets. *\/\n  const [measured, setMeasured] = useState(0);\n\n  const [scrollTop, setScrollTop] = useState(0);\n  const [clientHeight, setClientHeight] = useState(0);\n\n  const attachViewport = useCallback(\n    (el: HTMLDivElement | null) => {\n      viewportRef.current = el;\n      if (typeof ref === \"function\") ref(el);\n      else if (ref) (ref as { current: HTMLDivElement | null }).current = el;\n    },\n    [ref],\n  );\n\n  \/\/ A number `height` IS the viewport height, and is usable before the element\n  \/\/ has been measured. Without one, render a conservative first window so there\n  \/\/ is something on screen for the measurement pass to work from.\n  const viewport =\n    clientHeight > 0 ? clientHeight : typeof height === \"number\" ? height : estimateRowHeight * 20;\n\n  \/**\n   * Prefix sums of row heights: `offsets[i]` is where row `i` starts, and the\n   * last entry is the total. Rebuilt whenever rows or measurements change \u2014\n   * O(n) once per change, against O(log n) per scroll from the binary search,\n   * rather than O(n) on every scroll frame.\n   *\/\n  const { offsets, total } = useMemo(() => {\n    const offs = new Float64Array(items.length + 1);\n    for (let i = 0; i < items.length; i += 1) {\n      offs[i + 1] = offs[i] + (heights.current.get(items[i].id) ?? estimateRowHeight);\n    }\n\n    return { offsets: offs, total: items.length === 0 ? 0 : offs[items.length] };\n  }, [items, measured, estimateRowHeight]);\n\n  \/** First row whose span contains `y`. *\/\n  const indexAt = useCallback(\n    (y: number): number => {\n      let lo = 0;\n      let hi = items.length - 1;\n      while (lo < hi) {\n        const mid = (lo + hi + 1) >> 1;\n        if (offsets[mid] <= y) lo = mid;\n        else hi = mid - 1;\n      }\n\n      return Math.max(0, lo);\n    },\n    [items.length, offsets],\n  );\n\n  const start = Math.max(0, indexAt(scrollTop) - overscan);\n  const end = Math.min(items.length, indexAt(scrollTop + viewport) + 1 + overscan);\n  const visible = useMemo(() => items.slice(start, end), [items, start, end]);\n\n  \/\/ ---------------------------------------------------------------- measuring\n\n  const observer = useRef<ResizeObserver | null>(null);\n\n  useEffect(() => {\n    \/\/ Absent in jsdom and in older runtimes. Without it the list still measures\n    \/\/ on every render; it just does not notice a row reflowing in place.\n    if (typeof ResizeObserver === \"undefined\") return;\n\n    const ro = new ResizeObserver(() => setMeasured((v) => v + 1));\n    observer.current = ro;\n\n    return () => {\n      ro.disconnect();\n      observer.current = null;\n    };\n  }, []);\n\n  \/\/ No dependency array on purpose: every render re-measures what is on screen,\n  \/\/ and the `changed` guard is what stops it looping. A render that learns\n  \/\/ nothing new sets no state.\n  useLayoutEffect(() => {\n    const win = windowRef.current;\n    if (!win) return;\n\n    let changed = false;\n    for (const el of Array.from(win.children) as HTMLElement[]) {\n      const id = el.getAttribute(ROW_ID_ATTR);\n      if (id === null) continue;\n\n      observer.current?.observe(el);\n\n      const h = el.getBoundingClientRect().height;\n      \/\/ A zero height is \"not laid out yet\", not a 0px row. Recording it would\n      \/\/ collapse the list and make every offset after it wrong.\n      if (h > 0 && heights.current.get(id) !== h) {\n        heights.current.set(id, h);\n        changed = true;\n      }\n    }\n\n    const vp = viewportRef.current;\n    if (vp && vp.clientHeight > 0 && vp.clientHeight !== clientHeight) setClientHeight(vp.clientHeight);\n\n    if (changed) setMeasured((v) => v + 1);\n  });\n\n  \/\/ ------------------------------------------------------------- deep linking\n\n  \/** The target this component last moved to, to detect that it has settled. *\/\n  const anchored = useRef<number | null>(null);\n  const resolved = useRef<string | null>(null);\n\n  useEffect(() => {\n    \/\/ A new id is a new request, even if the old one had resolved.\n    anchored.current = null;\n    resolved.current = null;\n  }, [scrollToId]);\n\n  useLayoutEffect(() => {\n    if (scrollToId === null || resolved.current === scrollToId) return;\n\n    const index = items.findIndex((item) => item.id === scrollToId);\n    if (index < 0) {\n      \/\/ A link to a row that is no longer in the list. Leaving the viewport\n      \/\/ where it is and SAYING SO is the only honest outcome \u2014 scrolling to the\n      \/\/ top would be indistinguishable from a successful landing.\n      resolved.current = scrollToId;\n      onScrollToIdResolved?.(scrollToId, false);\n\n      return;\n    }\n\n    const vp = viewportRef.current;\n    if (!vp) return;\n\n    \/\/ Clamped the way the browser clamps: a row inside the final viewport-height\n    \/\/ of the list cannot be brought to the top, and pretending otherwise would\n    \/\/ leave this waiting for a position that can never be reached.\n    const target = Math.max(0, Math.min(offsets[index], Math.max(0, total - viewport)));\n\n    if (anchored.current !== target) {\n      anchored.current = target;\n      vp.scrollTop = target;\n      setScrollTop(target);\n\n      return;\n    }\n\n    \/\/ The target stopped moving as rows above it were measured, and we are on\n    \/\/ it. Only now is the deep link actually honoured.\n    if (Math.abs(vp.scrollTop - target) <= 1) {\n      resolved.current = scrollToId;\n      onScrollToIdResolved?.(scrollToId, true);\n    }\n  });\n\n  \/\/ ------------------------------------------------------------- tail-follow\n\n  const reportedFollow = useRef(followTail);\n  const programmatic = useRef(false);\n\n  useEffect(() => {\n    \/\/ The prop is the truth, so adopt it rather than remembering what we asked\n    \/\/ for. Otherwise a consumer that declines an `onFollowChange(false)` would\n    \/\/ never be asked again.\n    reportedFollow.current = followTail;\n  }, [followTail]);\n\n  const askFollow = useCallback(\n    (next: boolean) => {\n      if (reportedFollow.current === next) return;\n      reportedFollow.current = next;\n      onFollowChange?.(next);\n    },\n    [onFollowChange],\n  );\n\n  useLayoutEffect(() => {\n    if (!followTail) return;\n    \/\/ A pending deep link wins while it resolves; otherwise the two would fight\n    \/\/ over the same scrollTop and the link would lose.\n    if (scrollToId !== null && resolved.current !== scrollToId) return;\n\n    const vp = viewportRef.current;\n    if (!vp) return;\n\n    const bottom = Math.max(0, total - viewport);\n    if (Math.abs(vp.scrollTop - bottom) > 0.5) {\n      programmatic.current = true;\n      vp.scrollTop = bottom;\n      setScrollTop(bottom);\n    }\n  });\n\n  const onScroll = useCallback(() => {\n    const vp = viewportRef.current;\n    if (!vp) return;\n\n    const next = vp.scrollTop;\n    const previous = scrollTop;\n    setScrollTop(next);\n\n    const bottom = Math.max(0, total - viewport);\n    const wasProgrammatic = programmatic.current;\n    programmatic.current = false;\n\n    if (next <= bottom - BOTTOM_SLACK) {\n      \/\/ Moved up, and not because we moved it.\n      if (!wasProgrammatic && next < previous) askFollow(false);\n    } else {\n      askFollow(true);\n    }\n  }, [scrollTop, total, viewport, askFollow]);\n\n  \/** Any upward gesture is a statement of intent, whatever scrollTop says. *\/\n  const onWheel = useCallback(\n    (e: WheelEvent<HTMLDivElement>) => {\n      if (e.deltaY < 0) askFollow(false);\n    },\n    [askFollow],\n  );\n\n  const onKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLDivElement>) => {\n      if (UPWARD_KEYS.has(e.key)) askFollow(false);\n    },\n    [askFollow],\n  );\n\n  const touchStart = useRef<number | null>(null);\n\n  const onTouchStart = useCallback((e: TouchEvent<HTMLDivElement>) => {\n    touchStart.current = e.touches[0]?.clientY ?? null;\n  }, []);\n\n  const onTouchMove = useCallback(\n    (e: TouchEvent<HTMLDivElement>) => {\n      const from = touchStart.current;\n      const y = e.touches[0]?.clientY;\n      \/\/ Finger travelling DOWN drags the content down, which scrolls up.\n      if (from !== null && y !== undefined && y - from > TOUCH_SLOP) askFollow(false);\n    },\n    [askFollow],\n  );\n\n  \/\/ ------------------------------------------------------------ unseen count\n\n  const seen = useRef(items.length);\n  const reportedUnseen = useRef<number | null>(null);\n\n  useEffect(() => {\n    const unseen = followTail ? 0 : Math.max(0, items.length - seen.current);\n    if (followTail) seen.current = items.length;\n\n    if (reportedUnseen.current === unseen) return;\n    reportedUnseen.current = unseen;\n    onUnseenChange?.(unseen);\n  }, [items.length, followTail, onUnseenChange]);\n\n  \/\/ ------------------------------------------------------------------ render\n\n  return (\n    <div\n      ref={attachViewport}\n      data-react-fancy-virtual-list=\"\"\n      data-react-fancy-virtual-list-following={followTail ? \"true\" : \"false\"}\n      data-react-fancy-virtual-list-rows={items.length}\n      role=\"log\"\n      tabIndex={0}\n      className={cn(\"relative overflow-y-auto overflow-x-hidden outline-none\", className)}\n      style={{ height }}\n      onScroll={onScroll}\n      onWheel={onWheel}\n      onKeyDown={onKeyDown}\n      onTouchStart={onTouchStart}\n      onTouchMove={onTouchMove}\n      {...props}\n    >\n      {\/* Holds the full scroll range so the scrollbar is honest from the first\n          paint, instead of growing as rows are discovered. *\/}\n      <div\n        data-react-fancy-virtual-list-sizer=\"\"\n        style={{ height: `${total}px`, position: \"relative\" }}\n      >\n        <div\n          ref={windowRef}\n          data-react-fancy-virtual-list-window=\"\"\n          data-react-fancy-virtual-list-start={start}\n          style={{ transform: `translateY(${offsets[start] ?? 0}px)` }}\n        >\n          {visible.map((item, i) => (\n            <div key={item.id} data-react-fancy-virtual-row-id={item.id}>\n              {renderRow(item, start + i)}\n            <\/div>\n          ))}\n        <\/div>\n      <\/div>\n    <\/div>\n  );\n}\n\nconst ROW_ID_ATTR = \"data-react-fancy-virtual-row-id\";\n\/** Within this many px of the bottom counts as \"at the bottom\". *\/\nconst BOTTOM_SLACK = 4;\n\/** Ignore a touch jitter smaller than this before calling it an upward drag. *\/\nconst TOUCH_SLOP = 4;\nconst UPWARD_KEYS = new Set([\"ArrowUp\", \"PageUp\", \"Home\"]);\n","type":"registry:ui","target":"components\/fancy\/virtual-list\/VirtualList.tsx"},{"path":"components\/fancy\/virtual-list\/VirtualList.types.ts","content":"import type { HTMLAttributes, ReactNode, Ref } from \"react\";\n\n\/**\n * The one thing `VirtualList` requires of a row: a stable id.\n *\n * It is required rather than derived from the array index because every feature\n * here outlives a reindex \u2014 `scrollToId` resolves a deep link after rows have\n * been prepended, and a measured height must follow its row rather than its\n * position.\n *\/\nexport interface VirtualListRow {\n  id: string;\n}\n\nexport interface VirtualListProps<T extends VirtualListRow>\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"children\" | \"onScroll\"> {\n  \/** The full list. Only the visible window is ever rendered. *\/\n  items: readonly T[];\n  \/** Render one row. Called only for rows inside the window. *\/\n  renderRow: (item: T, index: number) => ReactNode;\n  \/**\n   * Height of the scroll viewport. A number is also used as the viewport height\n   * for windowing before the element has been measured, so prefer one when you\n   * know it \u2014 `\"100%\"` works, it just renders a conservative first window.\n   *\/\n  height?: number | string;\n  \/**\n   * Seed height for rows that have not been measured yet. Only affects rows\n   * never rendered: a row's real height replaces it the moment it is on screen.\n   * Pick the common case, not the average.\n   *\/\n  estimateRowHeight?: number;\n  \/** Rows to render beyond each edge of the viewport. *\/\n  overscan?: number;\n\n  \/**\n   * Stick to the bottom as rows arrive. **Controlled** \u2014 this component never\n   * flips it on its own, so an agent can read and write the follow state\n   * through a bridge. Pair it with {@link onFollowChange}.\n   *\/\n  followTail?: boolean;\n  \/**\n   * The list asking to change {@link followTail}: `false` the instant the human\n   * scrolls up, `true` when they return to the bottom. Honour it by updating\n   * your own state \u2014 until you do, nothing changes.\n   *\/\n  onFollowChange?: (following: boolean) => void;\n  \/**\n   * How many rows have arrived since tail-following stopped. Drives a\n   * `\u2193 n new` pill, which is yours to render. Always `0` while following.\n   *\/\n  onUnseenChange?: (count: number) => void;\n\n  \/**\n   * Put the row with this id at the top of the viewport \u2014 the `?at=<rowId>`\n   * deep link. Resolution is not instant for a row far down an unmeasured list:\n   * the target settles as the rows above it are measured. See\n   * {@link onScrollToIdResolved}.\n   *\/\n  scrollToId?: string | null;\n  \/**\n   * Fired once the component has stopped moving for a given `scrollToId`.\n   * `landed` is `false` when the id is not in `items` \u2014 a deep link to a pruned\n   * row, which must be distinguishable from a successful scroll to the top.\n   *\/\n  onScrollToIdResolved?: (id: string, landed: boolean) => void;\n\n  \/** The scroll viewport element. *\/\n  ref?: Ref<HTMLDivElement>;\n}\n","type":"registry:ui","target":"components\/fancy\/virtual-list\/VirtualList.types.ts"},{"path":"components\/fancy\/virtual-list\/index.ts","content":"export { VirtualList } from \".\/VirtualList\";\nexport type { VirtualListProps, VirtualListRow } from \".\/VirtualList.types\";\n","type":"registry:ui","target":"components\/fancy\/virtual-list\/index.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"}]}