@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
173 lines (172 loc) • 6.17 kB
JavaScript
import { keyed } from '@humanspeak/svelte-keyed';
import { derived, writable } from 'svelte/store';
/**
* Filters rows based on column filter values.
* @internal
*/
const getFilteredRows = (rows, filterValues, columnOptions) => {
const $filteredRows = rows
// Filter `subRows`
.map((row) => {
const { subRows } = row;
if (subRows === undefined) {
return row;
}
const filteredSubRows = getFilteredRows(subRows, filterValues, columnOptions);
const clonedRow = row.clone();
clonedRow.subRows = filteredSubRows;
return clonedRow;
})
.filter((row) => {
if ((row.subRows?.length ?? 0) !== 0) {
return true;
}
for (const [columnId, columnOption] of Object.entries(columnOptions)) {
const bodyCell = row.cellForId[columnId];
if (!bodyCell.isData()) {
continue;
}
const { value } = bodyCell;
const filterValue = filterValues[columnId];
if (filterValue === undefined) {
continue;
}
const isMatch = columnOption.fn({ value, filterValue });
if (!isMatch) {
return false;
}
}
return true;
});
return $filteredRows;
};
/**
* Creates a column filters plugin that enables per-column filtering with custom filter functions.
*
* @template Item - The type of data items in the table.
* @param config - Configuration options.
* @returns A TablePlugin that provides column filtering functionality.
* @example
* ```typescript
* const table = createTable(data, {
* colFilter: addColumnFilters()
* })
*
* // Configure per-column in column definitions:
* table.column({
* accessor: 'status',
* header: 'Status',
* plugins: {
* colFilter: {
* fn: matchFilter,
* initialFilterValue: undefined
* }
* }
* })
* ```
*/
export const addColumnFilters = ({ serverSide = false } = {}) => ({ columnOptions, tableState }) => {
const filterValues = writable({});
const preFilteredRows = writable([]);
const filteredRows = writable([]);
const pluginState = { filterValues, preFilteredRows };
const deriveRows = (rows) => {
return derived([rows, filterValues], ([$rows, $filterValues]) => {
preFilteredRows.set($rows);
if (serverSide) {
filteredRows.set($rows);
return $rows;
}
const _filteredRows = getFilteredRows($rows, $filterValues, columnOptions);
filteredRows.set(_filteredRows);
return _filteredRows;
});
};
return {
pluginState,
deriveRows,
hooks: {
'thead.tr.th': (headerCell) => {
const filterValue = keyed(filterValues, headerCell.id);
const props = derived([], () => {
const columnOption = columnOptions[headerCell.id];
if (columnOption === undefined) {
return undefined;
}
filterValue.set(columnOption.initialFilterValue);
const preFilteredValues = derived(preFilteredRows, ($rows) => {
if (headerCell.isData()) {
return $rows.map((row) => {
// TODO check and handle different BodyCell types
const cell = row.cellForId[headerCell.id];
return cell?.value;
});
}
return [];
});
const values = derived(filteredRows, ($rows) => {
if (headerCell.isData()) {
return $rows.map((row) => {
// TODO check and handle different BodyCell types
const cell = row.cellForId[headerCell.id];
return cell?.value;
});
}
return [];
});
const render = columnOption.render?.({
id: headerCell.id,
filterValue,
...tableState,
values,
preFilteredRows,
preFilteredValues
});
return { render };
});
return { props };
}
}
};
};
/**
* A filter function that matches exact values.
* Returns true if filterValue is undefined or equals the cell value.
*
* @param props - The filter props containing filterValue and value.
* @returns True if the value matches the filter.
*/
export const matchFilter = ({ filterValue, value }) => {
if (filterValue === undefined) {
return true;
}
return filterValue === value;
};
/**
* A filter function that matches text by prefix (case-insensitive).
* Returns true if filterValue is empty or value starts with filterValue.
*
* @param props - The filter props containing filterValue and value.
* @returns True if the value starts with the filter text.
*/
export const textPrefixFilter = ({ filterValue, value }) => {
if (filterValue === '') {
return true;
}
return String(value).toLowerCase().startsWith(String(filterValue).toLowerCase());
};
/**
* A filter function that matches numbers within a range.
* The range is [min, max] inclusive. Use null for unbounded.
*
* @param props - The filter props with a [min, max] filterValue and numeric value.
* @returns True if the value is within the specified range.
* @example
* ```typescript
* numberRangeFilter({ filterValue: [10, 50], value: 25 }) // true
* numberRangeFilter({ filterValue: [null, 100], value: 50 }) // true (no min)
* ```
*/
export const numberRangeFilter = ({ filterValue: [min, max], value }) => {
return (min ?? -Infinity) <= value && value <= (max ?? Infinity);
};