@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.
- package/dist/components/Cell.svelte +249 -0
- package/dist/components/Cell.svelte.d.ts +39 -0
- package/dist/components/Grid.svelte +504 -0
- package/dist/components/Grid.svelte.d.ts +80 -0
- package/dist/components/GridBody.svelte +194 -0
- package/dist/components/GridBody.svelte.d.ts +49 -0
- package/dist/components/GridHeader.svelte +99 -0
- package/dist/components/GridHeader.svelte.d.ts +31 -0
- package/dist/components/GroupHeader.svelte +192 -0
- package/dist/components/GroupHeader.svelte.d.ts +35 -0
- package/dist/components/HeaderCell.svelte +623 -0
- package/dist/components/HeaderCell.svelte.d.ts +40 -0
- package/dist/components/Menu.svelte +215 -0
- package/dist/components/Menu.svelte.d.ts +33 -0
- package/dist/components/Popup.svelte +189 -0
- package/dist/components/Popup.svelte.d.ts +18 -0
- package/dist/components/Row.svelte +115 -0
- package/dist/components/Row.svelte.d.ts +36 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +20 -0
- package/dist/state/gridState.svelte.d.ts +299 -0
- package/dist/state/gridState.svelte.js +1025 -0
- package/dist/themes.d.ts +61 -0
- package/dist/themes.js +192 -0
- package/dist/types.d.ts +291 -0
- package/dist/types.js +4 -0
- package/package.json +66 -0
|
@@ -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
|
+
}
|