@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
233 lines (232 loc) • 9.25 kB
JavaScript
import { MemoryCache } from '@humanspeak/memory-cache';
import { derived, writable } from 'svelte/store';
import { compare } from '../utils/compare.js';
import { isShiftClick } from '../utils/event.js';
const DEFAULT_TOGGLE_ORDER = ['asc', 'desc', undefined];
/**
* Creates a writable store for managing sort keys with toggle and clear methods.
*
* @param initKeys - Initial sort keys.
* @returns A WritableSortKeys store with toggle and clear functionality.
* @example
* ```typescript
* const sortKeys = createSortKeysStore([{ id: 'name', order: 'asc' }])
* sortKeys.toggleId('age') // Adds ascending sort by age
* sortKeys.clearId('name') // Removes sort by name
* ```
*/
export const createSortKeysStore = (initKeys) => {
const { subscribe, update, set } = writable(initKeys);
const toggleId = (id, { multiSort = true, toggleOrder = DEFAULT_TOGGLE_ORDER } = {}) => {
update(($sortKeys) => {
const keyIdx = $sortKeys.findIndex((key) => key.id === id);
const key = $sortKeys[keyIdx];
const order = key?.order;
const orderIdx = toggleOrder.findIndex((o) => o === order);
const nextOrderIdx = (orderIdx + 1) % toggleOrder.length;
const nextOrder = toggleOrder[nextOrderIdx];
if (!multiSort) {
if (nextOrder === undefined) {
return [];
}
return [{ id, order: nextOrder }];
}
if (keyIdx === -1 && nextOrder !== undefined) {
return [...$sortKeys, { id, order: nextOrder }];
}
if (nextOrder === undefined) {
return [...$sortKeys.slice(0, keyIdx), ...$sortKeys.slice(keyIdx + 1)];
}
return [
...$sortKeys.slice(0, keyIdx),
{ id, order: nextOrder },
...$sortKeys.slice(keyIdx + 1)
];
});
};
const clearId = (id) => {
update(($sortKeys) => {
const keyIdx = $sortKeys.findIndex((key) => key.id === id);
if (keyIdx === -1) {
return $sortKeys;
}
return [...$sortKeys.slice(0, keyIdx), ...$sortKeys.slice(keyIdx + 1)];
});
};
return {
subscribe,
update,
set,
toggleId,
clearId
};
};
/**
* Sorts rows based on the provided sort keys and column options.
* Recursively sorts subRows as well.
*
* @template Item - The type of data items.
* @template Row - The row type.
* @param rows - The rows to sort.
* @param sortKeys - The sort keys to apply.
* @param columnOptions - Per-column sort configuration.
* @returns A new array of sorted rows.
* @internal
*/
const getSortedRows = (rows, sortKeys, columnOptions) => {
// Pre-compute sort config for each key to avoid repeated lookups during comparison
const sortConfig = sortKeys.map((key) => ({
id: key.id,
order: key.order,
invert: columnOptions[key.id]?.invert ?? false,
compareFn: columnOptions[key.id]?.compareFn,
getSortValue: columnOptions[key.id]?.getSortValue,
orderFactor: (key.order === 'desc' ? -1 : 1) * (columnOptions[key.id]?.invert ? -1 : 1)
}));
// Shallow clone to prevent sort affecting `preSortedRows`.
const $sortedRows = [...rows];
$sortedRows.sort((a, b) => {
for (const config of sortConfig) {
// TODO check why cellForId returns `undefined`.
const cellA = a.cellForId[config.id];
const cellB = b.cellForId[config.id];
// Only need to check properties of `cellA` as both should have the same
// properties.
if (!cellA.isData()) {
return 0;
}
const valueA = cellA.value;
const valueB = cellB.value;
let order = 0;
if (config.compareFn !== undefined) {
order = config.compareFn(valueA, valueB);
}
else if (config.getSortValue !== undefined) {
const sortValueA = config.getSortValue(valueA);
const sortValueB = config.getSortValue(valueB);
order = compare(sortValueA, sortValueB);
}
else if (typeof valueA === 'string' || typeof valueA === 'number') {
// typeof `cellB.value` is logically equal to `cellA.value`.
order = compare(valueA, valueB);
}
else if (valueA instanceof Date || valueB instanceof Date) {
const sortValueA = valueA instanceof Date ? valueA.getTime() : 0;
const sortValueB = valueB instanceof Date ? valueB.getTime() : 0;
order = compare(sortValueA, sortValueB);
}
if (order !== 0) {
return order * config.orderFactor;
}
}
return 0;
});
for (let i = 0; i < $sortedRows.length; i++) {
const { subRows } = $sortedRows[i];
if (subRows === undefined) {
continue;
}
const sortedSubRows = getSortedRows(subRows, sortKeys, columnOptions);
const clonedRow = $sortedRows[i].clone();
clonedRow.subRows = sortedSubRows;
$sortedRows[i] = clonedRow;
}
return $sortedRows;
};
/**
* Creates a sort plugin that enables sorting table rows by one or more columns.
* Supports ascending, descending, and unsorted states with customizable toggle order.
*
* @template Item - The type of data items in the table.
* @param config - Configuration options for sorting behavior.
* @returns A TablePlugin that provides sorting functionality.
* @example
* ```typescript
* const table = createTable(data, {
* sort: addSortBy({
* initialSortKeys: [{ id: 'name', order: 'asc' }],
* disableMultiSort: false
* })
* })
*
* // Access sort state in your component
* const { sortKeys } = table.pluginStates.sort
* ```
*/
export const addSortBy = ({ initialSortKeys = [], disableMultiSort = false, isMultiSortEvent = isShiftClick, toggleOrder, serverSide = false } = {}) => ({ columnOptions }) => {
const disabledSortIds = Object.entries(columnOptions)
.filter(([, option]) => option.disable === true)
.map(([columnId]) => columnId);
const sortKeys = createSortKeysStore(initialSortKeys);
const preSortedRows = writable([]);
const deriveRows = (rows) => {
return derived([rows, sortKeys], ([$rows, $sortKeys]) => {
preSortedRows.set($rows);
// Early return if no sorting needed
if (serverSide || $sortKeys.length === 0) {
return $rows;
}
return getSortedRows($rows, $sortKeys, columnOptions);
});
};
const pluginState = { sortKeys, preSortedRows };
// The `tbody.tr.td` hook output only depends on `cell.id` (the
// column ID — closed-over) and the reactive `sortKeys` store.
// Two body cells in the same column produce identical Readables,
// so we can share one Readable per column ID across every row.
// For rows-10k × 8 cols that collapses 80,000 Readable allocations
// per cold mount into 8. LRU eviction means we stay bounded even
// if column IDs churn over the view-model's lifetime.
const tdPropsCache = new MemoryCache({
maxSize: 256
});
return {
pluginState,
deriveRows,
hooks: {
'thead.tr.th': (cell) => {
const disabled = disabledSortIds.includes(cell.id);
const props = derived(sortKeys, ($sortKeys) => {
const key = $sortKeys.find((k) => k.id === cell.id);
const toggle = (event) => {
if (!cell.isData())
return;
if (disabled)
return;
sortKeys.toggleId(cell.id, {
multiSort: disableMultiSort ? false : isMultiSortEvent(event),
toggleOrder
});
};
const clear = () => {
if (!cell.isData())
return;
if (disabledSortIds.includes(cell.id))
return;
sortKeys.clearId(cell.id);
};
return {
order: key?.order,
toggle,
clear,
disabled
};
});
return { props };
},
'tbody.tr.td': (cell) => {
let props = tdPropsCache.get(cell.id);
if (props === undefined) {
props = derived(sortKeys, ($sortKeys) => {
const key = $sortKeys.find((k) => k.id === cell.id);
return {
order: key?.order
};
});
tdPropsCache.set(cell.id, props);
}
return { props };
}
}
};
};