compote-ui
Version:
An opinionated UI component library for Svelte, built on top of [Ark UI](https://ark-ui.com) with additional components and features not available in the core Ark UI library.
246 lines (245 loc) • 9.99 kB
JavaScript
import { createTable as createSvelteTable } from '@tanstack/svelte-table';
import { useLocaleContext } from '@ark-ui/svelte/locale';
import { onDestroy, untrack } from 'svelte';
import { renderComponent, renderSnippet } from '@tanstack/svelte-table';
import { dataTableFeatures } from './features';
import { TYPE_NUMBER_FORMAT_DEFAULTS } from './data-table-utils';
const oneOfFilterFn = (row, columnId, filterValue) => {
return filterValue.includes(String(row.getValue(columnId)));
};
oneOfFilterFn.autoRemove = (val) => !Array.isArray(val) || val.length === 0;
export function createTable(options) {
const localeCtx = useLocaleContext();
const initialColumnVisibility = {
...createColumnVisibility(options.columns),
...options.initialState?.columnVisibility
};
// Recompute the resolved column defs only when the source columns change.
// $derived keeps the reference stable between changes so TanStack's column
// memoization isn't invalidated on every access. Pass `columns` through a
// getter (like `data`) to make adding/removing/reordering columns reactive.
const columnDefs = $derived(createColumns(options.columns, localeCtx));
const table = createSvelteTable({
features: dataTableFeatures,
get data() {
return options.data;
},
get columns() {
return columnDefs;
},
columnResizeMode: options.columnResizeMode,
getRowId: options.getRowId,
enableRowSelection: options.enableRowSelection ?? false,
enableMultiRowSelection: options.enableMultiRowSelection,
enableSorting: options.enableSorting,
debugTable: options.debugTable,
initialState: {
columnVisibility: initialColumnVisibility,
columnSizing: {
...createColumnSizing(options.columns),
...options.initialState?.columnSizing
},
columnPinning: options.initialState?.columnPinning ?? createColumnPinning(options.columns),
rowSelection: options.initialState?.rowSelection ?? {},
sorting: options.initialState?.sorting ?? [],
columnFilters: options.initialState?.columnFilters ?? []
}
});
// The svelte adapter's internal $effect.pre only tracks `data` and `state` — not
// `columns` — so reactive add/remove/reorder of columns never reaches the table.
// Track the derived column defs here and push them through setOptions ourselves.
// Re-supply the `data` getter so spreading `prev` doesn't freeze data reactivity.
// CAUTION: upstream guidance says a second setOptions sync can race the adapter's
// own $effect.pre (which getter-merges the original options on data/state change).
// Both syncs write consistent values here, but revisit if the beta adapter ever
// starts tracking `columns` itself.
$effect.pre(() => {
const columns = columnDefs;
untrack(() => {
table.setOptions((prev) => ({
...prev,
get data() {
return options.data;
},
columns
}));
});
});
// Notify consumers of visibility changes without taking control of the slice
// (overriding the table's own `onColumnVisibilityChange` option would suppress
// internal updates). The atom's `subscribe` is an explicit observer that runs
// the callback outside Svelte's dependency tracking, so consumer-side reads
// (e.g. a persisted-state proxy the callback writes into) can never become
// dependencies that re-trigger the notification — the failure mode of
// observing via $effect. The initial-replay guard compares by reference:
// table state updates are immutable, so only the subscription's initial
// emission (if the atom flavor emits one) can match the snapshot.
if (options.onColumnVisibilityChange) {
const initialVisibility = table.atoms.columnVisibility.get();
const subscription = table.atoms.columnVisibility.subscribe((visibility) => {
if (visibility === initialVisibility)
return;
options.onColumnVisibilityChange?.(visibility);
});
onDestroy(() => subscription.unsubscribe());
}
return table;
}
function createColumnVisibility(columns) {
return getLeafColumns(columns).reduce((visibility, column) => {
visibility[getColumnId(column)] = true;
return visibility;
}, {});
}
function createColumnSizing(columns) {
return getLeafColumns(columns).reduce((sizes, column) => {
if (typeof column.size === 'number') {
sizes[getColumnId(column)] = column.size;
}
return sizes;
}, {});
}
function createColumnPinning(columns) {
const leafCols = getLeafColumns(columns);
return {
left: leafCols.filter((c) => c.pinned === 'left').map(getColumnId),
right: leafCols.filter((c) => c.pinned === 'right').map(getColumnId)
};
}
function createColumns(columns, localeCtx) {
return columns.map((column) => {
if (isGroupColumn(column)) {
return {
id: getGroupColumnId(column),
header: column.header,
columns: createColumns(column.columns, localeCtx),
meta: {
align: column.align
}
};
}
const columnId = getColumnId(column);
const derivedFilterFn = column.filterFn ?? getFilterFnForType(column.type);
const columnDef = {
id: columnId,
header: column.header,
size: column.size,
minSize: column.minSize,
maxSize: column.maxSize,
enableResizing: column.enableResizing,
enableHiding: column.enableHiding,
enableSorting: column.enableSorting,
sortDescFirst: column.sortDescFirst,
enableColumnFilter: column.enableColumnFilter,
...(derivedFilterFn !== undefined ? { filterFn: derivedFilterFn } : {}),
meta: {
align: column.align,
type: column.type,
formatOptions: column.formatOptions,
formatLocale: column.formatLocale,
grow: column.grow,
sum: column.sum,
footer: column.footer
}
};
if (typeof column.accessorFn === 'function') {
return {
...columnDef,
accessorFn: column.accessorFn,
cell: (context) => formatCellValue(column, context.getValue(), context.row.original, localeCtx)
};
}
return {
...columnDef,
accessorKey: column.accessorKey,
cell: (context) => formatCellValue(column, context.getValue(), context.row.original, localeCtx)
};
});
}
function isGroupColumn(column) {
return Array.isArray(column.columns);
}
function getLeafColumns(columns) {
return columns.flatMap((column) => isGroupColumn(column) ? getLeafColumns(column.columns) : column);
}
function getGroupColumnId(column) {
return column.id ?? column.header;
}
export function getColumnId(column) {
if (column.id !== undefined)
return column.id;
if ('accessorKey' in column && column.accessorKey !== undefined)
return column.accessorKey;
throw new Error('DataTableColumn with accessorFn requires an id.');
}
const TYPE_DATE_FORMAT_DEFAULTS = {
date: { day: '2-digit', month: '2-digit', year: 'numeric' },
time: { hour: '2-digit', minute: '2-digit' },
'date-time': {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
};
function getFilterFnForType(type) {
switch (type) {
case 'number':
case 'currency':
case 'percent':
return 'inNumberRange';
case 'boolean':
return 'equals';
case 'select':
return oneOfFilterFn;
default:
return undefined;
}
}
function applyTypeFormat(column, value, localeCtx) {
if (value === null || value === undefined || value === '')
return undefined;
const numDefaults = column.type ? TYPE_NUMBER_FORMAT_DEFAULTS[column.type] : undefined;
if (numDefaults !== undefined) {
const numericValue = Number(value);
if (isNaN(numericValue))
return undefined;
const locale = column.formatLocale ?? localeCtx().locale;
return new Intl.NumberFormat(locale, {
...numDefaults,
...column.formatOptions
}).format(numericValue);
}
if (column.type === 'date' || column.type === 'time' || column.type === 'date-time') {
const dateValue = value instanceof Date ? value : new Date(value);
if (isNaN(dateValue.getTime()))
return undefined;
const locale = column.formatLocale ?? localeCtx().locale;
return new Intl.DateTimeFormat(locale, {
...TYPE_DATE_FORMAT_DEFAULTS[column.type],
...column.formatOptions
}).format(dateValue);
}
if (column.type === 'boolean')
return value ? 'Yes' : 'No';
return value;
}
function formatCellValue(column, value, row, localeCtx) {
if (column.cellComponent) {
return renderComponent(column.cellComponent, getCellComponentProps(column, value, row));
}
if (column.cellSnippet) {
return renderSnippet(column.cellSnippet, { value, row });
}
const rendered = column.cell
? column.cell(value, row)
: applyTypeFormat(column, value, localeCtx);
if (rendered === null || rendered === undefined || rendered === '') {
return '-';
}
return String(rendered);
}
function getCellComponentProps(column, value, row) {
return column.cellProps ? column.cellProps(value, row) : { value, row };
}