Menu
Public repository / master
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 });
});