{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "virtual-list",
  "type": "registry:ui",
  "title": "Virtual List",
  "description": "A windowed listbox that renders only the visible rows of a huge list, with measured variable heights, sticky group headers and scrollToIndex.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/virtual-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\ntype ItemKey = string | number;\n\nexport interface VirtualListScrollOptions {\n  /** Where the row should land. \"auto\" scrolls the minimum distance (no-op when already visible). */\n  align?: \"start\" | \"center\" | \"end\" | \"auto\";\n  behavior?: \"auto\" | \"smooth\";\n  /** Also make it the active row (the keyboard cursor). */\n  activate?: boolean;\n}\n\nexport interface VirtualListHandle {\n  scrollToIndex: (index: number, options?: VirtualListScrollOptions) => void;\n  /** The element that scrolls: the list itself, or `scrollContainer` when given. */\n  getScrollElement: () => HTMLElement | null;\n}\n\nexport interface VirtualListRowState {\n  index: number;\n  /** The keyboard cursor is on this row. */\n  active: boolean;\n  selected: boolean;\n}\n\nexport interface VirtualListRange {\n  /** First and last item index inside the viewport (overscan excluded). */\n  start: number;\n  end: number;\n  /** Item rows currently mounted (viewport + overscan + the active row). */\n  rendered: number;\n}\n\nexport interface VirtualListProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"onSelect\" | \"role\"> {\n  items: readonly T[];\n  /** Renders one row. Rows may have any height; each is measured with a ResizeObserver. */\n  renderItem: (item: T, state: VirtualListRowState) => React.ReactNode;\n  /** Stable key per item (defaults to the index). Measured heights are cached by key. */\n  getKey?: (item: T, index: number) => ItemKey;\n  /** Accessible name of the listbox. */\n  label: string;\n  /** Height guess for rows that haven't been measured yet, in px. */\n  estimateSize?: number;\n  /** Extra rows mounted above and below the viewport. */\n  overscan?: number;\n  /** Viewport height in px when the list scrolls itself (ignored with `scrollContainer`). */\n  height?: number;\n  /** Let an outer element do the scrolling; the list renders at full height inside it. */\n  scrollContainer?: React.RefObject<HTMLElement | null>;\n  /** Group name per item. Consecutive items with the same group share a header. */\n  getGroup?: (item: T, index: number) => string;\n  /** Pin the current group's header to the top while scrolling (needs `getGroup`). */\n  stickyHeaders?: boolean;\n  renderGroupHeader?: (group: string) => React.ReactNode;\n  /** Fade newly mounted rows in. Off by default; ignored with reduced motion. */\n  animateEntry?: boolean;\n  /** Space between rows in px. */\n  gap?: number;\n  /** Controlled selection (item key). */\n  selectedKey?: ItemKey | null;\n  defaultSelectedKey?: ItemKey | null;\n  /** Called on click, Enter or Space. */\n  onSelect?: (item: T, index: number) => void;\n  onActiveIndexChange?: (index: number) => void;\n  onRangeChange?: (range: VirtualListRange) => void;\n  /** Focus and selection colour. */\n  accent?: string;\n  ref?: React.Ref<VirtualListHandle>;\n}\n\ninterface Layout {\n  /** Item index for each entry, or -1 for a group header. */\n  item: Int32Array;\n  key: string[];\n  group: string[];\n  offset: Float64Array;\n  size: Float64Array;\n  /** Item index → entry index. */\n  entryOf: Int32Array;\n  /** Entry indexes of the group headers, in order. */\n  headers: number[];\n  total: number;\n}\n\nconst HEADER_ESTIMATE = 30;\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\nfunction buildLayout<T>(\n  items: readonly T[],\n  getKey: ((item: T, index: number) => ItemKey) | undefined,\n  getGroup: ((item: T, index: number) => string) | undefined,\n  measured: ReadonlyMap<string, number>,\n  estimate: number,\n  gap: number,\n): Layout {\n  const groups = getGroup ? items.map((item, i) => getGroup(item, i)) : null;\n  let headerCount = 0;\n  if (groups) for (let i = 0; i < groups.length; i++) if (i === 0 || groups[i] !== groups[i - 1]) headerCount++;\n  const n = items.length + headerCount;\n  const layout: Layout = {\n    item: new Int32Array(n),\n    key: new Array<string>(n),\n    group: new Array<string>(n),\n    offset: new Float64Array(n),\n    size: new Float64Array(n),\n    entryOf: new Int32Array(items.length),\n    headers: [],\n    total: 0,\n  };\n  let y = 0;\n  let e = 0;\n  const push = (itemIndex: number, key: string, group: string, guess: number) => {\n    const size = measured.get(key) ?? guess;\n    layout.item[e] = itemIndex;\n    layout.key[e] = key;\n    layout.group[e] = group;\n    layout.offset[e] = y;\n    layout.size[e] = size;\n    y += size + gap;\n    e++;\n  };\n  for (let i = 0; i < items.length; i++) {\n    const group = groups ? groups[i] : \"\";\n    if (groups && (i === 0 || group !== groups[i - 1])) {\n      layout.headers.push(e);\n      push(-1, `h:${layout.headers.length}:${group}`, group, HEADER_ESTIMATE);\n    }\n    layout.entryOf[i] = e;\n    push(i, `r:${getKey ? getKey(items[i], i) : i}`, group, estimate);\n  }\n  layout.total = Math.max(0, y - gap);\n  return layout;\n}\n\n/** Last entry whose top edge is at or above `y` (binary search over the offsets). */\nfunction entryAt(layout: Layout, y: number) {\n  let lo = 0;\n  let hi = layout.offset.length - 1;\n  if (hi < 0) return -1;\n  while (lo < hi) {\n    const mid = (lo + hi + 1) >> 1;\n    if (layout.offset[mid] <= y) lo = mid;\n    else hi = mid - 1;\n  }\n  return lo;\n}\n\nfunction visibleRange(layout: Layout, top: number, height: number, overscan: number): [number, number] {\n  const n = layout.offset.length;\n  if (n === 0) return [0, -1];\n  const first = Math.max(0, entryAt(layout, top));\n  const last = Math.max(first, entryAt(layout, top + height));\n  return [Math.max(0, first - overscan), Math.min(n - 1, last + overscan)];\n}\n\n/** The header whose group is showing at the top, plus the next header (for the push-up effect). */\nfunction stickyAt(layout: Layout, top: number): [number, number] {\n  const h = layout.headers;\n  if (!h.length) return [-1, -1];\n  let lo = 0;\n  let hi = h.length - 1;\n  while (lo < hi) {\n    const mid = (lo + hi + 1) >> 1;\n    if (layout.offset[h[mid]] <= top + 0.5) lo = mid;\n    else hi = mid - 1;\n  }\n  return [h[lo], h[lo + 1] ?? -1];\n}\n\n/**\n * A windowed listbox for thousands of rows. Only the rows in (and just around) the viewport are\n * mounted; every row is measured with a ResizeObserver, so heights can vary freely. Unmeasured rows\n * use an estimate, and when a row above the viewport turns out taller or shorter the scroll position\n * is corrected in the same frame, so nothing jumps. It follows the listbox pattern\n * (aria-activedescendant, aria-setsize / aria-posinset) with arrow, Page and Home / End keys.\n */\nexport function VirtualList<T>({\n  items,\n  renderItem,\n  getKey,\n  label,\n  estimateSize = 64,\n  overscan = 6,\n  height = 420,\n  scrollContainer,\n  getGroup,\n  stickyHeaders = true,\n  renderGroupHeader,\n  animateEntry = false,\n  gap = 0,\n  selectedKey: selectedKeyProp,\n  defaultSelectedKey = null,\n  onSelect,\n  onActiveIndexChange,\n  onRangeChange,\n  accent = \"#FF7448\",\n  ref,\n  className = \"\",\n  style,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  ...rest\n}: VirtualListProps<T>) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const external = scrollContainer !== undefined;\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const stickyRef = React.useRef<HTMLDivElement>(null);\n\n  const [measured, setMeasured] = React.useState<ReadonlyMap<string, number>>(() => new Map());\n  const [view, setView] = React.useState(() => ({ top: 0, height: external ? 640 : height }));\n  const [activeIndex, setActiveIndexState] = React.useState(-1);\n  const [selectedInternal, setSelectedInternal] = React.useState<ItemKey | null>(defaultSelectedKey);\n  const selectedKey = selectedKeyProp !== undefined ? selectedKeyProp : selectedInternal;\n\n  const layout = React.useMemo(() => buildLayout(items, getKey, getGroup, measured, estimateSize, gap), [items, getKey, getGroup, measured, estimateSize, gap]);\n  const [startEntry, endEntry] = visibleRange(layout, view.top, view.height, overscan);\n  const [stickyEntry] = getGroup && stickyHeaders ? stickyAt(layout, view.top) : [-1];\n  const activeEntry = activeIndex >= 0 && activeIndex < items.length ? layout.entryOf[activeIndex] : -1;\n\n  // Mutable mirrors for listeners (written after commit, never during render).\n  const live = React.useRef({ layout, overscan, range: [startEntry, endEntry] as [number, number], view, animateEntry, reduceMotion, estimateSize, onActiveIndexChange });\n  const measuredRef = React.useRef(new Map<string, number>());\n  const pendingAdjust = React.useRef(0);\n  const alignTarget = React.useRef<{ index: number; align: NonNullable<VirtualListScrollOptions[\"align\"]>; until: number } | null>(null);\n  const roRef = React.useRef<ResizeObserver | null>(null);\n  const pointerFocus = React.useRef(false);\n\n  // The outer scroller (if any), read lazily so a ref object created by the parent always resolves.\n  const containerProp = React.useRef(scrollContainer);\n  React.useLayoutEffect(() => {\n    containerProp.current = scrollContainer;\n  });\n  const getScroller = React.useCallback((): HTMLElement | null => (external ? (containerProp.current?.current ?? null) : rootRef.current), [external]);\n\n  /** Scroll position and viewport height in list coordinates (works for an outer scroller too). */\n  const metrics = React.useCallback(() => {\n    const scroller = getScroller();\n    const root = rootRef.current;\n    if (!scroller || !root) return { top: 0, height: 0, base: 0 };\n    if (!external) return { top: scroller.scrollTop, height: scroller.clientHeight, base: 0 };\n    const base = root.getBoundingClientRect().top - scroller.getBoundingClientRect().top - scroller.clientTop + scroller.scrollTop;\n    return { top: scroller.scrollTop - base, height: scroller.clientHeight, base };\n  }, [external, getScroller]);\n\n  /** Moves the pinned header up as the next group's header arrives. */\n  const updateSticky = React.useCallback((top: number) => {\n    const el = stickyRef.current;\n    if (!el) return;\n    const L = live.current.layout;\n    const [, next] = stickyAt(L, top);\n    const h = el.offsetHeight;\n    const push = next >= 0 ? Math.min(0, L.offset[next] - top - h) : 0;\n    el.style.transform = `translateY(${round(push)}px)`;\n  }, []);\n\n  /** Re-render only when the mounted range would change, not on every scroll frame. */\n  const sync = React.useCallback(() => {\n    const m = metrics();\n    const L = live.current;\n    const [s, e] = visibleRange(L.layout, m.top, m.height, L.overscan);\n    if (s !== L.range[0] || e !== L.range[1] || Math.abs(m.height - L.view.height) > 1) setView({ top: m.top, height: m.height });\n    updateSticky(m.top);\n  }, [metrics, updateSticky]);\n\n  const scrollToIndexInternal = React.useCallback(\n    (index: number, align: NonNullable<VirtualListScrollOptions[\"align\"]>, behavior: ScrollBehavior) => {\n      const scroller = getScroller();\n      const L = live.current.layout;\n      if (!scroller || index < 0 || index >= L.entryOf.length) return;\n      const e = L.entryOf[index];\n      const start = L.offset[e];\n      const size = L.size[e];\n      const m = metrics();\n      const pinned = stickyRef.current?.offsetHeight ?? 0;\n      let target: number;\n      if (align === \"start\") target = start - pinned;\n      else if (align === \"end\") target = start + size - m.height;\n      else if (align === \"center\") target = start + size / 2 - (m.height + pinned) / 2 + pinned;\n      else if (start - pinned < m.top) target = start - pinned;\n      else if (start + size > m.top + m.height) target = start + size - m.height;\n      else return;\n      scroller.scrollTo({ top: Math.max(0, m.base + target), behavior });\n    },\n    [getScroller, metrics],\n  );\n\n  const scrollToIndex = React.useCallback(\n    (index: number, options: VirtualListScrollOptions = {}) => {\n      const { align = \"auto\", behavior = \"auto\", activate = false } = options;\n      const smooth = behavior === \"smooth\" && !live.current.reduceMotion;\n      if (activate) {\n        setActiveIndexState(index);\n        live.current.onActiveIndexChange?.(index);\n      }\n      scrollToIndexInternal(index, align, smooth ? \"smooth\" : \"auto\");\n      // Rows measured after landing can move the target: keep re-aligning briefly (after a smooth scroll ends).\n      alignTarget.current = { index, align, until: smooth ? Infinity : performance.now() + 400 };\n      if (smooth) {\n        const scroller = getScroller();\n        const settle = () => {\n          window.clearTimeout(timer);\n          scroller?.removeEventListener(\"scrollend\", settle);\n          if (alignTarget.current?.index !== index) return;\n          alignTarget.current = { index, align, until: performance.now() + 300 };\n          scrollToIndexInternal(index, align, \"auto\");\n        };\n        const timer = window.setTimeout(settle, 900);\n        scroller?.addEventListener(\"scrollend\", settle);\n      }\n    },\n    [getScroller, scrollToIndexInternal],\n  );\n\n  React.useImperativeHandle(ref, () => ({ scrollToIndex, getScrollElement: getScroller }), [scrollToIndex, getScroller]);\n\n  // After every commit: refresh the mirrors, apply scroll-anchor corrections, keep a target aligned.\n  React.useLayoutEffect(() => {\n    live.current = { layout, overscan, range: [startEntry, endEntry], view, animateEntry, reduceMotion, estimateSize, onActiveIndexChange };\n    const scroller = getScroller();\n    if (scroller && pendingAdjust.current !== 0) {\n      scroller.scrollTop += pendingAdjust.current;\n      pendingAdjust.current = 0;\n    }\n    const t = alignTarget.current;\n    if (t && performance.now() < t.until && t.until !== Infinity) scrollToIndexInternal(t.index, t.align, \"auto\");\n    updateSticky(metrics().top);\n  });\n\n  // Scroll + viewport resize listeners on whichever element scrolls.\n  React.useEffect(() => {\n    const scroller = getScroller();\n    if (!scroller) return;\n    let frame = 0;\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(() => {\n        frame = 0;\n        sync();\n      });\n    };\n    const onUserScroll = () => {\n      // A wheel / touch / key scroll by the user cancels any pending alignment.\n      alignTarget.current = null;\n    };\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n    scroller.addEventListener(\"wheel\", onUserScroll, { passive: true });\n    scroller.addEventListener(\"touchmove\", onUserScroll, { passive: true });\n    const ro = new ResizeObserver(onScroll);\n    ro.observe(scroller);\n    onScroll();\n    return () => {\n      cancelAnimationFrame(frame);\n      scroller.removeEventListener(\"scroll\", onScroll);\n      scroller.removeEventListener(\"wheel\", onUserScroll);\n      scroller.removeEventListener(\"touchmove\", onUserScroll);\n      ro.disconnect();\n    };\n  }, [getScroller, sync]);\n\n  React.useEffect(() => () => roRef.current?.disconnect(), []);\n\n  // Report the visible item range (viewport only, overscan excluded).\n  const firstItem = firstItemFrom(layout, entryAt(layout, view.top), 1);\n  const lastItem = firstItemFrom(layout, entryAt(layout, view.top + view.height), -1);\n  let renderedRows = 0;\n  for (let e = startEntry; e <= endEntry; e++) if (layout.item[e] >= 0) renderedRows++;\n  if (activeEntry >= 0 && (activeEntry < startEntry || activeEntry > endEntry)) renderedRows++;\n  React.useEffect(() => {\n    onRangeChange?.({ start: firstItem, end: lastItem, rendered: renderedRows });\n  }, [firstItem, lastItem, renderedRows, onRangeChange]);\n\n  /** One shared ResizeObserver measures every mounted row and header. */\n  const observe = React.useCallback((el: HTMLDivElement | null) => {\n    if (!el) return;\n    if (!roRef.current) {\n      roRef.current = new ResizeObserver((entries) => {\n        const L = live.current.layout;\n        const scroller = getScroller();\n        const root = rootRef.current;\n        let top = scroller?.scrollTop ?? 0;\n        if (external && scroller && root) top = scroller.getBoundingClientRect().top - root.getBoundingClientRect().top;\n        let changed = false;\n        for (const entry of entries) {\n          const target = entry.target as HTMLElement;\n          const key = target.dataset.vkey;\n          if (!key) continue;\n          const size = round(entry.borderBoxSize?.[0]?.blockSize ?? target.offsetHeight);\n          const index = Number(target.dataset.ventry);\n          const previous = measuredRef.current.get(key) ?? (L.key[index] === key ? L.size[index] : size);\n          if (Math.abs(previous - size) < 0.5 && measuredRef.current.has(key)) continue;\n          measuredRef.current.set(key, size);\n          changed = true;\n          // A row that sits entirely above the viewport changed height: shift the scroll position by\n          // the same amount so the rows the user is looking at stay put.\n          if (L.key[index] === key && L.offset[index] + previous <= top && top > 0) pendingAdjust.current += size - previous;\n        }\n        if (changed) setMeasured(new Map(measuredRef.current));\n      });\n    }\n    const ro = roRef.current;\n    ro.observe(el);\n    const { animateEntry: animate, reduceMotion: reduce } = live.current;\n    if (animate && !reduce && el.dataset.vkind === \"row\" && typeof el.animate === \"function\") {\n      el.animate([{ opacity: 0, translate: \"0 6px\" }, { opacity: 1, translate: \"0 0\" }], { duration: 260, easing: \"cubic-bezier(0.2, 0.8, 0.2, 1)\" });\n    }\n    return () => ro.unobserve(el);\n  }, [external, getScroller]);\n\n  const setActive = (index: number, reveal: boolean) => {\n    const next = Math.max(0, Math.min(items.length - 1, index));\n    setActiveIndexState(next);\n    onActiveIndexChange?.(next);\n    if (reveal) {\n      alignTarget.current = { index: next, align: \"auto\", until: performance.now() + 300 };\n      scrollToIndexInternal(next, \"auto\", \"auto\");\n    }\n  };\n  const select = (index: number) => {\n    const item = items[index];\n    if (item === undefined) return;\n    const key = getKey ? getKey(item, index) : index;\n    if (selectedKeyProp === undefined) setSelectedInternal(key);\n    onSelect?.(item, index);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || !items.length) return;\n    const m = metrics();\n    const current = activeIndex >= 0 ? activeIndex : -1;\n    const firstVisible = Math.max(0, firstItemFrom(layout, entryAt(layout, m.top), 1));\n    let next: number;\n    switch (event.key) {\n      case \"ArrowDown\":\n        next = current < 0 ? firstVisible : current + 1;\n        break;\n      case \"ArrowUp\":\n        next = current < 0 ? firstVisible : current - 1;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = items.length - 1;\n        break;\n      case \"PageDown\":\n      case \"PageUp\": {\n        const from = current < 0 ? firstVisible : current;\n        const y = layout.offset[layout.entryOf[from]] + (event.key === \"PageDown\" ? m.height : -m.height);\n        const e = Math.max(0, entryAt(layout, y));\n        next = firstItemFrom(layout, e, event.key === \"PageDown\" ? 1 : -1);\n        if (next < 0) next = event.key === \"PageDown\" ? items.length - 1 : 0;\n        break;\n      }\n      case \"Enter\":\n      case \" \":\n        event.preventDefault();\n        if (current >= 0) select(current);\n        return;\n      default:\n        return;\n    }\n    event.preventDefault();\n    setActive(next, true);\n  };\n\n  // Mounted entries: the window plus the active row, so aria-activedescendant always resolves.\n  const mounted: number[] = [];\n  for (let e = startEntry; e <= endEntry; e++) mounted.push(e);\n  if (activeEntry >= 0 && (activeEntry < startEntry || activeEntry > endEntry)) {\n    if (activeEntry < startEntry) mounted.unshift(activeEntry);\n    else mounted.push(activeEntry);\n  }\n\n  const header = (group: string) =>\n    renderGroupHeader ? (\n      renderGroupHeader(group)\n    ) : (\n      <div className=\"border-b border-zinc-900/[0.07] bg-zinc-50 px-4 py-2 font-mono text-[10px] uppercase leading-none tracking-[0.14em] text-zinc-500 dark:border-white/[0.07] dark:bg-[#1a1d21] dark:text-zinc-400\">\n        {group}\n      </div>\n    );\n\n  const rowId = (index: number) => `${uid}-row-${index}`;\n\n  const content = (\n    <div className=\"relative w-full\" style={{ height: round(layout.total) }}>\n      {stickyEntry >= 0 ? (\n        <div aria-hidden=\"true\" className=\"pointer-events-none sticky top-0 z-[2] h-0\">\n          <div ref={stickyRef} className=\"absolute inset-x-0 top-0\">\n            {header(layout.group[stickyEntry])}\n          </div>\n        </div>\n      ) : null}\n      {mounted.map((e) => {\n        const index = layout.item[e];\n        const position: React.CSSProperties = { transform: `translateY(${round(layout.offset[e])}px)` };\n        if (index < 0) {\n          return (\n            <div key={layout.key[e]} ref={observe} data-vkey={layout.key[e]} data-ventry={e} data-vkind=\"header\" aria-hidden=\"true\" className=\"absolute inset-x-0 top-0\" style={position}>\n              {header(layout.group[e])}\n            </div>\n          );\n        }\n        const item = items[index];\n        const key = getKey ? getKey(item, index) : index;\n        const active = index === activeIndex;\n        const selected = selectedKey !== null && selectedKey === key;\n        return (\n          <div\n            key={layout.key[e]}\n            ref={observe}\n            id={rowId(index)}\n            role=\"option\"\n            aria-selected={selected}\n            aria-setsize={items.length}\n            aria-posinset={index + 1}\n            data-vkey={layout.key[e]}\n            data-ventry={e}\n            data-vkind=\"row\"\n            data-active={active || undefined}\n            data-selected={selected || undefined}\n            onPointerDown={(event) => {\n              if (event.button === 0) setActive(index, false);\n            }}\n            onClick={() => select(index)}\n            className=\"absolute inset-x-0 top-0 cursor-default select-none outline-offset-[-2px] transition-colors duration-100 data-[selected]:bg-[color-mix(in_oklab,var(--vl-accent)_11%,transparent)] group-focus-visible/vl:data-[active]:outline-2 group-focus-visible/vl:data-[active]:outline-solid group-focus-visible/vl:data-[active]:outline-[var(--vl-accent)] [@media(hover:hover)]:hover:bg-zinc-900/[0.03] dark:[@media(hover:hover)]:hover:bg-white/[0.03] [@media(hover:hover)]:data-[selected]:hover:bg-[color-mix(in_oklab,var(--vl-accent)_14%,transparent)]\"\n            style={position}\n          >\n            {renderItem(item, { index, active, selected })}\n          </div>\n        );\n      })}\n    </div>\n  );\n\n  return (\n    <div\n      {...rest}\n      ref={rootRef}\n      role=\"listbox\"\n      tabIndex={0}\n      aria-label={label}\n      aria-activedescendant={activeIndex >= 0 && activeIndex < items.length ? rowId(activeIndex) : undefined}\n      onKeyDown={handleKeyDown}\n      onPointerDownCapture={() => {\n        pointerFocus.current = true;\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        pointerFocus.current = false;\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        const byPointer = pointerFocus.current;\n        pointerFocus.current = false;\n        // A click picks its own row; keyboard focus lands on a sensible one.\n        if (byPointer || event.target !== event.currentTarget || activeIndex >= 0 || !items.length) return;\n        // First focus: start on the selected row if it is in view, otherwise the first visible row.\n        const m = metrics();\n        const firstVisible = Math.max(0, firstItemFrom(layout, entryAt(layout, m.top), 1));\n        const selectedIndex = selectedKey === null ? -1 : items.findIndex((item, i) => (getKey ? getKey(item, i) : i) === selectedKey);\n        const se = selectedIndex >= 0 ? layout.entryOf[selectedIndex] : -1;\n        const inView = se >= 0 && layout.offset[se] >= m.top && layout.offset[se] + layout.size[se] <= m.top + m.height;\n        setActive(inView ? selectedIndex : firstVisible, false);\n      }}\n      className={`group/vl relative isolate w-full rounded-xl border border-zinc-900/10 bg-white text-zinc-900 outline-hidden [overflow-anchor:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--vl-accent)] dark:border-white/10 dark:bg-[#16191c] dark:text-zinc-100 ${external ? \"\" : \"overflow-y-auto overscroll-contain\"} ${className}`}\n      style={{ [\"--vl-accent\" as string]: accent, ...(external ? null : { height }), ...style }}\n    >\n      {content}\n    </div>\n  );\n}\n\n/** Nearest item index to entry `e`, walking forward (1) or backward (-1) past headers. */\nfunction firstItemFrom(layout: Layout, e: number, dir: 1 | -1) {\n  const n = layout.item.length;\n  for (let i = Math.max(0, Math.min(n - 1, e)); i >= 0 && i < n; i += dir) if (layout.item[i] >= 0) return layout.item[i];\n  return -1;\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/virtual-list"
}
