@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
146 lines (145 loc) • 5.34 kB
JavaScript
import { derived, writable } from 'svelte/store';
import { recordSetStore } from '../utils/store.js';
import { textPrefixFilter } from './addColumnFilters.js';
/**
* Checks if a row matches the filter criteria.
* Uses O(1) Set-based lookups for visibility checks instead of O(m) array searches.
*
* @template Item - The type of data items in the table.
* @param row - The row to check.
* @param options - The filter options.
* @returns True if any cell in the row matches the filter, or if the row has subRows.
*/
export const rowMatchesFilter = (row, options) => {
const { columnOptions, filterValue, fn, includeHiddenColumns, tableCellMatches } = options;
// Parent rows with children are always included
if ((row.subRows?.length ?? 0) !== 0) {
return true;
}
// Pre-compute visible cell IDs once per row - O(m)
// This is the optimization: uses Set.has() which is O(1) instead of .find() which is O(m)
const visibleCellIds = new Set(row.cells.map((c) => c.id));
const rowCellMatches = Object.values(row.cellForId).map((cell) => {
const cellOptions = columnOptions[cell.id];
if (cellOptions?.exclude === true) {
return false;
}
// O(1) lookup instead of O(m) .find()
const isHidden = !visibleCellIds.has(cell.id);
if (isHidden && !includeHiddenColumns) {
return false;
}
if (!cell.isData()) {
return false;
}
let value = cell.value;
if (cellOptions?.getFilterValue !== undefined) {
value = cellOptions.getFilterValue(value);
}
const matches = fn({ value: String(value), filterValue });
if (matches) {
const dataRowColId = cell.dataRowColId();
if (dataRowColId !== undefined) {
tableCellMatches[dataRowColId] = matches;
}
}
return matches;
});
return rowCellMatches.includes(true);
};
/**
* Recursively filters rows and their subRows based on the filter criteria.
*
* @template Item - The type of data items in the table.
* @template Row - The row type.
* @param rows - The rows to filter.
* @param filterValue - The current filter value.
* @param options - The filter options.
* @returns The filtered rows array.
* @internal
*/
const getFilteredRows = (rows, filterValue, options) => {
const { columnOptions, tableCellMatches, fn, includeHiddenColumns } = options;
const $filteredRows = rows
// Filter `subRows`
.map((row) => {
const { subRows } = row;
if (subRows === undefined) {
return row;
}
const filteredSubRows = getFilteredRows(subRows, filterValue, options);
const clonedRow = row.clone();
clonedRow.subRows = filteredSubRows;
return clonedRow;
})
.filter((row) => rowMatchesFilter(row, {
columnOptions,
filterValue,
fn,
includeHiddenColumns,
tableCellMatches
}));
return $filteredRows;
};
/**
* Creates a table filter plugin that enables filtering rows by text search across columns.
*
* @template Item - The type of data items in the table.
* @param config - Configuration options for the filter.
* @returns A TablePlugin that provides filtering functionality.
* @example
* ```typescript
* const table = createTable(data, {
* filter: addTableFilter({
* fn: ({ filterValue, value }) => value.toLowerCase().includes(filterValue.toLowerCase()),
* initialFilterValue: '',
* includeHiddenColumns: false
* })
* })
*
* // Access the filter state
* const { filterValue } = table.pluginStates.filter
* filterValue.set('search term')
* ```
*/
export const addTableFilter = ({ fn = textPrefixFilter, initialFilterValue = '', includeHiddenColumns = false, serverSide = false } = {}) => ({ columnOptions }) => {
const filterValue = writable(initialFilterValue);
const preFilteredRows = writable([]);
const tableCellMatches = recordSetStore();
const pluginState = { filterValue, preFilteredRows };
const deriveRows = (rows) => {
return derived([rows, filterValue], ([$rows, $filterValue]) => {
preFilteredRows.set($rows);
tableCellMatches.clear();
const $tableCellMatches = {};
const $filteredRows = getFilteredRows($rows, $filterValue, {
columnOptions,
tableCellMatches: $tableCellMatches,
fn,
includeHiddenColumns
});
tableCellMatches.set($tableCellMatches);
if (serverSide) {
return $rows;
}
return $filteredRows;
});
};
return {
pluginState,
deriveRows,
hooks: {
'tbody.tr.td': (cell) => {
const props = derived([filterValue, tableCellMatches], ([$filterValue, $tableCellMatches]) => {
const dataRowColId = cell.dataRowColId();
return {
matches: $filterValue !== '' &&
dataRowColId !== undefined &&
($tableCellMatches[dataRowColId] ?? false)
};
});
return { props };
}
}
};
};