{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "virtual-list",
  "title": "Virtual List",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/virtual-list/virtual-list.component.ts",
      "content": "import {\n  Component,\n  ElementRef,\n  EventEmitter,\n  Input,\n  Output,\n  ContentChild,\n  TemplateRef,\n  AfterViewInit,\n  OnDestroy,\n  OnChanges,\n  SimpleChanges,\n  inject,\n  ChangeDetectorRef,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NgTemplateOutlet } from '@angular/common'\nimport { cn } from '@/lib/utils'\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 * Angular port of UIPKGE VirtualList — windowed scroller rendering only\n * visible rows plus overscan. Same API as React and Vue (items, itemSize fixed or fn,\n * height, overscan, keyField, direction).\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-virtual-list, [ui-virtual-list]',\n  standalone: true,\n  imports: [NgTemplateOutlet],\n  host: {\n    '[attr.data-slot]': '\"virtual-list\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-direction]': 'direction',\n    '[class]': 'hostClass',\n    '[style]': 'containerStyle',\n    '(scroll)': 'onScroll($event)',\n  },\n  template: `\n    <div [style]=\"innerStyle\">\n      @for (item of visibleItems; track keyAt(range[0] + $index); let i = $index) {\n        <div [style]=\"rowStyle(item, range[0] + i)\">\n          @if (resolvedTemplate) {\n            <ng-container *ngTemplateOutlet=\"resolvedTemplate; context: { $implicit: item, index: range[0] + i }\" />\n          }\n        </div>\n      }\n    </div>\n  `,\n})\nexport class UiVirtualListComponent implements AfterViewInit, OnDestroy, OnChanges, VirtualListHandle {\n  private elementRef?: ElementRef<HTMLElement>\n  private cdr?: ChangeDetectorRef\n\n  constructor() {\n    try {\n      this.elementRef = inject(ElementRef, { optional: true }) ?? undefined\n      this.cdr = inject(ChangeDetectorRef, { optional: true }) ?? undefined\n    } catch {\n      // Instantiated outside injection context (e.g. unit tests)\n    }\n  }\n\n  @Input() items: unknown[] = []\n  @Input() itemSize: number | ((item: unknown, index: number) => number) = 32\n  @Input() height: number | string = 400\n  @Input() overscan = 3\n  @Input() keyField = 'id'\n  @Input() direction: 'vertical' | 'horizontal' = 'vertical'\n  @Input('class') className?: string\n  @Input() itemTemplate?: TemplateRef<{ $implicit: unknown; index: number }>\n\n  @ContentChild(TemplateRef) contentTemplate?: TemplateRef<{ $implicit: unknown; index: number }>\n\n  @Output() scroll = new EventEmitter<Event>()\n  @Output() rangeChange = new EventEmitter<[number, number]>()\n\n  scrollOffset = 0\n  viewportSize = 0\n  private resizeObserver: ResizeObserver | null = null\n\n  get resolvedTemplate(): TemplateRef<{ $implicit: unknown; index: number }> | undefined {\n    return this.itemTemplate ?? this.contentTemplate\n  }\n\n  get hostClass(): string {\n    return cn('block relative w-full', this.className)\n  }\n\n  get heightStyle(): string {\n    return typeof this.height === 'number' ? `${this.height}px` : this.height\n  }\n\n  get isVertical(): boolean {\n    return this.direction === 'vertical'\n  }\n\n  get containerStyle(): string {\n    const h = this.heightStyle\n    return this.isVertical ? `height: ${h}; overflow-y: auto;` : `width: ${h}; overflow-x: auto;`\n  }\n\n  sizeAt(index: number): number {\n    if (typeof this.itemSize === 'number') return this.itemSize\n    return this.itemSize(this.items[index], index)\n  }\n\n  totalSize(): number {\n    let total = 0\n    for (let i = 0; i < this.items.length; i++) total += this.sizeAt(i)\n    return total\n  }\n\n  offsetAt(index: number): number {\n    let offset = 0\n    for (let i = 0; i < index && i < this.items.length; i++) offset += this.sizeAt(i)\n    return offset\n  }\n\n  keyAt(index: number): string {\n    const item = this.items[index] as Record<string, unknown> | null\n    const k = item?.[this.keyField]\n    return k == null ? String(index) : String(k)\n  }\n\n  private getOffsets(): number[] {\n    const out: number[] = [0]\n    for (let i = 0; i < this.items.length; i++) {\n      out.push(out[i]! + this.sizeAt(i))\n    }\n    return out\n  }\n\n  private findIndex(offsets: number[], target: number): number {\n    let lo = 0\n    let hi = offsets.length - 1\n    while (lo < hi) {\n      const mid = (lo + hi + 1) >> 1\n      if (offsets[mid]! <= target) lo = mid\n      else hi = mid - 1\n    }\n    return lo\n  }\n\n  visibleRange(scrollOffset: number, viewportSize: number): [number, number] {\n    if (!this.items.length || (viewportSize === 0 && this.viewportSize === 0)) return [0, 0]\n    const vs = viewportSize || this.viewportSize\n    const offsets = this.getOffsets()\n    const startIdx = this.findIndex(offsets, scrollOffset)\n    const start = Math.max(0, startIdx - this.overscan)\n    const endIdx = this.findIndex(offsets, scrollOffset + vs)\n    const end = Math.min(this.items.length, endIdx + 1 + this.overscan)\n    const range: [number, number] = [start, end]\n    this.rangeChange.emit(range)\n    return range\n  }\n\n  get range(): [number, number] {\n    if (!this.items.length) return [0, 0]\n    const vs =\n      this.viewportSize ||\n      (typeof this.height === 'number' ? this.height : Number.parseInt(String(this.height), 10) || 400)\n    return this.visibleRange(this.scrollOffset, vs)\n  }\n\n  get visibleItems(): unknown[] {\n    const [start, end] = this.range\n    return this.items.slice(start, end)\n  }\n\n  get offsetStart(): number {\n    return this.offsetAt(this.range[0])\n  }\n\n  get innerStyle(): string {\n    const total = this.totalSize()\n    return this.isVertical\n      ? `height: ${total}px; position: relative; width: 100%;`\n      : `width: ${total}px; position: relative; height: 100%;`\n  }\n\n  rowStyle(item: unknown, index: number): string {\n    const size = this.sizeAt(index)\n    const start = this.offsetAt(index)\n    return this.isVertical\n      ? `position: absolute; top: 0; left: 0; width: 100%; height: ${size}px; transform: translateY(${start}px);`\n      : `position: absolute; top: 0; left: 0; height: 100%; width: ${size}px; transform: translateX(${start}px);`\n  }\n\n  get offsetStyle(): string {\n    return this.isVertical\n      ? `transform: translateY(${this.offsetStart}px);`\n      : `transform: translateX(${this.offsetStart}px); height: 100%; display: flex;`\n  }\n\n  itemWrapperStyle(item: unknown, index: number): string {\n    const s = this.sizeAt(index)\n    return this.isVertical ? `height: ${s}px;` : `width: ${s}px; flex-shrink: 0;`\n  }\n\n  onScroll(event: Event): void {\n    const el = event.target as HTMLElement | null\n    if (el) {\n      this.scrollOffset = this.isVertical ? el.scrollTop : el.scrollLeft\n      this.cdr?.markForCheck()\n    }\n    this.scroll.emit(event)\n  }\n\n  private measure(): void {\n    const el = this.elementRef?.nativeElement as HTMLElement | undefined\n    if (el) {\n      const measured = this.isVertical ? el.clientHeight : el.clientWidth\n      const fallback = typeof this.height === 'number' ? this.height : Number.parseInt(String(this.height), 10) || 0\n      this.viewportSize = measured || fallback\n      this.cdr?.markForCheck()\n    }\n  }\n\n  ngAfterViewInit(): void {\n    this.measure()\n    const el = this.elementRef?.nativeElement as HTMLElement | undefined\n    if (typeof ResizeObserver !== 'undefined' && el) {\n      this.resizeObserver = new ResizeObserver(() => this.measure())\n      this.resizeObserver.observe(el)\n    }\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    if (changes['height']) {\n      this.measure()\n    }\n    this.cdr?.markForCheck()\n  }\n\n  ngOnDestroy(): void {\n    this.resizeObserver?.disconnect()\n    this.resizeObserver = null\n  }\n\n  scrollToOffset(px: number): void {\n    const el = this.elementRef?.nativeElement as HTMLElement | undefined\n    if (!el) return\n    if (this.isVertical) el.scrollTop = px\n    else el.scrollLeft = px\n  }\n\n  scrollToIndex(index: number, options: { align?: 'start' | 'center' | 'end' } = {}): void {\n    const align = options.align ?? 'start'\n    const start = this.offsetAt(index)\n    const size = this.sizeAt(index)\n    const vs =\n      this.viewportSize ||\n      (typeof this.height === 'number' ? this.height : Number.parseInt(String(this.height), 10) || 400)\n    let target = start\n    if (align === 'center') target = start - vs / 2 + size / 2\n    else if (align === 'end') target = start - vs + size\n    this.scrollToOffset(Math.max(0, target))\n  }\n\n  getVisibleRange(): [number, number] {\n    return this.range\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/virtual-list/virtual-list.component.ts"
    },
    {
      "path": "packages/registry-angular/components/virtual-list/index.ts",
      "content": "export { UiVirtualListComponent } from './virtual-list.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/virtual-list/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "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"
  ]
}