@smartnet360/svelte-grid 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,1025 @@
1
+ /**
2
+ * Grid State Factory
3
+ *
4
+ * Creates reactive state for the grid using Svelte 5 runes.
5
+ * This is the central state management for all grid operations.
6
+ */
7
+ const GRID_CONTEXT_KEY = Symbol('svelte-grid');
8
+ export { GRID_CONTEXT_KEY };
9
+ /**
10
+ * Grid state class using Svelte 5 runes
11
+ * Manages all reactive state for a grid instance
12
+ */
13
+ export class GridStateManager {
14
+ // ============================================
15
+ // Core Reactive State
16
+ // ============================================
17
+ /** Raw data array */
18
+ rawData = $state([]);
19
+ /** Column definitions */
20
+ columns = $state([]);
21
+ /** Scroll position */
22
+ scrollTop = $state(0);
23
+ scrollLeft = $state(0);
24
+ /** Container dimensions */
25
+ containerHeight = $state(0);
26
+ containerWidth = $state(0);
27
+ /** Row height for virtual scrolling */
28
+ rowHeight = $state(40);
29
+ /** Grid options */
30
+ options = $state({});
31
+ // ============================================
32
+ // Sorting State
33
+ // ============================================
34
+ /** Current sort configuration (supports multi-sort) */
35
+ sortConfigs = $state([]);
36
+ // ============================================
37
+ // Filtering State
38
+ // ============================================
39
+ /** Current filter configurations */
40
+ filterConfigs = $state([]);
41
+ /** Header filter values (for UI inputs) */
42
+ headerFilterValues = $state({});
43
+ // ============================================
44
+ // Grouping State
45
+ // ============================================
46
+ /** Group configuration */
47
+ groupByConfig = $state([]);
48
+ /** Set of open group keys (using string path like "field:value" or "field:value/field2:value2" for nested) */
49
+ openGroups = $state(new Set());
50
+ /** Height of group header rows */
51
+ groupHeaderHeight = $state(36);
52
+ // ============================================
53
+ // Column Resizing State
54
+ // ============================================
55
+ /** Column widths (overrides column.width when user resizes) */
56
+ columnWidths = $state({});
57
+ // ============================================
58
+ // Plugin State
59
+ // ============================================
60
+ /** Registered plugins */
61
+ plugins = new Map();
62
+ /** Data pipeline handlers sorted by priority */
63
+ pipelineHandlers = [];
64
+ // ============================================
65
+ // Derived State (computed from raw state)
66
+ // ============================================
67
+ /** Data after passing through filtering, sorting, and plugin pipeline */
68
+ processedData = $derived.by(() => {
69
+ let data = [...this.rawData];
70
+ // 1. Apply filters
71
+ if (this.filterConfigs.length > 0) {
72
+ data = this.applyFilters(data);
73
+ }
74
+ // 2. Apply header filters
75
+ const headerFilters = Object.entries(this.headerFilterValues).filter(([, v]) => v.trim() !== '');
76
+ if (headerFilters.length > 0) {
77
+ data = this.applyHeaderFilters(data, headerFilters);
78
+ }
79
+ // 3. Apply sorting
80
+ if (this.sortConfigs.length > 0) {
81
+ data = this.applySorting(data);
82
+ }
83
+ // 4. Apply plugin pipeline handlers
84
+ for (const { handler } of this.pipelineHandlers) {
85
+ data = handler(data);
86
+ }
87
+ return data;
88
+ });
89
+ /** Whether grouping is enabled */
90
+ isGrouped = $derived(this.groupByConfig.length > 0);
91
+ /** Build groups from processed data */
92
+ groupedData = $derived.by(() => {
93
+ if (!this.isGrouped)
94
+ return [];
95
+ return this.buildGroups(this.processedData, this.groupByConfig, 0, '');
96
+ });
97
+ /** Display rows - either flat data or grouped data with headers */
98
+ displayRows = $derived.by(() => {
99
+ if (!this.isGrouped) {
100
+ // No grouping - return flat rows
101
+ return this.processedData.map((data, index) => ({
102
+ type: 'row',
103
+ data,
104
+ index
105
+ }));
106
+ }
107
+ // With grouping - flatten groups into display rows
108
+ return this.flattenGroups(this.groupedData);
109
+ });
110
+ /** Flatten groups into display rows (group headers + visible rows) */
111
+ flattenGroups(groups, result = []) {
112
+ for (const group of groups) {
113
+ // Add group header
114
+ result.push({ type: 'group', group });
115
+ // If group is open, add its contents
116
+ if (group.isOpen) {
117
+ if (group.children && group.children.length > 0) {
118
+ // Nested groups - recurse
119
+ this.flattenGroups(group.children, result);
120
+ }
121
+ else {
122
+ // Leaf group - add data rows
123
+ for (const row of group.rows) {
124
+ result.push({
125
+ type: 'row',
126
+ data: row,
127
+ index: this.processedData.indexOf(row)
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return result;
134
+ }
135
+ /** Build group hierarchy recursively */
136
+ buildGroups(data, configs, level, parentPath) {
137
+ if (configs.length === 0 || data.length === 0)
138
+ return [];
139
+ const config = configs[0];
140
+ const remainingConfigs = configs.slice(1);
141
+ // Get field name and grouping function
142
+ const field = typeof config.field === 'string' ? config.field : 'custom';
143
+ const getGroupKey = typeof config.field === 'function'
144
+ ? config.field
145
+ : (row) => String(row[config.field] ?? '(empty)');
146
+ // Group rows by key
147
+ const groupMap = new Map();
148
+ for (const row of data) {
149
+ const key = getGroupKey(row);
150
+ if (!groupMap.has(key)) {
151
+ groupMap.set(key, []);
152
+ }
153
+ groupMap.get(key).push(row);
154
+ }
155
+ // Sort group keys (default to ascending)
156
+ let sortedKeys = Array.from(groupMap.keys());
157
+ const order = config.order ?? 'asc';
158
+ if (typeof order === 'function') {
159
+ sortedKeys.sort(order);
160
+ }
161
+ else {
162
+ // Smart comparison: use numeric sort if all keys are numbers
163
+ const allNumeric = sortedKeys.every(k => !isNaN(Number(k)) && k.trim() !== '');
164
+ if (allNumeric) {
165
+ // Numeric sort
166
+ sortedKeys.sort((a, b) => {
167
+ const diff = Number(a) - Number(b);
168
+ return order === 'desc' ? -diff : diff;
169
+ });
170
+ }
171
+ else {
172
+ // String sort
173
+ sortedKeys.sort((a, b) => {
174
+ const result = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
175
+ return order === 'desc' ? -result : result;
176
+ });
177
+ }
178
+ }
179
+ // Build GroupInfo objects
180
+ const groups = [];
181
+ for (const key of sortedKeys) {
182
+ const rows = groupMap.get(key);
183
+ const groupPath = parentPath ? `${parentPath}/${field}:${key}` : `${field}:${key}`;
184
+ // Determine if group is open
185
+ const groupInfo = {
186
+ key,
187
+ field,
188
+ rows,
189
+ level,
190
+ count: rows.length,
191
+ isOpen: this.isGroupOpen(groupPath, config),
192
+ toggle: () => this.toggleGroup(groupPath),
193
+ aggregates: this.calculateAggregates(rows),
194
+ parent: undefined, // Will be set if needed
195
+ children: undefined
196
+ };
197
+ // Build children if there are more group levels
198
+ if (remainingConfigs.length > 0) {
199
+ groupInfo.children = this.buildGroups(rows, remainingConfigs, level + 1, groupPath);
200
+ // Update count to include all nested rows
201
+ groupInfo.count = this.countNestedRows(groupInfo);
202
+ }
203
+ groups.push(groupInfo);
204
+ }
205
+ return groups;
206
+ }
207
+ /** Count total rows in a group including nested groups */
208
+ countNestedRows(group) {
209
+ if (!group.children || group.children.length === 0) {
210
+ return group.rows.length;
211
+ }
212
+ return group.children.reduce((sum, child) => sum + this.countNestedRows(child), 0);
213
+ }
214
+ /** Calculate aggregates for a group's rows */
215
+ calculateAggregates(rows) {
216
+ const aggregates = {};
217
+ // Get numeric columns
218
+ for (const column of this.columns) {
219
+ const values = [];
220
+ for (const row of rows) {
221
+ const value = row[column.field];
222
+ if (typeof value === 'number' && !isNaN(value)) {
223
+ values.push(value);
224
+ }
225
+ }
226
+ if (values.length > 0) {
227
+ const sum = values.reduce((a, b) => a + b, 0);
228
+ aggregates[column.field] = {
229
+ sum,
230
+ avg: sum / values.length,
231
+ min: Math.min(...values),
232
+ max: Math.max(...values),
233
+ count: values.length
234
+ };
235
+ }
236
+ else {
237
+ aggregates[column.field] = { count: rows.length };
238
+ }
239
+ }
240
+ return aggregates;
241
+ }
242
+ /** Check if a group is open */
243
+ isGroupOpen(groupPath, config) {
244
+ // Check explicit open state first
245
+ if (this.openGroups.has(groupPath)) {
246
+ return true;
247
+ }
248
+ // Check if explicitly closed (we track both states)
249
+ if (this.openGroups.has(`closed:${groupPath}`)) {
250
+ return false;
251
+ }
252
+ // Use default from config
253
+ if (typeof config.startOpen === 'function') {
254
+ // Can't call function here without GroupInfo, so default to true
255
+ return true;
256
+ }
257
+ return config.startOpen !== false; // Default to open
258
+ }
259
+ /** Apply filter configs to data */
260
+ applyFilters(data) {
261
+ return data.filter((row) => {
262
+ return this.filterConfigs.every((filter) => {
263
+ const value = row[filter.field];
264
+ return this.matchesFilter(value, filter.operator, filter.value);
265
+ });
266
+ });
267
+ }
268
+ /** Apply header filter values to data */
269
+ applyHeaderFilters(data, filters) {
270
+ return data.filter((row) => {
271
+ return filters.every(([field, filterValue]) => {
272
+ const value = row[field];
273
+ if (value === null || value === undefined)
274
+ return false;
275
+ return String(value).toLowerCase().includes(filterValue.toLowerCase());
276
+ });
277
+ });
278
+ }
279
+ /** Check if a value matches a filter condition */
280
+ matchesFilter(value, operator, filterValue) {
281
+ if (value === null || value === undefined) {
282
+ return operator === 'neq';
283
+ }
284
+ switch (operator) {
285
+ case 'eq':
286
+ return value === filterValue;
287
+ case 'neq':
288
+ return value !== filterValue;
289
+ case 'gt':
290
+ return Number(value) > Number(filterValue);
291
+ case 'gte':
292
+ return Number(value) >= Number(filterValue);
293
+ case 'lt':
294
+ return Number(value) < Number(filterValue);
295
+ case 'lte':
296
+ return Number(value) <= Number(filterValue);
297
+ case 'contains':
298
+ return String(value).toLowerCase().includes(String(filterValue).toLowerCase());
299
+ case 'startswith':
300
+ return String(value).toLowerCase().startsWith(String(filterValue).toLowerCase());
301
+ case 'endswith':
302
+ return String(value).toLowerCase().endsWith(String(filterValue).toLowerCase());
303
+ case 'regex':
304
+ try {
305
+ return new RegExp(String(filterValue), 'i').test(String(value));
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ default:
311
+ return true;
312
+ }
313
+ }
314
+ /** Apply sort configs to data */
315
+ applySorting(data) {
316
+ return data.sort((a, b) => {
317
+ for (const sort of this.sortConfigs) {
318
+ if (sort.direction === 'none')
319
+ continue;
320
+ const aVal = a[sort.field];
321
+ const bVal = b[sort.field];
322
+ let comparison = 0;
323
+ // Handle null/undefined
324
+ if (aVal === null || aVal === undefined)
325
+ comparison = -1;
326
+ else if (bVal === null || bVal === undefined)
327
+ comparison = 1;
328
+ // Compare strings
329
+ else if (typeof aVal === 'string' && typeof bVal === 'string') {
330
+ comparison = aVal.localeCompare(bVal);
331
+ }
332
+ // Compare numbers
333
+ else if (typeof aVal === 'number' && typeof bVal === 'number') {
334
+ comparison = aVal - bVal;
335
+ }
336
+ // Compare dates
337
+ else if (aVal instanceof Date && bVal instanceof Date) {
338
+ comparison = aVal.getTime() - bVal.getTime();
339
+ }
340
+ // Fallback to string comparison
341
+ else {
342
+ comparison = String(aVal).localeCompare(String(bVal));
343
+ }
344
+ if (comparison !== 0) {
345
+ return sort.direction === 'desc' ? -comparison : comparison;
346
+ }
347
+ }
348
+ return 0;
349
+ });
350
+ }
351
+ /** Total number of rows after processing (includes group headers when grouped) */
352
+ totalRows = $derived(this.displayRows.length);
353
+ /** Total data rows (without group headers) */
354
+ totalDataRows = $derived(this.processedData.length);
355
+ /** Visible columns (filtered for visibility) */
356
+ visibleColumns = $derived(this.columns.filter((col) => col.visible !== false));
357
+ /** Columns frozen to the left */
358
+ frozenLeftColumns = $derived(this.visibleColumns.filter((col) => col.frozen === true || col.frozen === 'left'));
359
+ /** Columns frozen to the right */
360
+ frozenRightColumns = $derived(this.visibleColumns.filter((col) => col.frozen === 'right'));
361
+ /** Scrollable (non-frozen) columns */
362
+ scrollableColumns = $derived(this.visibleColumns.filter((col) => !col.frozen));
363
+ /** Number of rows frozen at the top (only applies when not grouped) */
364
+ frozenRowCount = $state(0);
365
+ /** Frozen rows (always rendered at top) - only when not grouped */
366
+ frozenRows = $derived(this.isGrouped ? [] : this.processedData.slice(0, this.frozenRowCount));
367
+ /** Non-frozen display rows for virtual scrolling */
368
+ scrollableDisplayRows = $derived.by(() => {
369
+ if (this.isGrouped) {
370
+ // When grouped, all display rows are scrollable
371
+ return this.displayRows;
372
+ }
373
+ // When not grouped, exclude frozen rows
374
+ return this.displayRows.slice(this.frozenRowCount);
375
+ });
376
+ /** Total scrollable rows (excluding frozen) */
377
+ scrollableRowCount = $derived(this.scrollableDisplayRows.length);
378
+ /** Get effective row height for a display row */
379
+ getDisplayRowHeight(displayRow) {
380
+ return displayRow.type === 'group' ? this.groupHeaderHeight : this.rowHeight;
381
+ }
382
+ /** Total content height for scrollable area */
383
+ totalHeight = $derived.by(() => {
384
+ if (!this.isGrouped) {
385
+ return this.scrollableRowCount * this.rowHeight;
386
+ }
387
+ // Calculate mixed heights for grouped data
388
+ let height = 0;
389
+ for (const row of this.scrollableDisplayRows) {
390
+ height += row.type === 'group' ? this.groupHeaderHeight : this.rowHeight;
391
+ }
392
+ return height;
393
+ });
394
+ /** Number of visible rows that fit in container (accounting for frozen row height) */
395
+ visibleRowCount = $derived.by(() => {
396
+ const frozenHeight = this.frozenRowCount * this.rowHeight;
397
+ const availableHeight = this.containerHeight - frozenHeight;
398
+ // Use row height as approximation, will show a bit more for groups
399
+ return Math.ceil(availableHeight / this.rowHeight) + 2;
400
+ });
401
+ /** Start index for visible scrollable rows (accounts for variable heights when grouped) */
402
+ startIndex = $derived.by(() => {
403
+ if (!this.isGrouped) {
404
+ return Math.max(0, Math.floor(this.scrollTop / this.rowHeight));
405
+ }
406
+ // With grouping, calculate based on accumulated heights
407
+ let accumulatedHeight = 0;
408
+ for (let i = 0; i < this.scrollableDisplayRows.length; i++) {
409
+ const rowHeight = this.scrollableDisplayRows[i].type === 'group'
410
+ ? this.groupHeaderHeight
411
+ : this.rowHeight;
412
+ if (accumulatedHeight + rowHeight > this.scrollTop) {
413
+ return i;
414
+ }
415
+ accumulatedHeight += rowHeight;
416
+ }
417
+ return Math.max(0, this.scrollableDisplayRows.length - 1);
418
+ });
419
+ /** End index for visible scrollable rows */
420
+ endIndex = $derived(Math.min(this.scrollableRowCount, this.startIndex + this.visibleRowCount));
421
+ /** Visible display rows slice */
422
+ visibleDisplayRows = $derived(this.scrollableDisplayRows.slice(this.startIndex, this.endIndex));
423
+ /** Legacy: visible data rows (for non-grouped compatibility) */
424
+ visibleRows = $derived.by(() => {
425
+ if (this.isGrouped) {
426
+ // Return only data rows from visible display rows
427
+ return this.visibleDisplayRows
428
+ .filter((r) => r.type === 'row')
429
+ .map(r => r.data);
430
+ }
431
+ return this.visibleDisplayRows
432
+ .filter((r) => r.type === 'row')
433
+ .map(r => r.data);
434
+ });
435
+ /** Offset for positioning visible rows (accounts for variable heights when grouped) */
436
+ offsetY = $derived.by(() => {
437
+ if (!this.isGrouped) {
438
+ return this.startIndex * this.rowHeight;
439
+ }
440
+ // Calculate offset based on accumulated heights up to startIndex
441
+ let offset = 0;
442
+ for (let i = 0; i < this.startIndex && i < this.scrollableDisplayRows.length; i++) {
443
+ offset += this.scrollableDisplayRows[i].type === 'group'
444
+ ? this.groupHeaderHeight
445
+ : this.rowHeight;
446
+ }
447
+ return offset;
448
+ });
449
+ // ============================================
450
+ // Initialization
451
+ // ============================================
452
+ constructor(options) {
453
+ this.setOptions(options);
454
+ }
455
+ /**
456
+ * Set grid options and update state
457
+ */
458
+ setOptions(options) {
459
+ this.options = options;
460
+ this.rawData = options.data ?? [];
461
+ this.columns = options.columns ?? [];
462
+ this.rowHeight = options.rowHeight ?? 40;
463
+ }
464
+ // ============================================
465
+ // Data Methods
466
+ // ============================================
467
+ /**
468
+ * Set new data
469
+ */
470
+ setData(data) {
471
+ this.rawData = data;
472
+ }
473
+ /**
474
+ * Update a single row by index
475
+ */
476
+ updateRow(index, data) {
477
+ if (index >= 0 && index < this.rawData.length) {
478
+ this.rawData[index] = { ...this.rawData[index], ...data };
479
+ }
480
+ }
481
+ /**
482
+ * Add a row at the end or at specific index
483
+ */
484
+ addRow(data, index) {
485
+ if (index !== undefined && index >= 0) {
486
+ this.rawData.splice(index, 0, data);
487
+ }
488
+ else {
489
+ this.rawData.push(data);
490
+ }
491
+ }
492
+ /**
493
+ * Remove a row by index
494
+ */
495
+ removeRow(index) {
496
+ if (index >= 0 && index < this.rawData.length) {
497
+ this.rawData.splice(index, 1);
498
+ }
499
+ }
500
+ /**
501
+ * Get row by key value
502
+ */
503
+ getRowByKey(keyField, keyValue) {
504
+ return this.rawData.find((row) => row[keyField] === keyValue);
505
+ }
506
+ // ============================================
507
+ // Column Methods
508
+ // ============================================
509
+ /**
510
+ * Set column definitions
511
+ */
512
+ setColumns(columns) {
513
+ this.columns = columns;
514
+ }
515
+ /**
516
+ * Update a single column definition
517
+ */
518
+ updateColumn(field, updates) {
519
+ const index = this.columns.findIndex((col) => col.field === field);
520
+ if (index !== -1) {
521
+ this.columns[index] = { ...this.columns[index], ...updates };
522
+ }
523
+ }
524
+ /**
525
+ * Show/hide a column
526
+ */
527
+ setColumnVisibility(field, visible) {
528
+ this.updateColumn(field, { visible });
529
+ }
530
+ /**
531
+ * Get column by field name
532
+ */
533
+ getColumn(field) {
534
+ return this.columns.find((col) => col.field === field);
535
+ }
536
+ // ============================================
537
+ // Sorting Methods
538
+ // ============================================
539
+ /**
540
+ * Set sort for a field (replaces existing sort)
541
+ */
542
+ setSort(field, direction) {
543
+ if (direction === 'none') {
544
+ this.sortConfigs = this.sortConfigs.filter((s) => s.field !== field);
545
+ }
546
+ else {
547
+ const existing = this.sortConfigs.findIndex((s) => s.field === field);
548
+ if (existing !== -1) {
549
+ this.sortConfigs[existing] = { field, direction };
550
+ }
551
+ else {
552
+ // Single sort by default - replace all
553
+ this.sortConfigs = [{ field, direction }];
554
+ }
555
+ }
556
+ }
557
+ /**
558
+ * Toggle sort for a field (cycles through: none -> asc -> desc -> none)
559
+ */
560
+ toggleSort(field, multiSort = false) {
561
+ const existing = this.sortConfigs.find((s) => s.field === field);
562
+ const currentDirection = existing?.direction ?? 'none';
563
+ let newDirection;
564
+ if (currentDirection === 'none')
565
+ newDirection = 'asc';
566
+ else if (currentDirection === 'asc')
567
+ newDirection = 'desc';
568
+ else
569
+ newDirection = 'none';
570
+ if (multiSort) {
571
+ // Multi-sort: update or add to existing sorts
572
+ if (newDirection === 'none') {
573
+ this.sortConfigs = this.sortConfigs.filter((s) => s.field !== field);
574
+ }
575
+ else if (existing) {
576
+ existing.direction = newDirection;
577
+ this.sortConfigs = [...this.sortConfigs]; // Trigger reactivity
578
+ }
579
+ else {
580
+ this.sortConfigs = [...this.sortConfigs, { field, direction: newDirection }];
581
+ }
582
+ }
583
+ else {
584
+ // Single sort: replace all sorts
585
+ this.setSort(field, newDirection);
586
+ }
587
+ }
588
+ /**
589
+ * Clear all sorting
590
+ */
591
+ clearSort() {
592
+ this.sortConfigs = [];
593
+ }
594
+ /**
595
+ * Get current sort direction for a field
596
+ */
597
+ getSortDirection(field) {
598
+ return this.sortConfigs.find((s) => s.field === field)?.direction ?? 'none';
599
+ }
600
+ /**
601
+ * Get sort index for multi-sort (0-based, -1 if not sorted)
602
+ */
603
+ getSortIndex(field) {
604
+ return this.sortConfigs.findIndex((s) => s.field === field && s.direction !== 'none');
605
+ }
606
+ // ============================================
607
+ // Filtering Methods
608
+ // ============================================
609
+ /**
610
+ * Set a programmatic filter
611
+ */
612
+ setFilter(field, operator, value) {
613
+ const existing = this.filterConfigs.findIndex((f) => f.field === field);
614
+ if (existing !== -1) {
615
+ this.filterConfigs[existing] = { field, operator, value };
616
+ }
617
+ else {
618
+ this.filterConfigs = [...this.filterConfigs, { field, operator, value }];
619
+ }
620
+ }
621
+ /**
622
+ * Remove a filter for a field
623
+ */
624
+ removeFilter(field) {
625
+ this.filterConfigs = this.filterConfigs.filter((f) => f.field !== field);
626
+ }
627
+ /**
628
+ * Clear all filters
629
+ */
630
+ clearFilters() {
631
+ this.filterConfigs = [];
632
+ this.headerFilterValues = {};
633
+ }
634
+ /**
635
+ * Set header filter value (for UI input)
636
+ */
637
+ setHeaderFilter(field, value) {
638
+ this.headerFilterValues = { ...this.headerFilterValues, [field]: value };
639
+ }
640
+ /**
641
+ * Get header filter value
642
+ */
643
+ getHeaderFilter(field) {
644
+ return this.headerFilterValues[field] ?? '';
645
+ }
646
+ // ============================================
647
+ // Column Resizing Methods
648
+ // ============================================
649
+ /**
650
+ * Set column width (from user resize)
651
+ */
652
+ setColumnWidth(field, width) {
653
+ const column = this.getColumn(field);
654
+ const minWidth = column?.minWidth ?? 50;
655
+ const maxWidth = column?.maxWidth ?? Infinity;
656
+ const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width));
657
+ this.columnWidths = { ...this.columnWidths, [field]: clampedWidth };
658
+ }
659
+ /**
660
+ * Get effective column width (user-set or default)
661
+ */
662
+ getColumnWidth(field) {
663
+ return this.columnWidths[field];
664
+ }
665
+ /**
666
+ * Reset column width to default
667
+ */
668
+ resetColumnWidth(field) {
669
+ const { [field]: _, ...rest } = this.columnWidths;
670
+ this.columnWidths = rest;
671
+ }
672
+ /**
673
+ * Reset all column widths
674
+ */
675
+ resetAllColumnWidths() {
676
+ this.columnWidths = {};
677
+ }
678
+ /**
679
+ * Auto-fit column width to content (double-click resize)
680
+ */
681
+ autoFitColumn(field) {
682
+ const column = this.getColumn(field);
683
+ if (!column)
684
+ return;
685
+ // Create a temporary canvas for text measurement
686
+ const canvas = document.createElement('canvas');
687
+ const ctx = canvas.getContext('2d');
688
+ if (!ctx)
689
+ return;
690
+ // Use the grid's font style
691
+ ctx.font = '14px system-ui, -apple-system, sans-serif';
692
+ // Measure header text
693
+ let maxWidth = ctx.measureText(column.title).width;
694
+ // Measure all data values in this column
695
+ for (const row of this.processedData) {
696
+ const value = row[field];
697
+ if (value !== null && value !== undefined) {
698
+ const text = String(value);
699
+ const width = ctx.measureText(text).width;
700
+ if (width > maxWidth) {
701
+ maxWidth = width;
702
+ }
703
+ }
704
+ }
705
+ // Add padding (cell padding + some buffer for sort indicators, etc.)
706
+ const padding = 48; // ~24px on each side + buffer
707
+ const calculatedWidth = Math.ceil(maxWidth + padding);
708
+ // Apply min/max constraints
709
+ const minWidth = column.minWidth ?? 50;
710
+ const maxAllowedWidth = column.maxWidth ?? 500; // Cap at 500px by default
711
+ const finalWidth = Math.max(minWidth, Math.min(maxAllowedWidth, calculatedWidth));
712
+ this.setColumnWidth(field, finalWidth);
713
+ }
714
+ /**
715
+ * Auto-fit all columns to their content
716
+ */
717
+ autoFitAllColumns() {
718
+ for (const column of this.columns) {
719
+ this.autoFitColumn(column.field);
720
+ }
721
+ }
722
+ // ============================================
723
+ // Frozen Column Methods
724
+ // ============================================
725
+ /**
726
+ * Get the sticky left position for a frozen left column
727
+ */
728
+ getFrozenLeftPosition(field) {
729
+ let position = 0;
730
+ for (const col of this.frozenLeftColumns) {
731
+ if (col.field === field) {
732
+ return position;
733
+ }
734
+ // Get width: user-resized width, column.width, or default 100px
735
+ const width = this.columnWidths[col.field] ?? (typeof col.width === 'number' ? col.width : 100);
736
+ position += width;
737
+ }
738
+ return position;
739
+ }
740
+ /**
741
+ * Get the sticky right position for a frozen right column
742
+ */
743
+ getFrozenRightPosition(field) {
744
+ let position = 0;
745
+ // Iterate in reverse order for right-frozen columns
746
+ const rightCols = [...this.frozenRightColumns].reverse();
747
+ for (const col of rightCols) {
748
+ if (col.field === field) {
749
+ return position;
750
+ }
751
+ const width = this.columnWidths[col.field] ?? (typeof col.width === 'number' ? col.width : 100);
752
+ position += width;
753
+ }
754
+ return position;
755
+ }
756
+ /**
757
+ * Get total width of frozen left columns
758
+ */
759
+ get frozenLeftWidth() {
760
+ let width = 0;
761
+ for (const col of this.frozenLeftColumns) {
762
+ width += this.columnWidths[col.field] ?? (typeof col.width === 'number' ? col.width : 100);
763
+ }
764
+ return width;
765
+ }
766
+ /**
767
+ * Get total width of frozen right columns
768
+ */
769
+ get frozenRightWidth() {
770
+ let width = 0;
771
+ for (const col of this.frozenRightColumns) {
772
+ width += this.columnWidths[col.field] ?? (typeof col.width === 'number' ? col.width : 100);
773
+ }
774
+ return width;
775
+ }
776
+ /**
777
+ * Set the number of frozen rows
778
+ */
779
+ setFrozenRowCount(count) {
780
+ this.frozenRowCount = Math.max(0, count);
781
+ }
782
+ // ============================================
783
+ // Grouping Methods
784
+ // ============================================
785
+ /**
786
+ * Set group configuration
787
+ * @param groupBy - Field name, array of fields, function, or GroupConfig
788
+ */
789
+ setGroupBy(groupBy) {
790
+ if (!groupBy) {
791
+ this.groupByConfig = [];
792
+ return;
793
+ }
794
+ // Normalize to array of GroupConfig
795
+ if (typeof groupBy === 'string') {
796
+ this.groupByConfig = [{ field: groupBy }];
797
+ }
798
+ else if (typeof groupBy === 'function') {
799
+ this.groupByConfig = [{ field: groupBy }];
800
+ }
801
+ else if (Array.isArray(groupBy)) {
802
+ this.groupByConfig = groupBy.map(item => {
803
+ if (typeof item === 'string') {
804
+ return { field: item };
805
+ }
806
+ return item;
807
+ });
808
+ }
809
+ else {
810
+ this.groupByConfig = [groupBy];
811
+ }
812
+ // Reset open groups when grouping changes
813
+ this.openGroups = new Set();
814
+ }
815
+ /**
816
+ * Toggle a group open/closed
817
+ */
818
+ toggleGroup(groupPath) {
819
+ const newOpenGroups = new Set(this.openGroups);
820
+ const closedKey = `closed:${groupPath}`;
821
+ if (newOpenGroups.has(groupPath)) {
822
+ // Currently explicitly open -> mark as closed
823
+ newOpenGroups.delete(groupPath);
824
+ newOpenGroups.add(closedKey);
825
+ }
826
+ else if (newOpenGroups.has(closedKey)) {
827
+ // Currently explicitly closed -> mark as open
828
+ newOpenGroups.delete(closedKey);
829
+ newOpenGroups.add(groupPath);
830
+ }
831
+ else {
832
+ // Not explicitly set -> check default and toggle
833
+ // Default is open, so toggle to closed
834
+ newOpenGroups.add(closedKey);
835
+ }
836
+ this.openGroups = newOpenGroups;
837
+ }
838
+ /**
839
+ * Expand all groups
840
+ */
841
+ expandAllGroups() {
842
+ const newOpenGroups = new Set();
843
+ // Clear all closed markers and add open markers for all groups
844
+ const addGroupPaths = (groups) => {
845
+ for (const group of groups) {
846
+ const path = this.getGroupPath(group);
847
+ newOpenGroups.add(path);
848
+ if (group.children) {
849
+ addGroupPaths(group.children);
850
+ }
851
+ }
852
+ };
853
+ addGroupPaths(this.groupedData);
854
+ this.openGroups = newOpenGroups;
855
+ }
856
+ /**
857
+ * Collapse all groups
858
+ */
859
+ collapseAllGroups() {
860
+ const newOpenGroups = new Set();
861
+ // Add closed markers for all top-level groups
862
+ const addClosedPaths = (groups, parentPath = '') => {
863
+ for (const group of groups) {
864
+ const path = parentPath ? `${parentPath}/${group.field}:${group.key}` : `${group.field}:${group.key}`;
865
+ newOpenGroups.add(`closed:${path}`);
866
+ if (group.children) {
867
+ addClosedPaths(group.children, path);
868
+ }
869
+ }
870
+ };
871
+ addClosedPaths(this.groupedData);
872
+ this.openGroups = newOpenGroups;
873
+ }
874
+ /**
875
+ * Get the path for a group (used for open/closed tracking)
876
+ */
877
+ getGroupPath(group, parentPath = '') {
878
+ return parentPath ? `${parentPath}/${group.field}:${group.key}` : `${group.field}:${group.key}`;
879
+ }
880
+ /**
881
+ * Get groups at a specific level
882
+ */
883
+ getGroupsAtLevel(level) {
884
+ const groups = [];
885
+ const collectAtLevel = (items, currentLevel) => {
886
+ for (const item of items) {
887
+ if (currentLevel === level) {
888
+ groups.push(item);
889
+ }
890
+ else if (item.children && currentLevel < level) {
891
+ collectAtLevel(item.children, currentLevel + 1);
892
+ }
893
+ }
894
+ };
895
+ collectAtLevel(this.groupedData, 0);
896
+ return groups;
897
+ }
898
+ /**
899
+ * Set group header height
900
+ */
901
+ setGroupHeaderHeight(height) {
902
+ this.groupHeaderHeight = Math.max(24, height);
903
+ }
904
+ // ============================================
905
+ // Scroll Methods
906
+ // ============================================
907
+ /**
908
+ * Update scroll position
909
+ */
910
+ setScrollPosition(top, left) {
911
+ this.scrollTop = Math.max(0, top);
912
+ if (left !== undefined) {
913
+ this.scrollLeft = Math.max(0, left);
914
+ }
915
+ }
916
+ /**
917
+ * Update container dimensions
918
+ */
919
+ setContainerDimensions(width, height) {
920
+ this.containerWidth = width;
921
+ this.containerHeight = height;
922
+ }
923
+ /**
924
+ * Scroll to a specific row index
925
+ */
926
+ scrollToRow(index) {
927
+ const targetScroll = index * this.rowHeight;
928
+ this.scrollTop = Math.max(0, Math.min(targetScroll, this.totalHeight - this.containerHeight));
929
+ }
930
+ // ============================================
931
+ // Plugin Methods (for Phase 3+)
932
+ // ============================================
933
+ /**
934
+ * Register a plugin
935
+ */
936
+ registerPlugin(plugin) {
937
+ if (this.plugins.has(plugin.name)) {
938
+ console.warn(`Plugin "${plugin.name}" is already registered`);
939
+ return;
940
+ }
941
+ const instance = plugin.init({
942
+ getState: () => ({
943
+ rawData: this.rawData,
944
+ columns: this.columns,
945
+ scrollTop: this.scrollTop,
946
+ containerHeight: this.containerHeight,
947
+ containerWidth: this.containerWidth,
948
+ rowHeight: this.rowHeight
949
+ }),
950
+ updateState: (updates) => {
951
+ if (updates.rawData !== undefined)
952
+ this.rawData = updates.rawData;
953
+ if (updates.columns !== undefined)
954
+ this.columns = updates.columns;
955
+ if (updates.scrollTop !== undefined)
956
+ this.scrollTop = updates.scrollTop;
957
+ },
958
+ subscribe: (callback) => {
959
+ // In Svelte 5, effects auto-track dependencies
960
+ // This is a simplified version - full implementation in Phase 3
961
+ return () => { };
962
+ }
963
+ });
964
+ this.plugins.set(plugin.name, instance);
965
+ // Add pipeline handler if provided
966
+ if (instance.dataPipeline) {
967
+ this.pipelineHandlers.push(instance.dataPipeline);
968
+ // Sort by priority (lower = earlier in pipeline)
969
+ this.pipelineHandlers.sort((a, b) => a.priority - b.priority);
970
+ }
971
+ }
972
+ /**
973
+ * Unregister a plugin
974
+ */
975
+ unregisterPlugin(name) {
976
+ const instance = this.plugins.get(name);
977
+ if (instance) {
978
+ instance.destroy?.();
979
+ this.plugins.delete(name);
980
+ // Remove pipeline handler
981
+ if (instance.dataPipeline) {
982
+ const idx = this.pipelineHandlers.indexOf(instance.dataPipeline);
983
+ if (idx !== -1) {
984
+ this.pipelineHandlers.splice(idx, 1);
985
+ }
986
+ }
987
+ }
988
+ }
989
+ /**
990
+ * Get plugin API
991
+ */
992
+ getPluginApi(name) {
993
+ return this.plugins.get(name)?.api;
994
+ }
995
+ /**
996
+ * Get all plugin header cell enhancements for a column
997
+ */
998
+ getHeaderCellEnhancements(column) {
999
+ let enhancements = {};
1000
+ for (const instance of this.plugins.values()) {
1001
+ if (instance.headerCell) {
1002
+ enhancements = { ...enhancements, ...instance.headerCell(column) };
1003
+ }
1004
+ }
1005
+ return enhancements;
1006
+ }
1007
+ // ============================================
1008
+ // Cleanup
1009
+ // ============================================
1010
+ /**
1011
+ * Destroy the state manager and cleanup plugins
1012
+ */
1013
+ destroy() {
1014
+ for (const [name] of this.plugins) {
1015
+ this.unregisterPlugin(name);
1016
+ }
1017
+ this.pipelineHandlers = [];
1018
+ }
1019
+ }
1020
+ /**
1021
+ * Factory function to create a new grid state manager
1022
+ */
1023
+ export function createGridState(options) {
1024
+ return new GridStateManager(options);
1025
+ }