k5kit
Version:
Utilities for TypeScript and Svelte
308 lines (307 loc) • 11.9 kB
JavaScript
import { untrack } from 'svelte';
export const RefreshLevel = {
Nothing: 0,
// Render rows that go in/out of the viewport
NewRows: 1,
// Render all rows in the viewport
AllRows: 2,
};
/**
* ## Virtual grid
* High-performance virtual grid. Renders rows as HTML elements directly for better scroll performance instead of relying on a JS framework (which means custom markup needs to be created with JS).
*
* Features:
* - Renders rows as HTML elements directly instead of using a JS framework (for better scroll performance)
* - Rows can be dynamically loaded
* - Columns can have fixed and percentage widths
*
* Note that parentElement is not reactive.
* Do not add other elements into the row elements. Things would break because cells are referenced by indexing into the row's children.
*/
export class VirtualGrid {
source_items;
options;
main_element;
viewport;
size_observer;
refreshing = RefreshLevel.Nothing;
/** Use the `.create()` method instead. I couldn't get the constructor to infer generics */
constructor(source_items, options) {
this.source_items = source_items;
this.options = options;
}
static create(source_items, options) {
return new VirtualGrid(source_items, options);
}
set_source_items(source_items) {
this.source_items = source_items;
this.#update_viewport_size();
this.refresh(RefreshLevel.AllRows);
}
get_row_index_from_event(e) {
if (!(e.target instanceof Element)) {
return null;
}
const row_element = e.target?.closest('[aria-rowindex]');
const row_number = parseInt(row_element?.getAttribute('aria-rowindex') ?? '');
if (!row_element || !Number.isInteger(row_number)) {
return null;
}
return row_number - 1;
}
viewport_row_count = 0;
#update_viewport_size() {
const viewport_height = this.viewport?.clientHeight ?? 0;
this.viewport_row_count = Math.ceil(viewport_height / this.options.row_height);
const height = this.source_items.length * this.options.row_height;
if (this.main_element) {
this.main_element.style.height = height + 'px';
}
}
rows = [];
#recalculate_visible_indexes() {
if (!this.viewport) {
return;
}
const buffer = this.options.buffer ?? 5;
const rendered_count = this.viewport_row_count + buffer * 2;
let start_index = Math.max(0, Math.floor(this.viewport.scrollTop / this.options.row_height - buffer));
const end_index = Math.min(this.source_items.length - 1, start_index - 1 + rendered_count);
if (end_index - start_index + 1 < rendered_count) {
// fill backwards when scrolled to the end
start_index = Math.max(0, end_index + 1 - rendered_count);
}
// figure out which indexes should now be added
const new_visible_indexes = [];
for (let i = start_index; i <= end_index; i++) {
const exists = this.rows.find((row) => row.index === i);
if (!exists) {
new_visible_indexes.push(i);
}
}
// update the visible indexes
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows[i];
const still_visible = row.index !== null && row.index >= start_index && row.index <= end_index;
if (!still_visible) {
const new_index = new_visible_indexes.pop();
if (new_index !== undefined) {
// update it to a new visible index
row.index = new_index;
row.rendered = false;
}
else {
// if there are no new visible indexes left, remove it
row.index = null;
row.rendered = false;
}
}
}
if (new_visible_indexes.length > 0) {
// add new visible indexes
for (const index of new_visible_indexes) {
this.rows.push({
element: null,
index,
rendered: false,
});
}
}
}
refresh(level = RefreshLevel.AllRows) {
if (this.refreshing) {
if (level > this.refreshing) {
this.refreshing = level;
}
return;
}
this.refreshing = level;
requestAnimationFrame(() => {
this.#recalculate_visible_indexes();
if (this.refreshing === RefreshLevel.AllRows) {
for (const row of this.rows) {
row.rendered = false;
}
}
this.#render();
this.refreshing = 0;
});
}
/** Updating columns must be done via `set_columns` */
columns = $state([]);
#inner_columns = [];
set_columns(columns) {
const total_fixed_width = columns.reduce((sum, col) => sum + (col.is_pct ? 0 : col.width), 0);
const total_percent_pct = columns.reduce((sum, col) => sum + (col.is_pct ? col.width : 0), 0);
const container_width = this.viewport?.clientWidth ?? total_fixed_width;
const total_percent_width = container_width - total_fixed_width;
let offset = 0;
const new_columns = columns.map((col) => {
col = { ...col };
if (col.is_pct) {
const pct = col.width / total_percent_pct;
col.width = pct * total_percent_width;
}
col.offset = offset;
offset += col.width;
return col;
});
let resize_only = true;
if (this.#inner_columns.length !== new_columns.length) {
resize_only = false;
}
for (let i = 0; i < this.#inner_columns.length; i++) {
if (new_columns[i].key !== this.#inner_columns[i].key) {
resize_only = false;
break;
}
}
this.#inner_columns = new_columns;
this.columns = new_columns;
if (resize_only) {
for (const row of this.rows) {
if (!row.element) {
throw new Error('Unexpected missing row element');
}
for (let ci = 0; ci < this.#inner_columns.length; ci++) {
const column = this.#inner_columns[ci];
const cell = row.element.children[ci];
cell.style.width = `${column.width}px`;
cell.style.translate = `${column.offset}px 0`;
}
}
}
else {
// make all rows fully rerender
for (const row of this.rows) {
row.element?.remove();
}
this.rows = [];
this.refresh(RefreshLevel.NewRows);
}
return this.#inner_columns;
}
#render() {
// prepare rows, before any DOM updates
const items = [];
for (const row of this.rows) {
if (row.index === null || row.rendered) {
continue;
}
const row_item = this.options.row_prepare(this.source_items[row.index], row.index);
items[row.index] = row_item;
}
// create new row elements
for (const row of this.rows) {
if (row.element || row.index === null) {
continue;
}
const row_element = document.createElement('div');
row_element.className = 'row';
row_element.setAttribute('role', 'row');
row.element = row_element;
this.main_element?.appendChild(row_element);
for (const column of this.#inner_columns) {
const cell = document.createElement('div');
cell.className = `cell ${column.key}`;
cell.style.width = `${column.width}px`;
cell.style.translate = `${column.offset}px 0`;
row_element.appendChild(cell);
}
}
// render rows
for (const row of this.rows) {
if (row.rendered || row.index === null) {
continue;
}
if (!row.element) {
throw new Error('Unexpected missing row element');
}
row.element.style.translate = `0 ${row.index * this.options.row_height}px`;
row.element.setAttribute('aria-rowindex', String(row.index + 1));
const row_item = items[row.index];
for (let ci = 0; ci < this.#inner_columns.length; ci++) {
const column = this.#inner_columns[ci];
const cell = row.element.children[ci];
let cell_value = row_item[column.key];
if (cell_value === undefined || cell_value === null) {
cell_value = '';
}
if (column.cell_render) {
column.cell_render(cell, cell_value);
}
else {
cell.textContent = String(cell_value);
}
}
this.options.row_render?.(row.element, row_item, row.index);
row.rendered = true;
}
// delete rows that are no longer visible
for (let i = this.rows.length - 1; i >= 0; i--) {
const row = this.rows[i];
if (row.index === null) {
row.element?.remove();
this.rows.splice(i, 1);
}
}
}
scroll_to_index(index, scroll_margin_bottom = 0) {
if (!this.viewport) {
throw new Error('No viewport');
}
const dummy = document.createElement('div');
dummy.style.height = this.options.row_height + 'px';
dummy.style.position = 'absolute';
dummy.style.top = index * this.options.row_height + 'px';
dummy.style.scrollMarginBottom = scroll_margin_bottom + 'px';
this.viewport.prepend(dummy);
dummy.scrollIntoView({ behavior: 'instant', block: 'nearest' });
dummy.remove();
}
setup(node) {
this.main_element = node;
const viewport_result = this.main_element.parentElement;
if (!viewport_result) {
throw new Error('No viewport');
}
const viewport = viewport_result;
this.viewport = viewport;
this.#update_viewport_size();
this.size_observer = new ResizeObserver(() => {
this.set_columns(this.#inner_columns);
this.#update_viewport_size();
this.refresh(RefreshLevel.NewRows);
});
this.size_observer.observe(this.viewport);
const on_scroll = () => this.refresh(RefreshLevel.NewRows);
const on_keydown = (e) => {
let prevent = true;
if (e.key === 'Home')
viewport.scrollTop = 0;
else if (e.key === 'End')
viewport.scrollTop = viewport.scrollHeight;
else if (e.key === 'PageUp')
viewport.scrollTop -= viewport.clientHeight;
else if (e.key === 'PageDown')
viewport.scrollTop += viewport.clientHeight;
else
prevent = false;
if (prevent)
e.preventDefault();
};
viewport.addEventListener('scroll', on_scroll);
viewport.addEventListener('keydown', on_keydown);
return () => {
viewport.removeEventListener('scroll', on_scroll);
viewport.removeEventListener('keydown', on_keydown);
this.size_observer?.disconnect();
};
}
attach() {
return untrack(() => {
// This is a function in order to make `this` work
return (node) => this.setup(node);
});
}
}