AkurAI Build
Menu

Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8

AkurAI-Framework / site/frontend/virtual-list.js

Latest change f77b709bd5d10a720dc73afb8db12765666bb659 - OLI-244: virtual-list and rich-text islands with sanitizer, node tests, docs by Ólafur Búi Ólafsson

// virtual-list.js — windowing primitive for long lists (inbox-scale, 10k+ rows).
//
// Only the rows intersecting the viewport (plus overscan) exist in the DOM; a
// spacer keeps the scrollbar honest. Fixed row height keeps the math O(1).
//
//   computeWindow({count,rowHeight,scrollTop,viewport,overscan}) -> pure window math
//   mountVirtualList(el, {items,rowHeight,render,overscan}) -> {setItems,refresh,destroy}
//   island "virtual-list": <div data-island="virtual-list" data-row-height="40"
//       style="height:400px"><script type="application/json" data-island-state>
//       {"items":[...]}</script><template><div class="row"></div></template></div>
//     Row text comes from item.text unless a render fn is supplied via mountVirtualList.

import { registerIsland } from "./islands.js";

const DEFAULT_OVERSCAN = 5;

export function computeWindow({ count, rowHeight, scrollTop, viewport, overscan = DEFAULT_OVERSCAN }) {
  const totalHeight = count * rowHeight;
  const first = Math.floor(Math.max(0, scrollTop) / rowHeight);
  const visible = Math.ceil(viewport / rowHeight);
  const start = Math.min(count, Math.max(0, first - overscan));
  const end = Math.min(count, first + visible + overscan);
  return { start, end: Math.max(start, end), offsetTop: start * rowHeight, totalHeight };
}

export function mountVirtualList(el, { items = [], rowHeight, render, overscan = DEFAULT_OVERSCAN }) {
  const spacer = document.createElement("div");
  const rows = document.createElement("div");
  spacer.style.position = "relative";
  rows.style.position = "absolute";
  rows.style.insetInline = "0";
  spacer.appendChild(rows);
  el.style.overflowY = "auto";
  el.appendChild(spacer);

  let data = items;
  const refresh = () => {
    const w = computeWindow({
      count: data.length, rowHeight, scrollTop: el.scrollTop, viewport: el.clientHeight, overscan,
    });
    spacer.style.height = `${w.totalHeight}px`;
    rows.style.top = `${w.offsetTop}px`;
    const frag = document.createDocumentFragment();
    for (let i = w.start; i < w.end; i++) {
      const row = render(data[i], i);
      row.style.height = `${rowHeight}px`;
      row.dataset.index = String(i);
      frag.appendChild(row);
    }
    rows.replaceChildren(frag);
  };
  el.addEventListener("scroll", refresh, { passive: true });
  refresh();
  return {
    setItems(next) { data = next; refresh(); },
    refresh,
    destroy() { el.removeEventListener("scroll", refresh); spacer.remove(); },
  };
}

registerIsland("virtual-list", (el, state) => {
  const rowHeight = Number(el.dataset.rowHeight) || 40;
  const tpl = el.querySelector("template");
  const render = (item) => {
    const row = tpl ? tpl.content.firstElementChild.cloneNode(true) : document.createElement("div");
    row.textContent = item && item.text != null ? String(item.text) : String(item);
    return row;
  };
  el.__virtualList = mountVirtualList(el, { items: state.items || [], rowHeight, render });
});