# VirtualList

Renders large data lists into the viewport window: only visible items (plus a top/bottom buffer) are rendered, with head/tail padding placeholders to support the scroll height, and scroll events throttled via rAF. It is a generic rendering primitive reused by table / tree.

## Basic Usage

<DemoBlock title="Fixed-height virtual list">
  <div style="width: 100%">
    <oas-virtual-list id="vl-basic" height="320" item-height="36"></oas-virtual-list>
  </div>
</DemoBlock>

Provide data via the `items` property (or the `items` attribute as a JSON string); `height` sets the viewport height and `item-height` the height of each item. Without a template, each item renders `String(item)` by default.

## Custom Item Content

<DemoBlock title="oas-item event-bound content">
  <div style="width: 100%">
    <oas-virtual-list id="vl-item" height="280" item-height="44"></oas-virtual-list>
  </div>
</DemoBlock>

Each visible item emits `oas-item` after rendering with `detail` containing `{ index, item, element }`, which the host can use to fill in the item content; alternatively, a `<template slot="item">` inside the component can serve as a static per-item template.

## Scroll Events

<DemoBlock title="oas-scroll window display">
  <div style="width: 100%">
    <oas-virtual-list id="vl-scroll" height="200" item-height="32"></oas-virtual-list>
    <p style="width: 100%; color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); margin: var(--oas-space-2) 0 0">
      Visible window: <span id="vl-window">0–0</span> · scrollTop: <span id="vl-scrolltop">0</span>
    </p>
  </div>
</DemoBlock>

Scroll events are emitted as `oas-scroll`, throttled by rAF, with `detail` being `{ scrollTop, start, end }`.

## Scroll to a Specific Item

The `scrollToIndex(index, options?)` method scrolls to the item at the given index: `options.align` supports `start` (item top aligned to the viewport top) / `center` (vertically centered) / `end` (item bottom aligned to the viewport bottom) / `auto` (default: no scroll if the item is already fully visible, otherwise the minimal scroll to bring it into view); `options.smooth` enables smooth scrolling. Row positioning of embedded consumers (list / tree) also goes through this public contract without reaching into internal DOM.

<DemoBlock title="scrollToIndex scroll positioning">
  <div style="width: 100%">
    <div style="display: flex; gap: var(--oas-space-2); margin-bottom: var(--oas-space-3); align-items: center; flex-wrap: wrap">
      <oas-input id="vl-jump-index" type="number" value="500" style="width: 120px"></oas-input>
      <select id="vl-jump-align" aria-label="Jump alignment" style="padding: 5px var(--oas-space-2); border: 1px solid var(--oas-color-border); border-radius: var(--oas-radius-md); background: var(--oas-color-bg); color: var(--oas-color-text-primary); font-size: var(--oas-font-size-md)">
        <option value="auto">auto (minimal scroll)</option>
        <option value="start">start (top aligned)</option>
        <option value="center">center</option>
        <option value="end">end (bottom aligned)</option>
      </select>
      <oas-button id="vl-jump-btn" size="small" type="primary">Jump</oas-button>
      <span style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)">Current scrollTop: <span id="vl-jump-top">0</span></span>
    </div>
    <oas-virtual-list id="vl-jump" height="240" item-height="32"></oas-virtual-list>
  </div>
</DemoBlock>

## Dynamic Row Height

With `dynamic-height`, rows may have different heights: unmeasured rows are laid out using `estimated-item-height` (defaults to `item-height`), and rendered rows are measured by `ResizeObserver`, which progressively corrects total height and offsets. When rows above the viewport change height, scrollTop is compensated automatically to keep the visual anchor stable (no jump when scrolling up). `scrollToIndex` also works in dynamic mode (positioned by the height table). Picking an estimate close to the real average row height minimizes corrections.

<DemoBlock title="Dynamic height (mixed row heights)">
  <div style="width: 100%">
    <div style="display: flex; gap: var(--oas-space-2); margin-bottom: var(--oas-space-3); align-items: center">
      <oas-button id="vl-dyn-jump" size="small">scrollToIndex(40)</oas-button>
      <span style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)">Total height corrects progressively with measurement; scrolling stays smooth</span>
    </div>
    <oas-virtual-list id="vl-dyn" height="280" dynamic-height estimated-item-height="56"></oas-virtual-list>
  </div>
</DemoBlock>

## Render Buffer

`buffer` controls how many extra items are pre-rendered above and below the visible area (default `4`): a larger buffer means fewer blank areas (white screens) while scrolling, at the cost of more rendered DOM nodes.

<DemoBlock title="Render buffer buffer">
  <div style="width: 100%">
    <div style="display: flex; gap: var(--oas-space-4)">
      <div style="flex: 1; min-width: 0">
        <oas-virtual-list id="vl-buffer-0" height="160" item-height="32" buffer="0"></oas-virtual-list>
        <p style="margin: var(--oas-space-2) 0 0; color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)">
          <code>buffer="0"</code>: rendered items <span id="vl-buffer-0-count">—</span>; only items within the visible window are rendered.
        </p>
      </div>
      <div style="flex: 1; min-width: 0">
        <oas-virtual-list id="vl-buffer-8" height="160" item-height="32" buffer="8"></oas-virtual-list>
        <p style="margin: var(--oas-space-2) 0 0; color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)">
          <code>buffer="8"</code>: rendered items <span id="vl-buffer-8-count">—</span>; 8 extra items pre-rendered above and below.
        </p>
      </div>
    </div>
  </div>
</DemoBlock>

## Custom Scroll Container

<DemoBlock title="scroll-target external container">
  <div style="width: 100%">
    <div id="vl-outer" tabindex="0" style="height: 240px; overflow: auto; border: 1px solid var(--oas-color-border); border-radius: var(--oas-radius-md)">
      <oas-virtual-list id="vl-target" scroll-target="#vl-outer" height="240" item-height="36"></oas-virtual-list>
    </div>
  </div>
</DemoBlock>

When `scroll-target` (a CSS selector) is set, the component does not provide its own scrollbar; instead an external container scrolls the whole content (e.g. page-level scrolling), and the window is computed from the external container's `scrollTop`.

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  // Basic: 1000 fixed-height items
  const basic = document.querySelector('#vl-basic')
  if (basic) basic.items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`)

  // Custom item content bound via the oas-item event
  const itemList = document.querySelector('#vl-item')
  if (itemList) {
    const rows = Array.from({ length: 500 }, (_, i) => ({
      id: i + 1,
      label: `Task #${i + 1}`,
      status: ['In Progress', 'Done', 'Pending'][i % 3],
    }))
    itemList.items = rows
    itemList.addEventListener('oas-item', (e) => {
      const { item, element } = e.detail
      element.textContent = `${item.id} · ${item.label} · ${item.status}`
    })
  }

  // Scroll event window display
  const scroller = document.querySelector('#vl-scroll')
  if (scroller) {
    scroller.items = Array.from({ length: 200 }, (_, i) => i + 1)
    scroller.addEventListener('oas-scroll', (e) => {
      const { scrollTop, start, end } = e.detail
      document.querySelector('#vl-window').textContent = `${start}–${end}`
      document.querySelector('#vl-scrolltop').textContent = String(scrollTop)
    })
  }

  // Custom scroll container
  const target = document.querySelector('#vl-target')
  if (target) target.items = Array.from({ length: 500 }, (_, i) => `Record ${i + 1}`)

  // Scroll to a specific item: index input + alignment
  const jump = document.querySelector('#vl-jump')
  if (jump) {
    jump.items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`)
    jump.addEventListener('oas-scroll', (e) => {
      const top = document.querySelector('#vl-jump-top')
      if (top) top.textContent = String(e.detail.scrollTop)
    })
    const jumpBtn = document.querySelector('#vl-jump-btn')
    jumpBtn?.addEventListener('click', () => {
      // whenDefined guard: method calls must happen after the custom element upgrades
      customElements.whenDefined('oas-virtual-list').then(() => {
        const raw = Number(document.querySelector('#vl-jump-index')?.value)
        const index = Math.min(Math.max(1, Math.trunc(raw) || 1), 1000) - 1
        const align = document.querySelector('#vl-jump-align')?.value ?? 'auto'
        jump.scrollToIndex(index, { align })
      })
    })
  }

  // Render buffer comparison: count items actually rendered to the DOM
  const buf0 = document.querySelector('#vl-buffer-0')
  const buf8 = document.querySelector('#vl-buffer-8')
  const bufItems = Array.from({ length: 100 }, (_, i) => `Buffer item ${i + 1}`)
  const refreshBufferCounts = () => {
    const countOf = (el) => (el && el.shadowRoot ? el.shadowRoot.querySelectorAll('[part="item"]').length : 0)
    const c0 = document.querySelector('#vl-buffer-0-count')
    const c8 = document.querySelector('#vl-buffer-8-count')
    if (c0) c0.textContent = String(countOf(buf0))
    if (c8) c8.textContent = String(countOf(buf8))
  }
  if (buf0) {
    buf0.items = bufItems
    buf0.addEventListener('oas-scroll', refreshBufferCounts)
  }
  if (buf8) {
    buf8.items = bufItems
    buf8.addEventListener('oas-scroll', refreshBufferCounts)
  }
  refreshBufferCounts()

  // Dynamic height: mixed card rows (title + variable description lines)
  const dyn = document.querySelector('#vl-dyn')
  if (dyn) {
    const rows = Array.from({ length: 80 }, (_, i) => ({
      title: `Item ${i + 1}`,
      lines: (i % 3) + 1,
      tag: ['Normal', 'Priority', 'Urgent'][i % 3],
    }))
    dyn.items = rows
    dyn.addEventListener('oas-item', (e) => {
      const { item, element } = e.detail
      element.style.padding = 'var(--oas-space-2) var(--oas-space-3)'
      element.style.borderBottom = '1px solid var(--oas-color-border)'
      element.style.boxSizing = 'border-box'
      const head = document.createElement('div')
      head.style.cssText = 'display:flex;justify-content:space-between;font-weight:500'
      head.innerHTML = `<span></span><span style="color:var(--oas-color-primary);font-size:var(--oas-font-size-xs)"></span>`
      head.children[0].textContent = item.title
      head.children[1].textContent = item.tag
      element.appendChild(head)
      for (let k = 0; k < item.lines; k++) {
        const p = document.createElement('div')
        p.style.cssText = 'color:var(--oas-color-text-secondary);font-size:var(--oas-font-size-sm);line-height:1.5'
        p.textContent = `Description line ${k + 1}: height varies with content`
        element.appendChild(p)
      }
    })
    const jumpBtn = document.querySelector('#vl-dyn-jump')
    jumpBtn?.addEventListener('click', () => dyn.scrollToIndex(40, { align: 'start' }))
  }
})
</script>

## API

### oas-virtual-list

#### Attributes

| Attribute | Description | Type | Default |
| --- | --- | --- | --- |
| `buffer` | Number of items pre-rendered above/below (rendered early beyond the visible area to reduce scrolling blanks) | `string` | `4` |
| `dynamic-height` | Dynamic row height: rows may vary in height (unmeasured rows use the estimate; rendered rows are measured and progressively corrected; scrollTop is auto-compensated when rows above the viewport change) | `boolean` | — |
| `estimated-item-height` | Estimated row height for dynamic mode (px; defaults to item-height; closest to the real average minimizes corrections) | `string` | — |
| `height` | Viewport height (px) | `string` | `320` |
| `item-height` | Fixed height of each item (px) | `string` | `36` |
| `items` | Data array (property channel, takes precedence over the `items` attribute); data JSON string (attribute channel) | `unknown[]` | `[]` |
| `scroll-target` | CSS selector of the external scroll container; when set, the component has no scrollbar of its own and listens to the external scroll | `string` | — |

#### Events

| Event | Description |
| --- | --- |
| `oas-item` | Emitted after each visible item renders, `detail: { index, item, element }` |
| `oas-scroll` | Scroll event (rAF throttled), `detail: { scrollTop, start, end }` |

#### Slots

| Name | Description |
| --- | --- |
| `template[slot="item"]` | Static per-item template, cloned into each visible item container (optional) |

Parts: `::part(viewport)` scroll viewport, `::part(inner)` content, `::part(padding-top)` / `::part(padding-bottom)` placeholders, `::part(items)` window item container, `::part(item)` a single item.

`viewportFocusable` (JS property, default true) controls whether the viewport is Tab-focusable; embedded consumers (whose rows carry their own roving tabindex) set it to false to disable viewport focusing.
