{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "virtual-list",
  "title": "Virtual List",
  "type": "registry:ui",
  "description": "Generic windowed scroller. Renders only visible items plus a small overscan, with fixed or dynamic item sizes. Use for long lists where most rows are off-screen.",
  "categories": [
    "data-display"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/virtual-list/virtual-list.ts",
      "content": "import { LitElement, css, html, type PropertyValues } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport type VirtualListDirection = 'vertical' | 'horizontal'\nexport type ItemSizeFn<T = any> = (item: T, index: number) => number\n\nexport interface VirtualListHandle {\n  scrollToOffset: (px: number) => void\n  scrollToIndex: (index: number, options?: { align?: 'start' | 'center' | 'end' }) => void\n  getVisibleRange: () => [number, number]\n}\n\n/**\n * <uip-virtual-list> — Virtualized list rendering only items inside the current viewport plus overscan.\n */\nexport class UipVirtualList extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    items: { type: Array },\n    itemSize: { attribute: 'item-size' },\n    height: { type: String },\n    overscan: { type: Number },\n    keyField: { type: String, attribute: 'key-field' },\n    direction: { type: String },\n    renderItem: { attribute: false },\n    scrollTopPos: { state: true },\n    scrollLeftPos: { state: true },\n  }\n\n  items: any[] = []\n  itemSize: number | ItemSizeFn = 40\n  height: number | string = 400\n  overscan = 3\n  keyField = 'id'\n  direction: VirtualListDirection = 'vertical'\n  renderItem?: (item: any, index: number) => unknown\n\n  private scrollTopPos = 0\n  private scrollLeftPos = 0\n  private lastRange: [number, number] = [0, 0]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'virtual-list')\n  }\n\n  scrollToOffset(px: number) {\n    const scroller = this.renderRoot.querySelector('[part=scroller]') as HTMLElement | null\n    if (scroller) {\n      if (this.direction === 'vertical') {\n        scroller.scrollTop = px\n      } else {\n        scroller.scrollLeft = px\n      }\n    }\n  }\n\n  scrollToIndex(index: number, options?: { align?: 'start' | 'center' | 'end' }) {\n    if (index < 0 || index >= this.items.length) return\n    const scroller = this.renderRoot.querySelector('[part=scroller]') as HTMLElement | null\n    if (!scroller) return\n\n    const align = options?.align ?? 'start'\n    const isVertical = this.direction === 'vertical'\n    const viewportSize = isVertical ? scroller.clientHeight : scroller.clientWidth\n\n    let offset = 0\n    let size = typeof this.itemSize === 'function' ? this.itemSize(this.items[index], index) : Number(this.itemSize)\n    if (typeof this.itemSize === 'function') {\n      for (let i = 0; i < index; i++) {\n        offset += this.itemSize(this.items[i], i)\n      }\n    } else {\n      offset = index * Number(this.itemSize)\n    }\n\n    let targetScroll = offset\n    if (align === 'center') {\n      targetScroll = Math.max(0, offset - viewportSize / 2 + size / 2)\n    } else if (align === 'end') {\n      targetScroll = Math.max(0, offset - viewportSize + size)\n    }\n\n    if (isVertical) {\n      scroller.scrollTop = targetScroll\n    } else {\n      scroller.scrollLeft = targetScroll\n    }\n  }\n\n  getVisibleRange(): [number, number] {\n    return this.lastRange\n  }\n\n  private onScroll(e: Event) {\n    const el = e.currentTarget as HTMLElement\n    this.scrollTopPos = el.scrollTop\n    this.scrollLeftPos = el.scrollLeft\n  }\n\n  render() {\n    const isVertical = this.direction === 'vertical'\n    const numericHeight = typeof this.height === 'number' ? `${this.height}px` : this.height\n    const scrollerStyle = isVertical\n      ? `height: ${numericHeight}; overflow-y: auto;`\n      : `width: ${numericHeight}; height: 100%; overflow-x: auto;`\n\n    const count = this.items.length\n    const getItemSize = (i: number) =>\n      typeof this.itemSize === 'function' ? this.itemSize(this.items[i], i) : Number(this.itemSize) || 40\n\n    // Compute offsets\n    const offsets: number[] = new Array(count)\n    const sizes: number[] = new Array(count)\n    let totalSize = 0\n    for (let i = 0; i < count; i++) {\n      offsets[i] = totalSize\n      const s = getItemSize(i)\n      sizes[i] = s\n      totalSize += s\n    }\n\n    // Determine visible slice\n    const scrollPos = isVertical ? this.scrollTopPos : this.scrollLeftPos\n    const viewSize = typeof this.height === 'number' ? this.height : Number.parseInt(String(this.height), 10) || 400\n\n    let startIndex = 0\n    while (startIndex < count && offsets[startIndex] + sizes[startIndex] < scrollPos) {\n      startIndex++\n    }\n    startIndex = Math.max(0, startIndex - this.overscan)\n\n    let endIndex = startIndex\n    while (endIndex < count && offsets[endIndex] < scrollPos + viewSize) {\n      endIndex++\n    }\n    endIndex = Math.min(count, endIndex + this.overscan)\n\n    if (this.lastRange[0] !== startIndex || this.lastRange[1] !== endIndex) {\n      this.lastRange = [startIndex, endIndex]\n      this.dispatchEvent(\n        new CustomEvent('range-change', {\n          detail: { range: [startIndex, endIndex] },\n          bubbles: true,\n          composed: true,\n        }),\n      )\n    }\n\n    const visibleItems = []\n    for (let i = startIndex; i < endIndex; i++) {\n      const item = this.items[i]\n      const key = item?.[this.keyField] ?? i\n      const itemStart = offsets[i]\n      const itemSize = sizes[i]\n\n      const itemStyle = isVertical\n        ? `position: absolute; top: 0; left: 0; width: 100%; height: ${itemSize}px; transform: translateY(${itemStart}px);`\n        : `position: absolute; top: 0; left: 0; height: 100%; width: ${itemSize}px; transform: translateX(${itemStart}px);`\n\n      visibleItems.push(html`\n        <div key=${key} style=${itemStyle}>\n          ${this.renderItem\n            ? this.renderItem(item, i)\n            : html`<div class=\"flex h-full items-center border-b px-4 text-sm\">${item?.name ?? String(item)}</div>`}\n        </div>\n      `)\n    }\n\n    const innerStyle = isVertical\n      ? `height: ${totalSize}px; position: relative; width: 100%;`\n      : `width: ${totalSize}px; position: relative; height: 100%;`\n\n    return html`\n      <div\n        part=\"scroller\"\n        class=\"w-full\"\n        style=${scrollerStyle}\n        @scroll=${this.onScroll}\n      >\n        <div part=\"track\" style=${innerStyle}>\n          ${visibleItems}\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-virtual-list') || customElements.define('uip-virtual-list', UipVirtualList)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-virtual-list': UipVirtualList\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/virtual-list/virtual-list.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
