UNPKG

k5kit

Version:

Utilities for TypeScript and Svelte

308 lines (307 loc) 11.9 kB
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); }); } }