@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
256 lines (255 loc) • 9.34 kB
JavaScript
import { MemoryCache } from '@humanspeak/memory-cache';
import { derived, get } from 'svelte/store';
import { nonNull } from '../utils/filter.js';
import { recordSetStore } from '../utils/store.js';
import { DEFAULT_ROW_STATE_CACHE_CONFIG } from './cacheConfig.js';
/**
* Recursively checks if all sub-rows of a row are selected.
* @internal
*/
const isAllSubRowsSelectedForRow = (row, $selectedDataIds, linkDataSubRows) => {
if (row.isData()) {
if (!linkDataSubRows || row.subRows === undefined) {
return $selectedDataIds[row.dataId] === true;
}
}
if (row.subRows === undefined) {
return false;
}
return row.subRows.every((subRow) => isAllSubRowsSelectedForRow(subRow, $selectedDataIds, linkDataSubRows));
};
/**
* Recursively checks if any sub-rows of a row are selected.
* @internal
*/
const isSomeSubRowsSelectedForRow = (row, $selectedDataIds, linkDataSubRows) => {
if (row.isData()) {
if (!linkDataSubRows || row.subRows === undefined) {
return $selectedDataIds[row.dataId] === true;
}
}
if (row.subRows === undefined) {
return false;
}
return row.subRows.some((subRow) => isSomeSubRowsSelectedForRow(subRow, $selectedDataIds, linkDataSubRows));
};
/**
* Recursively writes selection state for a row and its sub-rows.
* @internal
*/
const writeSelectedDataIds = (row, value, $selectedDataIds, linkDataSubRows) => {
if (row.isData()) {
$selectedDataIds[row.dataId] = value;
if (!linkDataSubRows) {
return;
}
}
if (row.subRows === undefined) {
return;
}
row.subRows.forEach((subRow) => {
writeSelectedDataIds(subRow, value, $selectedDataIds, linkDataSubRows);
});
};
/**
* Creates a writable store for a row's selection state.
* @internal
*/
const getRowIsSelectedStore = (row, selectedDataIds, linkDataSubRows) => {
const { subscribe } = derived(selectedDataIds, ($selectedDataIds) => {
if (row.isData()) {
if (!linkDataSubRows) {
return $selectedDataIds[row.dataId] === true;
}
if ($selectedDataIds[row.dataId] === true) {
return true;
}
}
return isAllSubRowsSelectedForRow(row, $selectedDataIds, linkDataSubRows);
});
const update = (fn) => {
selectedDataIds.update(($selectedDataIds) => {
const oldValue = isAllSubRowsSelectedForRow(row, $selectedDataIds, linkDataSubRows);
const $updatedSelectedDataIds = { ...$selectedDataIds };
writeSelectedDataIds(row, fn(oldValue), $updatedSelectedDataIds, linkDataSubRows);
if (row.parentRow !== undefined && row.parentRow.isData()) {
$updatedSelectedDataIds[row.parentRow.dataId] = isAllSubRowsSelectedForRow(row.parentRow, $updatedSelectedDataIds, linkDataSubRows);
}
return $updatedSelectedDataIds;
});
};
const set = (value) => update(() => value);
return {
subscribe,
update,
set
};
};
/**
* Creates a row selection plugin that enables selecting/deselecting table rows.
* Supports hierarchical selection with parent-child row linking.
*
* @template Item - The type of data items in the table.
* @param config - Configuration options.
* @returns A TablePlugin that provides row selection functionality.
* @example
* ```typescript
* const table = createTable(data, {
* select: addSelectedRows({
* linkDataSubRows: true // Selecting parent selects children
* })
* })
*
* // Access selection state
* const { selectedDataIds, allRowsSelected } = table.pluginStates.select
*
* // Toggle all rows
* allRowsSelected.set(true)
*
* // Check if a specific row is selected
* const rowState = table.pluginStates.select.getRowState(row)
* $rowState.isSelected // true or false
* ```
*/
export const addSelectedRows = ({ initialSelectedDataIds = {}, linkDataSubRows = true } = {}) => ({ tableState }) => {
const selectedDataIds = recordSetStore(initialSelectedDataIds);
// LRU cache for memoized row state with automatic eviction.
// Prevents unbounded memory growth when row identities change.
const rowStateCache = new MemoryCache(DEFAULT_ROW_STATE_CACHE_CONFIG);
const getRowState = (row) => {
const cached = rowStateCache.get(row.id);
if (cached !== undefined) {
return cached;
}
const isSelected = getRowIsSelectedStore(row, selectedDataIds, linkDataSubRows);
const isSomeSubRowsSelected = derived([isSelected, selectedDataIds], ([$isSelected, $selectedDataIds]) => {
if ($isSelected)
return false;
return isSomeSubRowsSelectedForRow(row, $selectedDataIds, linkDataSubRows);
});
const isAllSubRowsSelected = derived(selectedDataIds, ($selectedDataIds) => {
return isAllSubRowsSelectedForRow(row, $selectedDataIds, linkDataSubRows);
});
const state = {
isSelected,
isSomeSubRowsSelected,
isAllSubRowsSelected
};
rowStateCache.set(row.id, state);
return state;
};
// Clear cache when selectedDataIds store is cleared (data reset scenario)
const unsubscribeSelectedDataIds = selectedDataIds.subscribe(($selectedDataIds) => {
if (Object.keys($selectedDataIds).length === 0) {
rowStateCache.clear();
}
});
// Cleanup function to prevent subscription leaks when table is destroyed
const invalidate = () => {
unsubscribeSelectedDataIds();
rowStateCache.clear();
};
// all rows
const _allRowsSelected = derived([tableState.rows, selectedDataIds], ([$rows, $selectedDataIds]) => {
return $rows.every((row) => {
if (!row.isData()) {
return true;
}
return $selectedDataIds[row.dataId] === true;
});
});
const setAllRowsSelected = ($allRowsSelected) => {
if ($allRowsSelected) {
const $rows = get(tableState.rows);
const allDataIds = $rows
.map((row) => (row.isData() ? row.dataId : null))
.filter(nonNull);
selectedDataIds.addAll(allDataIds);
}
else {
selectedDataIds.clear();
}
};
const allRowsSelected = {
subscribe: _allRowsSelected.subscribe,
update(fn) {
const $allRowsSelected = get(_allRowsSelected);
setAllRowsSelected(fn($allRowsSelected));
},
set: setAllRowsSelected
};
const someRowsSelected = derived([tableState.rows, selectedDataIds], ([$rows, $selectedDataIds]) => {
return $rows.some((row) => {
if (!row.isData()) {
return false;
}
return $selectedDataIds[row.dataId] === true;
});
});
// page rows
const _allPageRowsSelected = derived([tableState.pageRows, selectedDataIds], ([$pageRows, $selectedDataIds]) => {
return $pageRows.every((row) => {
if (!row.isData()) {
return true;
}
return $selectedDataIds[row.dataId] === true;
});
});
const setAllPageRowsSelected = ($allPageRowsSelected) => {
const $pageRows = get(tableState.pageRows);
const pageDataIds = $pageRows
.map((row) => (row.isData() ? row.dataId : null))
.filter(nonNull);
if ($allPageRowsSelected) {
selectedDataIds.addAll(pageDataIds);
}
else {
selectedDataIds.removeAll(pageDataIds);
}
};
const allPageRowsSelected = {
subscribe: _allPageRowsSelected.subscribe,
update(fn) {
const $allPageRowsSelected = get(_allPageRowsSelected);
setAllPageRowsSelected(fn($allPageRowsSelected));
},
set: setAllPageRowsSelected
};
const somePageRowsSelected = derived([tableState.pageRows, selectedDataIds], ([$pageRows, $selectedDataIds]) => {
return $pageRows.some((row) => {
if (!row.isData()) {
return false;
}
return $selectedDataIds[row.dataId] === true;
});
});
const pluginState = {
selectedDataIds,
getRowState,
allRowsSelected,
someRowsSelected,
allPageRowsSelected,
somePageRowsSelected,
invalidate
};
return {
pluginState,
hooks: {
'tbody.tr': (row) => {
const props = derived(selectedDataIds, ($selectedDataIds) => {
const someSubRowsSelected = isSomeSubRowsSelectedForRow(row, $selectedDataIds, linkDataSubRows);
const allSubRowsSelected = isAllSubRowsSelectedForRow(row, $selectedDataIds, linkDataSubRows);
const selected = row.isData()
? $selectedDataIds[row.dataId] === true
: allSubRowsSelected;
return {
selected,
someSubRowsSelected,
allSubRowsSelected
};
});
return { props };
}
}
};
};