@svelte-lean/table 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @svelte-lean/table
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eb039db: Initial release: `<DataTable />` renders a native `<table>` over the `createTable()` engine, with state in one serializable object and grouping, pivot, clipboard, CSV export, server data sources and persisted state as separate entry points.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ömer Say
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @svelte-lean/table
2
+
3
+ [Documentation](https://svelte-lean.vercel.app/table) ·
4
+ [Getting started](https://svelte-lean.vercel.app/table/getting-started) ·
5
+ [Repository](https://github.com/say16/svelte-lean/tree/main/packages/table) ·
6
+ [Changelog](https://github.com/say16/svelte-lean/blob/main/packages/table/CHANGELOG.md)
7
+
8
+ Svelte Lean Table: a data table for Svelte 5 that renders a native `<table>`, keeps its state in one
9
+ plain object and adds capabilities through separate entry points. Part of the
10
+ [Svelte Lean](https://github.com/say16/svelte-lean) ecosystem.
11
+
12
+ > Preview release (`0.1.0-next`).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ yarn add @svelte-lean/table
18
+ ```
19
+
20
+ Peer dependency: `svelte ^5.0.0`.
21
+
22
+ ## Use
23
+
24
+ ```svelte
25
+ <script lang="ts">
26
+ import { DataTable, type Column } from '@svelte-lean/table';
27
+ import '@svelte-lean/table/style.css';
28
+
29
+ type Member = { id: number; name: string; revenue: number };
30
+ const data: Member[] = [
31
+ { id: 1, name: 'Olivia Rhye', revenue: 1200 },
32
+ { id: 2, name: 'Phoenix Baker', revenue: 9119 }
33
+ ];
34
+ const columns: Column<Member>[] = [
35
+ { id: 'name', header: 'Member' },
36
+ { id: 'revenue', header: 'Revenue', align: 'end' }
37
+ ];
38
+ </script>
39
+
40
+ <DataTable {data} {columns} getRowId={(row) => row.id} />
41
+ ```
42
+
43
+ Sorting, search, column filters, pagination, selection, expansion, tree rows, sticky headers, column
44
+ pinning, resizing, visibility and ordering, inline editing, cell ranges, row reordering and loading,
45
+ empty and error states are props of `<DataTable />`. State is a serializable `TableState` object:
46
+ pass `bind:state` or `onStateChange` to own it.
47
+
48
+ Keyboard: with `onEdit` or `onCellRangeChange` the body is one Tab stop (roving `tabindex` over
49
+ cells, arrow keys move, Enter/F2 edits, focus returns to the cell after an edit). Text direction
50
+ follows the nearest `dir` attribute unless the `dir` prop is given. `selection: 'single'` needs an
51
+ `id` prop so the row radios share a name and form one native radio group.
52
+
53
+ Composition hooks:
54
+
55
+ - `headerCell` receives a `HeaderContext` (`column`, `sort`, `sorting`, `toggleSort()`,
56
+ `setSorting()`): with `sortable: false` a header hosts its own sort control, and `sortKeys` lists
57
+ further sort ids that count as the column's sort. `sortDescFirst` starts a column descending.
58
+ - `empty` receives `{ search, filtered, loading }`; `rowDetails` receives the row and
59
+ `{ rowId, index, collapse }`. A `<DataTable>` inside `rowDetails` keeps its own styles and
60
+ keyboard behavior; `expandColumn` puts the toggle in a leading column of its own.
61
+ - `onRowClick` ignores clicks on controls and inside `[data-no-row-click]`; rows then answer Enter
62
+ and Space. `rowLabel` names row controls. `rowClass` can tint a row by setting
63
+ `--slean-table-row-bg`.
64
+ - Server tables: `manual` with `rowCount`, or `pageCount` when the total is unknown; `searchDelay`
65
+ debounces the search box and the filter inputs; `searchText` replaces what a row is searched by;
66
+ `state.search` applies without the table's own search box. `loading` without rows draws
67
+ `skeletonRows`; `fetching` dims the body.
68
+ - `pageSizeOptions`, numbered pages, `placeholder` for empty values, `headerClass`, per-row `class`.
69
+
70
+ ## Below the component
71
+
72
+ `createTable()` is the engine `<DataTable />` renders. Use it to build a different renderer or to drive
73
+ the table from application code. Options are read lazily, so getters keep it reactive.
74
+
75
+ ```ts
76
+ import { createTable } from '@svelte-lean/table';
77
+
78
+ const table = createTable({
79
+ data: () => rows, // `$state` in the caller stays reactive through the getter
80
+ columns,
81
+ getRowId: (row) => row.id,
82
+ state: () => tableState, // a getter makes the table controlled …
83
+ onStateChange: (next) => (tableState = next) // … and every change is reported here
84
+ });
85
+
86
+ table.toggleSort('revenue');
87
+ table.model.rows; // paginated, sorted, filtered row nodes
88
+ table.visibleColumns; // leaf columns in pin and user order
89
+ ```
90
+
91
+ `createTable()` uses Svelte runes and therefore needs the Svelte compiler (any Svelte 5 project has
92
+ it). `createRowModel()` and the other helpers in `@svelte-lean/table/core` are pure functions; that
93
+ entry imports nothing from Svelte and runs under plain Node (checked by `fixtures/_shared/pack-smoke.mjs`).
94
+
95
+ ## Package shape
96
+
97
+ Everything above is the root entry. Larger workflows are explicit entry points that never load unless
98
+ imported:
99
+
100
+ ```ts
101
+ import { DataTable, createTable, createRowModel } from '@svelte-lean/table';
102
+ import { groupBy, summarize } from '@svelte-lean/table/grouping';
103
+ import { pivot } from '@svelte-lean/table/pivot';
104
+ import { createHistory, applyEdit } from '@svelte-lean/table/editing';
105
+ import { copyRange, pasteRange } from '@svelte-lean/table/clipboard';
106
+ import { toCsv, downloadText } from '@svelte-lean/table/export';
107
+ import { createDataSource } from '@svelte-lean/table/server';
108
+ import { serializeState, restoreState } from '@svelte-lean/table/state';
109
+ import '@svelte-lean/table/style.css';
110
+ ```
111
+
112
+ `style.css` styles the `slean-table-` classes. Its rules sit in the `slean.components` cascade layer
113
+ with `:where()` selectors, so unlayered application CSS overrides them without `!important`. It reads
114
+ `@svelte-lean/styles` tokens (`--slean-surface`, `--slean-fg`, `--slean-border`, `--slean-accent`,
115
+ `--slean-radius-*`, …) when they are defined; the fallbacks are the same defaults written with
116
+ `light-dark()`, so they follow the page's `color-scheme`. Override any `--slean-table-*` property
117
+ (`--slean-table-header-bg`, `--slean-table-row-hover`, `--slean-table-cell-padding-y`, … listed on
118
+ the Layout page) on `.slean-table` or a class passed through `class` to restyle a table. The selection
119
+ checkboxes carry `data-slean="checkbox"`: with `@svelte-lean/styles/checkbox.css` loaded they take the
120
+ Checkbox primitive's look (Tier 0, no runtime).
121
+
122
+ TypeScript: types resolve under `moduleResolution` `bundler` and `node16`/`nodenext` (ESM); the
123
+ build writes a `.d.svelte.ts` declaration next to each component for the latter.
124
+
125
+ Bundle sizes per entry are measured by `yarn size` into `artifacts/size.json`; the values on the
126
+ documentation site come from that file.
127
+
128
+ ## Documentation
129
+
130
+ The documentation site of the Svelte Lean monorepo (`apps/docs`) contains the examples and the API
131
+ reference.
132
+
133
+ MIT
@@ -0,0 +1,8 @@
1
+ import type { CellRange, Column, EditEvent, RowId } from './types.js';
2
+ export declare function rangeBounds<T>(range: CellRange, rows: readonly T[], columns: readonly Column<T>[], getRowId: (row: T) => RowId): [number, number, number, number] | null;
3
+ export declare function copyRange<T>(range: CellRange, rows: readonly T[], columns: readonly Column<T>[], getRowId: (row: T) => RowId): string;
4
+ /** Produces validated changes; the application decides when and how to commit the batch. */
5
+ export declare function pasteRange<T>(text: string, start: {
6
+ row: number;
7
+ column: number;
8
+ }, rows: readonly T[], columns: readonly Column<T>[], getRowId: (row: T) => RowId): EditEvent<T>[];
@@ -0,0 +1,49 @@
1
+ import { parseDelimited } from './export.js';
2
+ import { parseEdit, editEvent } from './editing.js';
3
+ import { valueOf } from './core.js';
4
+ export function rangeBounds(range, rows, columns, getRowId) {
5
+ const a = rows.findIndex((row) => getRowId(row) === range.start.row), b = rows.findIndex((row) => getRowId(row) === range.end.row);
6
+ const c = columns.findIndex((col) => col.id === range.start.column), d = columns.findIndex((col) => col.id === range.end.column);
7
+ return Math.min(a, b, c, d) < 0
8
+ ? null
9
+ : [Math.min(a, b), Math.max(a, b), Math.min(c, d), Math.max(c, d)];
10
+ }
11
+ export function copyRange(range, rows, columns, getRowId) {
12
+ const bounds = rangeBounds(range, rows, columns, getRowId);
13
+ if (!bounds)
14
+ return '';
15
+ const [r0, r1, c0, c1] = bounds;
16
+ const quote = (value) => {
17
+ const text = String(value ?? '');
18
+ return /[\t\n\r"]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
19
+ };
20
+ return rows
21
+ .slice(r0, r1 + 1)
22
+ .map((row) => columns
23
+ .slice(c0, c1 + 1)
24
+ .map((col) => quote(valueOf(row, col)))
25
+ .join('\t'))
26
+ .join('\n');
27
+ }
28
+ /** Produces validated changes; the application decides when and how to commit the batch. */
29
+ export function pasteRange(text, start, rows, columns, getRowId) {
30
+ const changes = [];
31
+ for (const [ri, cells] of parseDelimited(text, '\t').entries()) {
32
+ const row = rows[start.row + ri];
33
+ if (row === undefined)
34
+ break;
35
+ for (const [ci, text] of cells.entries()) {
36
+ const col = columns[start.column + ci];
37
+ if (!col)
38
+ break;
39
+ if (!col.editable || (typeof col.editable === 'function' && !col.editable(row)))
40
+ continue;
41
+ const value = parseEdit(col, text, row);
42
+ const error = col.validate?.(value, row);
43
+ if (error)
44
+ throw new Error(`${col.header}: ${error}`);
45
+ changes.push(editEvent(row, getRowId(row), col, value));
46
+ }
47
+ }
48
+ return changes;
49
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { Column, Filter, TableState, ModelContext, RowId, RowModel, RowNode, Sort } from './types.js';
2
+ export type * from './types.js';
3
+ export declare function defaultState(patch?: Partial<TableState>): TableState;
4
+ export declare function valueOf<T>(row: T, column: Column<T>): unknown;
5
+ export declare function leafColumns<T>(columns: readonly Column<T>[]): Column<T>[];
6
+ export declare function visibleColumns<T>(columns: readonly Column<T>[], state: TableState): Column<T>[];
7
+ export declare function fold(value: unknown, locale?: string): string;
8
+ export declare function matchesFilter(value: unknown, filter: Filter, locale?: string): boolean;
9
+ /**
10
+ * Cycles one sort id through none → ascending → descending → none, or none → descending →
11
+ * ascending → none with `descFirst`. `multi` keeps the other sorted ids.
12
+ */
13
+ export declare function toggleSort(sorting: Sort[], id: string, multi?: boolean, descFirst?: boolean): Sort[];
14
+ /** The first sort entry on the column's id or one of its `sortKeys`, with its precedence. */
15
+ export declare function sortOf<T>(sorting: readonly Sort[], column: Pick<Column<T>, 'id' | 'sortKeys'>): {
16
+ id: string;
17
+ desc: boolean;
18
+ index: number;
19
+ } | undefined;
20
+ /** Clamps a requested width to the column's `minWidth`/`maxWidth` (defaults 64 and 1200). */
21
+ export declare function clampWidth<T>(column: Column<T>, width: number): number;
22
+ export declare function columnWidth<T>(column: Column<T>, state: TableState): number;
23
+ export declare function toggleId(ids: RowId[], id: RowId): RowId[];
24
+ export declare function toggleSelection(current: RowId[], eligible: RowId[], select?: boolean): RowId[];
25
+ export declare function flattenRows<T>(nodes: readonly RowNode<T>[], expanded?: ReadonlySet<RowId>): RowNode<T>[];
26
+ /**
27
+ * Search reads declared searchable columns only, never arbitrary hidden payload fields, unless
28
+ * `searchText` says what a row's searchable text is.
29
+ */
30
+ export declare function prepareRows<T>(context: ModelContext<T>): RowNode<T>[];
31
+ export declare function paginateNodes<T>(nodes: RowNode<T>[], context: ModelContext<T>): RowModel<T>;
32
+ export declare function createRowModel<T>(context: ModelContext<T>): RowModel<T>;
33
+ export interface HeaderCell<T> {
34
+ key: string;
35
+ column: Column<T>;
36
+ colSpan: number;
37
+ rowSpan: number;
38
+ }
39
+ /** Reordering/pinning may split a group; render each contiguous fragment truthfully. */
40
+ export declare function headerRows<T>(columns: readonly Column<T>[], visible: readonly Column<T>[]): HeaderCell<T>[][];
41
+ export declare function reorder<T>(items: readonly T[], source: T, target: T): T[];
package/dist/core.js ADDED
@@ -0,0 +1,277 @@
1
+ export function defaultState(patch = {}) {
2
+ return {
3
+ sorting: [],
4
+ filters: [],
5
+ search: '',
6
+ pageIndex: 0,
7
+ pageSize: 20,
8
+ selection: [],
9
+ expanded: [],
10
+ hidden: [],
11
+ order: [],
12
+ widths: {},
13
+ pinning: {},
14
+ rowPinning: { top: [], bottom: [] },
15
+ ...patch
16
+ };
17
+ }
18
+ export function valueOf(row, column) {
19
+ if (typeof column.accessor === 'function')
20
+ return column.accessor(row);
21
+ const path = String(column.accessor ?? column.id).split('.');
22
+ return path.reduce((value, key) => value != null && typeof value === 'object'
23
+ ? value[key]
24
+ : undefined, row);
25
+ }
26
+ export function leafColumns(columns) {
27
+ return columns.flatMap((column) => column.children?.length ? leafColumns(column.children) : [column]);
28
+ }
29
+ export function visibleColumns(columns, state) {
30
+ const hidden = new Set(state.hidden);
31
+ const order = new Map(state.order.map((id, index) => [id, index]));
32
+ const pin = (column) => state.pinning[column.id] ?? column.pin;
33
+ const zone = (column) => pin(column) === 'left' ? 0 : pin(column) === 'right' ? 2 : 1;
34
+ return leafColumns(columns)
35
+ .filter((c) => !hidden.has(c.id))
36
+ .sort((a, b) => zone(a) - zone(b) || (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity));
37
+ }
38
+ export function fold(value, locale) {
39
+ return String(value ?? '')
40
+ .replace(/[ıİ]/g, 'i')
41
+ .normalize('NFD')
42
+ .replace(/[\u0300-\u036f]/g, '')
43
+ .toLocaleLowerCase(locale);
44
+ }
45
+ export function matchesFilter(value, filter, locale) {
46
+ const wanted = filter.value;
47
+ const op = filter.operator ?? 'contains';
48
+ if (op === 'empty')
49
+ return wanted === false ? value != null && value !== '' : value == null || value === '';
50
+ if (wanted == null || wanted === '')
51
+ return true;
52
+ if (op === 'in')
53
+ return Array.isArray(wanted) && wanted.some((item) => Object.is(item, value));
54
+ if (op === 'contains')
55
+ return fold(value, locale).includes(fold(wanted, locale));
56
+ if (op === 'startsWith')
57
+ return fold(value, locale).startsWith(fold(wanted, locale));
58
+ if (op === 'equals')
59
+ return fold(value, locale) === fold(wanted, locale);
60
+ if (value == null || value === '')
61
+ return false;
62
+ const numeric = (input) => input instanceof Date ? input.getTime() : Number(input);
63
+ const n = numeric(value);
64
+ if (op === 'between')
65
+ return (Array.isArray(wanted) &&
66
+ (wanted[0] == null || wanted[0] === '' || n >= numeric(wanted[0])) &&
67
+ (wanted[1] == null || wanted[1] === '' || n <= numeric(wanted[1])));
68
+ const target = numeric(wanted);
69
+ if (!Number.isFinite(n) || !Number.isFinite(target))
70
+ return false;
71
+ if (op === 'gt')
72
+ return n > target;
73
+ if (op === 'gte')
74
+ return n >= target;
75
+ if (op === 'lt')
76
+ return n < target;
77
+ return n <= target;
78
+ }
79
+ /**
80
+ * Cycles one sort id through none → ascending → descending → none, or none → descending →
81
+ * ascending → none with `descFirst`. `multi` keeps the other sorted ids.
82
+ */
83
+ export function toggleSort(sorting, id, multi = false, descFirst = false) {
84
+ const current = sorting.find((s) => s.id === id);
85
+ const next = !current
86
+ ? { id, desc: descFirst }
87
+ : current.desc === descFirst
88
+ ? { id, desc: !descFirst }
89
+ : null;
90
+ const others = multi ? sorting.filter((s) => s.id !== id) : [];
91
+ return next ? [...others, next] : others;
92
+ }
93
+ /** The first sort entry on the column's id or one of its `sortKeys`, with its precedence. */
94
+ export function sortOf(sorting, column) {
95
+ const index = sorting.findIndex((sort) => sort.id === column.id || !!column.sortKeys?.includes(sort.id));
96
+ const sort = sorting[index];
97
+ return sort ? { id: sort.id, desc: sort.desc, index } : undefined;
98
+ }
99
+ /** Clamps a requested width to the column's `minWidth`/`maxWidth` (defaults 64 and 1200). */
100
+ export function clampWidth(column, width) {
101
+ return Math.max(column.minWidth ?? 64, Math.min(column.maxWidth ?? 1200, Number.isFinite(width) ? width : 160));
102
+ }
103
+ export function columnWidth(column, state) {
104
+ return clampWidth(column, state.widths[column.id] ?? column.width ?? 160);
105
+ }
106
+ export function toggleId(ids, id) {
107
+ return ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id];
108
+ }
109
+ export function toggleSelection(current, eligible, select) {
110
+ const set = new Set(current);
111
+ const add = select ?? !eligible.every((id) => set.has(id));
112
+ for (const id of eligible)
113
+ if (add)
114
+ set.add(id);
115
+ else
116
+ set.delete(id);
117
+ return [...set];
118
+ }
119
+ export function flattenRows(nodes, expanded) {
120
+ const result = [];
121
+ for (const node of nodes) {
122
+ result.push(node);
123
+ if (!expanded || expanded.has(node.id))
124
+ result.push(...flattenRows(node.children, expanded));
125
+ }
126
+ return result;
127
+ }
128
+ /**
129
+ * Search reads declared searchable columns only, never arbitrary hidden payload fields, unless
130
+ * `searchText` says what a row's searchable text is.
131
+ */
132
+ export function prepareRows(context) {
133
+ const { data, getRowId, getSubRows, state, manual, locale, searchText } = context;
134
+ const columns = leafColumns(context.columns);
135
+ const byId = new Map(columns.map((c) => [c.id, c]));
136
+ const terms = fold(state.search, locale).split(/\s+/).filter(Boolean);
137
+ const searchColumns = columns.filter((c) => c.searchable !== false && !state.hidden.includes(c.id));
138
+ const searched = (row) => {
139
+ if (!terms.length)
140
+ return true;
141
+ if (searchText) {
142
+ const text = fold(searchText(row), locale);
143
+ return terms.every((term) => text.includes(term));
144
+ }
145
+ return terms.every((term) => searchColumns.some((col) => fold(valueOf(row, col), locale).includes(term)));
146
+ };
147
+ const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' });
148
+ const seen = new Set();
149
+ const compare = (a, b) => {
150
+ if (a.data === undefined || b.data === undefined)
151
+ return 0;
152
+ for (const sort of state.sorting) {
153
+ const col = byId.get(sort.id);
154
+ if (!col || col.sortable === false)
155
+ continue;
156
+ const av = valueOf(a.data, col), bv = valueOf(b.data, col);
157
+ const emptyA = av == null || av === '' || (typeof av === 'number' && Number.isNaN(av));
158
+ const emptyB = bv == null || bv === '' || (typeof bv === 'number' && Number.isNaN(bv));
159
+ if (emptyA || emptyB) {
160
+ if (emptyA !== emptyB)
161
+ return emptyA ? 1 : -1;
162
+ continue;
163
+ }
164
+ const result = col.compare
165
+ ? col.compare(av, bv, a.data, b.data)
166
+ : typeof av === 'number' && typeof bv === 'number'
167
+ ? av - bv
168
+ : av instanceof Date && bv instanceof Date
169
+ ? av.getTime() - bv.getTime()
170
+ : collator.compare(String(av), String(bv));
171
+ if (result)
172
+ return sort.desc ? -result : result;
173
+ }
174
+ return 0;
175
+ };
176
+ const walk = (rows, depth) => {
177
+ if (depth > 100)
178
+ throw new Error('Tree depth exceeds 100. Check for cycles.');
179
+ const result = [];
180
+ for (const row of rows) {
181
+ const id = getRowId(row);
182
+ if ((typeof id !== 'string' && typeof id !== 'number') ||
183
+ (typeof id === 'number' && !Number.isFinite(id)))
184
+ throw new Error('getRowId must return a finite number or string.');
185
+ if (seen.has(id))
186
+ throw new Error(`Duplicate row id: ${String(id)}`);
187
+ seen.add(id);
188
+ const children = walk(getSubRows?.(row) ?? [], depth + 1);
189
+ const matches = manual ||
190
+ (state.filters.every((filter) => {
191
+ const col = byId.get(filter.id);
192
+ return (!col ||
193
+ (col.filter
194
+ ? col.filter(valueOf(row, col), filter, row)
195
+ : matchesFilter(valueOf(row, col), filter, locale)));
196
+ }) &&
197
+ searched(row));
198
+ if (matches || children.length)
199
+ result.push({ id, data: row, depth, children, kind: 'row' });
200
+ }
201
+ return !manual && state.sorting.length ? result.sort(compare) : result;
202
+ };
203
+ return walk(data, 0);
204
+ }
205
+ export function paginateNodes(nodes, context) {
206
+ const { state, manual } = context;
207
+ const total = manual ? Math.max(0, context.rowCount ?? nodes.length) : nodes.length;
208
+ const size = Math.max(0, Math.floor(state.pageSize));
209
+ const unpinnedCount = manual
210
+ ? total
211
+ : nodes.filter((n) => !state.rowPinning.top.includes(n.id) && !state.rowPinning.bottom.includes(n.id)).length;
212
+ const pageCount = !size
213
+ ? 1
214
+ : manual && context.pageCount !== undefined
215
+ ? Math.max(1, Math.floor(context.pageCount))
216
+ : Math.max(1, Math.ceil(unpinnedCount / size));
217
+ const pageIndex = Math.max(0, Math.min(Math.floor(state.pageIndex), pageCount - 1));
218
+ const all = flattenRows(nodes).filter((node) => node.kind === 'row');
219
+ const top = new Set(state.rowPinning.top), bottom = new Set(state.rowPinning.bottom);
220
+ const page = manual || !size
221
+ ? nodes
222
+ : nodes
223
+ .filter((node) => !top.has(node.id) && !bottom.has(node.id))
224
+ .slice(pageIndex * size, (pageIndex + 1) * size);
225
+ const roots = [
226
+ ...nodes.filter((node) => top.has(node.id)),
227
+ ...page.filter((node) => !top.has(node.id) && !bottom.has(node.id)),
228
+ ...nodes.filter((node) => bottom.has(node.id) && !top.has(node.id))
229
+ ];
230
+ return { rows: flattenRows(roots, new Set(state.expanded)), total, pageCount, pageIndex, all };
231
+ }
232
+ export function createRowModel(context) {
233
+ return paginateNodes(prepareRows(context), context);
234
+ }
235
+ /** Reordering/pinning may split a group; render each contiguous fragment truthfully. */
236
+ export function headerRows(columns, visible) {
237
+ const paths = new Map();
238
+ const walk = (cols, parents) => {
239
+ for (const col of cols)
240
+ if (col.children?.length)
241
+ walk(col.children, [...parents, col]);
242
+ else
243
+ paths.set(col.id, [...parents, col]);
244
+ };
245
+ walk(columns, []);
246
+ const depth = Math.max(1, ...visible.map((c) => paths.get(c.id)?.length ?? 1));
247
+ return Array.from({ length: depth }, (_, level) => {
248
+ const result = [];
249
+ for (const col of visible) {
250
+ const path = paths.get(col.id) ?? [col];
251
+ const node = path[level];
252
+ if (!node)
253
+ continue;
254
+ const group = level < path.length - 1;
255
+ const previous = result.at(-1);
256
+ if (group && previous && previous.column === node)
257
+ previous.colSpan++;
258
+ else
259
+ result.push({
260
+ key: `${node.id}:${result.length}`,
261
+ column: node,
262
+ colSpan: 1,
263
+ rowSpan: group ? 1 : depth - level
264
+ });
265
+ }
266
+ return result;
267
+ });
268
+ }
269
+ export function reorder(items, source, target) {
270
+ const from = items.indexOf(source), to = items.indexOf(target);
271
+ if (from < 0 || to < 0 || from === to)
272
+ return [...items];
273
+ const result = [...items];
274
+ result.splice(from, 1);
275
+ result.splice(to, 0, source);
276
+ return result;
277
+ }
@@ -0,0 +1,16 @@
1
+ import type { Column, EditEvent, RowId } from './types.js';
2
+ /** Immutable setter rejects prototype-mutating paths. Accessor functions need an app setter. */
3
+ export declare function setValue<T>(row: T, path: string, value: unknown): T;
4
+ export declare function applyEdit<T>(rows: readonly T[], event: EditEvent<T>, columns: readonly Column<T>[], getRowId: (row: T) => RowId): T[];
5
+ export declare function parseEdit<T>(column: Column<T>, text: string, row: T): unknown;
6
+ /** Caller-owned history: use immutable values, no hidden row mutation. */
7
+ export declare function createHistory<T>(initial: T, limit?: number): {
8
+ readonly value: T;
9
+ readonly canUndo: boolean;
10
+ readonly canRedo: boolean;
11
+ commit(value: T): T;
12
+ undo(): T;
13
+ redo(): T;
14
+ reset(value: T): void;
15
+ };
16
+ export declare function editEvent<T>(row: T, rowId: RowId, column: Column<T>, value: unknown): EditEvent<T>;
@@ -0,0 +1,79 @@
1
+ import { valueOf } from './core.js';
2
+ /** Immutable setter rejects prototype-mutating paths. Accessor functions need an app setter. */
3
+ export function setValue(row, path, value) {
4
+ const parts = path.split('.');
5
+ if (!parts.length ||
6
+ parts.some((p) => !p || ['__proto__', 'prototype', 'constructor'].includes(p)))
7
+ throw new Error('Unsafe field path.');
8
+ const clone = (source, index) => {
9
+ const object = source != null && typeof source === 'object' ? source : {};
10
+ // Every segment is a non-empty string: the guard above rejected empty and unsafe ones.
11
+ const key = parts[index];
12
+ return { ...object, [key]: index === parts.length - 1 ? value : clone(object[key], index + 1) };
13
+ };
14
+ return clone(row, 0);
15
+ }
16
+ export function applyEdit(rows, event, columns, getRowId) {
17
+ const column = columns.find((c) => c.id === event.columnId);
18
+ if (!column || typeof column.accessor === 'function')
19
+ throw new Error('Use an application setter for computed/accessor-function columns.');
20
+ return rows.map((row) => getRowId(row) === event.rowId
21
+ ? setValue(row, String(column.accessor ?? column.id), event.value)
22
+ : row);
23
+ }
24
+ export function parseEdit(column, text, row) {
25
+ if (column.parse)
26
+ return column.parse(text, row);
27
+ if (column.editor === 'number') {
28
+ if (!text.trim())
29
+ return null;
30
+ const number = Number(text);
31
+ if (!Number.isFinite(number))
32
+ throw new Error('Enter a valid number.');
33
+ return number;
34
+ }
35
+ return text;
36
+ }
37
+ /** Caller-owned history: use immutable values, no hidden row mutation. */
38
+ export function createHistory(initial, limit = 100) {
39
+ let past = [], present = initial, future = [];
40
+ return {
41
+ get value() {
42
+ return present;
43
+ },
44
+ get canUndo() {
45
+ return past.length > 0;
46
+ },
47
+ get canRedo() {
48
+ return future.length > 0;
49
+ },
50
+ commit(value) {
51
+ past = [...past, present].slice(-Math.max(1, limit));
52
+ present = value;
53
+ future = [];
54
+ return present;
55
+ },
56
+ undo() {
57
+ if (past.length) {
58
+ future.push(present);
59
+ present = past.pop();
60
+ }
61
+ return present;
62
+ },
63
+ redo() {
64
+ if (future.length) {
65
+ past.push(present);
66
+ present = future.pop();
67
+ }
68
+ return present;
69
+ },
70
+ reset(value) {
71
+ past = [];
72
+ future = [];
73
+ present = value;
74
+ }
75
+ };
76
+ }
77
+ export function editEvent(row, rowId, column, value) {
78
+ return { row, rowId, columnId: column.id, previous: valueOf(row, column), value };
79
+ }
@@ -0,0 +1,11 @@
1
+ import type { Column } from './types.js';
2
+ export interface CsvOptions {
3
+ delimiter?: ',' | ';' | '\t';
4
+ bom?: boolean;
5
+ protectFormulas?: boolean;
6
+ headers?: boolean;
7
+ }
8
+ export declare function toCsv<T>(rows: readonly T[], columns: readonly Column<T>[], options?: CsvOptions): string;
9
+ export declare function downloadText(content: string, filename: string, type?: string): void;
10
+ /** RFC-style quoted cells, including embedded line breaks and escaped quotes. */
11
+ export declare function parseDelimited(text: string, delimiter?: string): string[][];