@softium/table-core 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.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * softium-ui — core type system (SPEC §3).
3
+ *
4
+ * This file is the contract. Guardrails encoded here:
5
+ * - `label` (immutable, export/binding key) ≠ `labelOverride` (user-facing, display only)
6
+ * - `rowId` (stable PK) ≠ `displayIndex` (per-page position) ≠ `globalIndex` (dataset position)
7
+ * - no `any`; the row type `T` is inferred end-to-end via generics
8
+ * - no React/DOM: cell/header renderers return a generic `TNode` (the React layer
9
+ * binds `TNode = ReactNode`), so table-core stays framework-agnostic.
10
+ */
11
+ type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'select' | 'custom';
12
+ type ColumnAlign = 'left' | 'center' | 'right';
13
+ interface ColumnDef<T, TNode = unknown> {
14
+ /** data-binding key (unique, immutable) */
15
+ key: keyof T & string;
16
+ /** original header text — the export/binding anchor. The user cannot change this. */
17
+ label: string;
18
+ type?: ColumnType;
19
+ width?: number;
20
+ minWidth?: number;
21
+ maxWidth?: number;
22
+ /** grow weight: a column with flex > 0 absorbs leftover horizontal space so the
23
+ * table fills its container (no empty gap on the right). 0/undefined = fixed width. */
24
+ flex?: number;
25
+ align?: ColumnAlign;
26
+ sortable?: boolean;
27
+ /** sort by a derived value instead of `row[key]` (e.g. an enum's rank). */
28
+ sortAccessor?: (row: T) => string | number | boolean | Date | null | undefined;
29
+ /** full custom comparator (ascending); overrides type/accessor. Receives raw rows. */
30
+ sortComparator?: (a: T, b: T) => number;
31
+ filterable?: boolean;
32
+ resizable?: boolean;
33
+ pinnable?: boolean;
34
+ hideable?: boolean;
35
+ /** allow inline editing of this column's cells (DataGrid). Default false. */
36
+ editable?: boolean;
37
+ /** allow copying a selected cell's value (Ctrl/Cmd+C) in a grid. Default true. */
38
+ copyable?: boolean;
39
+ /** override the exported value for this column (CSV/JSON/XML/XLSX). Defaults to
40
+ * the raw cell value; dates/numbers are handled automatically otherwise. */
41
+ exportValue?: (row: T) => string | number | boolean | null | undefined;
42
+ /** custom cell renderer. Returns `TNode` so core has no React dependency. */
43
+ renderCell?: (ctx: CellContext<T>) => TNode;
44
+ renderHeader?: (ctx: HeaderContext<T, TNode>) => TNode;
45
+ }
46
+ type PinSide = 'left' | 'right' | null;
47
+ interface ColumnState {
48
+ key: string;
49
+ visible: boolean;
50
+ order: number;
51
+ width?: number;
52
+ pinned?: PinSide;
53
+ /** user-chosen text alignment override (falls back to ColumnDef.align) */
54
+ align?: ColumnAlign;
55
+ /** user-renamed column header. Kept SEPARATE from `label` so data mapping never breaks. */
56
+ labelOverride?: string;
57
+ }
58
+ interface Row<T> {
59
+ /** stable identifier (PK). Never conflate with displayIndex. */
60
+ rowId: string;
61
+ /** 1-based position within the current page/view. Resets when the page changes. */
62
+ displayIndex: number;
63
+ /** 1-based position within the full dataset. Page-independent and unique. */
64
+ globalIndex: number;
65
+ /** the original object (immutable — never mutated by the library) */
66
+ data: T;
67
+ selected?: boolean;
68
+ }
69
+ interface CellContext<T> {
70
+ row: Row<T>;
71
+ column: ColumnDef<T>;
72
+ value: T[keyof T];
73
+ }
74
+ interface HeaderContext<T, TNode = unknown> {
75
+ column: ColumnDef<T, TNode>;
76
+ /** the label currently shown (labelOverride ?? label) */
77
+ displayLabel: string;
78
+ }
79
+ interface SearchState {
80
+ query: string;
81
+ /** which columns to search; 'all' or an explicit list of column keys */
82
+ scope: 'all' | string[];
83
+ }
84
+ type FilterOperator = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'between' | 'in';
85
+ interface Filter {
86
+ columnKey: string;
87
+ operator: FilterOperator;
88
+ value: unknown;
89
+ /** second operand for `between` */
90
+ value2?: unknown;
91
+ }
92
+ type SortDirection = 'asc' | 'desc';
93
+ interface SortRule {
94
+ columnKey: string;
95
+ direction: SortDirection;
96
+ }
97
+ interface TableInput<T, TNode = unknown> {
98
+ data: T[];
99
+ columns: ColumnDef<T, TNode>[];
100
+ /** stable row id resolver. Falls back to a global-index string when absent. */
101
+ getRowId?: (row: T) => string;
102
+ }
103
+ interface ResolvedColumn<T, TNode = unknown> {
104
+ key: keyof T & string;
105
+ /** immutable original label */
106
+ label: string;
107
+ /** what the header should display: labelOverride ?? label */
108
+ displayLabel: string;
109
+ type: ColumnType;
110
+ align: ColumnAlign;
111
+ order: number;
112
+ visible: boolean;
113
+ pinned: PinSide;
114
+ width?: number;
115
+ minWidth?: number;
116
+ maxWidth?: number;
117
+ flex?: number;
118
+ sortable: boolean;
119
+ filterable: boolean;
120
+ resizable: boolean;
121
+ pinnable: boolean;
122
+ hideable: boolean;
123
+ editable: boolean;
124
+ copyable: boolean;
125
+ renderCell?: (ctx: CellContext<T>) => TNode;
126
+ renderHeader?: (ctx: HeaderContext<T, TNode>) => TNode;
127
+ /** the originating definition (escape hatch for advanced renderers) */
128
+ def: ColumnDef<T, TNode>;
129
+ }
130
+
131
+ /**
132
+ * adaptArray — the simplest ERP response: a plain array of row objects.
133
+ *
134
+ * const data = adaptArray<Employee>(raw); // raw: [ {...}, {...} ]
135
+ *
136
+ * Columns are supplied separately by the developer. This only normalizes/validates
137
+ * the data array.
138
+ */
139
+ declare function adaptArray<T>(raw: unknown): T[];
140
+
141
+ /**
142
+ * adaptPaginated — the common `{ data, total, page, pageSize }` wrapper.
143
+ *
144
+ * const { data, total, page, pageSize } =
145
+ * adaptPaginated<Employee>(raw, { dataKey: 'data' });
146
+ *
147
+ * Key names are configurable for legacy ERP servers (e.g. rows/count/pageNo).
148
+ */
149
+ interface PaginatedResult<T> {
150
+ data: T[];
151
+ total: number;
152
+ page: number;
153
+ pageSize: number;
154
+ }
155
+ interface AdaptPaginatedOptions {
156
+ dataKey?: string;
157
+ totalKey?: string;
158
+ pageKey?: string;
159
+ pageSizeKey?: string;
160
+ }
161
+ declare function adaptPaginated<T>(raw: unknown, options?: AdaptPaginatedOptions): PaginatedResult<T>;
162
+
163
+ /**
164
+ * adaptDynamicSchema — legacy ERP that sends column definitions alongside rows
165
+ * (the table shape is decided by the server at runtime, not hardcoded in the app).
166
+ *
167
+ * const { columns, data } = adaptDynamicSchema(raw, {
168
+ * columnsKey: 'columns', rowsKey: 'rows', fieldKey: 'field', labelKey: 'label',
169
+ * });
170
+ *
171
+ * Produces real `ColumnDef`s so the rest of the library treats dynamic and static
172
+ * schemas identically.
173
+ */
174
+
175
+ interface DynamicSchemaResult<T> {
176
+ columns: ColumnDef<T>[];
177
+ data: T[];
178
+ }
179
+ interface AdaptDynamicSchemaOptions {
180
+ /** key under which the server lists column descriptors. Default 'columns'. */
181
+ columnsKey?: string;
182
+ /** key under which the server lists row objects. Default 'rows'. */
183
+ rowsKey?: string;
184
+ /** descriptor field holding the data-binding key. Default 'field'. */
185
+ fieldKey?: string;
186
+ /** descriptor field holding the header text. Default 'label'. */
187
+ labelKey?: string;
188
+ /** descriptor field holding the column type (optional). Default 'type'. */
189
+ typeKey?: string;
190
+ }
191
+ declare function adaptDynamicSchema<T = Record<string, unknown>>(raw: unknown, options?: AdaptDynamicSchemaOptions): DynamicSchemaResult<T>;
192
+
193
+ /**
194
+ * Column derivation (SPEC §2): the final render columns are a pure composition of
195
+ * the immutable `columnDefs` (developer) and the mutable `columnState` (end user).
196
+ *
197
+ * renderColumns = columnDefs
198
+ * .map(def => merge(def, columnState[def.key]))
199
+ * .filter(c => c.visible)
200
+ * .sort by (pinned group, order)
201
+ *
202
+ * `columnDefs` and the input `columnState` are never mutated.
203
+ */
204
+
205
+ /** Build the default view state for a set of column defs (order = declaration order). */
206
+ declare function createInitialColumnState<T, TNode>(defs: ColumnDef<T, TNode>[]): ColumnState[];
207
+ /**
208
+ * Reconcile a persisted (e.g. localStorage) columnState against the current defs:
209
+ * - keep stored entries for columns that still exist
210
+ * - drop stored entries for columns that no longer exist
211
+ * - append defaults for newly added columns (so a schema change never breaks)
212
+ *
213
+ * This makes persistence forward-compatible with evolving column definitions.
214
+ */
215
+ declare function reconcileColumnState<T, TNode>(defs: ColumnDef<T, TNode>[], stored: ColumnState[]): ColumnState[];
216
+ /**
217
+ * Merge defs with state, drop hidden columns, and order them.
218
+ * Ordering: left-pinned first, then unpinned, then right-pinned; `order` breaks ties.
219
+ */
220
+ declare function resolveColumns<T, TNode>(defs: ColumnDef<T, TNode>[], state: ColumnState[]): ResolvedColumn<T, TNode>[];
221
+
222
+ /**
223
+ * Column operations (SPEC §3, §10): every user gesture on the table — hide, reorder,
224
+ * resize, pin, rename — is a pure transformation of `ColumnState[]` into a NEW array.
225
+ *
226
+ * Invariants enforced by these functions:
227
+ * - `columnDefs` and `data` are never referenced here (this layer only knows view state)
228
+ * - the input `state` array is never mutated; a fresh array is always returned
229
+ * - renaming writes `labelOverride`, NEVER the immutable `label`
230
+ */
231
+
232
+ /** Apply a partial patch to one column's state, returning a new array. */
233
+ declare function patchColumnState(state: ColumnState[], key: string, patch: Partial<Omit<ColumnState, 'key'>>): ColumnState[];
234
+ declare function setColumnVisible(state: ColumnState[], key: string, visible: boolean): ColumnState[];
235
+ declare function setColumnPinned(state: ColumnState[], key: string, pinned: PinSide): ColumnState[];
236
+ declare function setColumnWidth(state: ColumnState[], key: string, width: number): ColumnState[];
237
+ declare function setColumnAlign(state: ColumnState[], key: string, align: ColumnAlign): ColumnState[];
238
+ /**
239
+ * Rename a column. An empty/whitespace override clears back to the original `label`.
240
+ * The original `label` lives on the ColumnDef and is untouched.
241
+ */
242
+ declare function setColumnLabelOverride(state: ColumnState[], key: string, labelOverride: string | undefined): ColumnState[];
243
+ /**
244
+ * Reorder so that `activeKey` lands where `overKey` currently sits (DnD semantics),
245
+ * then renumber `order` densely (0..n-1) in the resulting sequence.
246
+ */
247
+ declare function moveColumn(state: ColumnState[], activeKey: string, overKey: string): ColumnState[];
248
+
249
+ /**
250
+ * Sorting (SPEC §5): single and multi-column. Pure — returns a NEW array, never
251
+ * mutates the input. JS sort is stable, so a single comparator that walks the rule
252
+ * list in priority order yields correct multi-sort with stable ties.
253
+ */
254
+
255
+ /** Per-column sort configuration. */
256
+ interface ColumnSort<T> {
257
+ type?: ColumnType;
258
+ /** sort by a derived value instead of row[key] */
259
+ accessor?: (row: T) => unknown;
260
+ /** full custom comparator (ascending); overrides type/accessor */
261
+ comparator?: (a: T, b: T) => number;
262
+ }
263
+ type SortLookup<T> = (columnKey: string) => ColumnSort<T> | undefined;
264
+ /**
265
+ * Sort by the active rules. `getSort` supplies each column's sort behavior:
266
+ * - comparator: full custom (ascending) comparator over rows
267
+ * - accessor: sort by a derived value instead of row[key]
268
+ * - type: typed comparison (number/date/boolean/text)
269
+ */
270
+ declare function sortRows<T>(data: T[], rules: SortRule[], getSort: SortLookup<T>): T[];
271
+ /**
272
+ * Cycle a column's sort for click-to-sort headers: none → asc → desc → none.
273
+ * `multi` keeps existing rules (appending/updating this column); otherwise this
274
+ * column becomes the sole sort.
275
+ */
276
+ declare function toggleSort(rules: SortRule[], columnKey: string, multi?: boolean): SortRule[];
277
+
278
+ /**
279
+ * Column filters (SPEC §3, §5): structured per-column data queries. Distinct from
280
+ * global search (which is free-text UX). Pure — returns a NEW filtered array.
281
+ */
282
+
283
+ declare function matchesFilter(value: unknown, filter: Filter, type?: ColumnType): boolean;
284
+ type TypeLookup = (columnKey: string) => ColumnType | undefined;
285
+ /** Apply all filters with AND semantics. An empty filter list returns the input as-is. */
286
+ declare function applyFilters<T>(data: T[], filters: Filter[], getType: TypeLookup): T[];
287
+
288
+ /**
289
+ * Global search (SPEC §3): free-text UX matching, kept deliberately separate from
290
+ * structured column filters. Case-insensitive substring match over the column scope.
291
+ * Pure — returns a NEW array (or the input untouched when the query is empty).
292
+ */
293
+
294
+ declare function applySearch<T>(data: T[], search: SearchState, allKeys: string[]): T[];
295
+
296
+ /**
297
+ * Pagination (SPEC §5, §6). Pure slicing over the already filtered/searched/sorted
298
+ * data. The companion `offset` lets buildRows keep `globalIndex` dataset-absolute
299
+ * while `displayIndex` resets per page.
300
+ */
301
+ declare function getPageCount(total: number, pageSize: number): number;
302
+ /** Clamp a requested page into the valid 1..pageCount range. */
303
+ declare function clampPage(page: number, total: number, pageSize: number): number;
304
+ interface PageSlice<T> {
305
+ /** the rows for this page */
306
+ items: T[];
307
+ /** number of rows preceding this page in the full set — pass to buildRows */
308
+ offset: number;
309
+ page: number;
310
+ pageCount: number;
311
+ }
312
+ declare function paginate<T>(data: T[], page: number, pageSize: number): PageSlice<T>;
313
+
314
+ /**
315
+ * Pivot / cross-tab (AG-Grid style). Pure — groups `data` by one or more row
316
+ * fields and column fields, aggregating a value field into a matrix with row /
317
+ * column / grand totals. The original data is never mutated.
318
+ *
319
+ * v1: row + column fields are composite keys (joined paths); a single value field
320
+ * with sum / count / avg / min / max.
321
+ */
322
+ type PivotAggregate = 'sum' | 'count' | 'avg' | 'min' | 'max';
323
+ interface PivotConfig<T> {
324
+ rows: (keyof T & string)[];
325
+ columns: (keyof T & string)[];
326
+ value: keyof T & string;
327
+ aggregate: PivotAggregate;
328
+ }
329
+ interface PivotResult {
330
+ rowFields: string[];
331
+ columnFields: string[];
332
+ value: string;
333
+ aggregate: PivotAggregate;
334
+ /** ordered row-key paths (one entry per row-field value combination) */
335
+ rowKeys: string[][];
336
+ /** ordered column-key paths */
337
+ columnKeys: string[][];
338
+ /** values[rowIndex][colIndex] — null when a cell has no data */
339
+ values: (number | null)[][];
340
+ rowTotals: (number | null)[];
341
+ columnTotals: (number | null)[];
342
+ grandTotal: number | null;
343
+ }
344
+ declare function pivot<T>(data: T[], config: PivotConfig<T>): PivotResult;
345
+
346
+ /**
347
+ * Row indexing (SPEC §3).
348
+ *
349
+ * Three distinct numbers, never conflated:
350
+ * - rowId stable PK (from getRowId, or a global-index fallback string)
351
+ * - displayIndex 1-based position within the current page/view slice
352
+ * - globalIndex 1-based position within the full dataset (page-independent)
353
+ *
354
+ * The original row objects are referenced, never mutated.
355
+ */
356
+
357
+ interface BuildRowsOptions<T> {
358
+ /** stable id resolver; falls back to the globalIndex as a string */
359
+ getRowId?: (row: T) => string;
360
+ /** how many rows precede this slice in the full dataset (for pagination). Default 0. */
361
+ offset?: number;
362
+ }
363
+ /**
364
+ * Turn a (possibly paginated) data slice into indexed rows.
365
+ * Pass the full array with offset 0 for the non-paginated case.
366
+ */
367
+ declare function buildRows<T>(data: T[], options?: BuildRowsOptions<T>): Row<T>[];
368
+
369
+ /** a single exportable cell value */
370
+ type ExportCell = string | number | boolean | null | undefined;
371
+ /** a rectangular export view: header labels + a matrix of stringifiable cells */
372
+ interface ExportTable {
373
+ headers: string[];
374
+ rows: ExportCell[][];
375
+ }
376
+ /** the supported export formats */
377
+ type ExportFormat = 'csv' | 'json' | 'xml' | 'xlsx';
378
+
379
+ interface CsvOptions {
380
+ /** field delimiter. Default ",". Use "\t" for TSV. */
381
+ delimiter?: string;
382
+ }
383
+ /** Serialize an export table to RFC-4180 CSV (CRLF line breaks). */
384
+ declare function toCSV(table: ExportTable, options?: CsvOptions): string;
385
+
386
+ interface JsonOptions {
387
+ /** pretty-print with 2-space indent. Default true. */
388
+ pretty?: boolean;
389
+ }
390
+ /** Serialize an export table to a JSON array of header-keyed record objects. */
391
+ declare function toJSON(table: ExportTable, options?: JsonOptions): string;
392
+
393
+ interface XmlOptions {
394
+ /** wrapping root element. Default "rows". */
395
+ rootTag?: string;
396
+ /** per-row element. Default "row". */
397
+ rowTag?: string;
398
+ }
399
+ /** XML-escape a text value for element content / attributes. */
400
+ declare function escapeXml(value: ExportCell): string;
401
+ /** Serialize an export table to XML: <root><row><header>value</header>…</row></root>. */
402
+ declare function toXML(table: ExportTable, options?: XmlOptions): string;
403
+
404
+ interface XlsxOptions {
405
+ /** worksheet tab name. Default "Sheet1". Excel caps names at 31 chars. */
406
+ sheetName?: string;
407
+ }
408
+ /**
409
+ * Serialize an export table to a real .xlsx workbook (OOXML zip) as raw bytes —
410
+ * no dependencies. Numeric cells are written as numbers; everything else as
411
+ * inline strings. Open the returned bytes in Excel / Numbers / Google Sheets.
412
+ */
413
+ declare function toXLSX(table: ExportTable, options?: XlsxOptions): Uint8Array;
414
+
415
+ /**
416
+ * Minimal store-only (uncompressed) ZIP writer — just enough to assemble a
417
+ * valid .xlsx (an OOXML zip) with no dependencies. Files are stored with
418
+ * method 0 (no compression), which Excel/Numbers/LibreOffice all open fine.
419
+ */
420
+ interface ZipEntry {
421
+ name: string;
422
+ data: Uint8Array;
423
+ }
424
+ /** Build a ZIP archive (stored, no compression) from the given entries. */
425
+ declare function zipStore(entries: ZipEntry[]): Uint8Array;
426
+
427
+ /** MIME type + file extension for each export format. */
428
+ declare const EXPORT_FORMAT_META: Record<ExportFormat, {
429
+ mime: string;
430
+ ext: string;
431
+ }>;
432
+ /**
433
+ * Serialize an export table to the given format. Text formats return a string;
434
+ * xlsx returns raw bytes (a Uint8Array). Framework-agnostic — the download
435
+ * itself (Blob + anchor) lives in the React layer.
436
+ */
437
+ declare function serializeExport(table: ExportTable, format: ExportFormat): string | Uint8Array;
438
+
439
+ /**
440
+ * @softium/table-core
441
+ *
442
+ * Headless core for the softium-ui Table.
443
+ * Pure TypeScript — does not import React, DOM, or `window`.
444
+ */
445
+ declare const VERSION = "0.0.0";
446
+
447
+ export { type AdaptDynamicSchemaOptions, type AdaptPaginatedOptions, type BuildRowsOptions, type CellContext, type ColumnAlign, type ColumnDef, type ColumnSort, type ColumnState, type ColumnType, type CsvOptions, type DynamicSchemaResult, EXPORT_FORMAT_META, type ExportCell, type ExportFormat, type ExportTable, type Filter, type FilterOperator, type HeaderContext, type JsonOptions, type PageSlice, type PaginatedResult, type PinSide, type PivotAggregate, type PivotConfig, type PivotResult, type ResolvedColumn, type Row, type SearchState, type SortDirection, type SortLookup, type SortRule, type TableInput, VERSION, type XlsxOptions, type XmlOptions, type ZipEntry, adaptArray, adaptDynamicSchema, adaptPaginated, applyFilters, applySearch, buildRows, clampPage, createInitialColumnState, escapeXml, getPageCount, matchesFilter, moveColumn, paginate, patchColumnState, pivot, reconcileColumnState, resolveColumns, serializeExport, setColumnAlign, setColumnLabelOverride, setColumnPinned, setColumnVisible, setColumnWidth, sortRows, toCSV, toJSON, toXLSX, toXML, toggleSort, zipStore };