@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
309 lines (308 loc) • 12.6 kB
JavaScript
import { BodyRow, DataBodyRow, getBodyRows, getColumnedBodyRows } from './bodyRows.js';
import { FlatColumn, getFlatColumns } from './columns.js';
import { getHeaderRows, HeaderRow } from './headerRows.js';
import { finalizeAttributes } from './utils/attributes.js';
import { nonUndefined } from './utils/filter.js';
import { derived, readable, writable } from 'svelte/store';
/**
* Creates a view model for rendering a table.
* The view model contains all reactive stores for the table, headers, and rows.
*
* @template Item - The type of data items in the table.
* @template Plugins - The plugins used by the table.
* @param table - The table instance created by `createTable`.
* @param columns - The column definitions.
* @param options - Optional configuration options.
* @returns A TableViewModel containing all reactive stores for rendering.
*/
export const createViewModel = (table, columns, { rowDataId } = {}) => {
const { data, plugins } = table;
// Initialize derivation call counters for debug instrumentation
const derivationCalls = {
tableAttrs: 0,
tableHeadAttrs: 0,
tableBodyAttrs: 0,
visibleColumns: 0,
columnedRows: 0,
rows: 0,
injectedRows: 0,
pageRows: 0,
injectedPageRows: 0,
headerRows: 0
};
// Per-derivation cumulative ms, populated alongside derivationCalls.
// Each `derived(...)` body wraps its work in performance.now() pairs
// so the perf bench can attribute a scenario's render budget to a
// specific derivation. `rows` / `pageRows` stay at 0 — they're
// plugin-pipeline pass-throughs that don't run a body of their own.
const derivationTimings = {
tableAttrs: 0,
tableHeadAttrs: 0,
tableBodyAttrs: 0,
visibleColumns: 0,
columnedRows: 0,
rows: 0,
injectedRows: 0,
pageRows: 0,
injectedPageRows: 0,
headerRows: 0
};
const $flatColumns = getFlatColumns(columns);
const flatColumns = readable($flatColumns);
const originalRows = derived([data, flatColumns], ([$data, $flatColumns]) => {
return getBodyRows($data, $flatColumns, { rowDataId });
});
// _stores need to be defined first to pass into plugins for initialization.
const _visibleColumns = writable([]);
const _headerRows = writable();
const _rows = writable([]);
const _pageRows = writable([]);
const _tableAttrs = writable({
role: 'table'
});
const _tableHeadAttrs = writable({});
const _tableBodyAttrs = writable({
role: 'rowgroup'
});
const pluginInitTableState = {
data,
columns,
flatColumns: $flatColumns,
tableAttrs: _tableAttrs,
tableHeadAttrs: _tableHeadAttrs,
tableBodyAttrs: _tableBodyAttrs,
visibleColumns: _visibleColumns,
headerRows: _headerRows,
originalRows,
rows: _rows,
pageRows: _pageRows
};
const pluginInstances = Object.fromEntries(Object.entries(plugins).map(([pluginName, plugin]) => {
const columnOptions = Object.fromEntries($flatColumns
.map((c) => {
const option = c.plugins?.[pluginName];
if (option === undefined)
return undefined;
return [c.id, option];
})
.filter(nonUndefined));
return [
pluginName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
plugin({ pluginName, tableState: pluginInitTableState, columnOptions })
];
}));
const pluginStates = Object.fromEntries(Object.entries(pluginInstances).map(([key, pluginInstance]) => [
key,
pluginInstance.pluginState
]));
const tableState = {
data,
columns,
flatColumns: $flatColumns,
tableAttrs: _tableAttrs,
tableHeadAttrs: _tableHeadAttrs,
tableBodyAttrs: _tableBodyAttrs,
visibleColumns: _visibleColumns,
headerRows: _headerRows,
originalRows,
rows: _rows,
pageRows: _pageRows,
pluginStates
};
const deriveTableAttrsFns = Object.values(pluginInstances)
.map((pluginInstance) => pluginInstance.deriveTableAttrs)
.filter(nonUndefined);
let tableAttrs = readable({
role: 'table'
});
deriveTableAttrsFns.forEach((fn) => {
tableAttrs = fn(tableAttrs);
});
const finalizedTableAttrs = derived(tableAttrs, ($tableAttrs) => {
const _t0 = performance.now();
derivationCalls.tableAttrs++;
const $finalizedAttrs = finalizeAttributes($tableAttrs);
_tableAttrs.set($finalizedAttrs);
derivationTimings.tableAttrs += performance.now() - _t0;
return $finalizedAttrs;
});
const deriveTableHeadAttrsFns = Object.values(pluginInstances)
.map((pluginInstance) => pluginInstance.deriveTableBodyAttrs)
.filter(nonUndefined);
let tableHeadAttrs = readable({});
deriveTableHeadAttrsFns.forEach((fn) => {
tableHeadAttrs = fn(tableHeadAttrs);
});
const finalizedTableHeadAttrs = derived(tableHeadAttrs, ($tableHeadAttrs) => {
const _t0 = performance.now();
derivationCalls.tableHeadAttrs++;
const $finalizedAttrs = finalizeAttributes($tableHeadAttrs);
_tableHeadAttrs.set($finalizedAttrs);
derivationTimings.tableHeadAttrs += performance.now() - _t0;
return $finalizedAttrs;
});
const deriveTableBodyAttrsFns = Object.values(pluginInstances)
.map((pluginInstance) => pluginInstance.deriveTableBodyAttrs)
.filter(nonUndefined);
let tableBodyAttrs = readable({
role: 'rowgroup'
});
deriveTableBodyAttrsFns.forEach((fn) => {
tableBodyAttrs = fn(tableBodyAttrs);
});
const finalizedTableBodyAttrs = derived(tableBodyAttrs, ($tableBodyAttrs) => {
const _t0 = performance.now();
derivationCalls.tableBodyAttrs++;
const $finalizedAttrs = finalizeAttributes($tableBodyAttrs);
_tableBodyAttrs.set($finalizedAttrs);
derivationTimings.tableBodyAttrs += performance.now() - _t0;
return $finalizedAttrs;
});
const deriveFlatColumnsFns = Object.values(pluginInstances)
.map((pluginInstance) => pluginInstance.deriveFlatColumns)
.filter(nonUndefined);
let visibleColumns = flatColumns;
deriveFlatColumnsFns.forEach((fn) => {
// Variance of generic type here is unstable. Not sure how to fix.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
visibleColumns = fn(visibleColumns);
});
const injectedColumns = derived(visibleColumns, ($visibleColumns) => {
const _t0 = performance.now();
derivationCalls.visibleColumns++;
_visibleColumns.set($visibleColumns);
derivationTimings.visibleColumns += performance.now() - _t0;
return $visibleColumns;
});
const columnedRows = derived([originalRows, injectedColumns], ([$originalRows, $injectedColumns]) => {
const _t0 = performance.now();
derivationCalls.columnedRows++;
const result = getColumnedBodyRows($originalRows, $injectedColumns.map((c) => c.id));
derivationTimings.columnedRows += performance.now() - _t0;
return result;
});
const deriveRowsFns = Object.values(pluginInstances)
.map((pluginInstance) => pluginInstance.deriveRows)
.filter(nonUndefined);
let rows = columnedRows;
deriveRowsFns.forEach((fn) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rows = fn(rows);
});
const pluginEntries = Object.entries(pluginInstances);
const trHookEntries = [];
const tdHookEntries = [];
for (const [name, instance] of pluginEntries) {
const trHook = instance.hooks?.['tbody.tr'];
if (trHook !== undefined)
trHookEntries.push([name, trHook]);
const tdHook = instance.hooks?.['tbody.tr.td'];
if (tdHook !== undefined)
tdHookEntries.push([name, tdHook]);
}
// Hoisted out of the per-row loop so we don't allocate a fresh
// closure on every iteration. For rows-10k that's 10,000 fewer
// closure allocations per derivation pass.
const injectCellState = (cell) => cell.injectState(tableState);
const injectedRows = derived(rows, ($rows) => {
const _t0 = performance.now();
derivationCalls.injectedRows++;
$rows.forEach((row) => {
row.injectState(tableState);
row.cells.forEach(injectCellState);
for (const [pluginName, trHook] of trHookEntries) {
row.applyHook(pluginName, trHook(row));
}
if (tdHookEntries.length > 0) {
row.cells.forEach((cell) => {
for (const [pluginName, tdHook] of tdHookEntries) {
cell.applyHook(pluginName, tdHook(cell));
}
});
}
});
_rows.set($rows);
derivationTimings.injectedRows += performance.now() - _t0;
return $rows;
});
const derivePageRowsFns = Object.values(pluginInstances)
.map((pluginInstance) => pluginInstance.derivePageRows)
.filter(nonUndefined);
// Must derive from `injectedRows` instead of `rows` to ensure that `_rows` is set.
let pageRows = injectedRows;
derivePageRowsFns.forEach((fn) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pageRows = fn(pageRows);
});
// Page rows are a subset of the same object references already processed
// by injectedRows — no need to re-inject state or re-apply hooks.
const injectedPageRows = derived(pageRows, ($pageRows) => {
const _t0 = performance.now();
derivationCalls.injectedPageRows++;
_pageRows.set($pageRows);
derivationTimings.injectedPageRows += performance.now() - _t0;
return $pageRows;
});
const headerRows = derived(injectedColumns, ($injectedColumns) => {
const _t0 = performance.now();
derivationCalls.headerRows++;
const $headerRows = getHeaderRows(columns, $injectedColumns.map((c) => c.id));
$headerRows.forEach((row) => {
row.injectState(tableState);
row.cells.forEach((cell) => cell.injectState(tableState));
for (const [pluginName, pluginInstance] of pluginEntries) {
const trHook = pluginInstance.hooks?.['thead.tr'];
if (trHook !== undefined) {
row.applyHook(pluginName, trHook(row));
}
const thHook = pluginInstance.hooks?.['thead.tr.th'];
if (thHook !== undefined) {
row.cells.forEach((cell) => cell.applyHook(pluginName, thHook(cell)));
}
}
});
_headerRows.set($headerRows);
derivationTimings.headerRows += performance.now() - _t0;
return $headerRows;
});
const _debug = {
pluginCount: Object.keys(plugins).length,
pluginNames: Object.keys(plugins),
derivedStoreCount: {
tableAttrs: deriveTableAttrsFns.length + 1, // +1 for finalized
tableHeadAttrs: deriveTableHeadAttrsFns.length + 1,
tableBodyAttrs: deriveTableBodyAttrsFns.length + 1,
visibleColumns: deriveFlatColumnsFns.length + 1, // +1 for injected
rows: deriveRowsFns.length + 2, // +2 for columned + injected
pageRows: derivePageRowsFns.length + 1 // +1 for injected
},
derivationCalls,
derivationTimings,
resetCounters: () => {
Object.keys(derivationCalls).forEach((key) => {
derivationCalls[key] = 0;
derivationTimings[key] = 0;
});
},
getTotalCalls: () => {
return Object.values(derivationCalls).reduce((sum, count) => sum + count, 0);
},
getTotalMs: () => {
return Object.values(derivationTimings).reduce((sum, ms) => sum + ms, 0);
}
};
return {
tableAttrs: finalizedTableAttrs,
tableHeadAttrs: finalizedTableHeadAttrs,
tableBodyAttrs: finalizedTableBodyAttrs,
visibleColumns: injectedColumns,
flatColumns: $flatColumns,
headerRows,
originalRows,
rows: injectedRows,
pageRows: injectedPageRows,
pluginStates,
_debug
};
};