UNPKG

@humanspeak/svelte-headless-table

Version:

A powerful, headless table library for Svelte that provides complete control over table UI while handling complex data operations like sorting, filtering, pagination, grouping, and row expansion. Build custom, accessible data tables with zero styling opin

315 lines (314 loc) 11.7 kB
import { derived, get, readable, writable } from 'svelte/store'; import { HeightManager } from '../utils/HeightManager.js'; /** * Default configuration values for virtual scroll. */ const DEFAULTS = { estimatedRowHeight: 40, bufferSize: 10, loadMoreThreshold: 200 }; /** * Creates a virtual scroll plugin that enables virtualized table rendering. * Only renders rows that are visible in the viewport plus a buffer, dramatically * improving performance for large datasets. * * @template Item - The type of data items in the table. * @param config - Configuration options for virtual scrolling. * @returns A TablePlugin that provides virtualization functionality. * * @example * ```typescript * const table = createTable(data, { * virtualScroll: addVirtualScroll({ * estimatedRowHeight: 48, * bufferSize: 5, * onLoadMore: async () => { * const more = await fetchMoreItems() * data.update(d => [...d, ...more]) * }, * hasMore: hasMoreStore * }) * }) * * const { * virtualScroll, * topSpacerHeight, * bottomSpacerHeight, * visibleRange * } = table.pluginStates.virtualScroll * ``` */ export const addVirtualScroll = ({ onLoadMore, hasMore: hasMoreConfig, loadMoreThreshold = DEFAULTS.loadMoreThreshold, estimatedRowHeight = DEFAULTS.estimatedRowHeight, bufferSize = DEFAULTS.bufferSize, getRowHeight } = {}) => () => { // Height management const heightManager = new HeightManager(estimatedRowHeight); // Scroll state const scrollTop = writable(0); const viewportHeight = writable(0); // Row IDs array (set by derivePageRows, used for calculations) // This is a simple array, not derived from rows to avoid circular deps const rowIds = writable([]); // Loading state const isLoading = writable(false); const hasMoreStore = typeof hasMoreConfig === 'object' && hasMoreConfig !== null ? hasMoreConfig : writable(hasMoreConfig ?? false); // Track whether we've already triggered a load to prevent duplicates let loadMorePending = false; // Scroll container reference (set by the action) let scrollContainer = null; // Cache for row lookup (set by derivePageRows) let allRowsCache = []; // Visible range calculation. // Return the same object reference when the range hasn't changed to avoid // unnecessary downstream store updates (spacer heights, rendered rows). let currentRange = { start: 0, end: 0 }; const visibleRange = derived([rowIds, scrollTop, viewportHeight], ([$rowIds, $scrollTop, $viewportHeight], set) => { const range = heightManager.getVisibleRange($rowIds, $scrollTop, $viewportHeight, bufferSize); if (range.start === currentRange.start && range.end === currentRange.end) { return; } currentRange = range; set(range); }, currentRange); // Total height of all rows const totalHeight = derived(rowIds, ($rowIds) => { return heightManager.getTotalHeight($rowIds); }); // Spacer heights const topSpacerHeight = derived([rowIds, visibleRange], ([$rowIds, $range]) => { return heightManager.getOffsetForIndex($rowIds, $range.start); }); const bottomSpacerHeight = derived([rowIds, visibleRange, totalHeight], ([$rowIds, $range, $total]) => { const endOffset = heightManager.getOffsetForIndex($rowIds, $range.end); return Math.max(0, $total - endOffset); }); // Total and rendered row counts const totalRows = derived(rowIds, ($rowIds) => $rowIds.length); const renderedRows = derived(visibleRange, ($range) => $range.end - $range.start); /** * Check if we should load more data and trigger the callback. */ const checkLoadMore = () => { if (!onLoadMore || loadMorePending || !get(hasMoreStore)) { return; } const $scrollTop = get(scrollTop); const $viewportHeight = get(viewportHeight); const $totalHeight = get(totalHeight); const distanceFromBottom = $totalHeight - ($scrollTop + $viewportHeight); if (distanceFromBottom <= loadMoreThreshold) { loadMorePending = true; isLoading.set(true); const result = onLoadMore(); if (result instanceof Promise) { result.finally(() => { loadMorePending = false; isLoading.set(false); }); } else { loadMorePending = false; isLoading.set(false); } } }; /** * Handle scroll events from the container. */ const handleScroll = (event) => { const target = event.target; scrollTop.set(target.scrollTop); checkLoadMore(); }; /** * Svelte action to attach to the scroll container. */ const virtualScroll = (node) => { scrollContainer = node; // Disable overflow-anchor to prevent the browser from adjusting // scrollTop when spacer heights change. Without this, a feedback // loop occurs: spacer change → browser adjusts scrollTop → scroll // event → new visible range → spacer change → cascades to bottom. node.style.overflowAnchor = 'none'; // Set initial viewport height const initialHeight = node.clientHeight; viewportHeight.set(initialHeight); // Create ResizeObserver to track viewport size changes const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { viewportHeight.set(entry.contentRect.height); } }); resizeObserver.observe(node); // Attach scroll listener node.addEventListener('scroll', handleScroll, { passive: true }); // Check if we need to load more initially checkLoadMore(); return { destroy() { scrollContainer = null; node.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); } }; }; /** * Scroll to a specific row index. */ const scrollToIndex = (index, options = {}) => { if (!scrollContainer) { return; } const { align = 'start', behavior = 'auto' } = options; const $rowIds = get(rowIds); if (index < 0 || index >= $rowIds.length) { return; } const targetOffset = heightManager.getOffsetForIndex($rowIds, index); const rowHeight = heightManager.getHeight($rowIds[index]); const $viewportHeight = get(viewportHeight); let scrollPosition; switch (align) { case 'center': scrollPosition = targetOffset - ($viewportHeight - rowHeight) / 2; break; case 'end': scrollPosition = targetOffset - $viewportHeight + rowHeight; break; case 'auto': { // Check if already visible const $scrollTop = get(scrollTop); const visibleStart = $scrollTop; const visibleEnd = $scrollTop + $viewportHeight; const rowStart = targetOffset; const rowEnd = targetOffset + rowHeight; if (rowStart >= visibleStart && rowEnd <= visibleEnd) { // Already fully visible return; } else if (rowStart < visibleStart) { scrollPosition = rowStart; } else { scrollPosition = rowEnd - $viewportHeight; } break; } case 'start': default: scrollPosition = targetOffset; break; } scrollContainer.scrollTo({ top: Math.max(0, scrollPosition), behavior }); }; /** * Notify the plugin that a row has been measured. */ const measureRow = (rowId, height) => { // If getRowHeight is provided, prefer that if (getRowHeight) { const row = allRowsCache.find((r) => r.id === rowId); if (row?.isData() && row.original) { const specifiedHeight = getRowHeight(row.original); if (specifiedHeight !== height) { height = specifiedHeight; } } } const changed = heightManager.setHeight(rowId, height); if (changed) { // Force recalculation of derived stores by updating rowIds // (touching it with the same value) rowIds.update((v) => v); } }; /** * Svelte action to automatically measure row height. * Attach to each <tr> element: <tr use:measureRowAction={row.id}> */ const measureRowAction = (node, rowId) => { // Measure initial height const measure = () => { const height = node.getBoundingClientRect().height; if (height > 0) { measureRow(rowId, height); } }; // Measure on mount measure(); // Use ResizeObserver to track height changes const resizeObserver = new ResizeObserver(() => { measure(); }); resizeObserver.observe(node); return { update(newRowId) { rowId = newRowId; measure(); }, destroy() { resizeObserver.disconnect(); } }; }; // Plugin state const pluginState = { scrollTop: { subscribe: scrollTop.subscribe }, viewportHeight: { subscribe: viewportHeight.subscribe }, visibleRange, totalHeight, topSpacerHeight, bottomSpacerHeight, isLoading: { subscribe: isLoading.subscribe }, hasMore: { subscribe: hasMoreStore.subscribe }, virtualScroll, scrollToIndex, measureRow, measureRowAction, totalRows, renderedRows }; /** * Derive visible rows from all page rows. * Re-runs when rows, scroll position, or viewport height changes. */ const derivePageRows = (rows) => { return derived([rows, scrollTop, viewportHeight], ([$rows, $scrollTop, $viewportHeight], set) => { // Cache rows for lookup in measureRow and hooks allRowsCache = $rows; // Extract row IDs and update the store (only if changed) const ids = $rows.map((r) => r.id); const currentIds = get(rowIds); if (ids.length !== currentIds.length || ids.some((id, i) => id !== currentIds[i])) { rowIds.set(ids); } // Calculate visible range const range = heightManager.getVisibleRange(ids, $scrollTop, $viewportHeight, bufferSize); // Return only the visible subset const visibleRows = $rows.slice(range.start, range.end); set(visibleRows); }); }; // Hooks to add virtual index props to rows const hooks = { 'tbody.tr': (row) => { const virtualIndex = allRowsCache.findIndex((r) => r.id === row.id); return { props: readable({ virtualIndex: virtualIndex >= 0 ? virtualIndex : 0, isVirtual: true }) }; } }; return { pluginState, derivePageRows, hooks }; };