@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
JavaScript
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
};
};