UNPKG

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.

279 lines (278 loc) • 12.6 kB
import { createTable as createSvelteTable, functionalUpdate } from '@tanstack/svelte-table'; import { useLocaleContext } from '@ark-ui/svelte/locale'; import { onDestroy } from 'svelte'; import { renderComponent, renderSnippet } from '@tanstack/svelte-table'; import { dataTableFeatures } from './features'; import { TYPE_NUMBER_FORMAT_DEFAULTS } from './data-table-utils'; import { filterKindFor } from './column-filter-kind'; import { dateCellTimeZone, formatDateCell } from './date-cell'; import { isEmptyDateRange, matchesDateRange, resolveDateRange } from '../../utils/date-filter'; import { cancelPendingFilterEdits } from './column-filter-editing.svelte'; import { changedFilterIds, FilterRevisions, registerFilterRevisions } from './filter-revisions.svelte'; import { formatSelectValue, matchesSelectFilter, selectCellValues } from '../../utils/select-filter'; const oneOfFilterFn = (row, columnId, filterValue) => { return matchesSelectFilter(row.getValue(columnId), filterValue); }; 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 filterRevisions = new FilterRevisions(); 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, // TanStack's default only inspects the FIRST row's value to decide whether a // column participates in global search. If that value is null/undefined the // column is excluded for the whole table, even when later rows have text. // Treat null/undefined as unknown → allow; still exclude non-text values // (booleans, objects, arrays) exactly as the default does. // Explicit param/return types keep this callback's signature independent of // `table` (which it closes over), avoiding a circular type inference. `as // never` sidesteps the option's generic-function contextual type — matching // the `filterFn as never` pattern above — so it doesn't perturb inference. getColumnCanGlobalFilter: ((column) => { const value = table .getCoreRowModel() .flatRows[0]?.getAllCellsByColumnId()[column.id]?.getValue(); return value == null || typeof value === 'string' || typeof value === 'number'; }), // Same write as TanStack's default handler, plus a per-column revision bump so // ColumnFilter can tell a filter changed even when it ends on its old value. onColumnFiltersChange: ((updater) => { const atom = table.baseAtoms.columnFilters; const prev = atom.get(); atom.set((old) => functionalUpdate(updater, old)); filterRevisions.bump(changedFilterIds(prev, atom.get())); }), 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 ?? [] } }); // 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()); } registerFilterRevisions(table, filterRevisions); // Debounced filter edits outlive the editors that made them, but not the table. onDestroy(() => cancelPendingFilterEdits(table)); // A reset drops pending filter edits too — including one on a column with no committed // filter, whose revision the reset leaves alone, so the edit would otherwise commit // afterwards and bring a filter back. TanStack assigns these as own methods of the table // (`table.reset` also writes state directly, bypassing onColumnFiltersChange). const resetColumnFilters = table.resetColumnFilters; table.resetColumnFilters = (defaultState) => { cancelPendingFilterEdits(table); resetColumnFilters(defaultState); }; const resetTable = table.reset; table.reset = () => { cancelPendingFilterEdits(table); resetTable(); }; 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); // Compote exposes `pinned: 'left' | 'right'` publicly; v9 uses logical // start/end pinning state, so translate at this boundary. return { start: leafCols.filter((c) => c.pinned === 'left').map(getColumnId), end: 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); 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 }, // Select cells may hold an array (multi-value / tag columns): facet each item // separately so the Funnel lists options, not value combinations. ...(column.type === 'select' ? { getUniqueValues: (row) => selectCellValues(readColumnValue(column, row)) } : {}) }; 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 readColumnValue(column, row) { return typeof column.accessorFn === 'function' ? column.accessorFn(row) : row[column.accessorKey]; } 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.'); } /** * A calendar-day range filter for a date column, judged in the zone the column is shown in * (read on every pass, so it follows a changed local zone like the cells do). */ function dateRangeFilterFn(formatOptions) { const filterFn = (row, columnId, range) => matchesDateRange(row.getValue(columnId), range, dateCellTimeZone(formatOptions)); filterFn.resolveFilterValue = resolveDateRange; filterFn.autoRemove = isEmptyDateRange; return filterFn; } function getFilterFnForType(column) { switch (filterKindFor(column.type)) { case 'range': return 'inNumberRange'; case 'boolean': return 'equals'; case 'select': return oneOfFilterFn; case 'date': return dateRangeFilterFn(column.formatOptions); 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') { return formatDateCell(value, column.type, column.formatLocale ?? localeCtx().locale, column.formatOptions); } if (column.type === 'boolean') return value ? 'Yes' : 'No'; if (column.type === 'select') return formatSelectValue(value); 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 }; }