{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/virtual-list",
  "title": "Virtual List",
  "description": "Virtualized list for 10k+ items",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/virtual-list.tsx",
      "content": "import { Box, Text } from \"ink\";\nimport React, { useEffect, useMemo, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport type { InteractionProps } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\n\nexport interface VirtualListProps<T> extends InteractionProps {\n  items: T[];\n  renderItem: (item: T, index: number, isActive: boolean) => ReactNode;\n  height: number;\n  onSelect?: (item: T, index: number) => void;\n  cursor?: string;\n  overscan?: number;\n  getItemKey?: (item: T, index: number) => React.Key;\n  getItemLabel?: (item: T, index: number) => string;\n  \"aria-label\"?: string;\n}\n\nexport const VirtualList = <T,>({\n  items,\n  renderItem,\n  height,\n  onSelect,\n  overscan = 2,\n  getItemKey,\n  getItemLabel = String,\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = \"Virtual list\",\n}: VirtualListProps<T>) => {\n  const theme = useTheme();\n  const unicode = useUnicode();\n  const thumbCharacter = unicode ? \"█\" : \"#\";\n  const trackCharacter = unicode ? \"│\" : \"|\";\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [windowStart, setWindowStart] = useState(0);\n\n  const { isFocused } = useInteraction(\n    (_input, key) => {\n      if (key.upArrow) {\n        setActiveIndex((prev) => {\n          const next = Math.max(0, prev - 1);\n          setWindowStart((ws) => Math.min(ws, next));\n          return next;\n        });\n      } else if (key.downArrow) {\n        setActiveIndex((prev) => {\n          const next = Math.min(items.length - 1, prev + 1);\n          setWindowStart((ws) => {\n            if (next >= ws + height) {\n              return next - height + 1;\n            }\n            return ws;\n          });\n          return next;\n        });\n      } else if (key.home) {\n        setActiveIndex(0);\n        setWindowStart(0);\n      } else if (key.end) {\n        const last = Math.max(0, items.length - 1);\n        setActiveIndex(last);\n        setWindowStart(Math.max(0, last - height + 1));\n      } else if (key.return) {\n        const item = items[activeIndex];\n        if (item !== undefined) {\n          onSelect?.(item, activeIndex);\n        }\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  useEffect(() => {\n    const last = Math.max(0, items.length - 1);\n    setActiveIndex((index) => Math.min(index, last));\n    setWindowStart((start) => Math.min(start, Math.max(0, last - height + 1)));\n  }, [height, items.length]);\n\n  const visibleStart = Math.max(0, windowStart - overscan);\n  const visibleEnd = Math.min(items.length, windowStart + height + overscan);\n  const visibleItems = useMemo(\n    () => items.slice(visibleStart, visibleEnd),\n    [items, visibleStart, visibleEnd]\n  );\n\n  const thumbSize = Math.max(\n    1,\n    Math.floor((height * height) / Math.max(1, items.length))\n  );\n  const thumbPosition =\n    items.length <= height\n      ? 0\n      : Math.floor((activeIndex / (items.length - 1)) * (height - thumbSize));\n\n  const scrollbar = useMemo(\n    () =>\n      Array.from({ length: height }, (_, i) => {\n        if (i >= thumbPosition && i < thumbPosition + thumbSize) {\n          return thumbCharacter;\n        }\n        return trackCharacter;\n      }),\n    [height, thumbCharacter, thumbPosition, thumbSize, trackCharacter]\n  );\n\n  return (\n    <Box\n      flexDirection=\"row\"\n      aria-role=\"listbox\"\n      aria-state={{ disabled: disabled || undefined }}\n    >\n      <Text aria-label={`${ariaLabel}. ${items.length} items.`}>{\"\"}</Text>\n      <Box flexDirection=\"column\" flexGrow={1}>\n        {visibleItems.map((item, localIdx) => {\n          const globalIdx = visibleStart + localIdx;\n          const isVisible =\n            globalIdx >= windowStart && globalIdx < windowStart + height;\n          if (!isVisible) {\n            return null;\n          }\n          const isActive = globalIdx === activeIndex;\n          return (\n            <Box\n              key={getItemKey?.(item, globalIdx) ?? globalIdx}\n              aria-role=\"option\"\n              aria-label={getItemLabel(item, globalIdx)}\n              aria-state={{ selected: isActive && isFocused }}\n            >\n              {renderItem(item, globalIdx, isActive && isFocused)}\n            </Box>\n          );\n        })}\n      </Box>\n      {items.length > height && (\n        <Box aria-hidden flexDirection=\"column\" marginLeft={1}>\n          {scrollbar.map((char, i) => (\n            <Text\n              key={i}\n              color={\n                char === thumbCharacter\n                  ? theme.colors.primary\n                  : theme.colors.mutedForeground\n              }\n            >\n              {char}\n            </Text>\n          ))}\n        </Box>\n      )}\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:ui"
}