@shotleybuilder/svelte-gridlite-kit 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.
Files changed (43) hide show
  1. package/README.md +260 -0
  2. package/dist/GridLite.svelte +1361 -0
  3. package/dist/GridLite.svelte.d.ts +42 -0
  4. package/dist/components/CellContextMenu.svelte +209 -0
  5. package/dist/components/CellContextMenu.svelte.d.ts +28 -0
  6. package/dist/components/ColumnMenu.svelte +234 -0
  7. package/dist/components/ColumnMenu.svelte.d.ts +29 -0
  8. package/dist/components/ColumnPicker.svelte +403 -0
  9. package/dist/components/ColumnPicker.svelte.d.ts +29 -0
  10. package/dist/components/FilterBar.svelte +390 -0
  11. package/dist/components/FilterBar.svelte.d.ts +38 -0
  12. package/dist/components/FilterCondition.svelte +643 -0
  13. package/dist/components/FilterCondition.svelte.d.ts +35 -0
  14. package/dist/components/GroupBar.svelte +463 -0
  15. package/dist/components/GroupBar.svelte.d.ts +33 -0
  16. package/dist/components/RowDetailModal.svelte +213 -0
  17. package/dist/components/RowDetailModal.svelte.d.ts +25 -0
  18. package/dist/components/SortBar.svelte +232 -0
  19. package/dist/components/SortBar.svelte.d.ts +30 -0
  20. package/dist/components/SortCondition.svelte +129 -0
  21. package/dist/components/SortCondition.svelte.d.ts +30 -0
  22. package/dist/index.d.ts +23 -0
  23. package/dist/index.js +29 -0
  24. package/dist/query/builder.d.ts +160 -0
  25. package/dist/query/builder.js +432 -0
  26. package/dist/query/live.d.ts +50 -0
  27. package/dist/query/live.js +118 -0
  28. package/dist/query/schema.d.ts +30 -0
  29. package/dist/query/schema.js +75 -0
  30. package/dist/state/migrations.d.ts +29 -0
  31. package/dist/state/migrations.js +113 -0
  32. package/dist/state/views.d.ts +54 -0
  33. package/dist/state/views.js +130 -0
  34. package/dist/styles/gridlite.css +966 -0
  35. package/dist/types.d.ts +164 -0
  36. package/dist/types.js +2 -0
  37. package/dist/utils/filters.d.ts +14 -0
  38. package/dist/utils/filters.js +49 -0
  39. package/dist/utils/formatters.d.ts +16 -0
  40. package/dist/utils/formatters.js +39 -0
  41. package/dist/utils/fuzzy.d.ts +47 -0
  42. package/dist/utils/fuzzy.js +142 -0
  43. package/package.json +76 -0
@@ -0,0 +1,1361 @@
1
+ <script>import { onMount, onDestroy } from "svelte";
2
+ import { introspectTable, getColumnNames } from "./query/schema.js";
3
+ import {
4
+ buildQuery,
5
+ buildCountQuery,
6
+ buildGroupSummaryQuery,
7
+ buildGroupCountQuery,
8
+ buildGroupDetailQuery
9
+ } from "./query/builder.js";
10
+ import {
11
+ createLiveQueryStore
12
+ } from "./query/live.js";
13
+ import { runMigrations } from "./state/migrations.js";
14
+ import FilterBar from "./components/FilterBar.svelte";
15
+ import SortBar from "./components/SortBar.svelte";
16
+ import GroupBar from "./components/GroupBar.svelte";
17
+ import CellContextMenu from "./components/CellContextMenu.svelte";
18
+ import ColumnMenu from "./components/ColumnMenu.svelte";
19
+ import ColumnPicker from "./components/ColumnPicker.svelte";
20
+ import RowDetailModal from "./components/RowDetailModal.svelte";
21
+ export let db;
22
+ export let table = void 0;
23
+ export let query = void 0;
24
+ export let config = void 0;
25
+ export let features = {};
26
+ export let classNames = {};
27
+ export let rowHeight = "medium";
28
+ export let columnSpacing = "normal";
29
+ export let toolbarLayout = "airtable";
30
+ export let onRowClick = void 0;
31
+ export let onStateChange = void 0;
32
+ let columns = [];
33
+ let allowedColumns = [];
34
+ let initialized = false;
35
+ let error = null;
36
+ let filters = config?.defaultFilters ?? [];
37
+ let filterLogic = config?.filterLogic ?? "and";
38
+ let sorting = config?.defaultSorting ?? [];
39
+ let grouping = config?.defaultGrouping ?? [];
40
+ let globalFilter = "";
41
+ let page = 0;
42
+ let pageSize = config?.pagination?.pageSize ?? 25;
43
+ let totalRows = 0;
44
+ let searchDebounceTimer;
45
+ let filterExpanded = false;
46
+ let sortExpanded = false;
47
+ let groupExpanded = false;
48
+ let contextMenu = null;
49
+ let columnMenuOpen = null;
50
+ let showRowHeightMenu = false;
51
+ let showColumnSpacingMenu = false;
52
+ let showColumnPicker = false;
53
+ const rowHeightOptions = ["short", "medium", "tall", "extra_tall"];
54
+ const columnSpacingOptions = ["narrow", "normal", "wide"];
55
+ let columnVisibility = {};
56
+ let columnOrder = config?.defaultColumnOrder ?? [];
57
+ let draggedColumnId = null;
58
+ let dragOverColumnId = null;
59
+ let columnSizing = config?.defaultColumnSizing ?? {};
60
+ let resizingColumn = null;
61
+ let resizeStartX = 0;
62
+ let resizeStartWidth = 0;
63
+ const COL_MIN_WIDTH = 62;
64
+ const COL_MAX_WIDTH = 1e3;
65
+ const COL_DEFAULT_WIDTH = 180;
66
+ let groupData = [];
67
+ let expandedGroups = /* @__PURE__ */ new Set();
68
+ let totalGroups = 0;
69
+ let groupLoading = /* @__PURE__ */ new Set();
70
+ let rowDetailOpen = false;
71
+ let rowDetailIndex = -1;
72
+ let store = null;
73
+ let storeState = {
74
+ rows: [],
75
+ fields: [],
76
+ loading: true,
77
+ error: null
78
+ };
79
+ $: totalPages = pageSize > 0 ? Math.ceil(totalRows / pageSize) : 0;
80
+ $: containerClass = [
81
+ "gridlite-container",
82
+ `gridlite-row-${rowHeight}`,
83
+ `gridlite-spacing-${columnSpacing}`,
84
+ `gridlite-layout-${toolbarLayout}`,
85
+ classNames.container ?? ""
86
+ ].filter(Boolean).join(" ");
87
+ $: visibleColumns = columns.filter((col) => {
88
+ if (col.name in columnVisibility) {
89
+ return columnVisibility[col.name];
90
+ }
91
+ if (config?.defaultVisibleColumns) {
92
+ return config.defaultVisibleColumns.includes(col.name);
93
+ }
94
+ return true;
95
+ });
96
+ $: orderedColumns = (() => {
97
+ const order = columnOrder.length > 0 ? columnOrder : config?.defaultColumnOrder ?? [];
98
+ if (order.length > 0) {
99
+ return [...visibleColumns].sort((a, b) => {
100
+ const ai = order.indexOf(a.name);
101
+ const bi = order.indexOf(b.name);
102
+ if (ai === -1 && bi === -1) return 0;
103
+ if (ai === -1) return 1;
104
+ if (bi === -1) return -1;
105
+ return ai - bi;
106
+ });
107
+ }
108
+ return visibleColumns;
109
+ })();
110
+ $: validGrouping = grouping.filter((g) => g.column !== "");
111
+ $: isGrouped = validGrouping.length > 0;
112
+ $: nonGroupedColumns = isGrouped ? orderedColumns.filter((col) => !validGrouping.some((g) => g.column === col.name)) : orderedColumns;
113
+ function groupKey(group) {
114
+ return Object.entries(group.values).map(([col, val]) => `${col}=${val === null || val === void 0 ? "__null__" : String(val)}`).join("::");
115
+ }
116
+ async function init() {
117
+ try {
118
+ await runMigrations(db);
119
+ if (table) {
120
+ columns = await introspectTable(db, table);
121
+ allowedColumns = columns.map((c) => c.name);
122
+ if (columns.length === 0) {
123
+ error = `Table "${table}" not found or has no columns`;
124
+ return;
125
+ }
126
+ }
127
+ initialized = true;
128
+ await rebuildQuery();
129
+ } catch (err) {
130
+ error = err instanceof Error ? err.message : String(err);
131
+ }
132
+ }
133
+ async function rebuildQuery() {
134
+ if (!initialized) return;
135
+ if (store) {
136
+ await store.destroy();
137
+ store = null;
138
+ }
139
+ let sql;
140
+ let params = [];
141
+ if (query) {
142
+ sql = query;
143
+ } else if (table) {
144
+ if (isGrouped) {
145
+ await rebuildGroupedQuery();
146
+ return;
147
+ }
148
+ const usePagination = features.pagination !== false;
149
+ const built = buildQuery({
150
+ table,
151
+ filters,
152
+ filterLogic,
153
+ sorting,
154
+ page: usePagination ? page : void 0,
155
+ pageSize: usePagination ? pageSize : void 0,
156
+ allowedColumns,
157
+ globalSearch: globalFilter || void 0
158
+ });
159
+ sql = built.sql;
160
+ params = built.params;
161
+ if (usePagination) {
162
+ await updateTotalCount();
163
+ }
164
+ } else {
165
+ error = "Either `table` or `query` prop is required";
166
+ return;
167
+ }
168
+ groupData = [];
169
+ expandedGroups = /* @__PURE__ */ new Set();
170
+ totalGroups = 0;
171
+ store = createLiveQueryStore(db, sql, params);
172
+ store.subscribe((state) => {
173
+ storeState = state;
174
+ });
175
+ }
176
+ function cleanAgg(g) {
177
+ return {
178
+ ...g,
179
+ aggregations: g.aggregations?.filter((a) => a.column !== "") ?? void 0
180
+ };
181
+ }
182
+ async function rebuildGroupedQuery() {
183
+ if (!table) return;
184
+ try {
185
+ const usePagination = features.pagination !== false;
186
+ const topGroupConfig = cleanAgg(validGrouping[0]);
187
+ const summaryQuery = buildGroupSummaryQuery({
188
+ table,
189
+ grouping: [topGroupConfig],
190
+ filters,
191
+ filterLogic,
192
+ allowedColumns,
193
+ globalSearch: globalFilter || void 0,
194
+ sorting,
195
+ page: usePagination ? page : void 0,
196
+ pageSize: usePagination ? pageSize : void 0
197
+ });
198
+ const summaryResult = await db.query(
199
+ summaryQuery.sql,
200
+ summaryQuery.params
201
+ );
202
+ if (usePagination) {
203
+ const countQuery = buildGroupCountQuery({
204
+ table,
205
+ grouping: [topGroupConfig],
206
+ filters,
207
+ filterLogic,
208
+ allowedColumns,
209
+ globalSearch: globalFilter || void 0
210
+ });
211
+ const countResult = await db.query(
212
+ countQuery.sql,
213
+ countQuery.params
214
+ );
215
+ totalGroups = parseInt(countResult.rows[0]?.total ?? "0", 10);
216
+ totalRows = totalGroups;
217
+ }
218
+ const topCol = validGrouping[0].column;
219
+ const newGroupData = summaryResult.rows.map((row) => {
220
+ const values = { [topCol]: row[topCol] };
221
+ const newGroup = {
222
+ values,
223
+ summary: { ...row },
224
+ count: Number(row._count ?? 0),
225
+ depth: 0,
226
+ subGroups: null,
227
+ children: null
228
+ };
229
+ const key = groupKey(newGroup);
230
+ const wasExpanded = expandedGroups.has(key);
231
+ const existing = groupData.find((g) => groupKey(g) === key);
232
+ if (wasExpanded && existing) {
233
+ newGroup.subGroups = existing.subGroups;
234
+ newGroup.children = existing.children;
235
+ }
236
+ return newGroup;
237
+ });
238
+ groupData = newGroupData;
239
+ for (const group of groupData) {
240
+ const key = groupKey(group);
241
+ if (expandedGroups.has(key) && group.subGroups === null && group.children === null) {
242
+ await fetchGroupChildren(group);
243
+ }
244
+ }
245
+ } catch (err) {
246
+ error = err instanceof Error ? err.message : String(err);
247
+ }
248
+ }
249
+ async function fetchGroupChildren(group) {
250
+ if (!table) return;
251
+ const key = groupKey(group);
252
+ groupLoading = /* @__PURE__ */ new Set([...groupLoading, key]);
253
+ try {
254
+ const nextDepth = group.depth + 1;
255
+ const parentValues = Object.entries(group.values).map(([column, value]) => ({
256
+ column,
257
+ value
258
+ }));
259
+ if (nextDepth < validGrouping.length) {
260
+ const subGroupConfig = cleanAgg(validGrouping[nextDepth]);
261
+ const summaryQuery = buildGroupSummaryQuery({
262
+ table,
263
+ grouping: [subGroupConfig],
264
+ filters: [
265
+ ...filters,
266
+ // Add parent group constraints as equals filters
267
+ ...parentValues.map((pv) => ({
268
+ id: `_group_${pv.column}`,
269
+ field: pv.column,
270
+ operator: pv.value === null ? "is_empty" : "equals",
271
+ value: pv.value
272
+ }))
273
+ ],
274
+ filterLogic,
275
+ allowedColumns,
276
+ globalSearch: globalFilter || void 0,
277
+ sorting
278
+ });
279
+ const result = await db.query(
280
+ summaryQuery.sql,
281
+ summaryQuery.params
282
+ );
283
+ const subCol = validGrouping[nextDepth].column;
284
+ const subGroups = result.rows.map((row) => {
285
+ const subValues = {
286
+ ...group.values,
287
+ [subCol]: row[subCol]
288
+ };
289
+ return {
290
+ values: subValues,
291
+ summary: { ...row },
292
+ count: Number(row._count ?? 0),
293
+ depth: nextDepth,
294
+ subGroups: null,
295
+ children: null
296
+ };
297
+ });
298
+ updateGroupInTree(key, { subGroups });
299
+ } else {
300
+ const detailQuery = buildGroupDetailQuery({
301
+ table,
302
+ groupValues: parentValues,
303
+ filters,
304
+ filterLogic,
305
+ sorting,
306
+ allowedColumns,
307
+ globalSearch: globalFilter || void 0
308
+ });
309
+ const result = await db.query(
310
+ detailQuery.sql,
311
+ detailQuery.params
312
+ );
313
+ updateGroupInTree(key, { children: result.rows });
314
+ }
315
+ } catch (err) {
316
+ console.error("Failed to fetch group children:", err);
317
+ } finally {
318
+ const next = new Set(groupLoading);
319
+ next.delete(key);
320
+ groupLoading = next;
321
+ }
322
+ }
323
+ function updateGroupInTree(targetKey, updates) {
324
+ groupData = groupData.map((g) => updateGroupNode(g, targetKey, updates));
325
+ }
326
+ function updateGroupNode(node, targetKey, updates) {
327
+ if (groupKey(node) === targetKey) {
328
+ return { ...node, ...updates };
329
+ }
330
+ if (node.subGroups) {
331
+ return {
332
+ ...node,
333
+ subGroups: node.subGroups.map((sg) => updateGroupNode(sg, targetKey, updates))
334
+ };
335
+ }
336
+ return node;
337
+ }
338
+ async function updateTotalCount() {
339
+ if (!table) return;
340
+ try {
341
+ const countQuery = buildCountQuery({
342
+ table,
343
+ filters,
344
+ filterLogic,
345
+ allowedColumns,
346
+ globalSearch: globalFilter || void 0
347
+ });
348
+ const result = await db.query(countQuery.sql, countQuery.params);
349
+ totalRows = parseInt(result.rows[0]?.total ?? "0", 10);
350
+ } catch {
351
+ totalRows = 0;
352
+ }
353
+ }
354
+ export function setFilters(newFilters, logic) {
355
+ filters = newFilters;
356
+ if (logic) filterLogic = logic;
357
+ page = 0;
358
+ rebuildQuery();
359
+ notifyStateChange();
360
+ }
361
+ export function setSorting(newSorting) {
362
+ sorting = newSorting;
363
+ rebuildQuery();
364
+ notifyStateChange();
365
+ }
366
+ export function setGrouping(newGrouping) {
367
+ const prevValid = grouping.filter((g) => g.column !== "");
368
+ grouping = newGrouping;
369
+ const nowValid = newGrouping.filter((g) => g.column !== "");
370
+ const changed = prevValid.length !== nowValid.length || prevValid.some((g, i) => g.column !== nowValid[i]?.column);
371
+ if (changed) {
372
+ expandedGroups = /* @__PURE__ */ new Set();
373
+ groupData = [];
374
+ page = 0;
375
+ }
376
+ rebuildQuery();
377
+ notifyStateChange();
378
+ }
379
+ export function setPage(newPage) {
380
+ page = Math.max(0, Math.min(newPage, totalPages - 1));
381
+ rebuildQuery();
382
+ notifyStateChange();
383
+ }
384
+ export function setPageSize(newPageSize) {
385
+ pageSize = newPageSize;
386
+ page = 0;
387
+ rebuildQuery();
388
+ notifyStateChange();
389
+ }
390
+ export function setGlobalFilter(search) {
391
+ globalFilter = search;
392
+ page = 0;
393
+ rebuildQuery();
394
+ notifyStateChange();
395
+ }
396
+ function notifyStateChange() {
397
+ if (onStateChange) {
398
+ onStateChange({
399
+ columnVisibility: Object.fromEntries(visibleColumns.map((c) => [c.name, true])),
400
+ columnOrder: columnOrder.length > 0 ? columnOrder : orderedColumns.map((c) => c.name),
401
+ columnSizing,
402
+ filters,
403
+ filterLogic,
404
+ sorting,
405
+ grouping,
406
+ globalFilter,
407
+ pagination: { page, pageSize, totalRows, totalPages }
408
+ });
409
+ }
410
+ }
411
+ async function toggleGroupExpand(group) {
412
+ const key = groupKey(group);
413
+ const next = new Set(expandedGroups);
414
+ if (next.has(key)) {
415
+ next.delete(key);
416
+ expandedGroups = next;
417
+ updateGroupInTree(key, { subGroups: null, children: null });
418
+ } else {
419
+ next.add(key);
420
+ expandedGroups = next;
421
+ await fetchGroupChildren(group);
422
+ }
423
+ }
424
+ function getGroupLabel(group) {
425
+ const groupConfig = validGrouping[group.depth];
426
+ if (!groupConfig) return "";
427
+ const val = group.values[groupConfig.column];
428
+ return val === null || val === void 0 ? "(Empty)" : String(val);
429
+ }
430
+ function getGroupAggregations(group) {
431
+ const aggs = [];
432
+ const groupConfig = validGrouping[group.depth];
433
+ if (groupConfig?.aggregations) {
434
+ for (const agg of groupConfig.aggregations) {
435
+ if (agg.column === "") continue;
436
+ const alias = agg.alias ?? `${agg.function}_${agg.column}`;
437
+ const rawVal = group.summary[alias];
438
+ if (rawVal !== null && rawVal !== void 0) {
439
+ const label = `${agg.function.charAt(0).toUpperCase() + agg.function.slice(1)} ${agg.column}`;
440
+ const value = typeof rawVal === "number" ? rawVal.toLocaleString() : String(rawVal);
441
+ aggs.push({ label, value });
442
+ }
443
+ }
444
+ }
445
+ return aggs;
446
+ }
447
+ function handleFiltersChange(newFilters) {
448
+ setFilters(newFilters, filterLogic);
449
+ }
450
+ function handleLogicChange(newLogic) {
451
+ filterLogic = newLogic;
452
+ page = 0;
453
+ rebuildQuery();
454
+ notifyStateChange();
455
+ }
456
+ function handleSortingChange(newSorting) {
457
+ setSorting(newSorting);
458
+ }
459
+ function handleGroupingChange(newGrouping) {
460
+ setGrouping(newGrouping);
461
+ }
462
+ function handleGlobalSearchInput(event) {
463
+ const value = event.target.value;
464
+ globalFilter = value;
465
+ clearTimeout(searchDebounceTimer);
466
+ searchDebounceTimer = setTimeout(() => {
467
+ page = 0;
468
+ rebuildQuery();
469
+ notifyStateChange();
470
+ }, 300);
471
+ }
472
+ function clearGlobalSearch() {
473
+ globalFilter = "";
474
+ clearTimeout(searchDebounceTimer);
475
+ page = 0;
476
+ rebuildQuery();
477
+ notifyStateChange();
478
+ }
479
+ function handleCellContextMenu(event, row, col) {
480
+ event.preventDefault();
481
+ const colConfig = config?.columns?.find((c) => c.name === col.name);
482
+ contextMenu = {
483
+ x: event.clientX,
484
+ y: event.clientY,
485
+ value: row[col.name],
486
+ columnName: col.name,
487
+ columnLabel: colConfig?.label ?? col.name,
488
+ isNumeric: col.dataType === "number"
489
+ };
490
+ }
491
+ function handleContextFilterEquals(columnName, value) {
492
+ const id = `ctx-${Date.now()}`;
493
+ const newFilter = { id, field: columnName, operator: "equals", value };
494
+ setFilters([...filters, newFilter], filterLogic);
495
+ filterExpanded = true;
496
+ }
497
+ function handleContextFilterNotEquals(columnName, value) {
498
+ const id = `ctx-${Date.now()}`;
499
+ const newFilter = { id, field: columnName, operator: "not_equals", value };
500
+ setFilters([...filters, newFilter], filterLogic);
501
+ filterExpanded = true;
502
+ }
503
+ function handleContextFilterGreaterThan(columnName, value) {
504
+ const id = `ctx-${Date.now()}`;
505
+ const newFilter = { id, field: columnName, operator: "greater_than", value };
506
+ setFilters([...filters, newFilter], filterLogic);
507
+ filterExpanded = true;
508
+ }
509
+ function handleContextFilterLessThan(columnName, value) {
510
+ const id = `ctx-${Date.now()}`;
511
+ const newFilter = { id, field: columnName, operator: "less_than", value };
512
+ setFilters([...filters, newFilter], filterLogic);
513
+ filterExpanded = true;
514
+ }
515
+ function handleColumnMenuSort(columnName, direction) {
516
+ const existing = sorting.findIndex((s) => s.column === columnName);
517
+ const newSorting = [...sorting];
518
+ if (existing >= 0) {
519
+ newSorting[existing] = { column: columnName, direction };
520
+ } else {
521
+ newSorting.push({ column: columnName, direction });
522
+ }
523
+ setSorting(newSorting);
524
+ }
525
+ function handleColumnMenuFilter(columnName) {
526
+ const id = `colmenu-${Date.now()}`;
527
+ const newFilter = { id, field: columnName, operator: "contains", value: "" };
528
+ setFilters([...filters, newFilter], filterLogic);
529
+ filterExpanded = true;
530
+ }
531
+ function handleColumnMenuGroup(columnName) {
532
+ if (!grouping.some((g) => g.column === columnName)) {
533
+ setGrouping([...grouping, { column: columnName }]);
534
+ groupExpanded = true;
535
+ }
536
+ }
537
+ function handleColumnMenuHide(columnName) {
538
+ toggleColumnVisibility(columnName);
539
+ }
540
+ function toggleColumnVisibility(columnName) {
541
+ const current = isColumnVisible(columnName);
542
+ columnVisibility = { ...columnVisibility, [columnName]: !current };
543
+ notifyStateChange();
544
+ }
545
+ function setColumnVisibility(columnName, visible) {
546
+ columnVisibility = { ...columnVisibility, [columnName]: visible };
547
+ notifyStateChange();
548
+ }
549
+ function toggleAllColumns(show) {
550
+ const newVisibility = {};
551
+ for (const col of columns) {
552
+ newVisibility[col.name] = show;
553
+ }
554
+ columnVisibility = newVisibility;
555
+ notifyStateChange();
556
+ }
557
+ function handleColumnOrderChange(newOrder) {
558
+ columnOrder = newOrder;
559
+ notifyStateChange();
560
+ }
561
+ function getColumnLabel(col) {
562
+ const cfg = config?.columns?.find((c) => c.name === col.name);
563
+ return cfg?.label ?? col.name;
564
+ }
565
+ function isColumnVisible(columnName) {
566
+ if (columnName in columnVisibility) {
567
+ return columnVisibility[columnName];
568
+ }
569
+ if (config?.defaultVisibleColumns) {
570
+ return config.defaultVisibleColumns.includes(columnName);
571
+ }
572
+ return true;
573
+ }
574
+ function closeViewMenus(event) {
575
+ const target = event.target;
576
+ if (!target.closest(".gridlite-view-control")) {
577
+ showRowHeightMenu = false;
578
+ showColumnSpacingMenu = false;
579
+ showColumnPicker = false;
580
+ }
581
+ }
582
+ function getColumnWidth(columnName) {
583
+ if (columnName in columnSizing) return columnSizing[columnName];
584
+ const colConfig = config?.columns?.find((c) => c.name === columnName);
585
+ return colConfig?.width ?? COL_DEFAULT_WIDTH;
586
+ }
587
+ function handleResizeStart(event, columnName) {
588
+ event.preventDefault();
589
+ event.stopPropagation();
590
+ resizingColumn = columnName;
591
+ resizeStartX = "touches" in event ? event.touches[0].clientX : event.clientX;
592
+ resizeStartWidth = getColumnWidth(columnName);
593
+ window.addEventListener("mousemove", handleResizeMove);
594
+ window.addEventListener("mouseup", handleResizeEnd);
595
+ window.addEventListener("touchmove", handleResizeMove);
596
+ window.addEventListener("touchend", handleResizeEnd);
597
+ }
598
+ function handleResizeMove(event) {
599
+ if (!resizingColumn) return;
600
+ const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
601
+ const delta = clientX - resizeStartX;
602
+ const newWidth = Math.max(COL_MIN_WIDTH, Math.min(COL_MAX_WIDTH, resizeStartWidth + delta));
603
+ columnSizing = { ...columnSizing, [resizingColumn]: newWidth };
604
+ }
605
+ function handleResizeEnd() {
606
+ resizingColumn = null;
607
+ window.removeEventListener("mousemove", handleResizeMove);
608
+ window.removeEventListener("mouseup", handleResizeEnd);
609
+ window.removeEventListener("touchmove", handleResizeMove);
610
+ window.removeEventListener("touchend", handleResizeEnd);
611
+ notifyStateChange();
612
+ }
613
+ function initColumnOrder() {
614
+ if (columnOrder.length === 0 && columns.length > 0) {
615
+ columnOrder = columns.map((c) => c.name);
616
+ }
617
+ }
618
+ function handleDragStart(event, columnName) {
619
+ if (features.columnReordering === false) return;
620
+ draggedColumnId = columnName;
621
+ if (event.dataTransfer) {
622
+ event.dataTransfer.effectAllowed = "move";
623
+ event.dataTransfer.setData("text/plain", columnName);
624
+ }
625
+ }
626
+ function handleDragOver(event, columnName) {
627
+ if (features.columnReordering === false || !draggedColumnId) return;
628
+ event.preventDefault();
629
+ if (event.dataTransfer) {
630
+ event.dataTransfer.dropEffect = "move";
631
+ }
632
+ dragOverColumnId = columnName;
633
+ }
634
+ function handleDrop(event, targetColumnId) {
635
+ if (features.columnReordering === false) return;
636
+ event.preventDefault();
637
+ if (!draggedColumnId || draggedColumnId === targetColumnId) {
638
+ draggedColumnId = null;
639
+ dragOverColumnId = null;
640
+ return;
641
+ }
642
+ initColumnOrder();
643
+ const oldIndex = columnOrder.indexOf(draggedColumnId);
644
+ const newIndex = columnOrder.indexOf(targetColumnId);
645
+ if (oldIndex !== -1 && newIndex !== -1) {
646
+ const newColumnOrder = [...columnOrder];
647
+ const [moved] = newColumnOrder.splice(oldIndex, 1);
648
+ newColumnOrder.splice(newIndex, 0, moved);
649
+ columnOrder = newColumnOrder;
650
+ notifyStateChange();
651
+ }
652
+ draggedColumnId = null;
653
+ dragOverColumnId = null;
654
+ }
655
+ function handleDragEnd() {
656
+ draggedColumnId = null;
657
+ dragOverColumnId = null;
658
+ }
659
+ function openRowDetail(index) {
660
+ rowDetailIndex = index;
661
+ rowDetailOpen = true;
662
+ }
663
+ function closeRowDetail() {
664
+ rowDetailOpen = false;
665
+ rowDetailIndex = -1;
666
+ }
667
+ function prevRowDetail() {
668
+ if (rowDetailIndex > 0) {
669
+ rowDetailIndex--;
670
+ }
671
+ }
672
+ function nextRowDetail() {
673
+ if (rowDetailIndex < storeState.rows.length - 1) {
674
+ rowDetailIndex++;
675
+ }
676
+ }
677
+ $: rowDetailRow = rowDetailIndex >= 0 ? storeState.rows[rowDetailIndex] ?? null : null;
678
+ function flattenGroupTree(groups) {
679
+ const items = [];
680
+ for (const group of groups) {
681
+ items.push({ type: "group", group });
682
+ const key = groupKey(group);
683
+ if (expandedGroups.has(key)) {
684
+ if (group.subGroups) {
685
+ items.push(...flattenGroupTree(group.subGroups));
686
+ }
687
+ if (group.children) {
688
+ for (const row of group.children) {
689
+ items.push({ type: "child", row, depth: group.depth + 1 });
690
+ }
691
+ }
692
+ }
693
+ }
694
+ return items;
695
+ }
696
+ $: flatGroupItems = isGrouped ? flattenGroupTree(groupData) : [];
697
+ onMount(() => {
698
+ init();
699
+ });
700
+ onDestroy(() => {
701
+ clearTimeout(searchDebounceTimer);
702
+ if (resizingColumn) {
703
+ handleResizeEnd();
704
+ }
705
+ if (store) {
706
+ store.destroy();
707
+ }
708
+ });
709
+ </script>
710
+
711
+ <svelte:window on:click={closeViewMenus} />
712
+
713
+ <div class={containerClass}>
714
+ {#if error}
715
+ <div class="gridlite-empty">{error}</div>
716
+ {:else if !initialized || storeState.loading}
717
+ <div class="gridlite-loading">Loading...</div>
718
+ {:else if storeState.error}
719
+ <div class="gridlite-empty">Error: {storeState.error.message}</div>
720
+ {:else}
721
+ {#if table && toolbarLayout !== 'aggrid'}
722
+ <div class="gridlite-toolbar">
723
+ <!-- Column Visibility (data control) -->
724
+ {#if features.columnVisibility}
725
+ <div class="gridlite-toolbar-columns gridlite-view-control">
726
+ <button
727
+ class="gridlite-view-control-btn"
728
+ class:active={showColumnPicker}
729
+ on:click|stopPropagation={() => {
730
+ showColumnPicker = !showColumnPicker;
731
+ showRowHeightMenu = false;
732
+ showColumnSpacingMenu = false;
733
+ }}
734
+ type="button"
735
+ title="Columns"
736
+ >
737
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
738
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
739
+ </svg>
740
+ <span class="gridlite-btn-label">Columns</span>
741
+ </button>
742
+ <ColumnPicker
743
+ {columns}
744
+ columnConfigs={config?.columns ?? []}
745
+ {columnVisibility}
746
+ {columnOrder}
747
+ isOpen={showColumnPicker}
748
+ defaultVisibleColumns={config?.defaultVisibleColumns}
749
+ onVisibilityChange={setColumnVisibility}
750
+ onToggleAll={toggleAllColumns}
751
+ onOrderChange={handleColumnOrderChange}
752
+ />
753
+ </div>
754
+ {/if}
755
+
756
+ <!-- Filter -->
757
+ {#if features.filtering}
758
+ <div class="gridlite-toolbar-filter">
759
+ <FilterBar
760
+ {db}
761
+ {table}
762
+ {columns}
763
+ columnConfigs={config?.columns ?? []}
764
+ {allowedColumns}
765
+ conditions={filters}
766
+ onConditionsChange={handleFiltersChange}
767
+ logic={filterLogic}
768
+ onLogicChange={handleLogicChange}
769
+ isExpanded={filterExpanded}
770
+ onExpandedChange={(expanded) => (filterExpanded = expanded)}
771
+ />
772
+ </div>
773
+ {/if}
774
+
775
+ <!-- Group -->
776
+ {#if features.grouping}
777
+ <div class="gridlite-toolbar-group">
778
+ <GroupBar
779
+ {columns}
780
+ columnConfigs={config?.columns ?? []}
781
+ {grouping}
782
+ onGroupingChange={handleGroupingChange}
783
+ isExpanded={groupExpanded}
784
+ onExpandedChange={(expanded) => (groupExpanded = expanded)}
785
+ />
786
+ </div>
787
+ {/if}
788
+
789
+ <!-- Sort -->
790
+ {#if features.sorting}
791
+ <div class="gridlite-toolbar-sort">
792
+ <SortBar
793
+ {columns}
794
+ columnConfigs={config?.columns ?? []}
795
+ {sorting}
796
+ onSortingChange={handleSortingChange}
797
+ isExpanded={sortExpanded}
798
+ onExpandedChange={(expanded) => (sortExpanded = expanded)}
799
+ />
800
+ </div>
801
+ {/if}
802
+
803
+ <!-- View Controls (Row Height + Column Spacing) -->
804
+ <div class="gridlite-toolbar-view gridlite-view-controls">
805
+ <div class="gridlite-view-control">
806
+ <button
807
+ class="gridlite-view-control-btn"
808
+ class:active={showRowHeightMenu}
809
+ on:click|stopPropagation={() => {
810
+ showRowHeightMenu = !showRowHeightMenu;
811
+ showColumnSpacingMenu = false;
812
+ showColumnPicker = false;
813
+ }}
814
+ type="button"
815
+ title="Row height"
816
+ >
817
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
818
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
819
+ </svg>
820
+ </button>
821
+ {#if showRowHeightMenu}
822
+ <div class="gridlite-view-dropdown">
823
+ <div class="gridlite-view-dropdown-title">Row Height</div>
824
+ {#each rowHeightOptions as rh}
825
+ <button
826
+ class="gridlite-view-dropdown-item"
827
+ class:selected={rowHeight === rh}
828
+ on:click={() => {
829
+ rowHeight = rh;
830
+ showRowHeightMenu = false;
831
+ }}
832
+ type="button"
833
+ >
834
+ {rh === 'extra_tall' ? 'Extra Tall' : rh.charAt(0).toUpperCase() + rh.slice(1)}
835
+ </button>
836
+ {/each}
837
+ </div>
838
+ {/if}
839
+ </div>
840
+ <div class="gridlite-view-control">
841
+ <button
842
+ class="gridlite-view-control-btn"
843
+ class:active={showColumnSpacingMenu}
844
+ on:click|stopPropagation={() => {
845
+ showColumnSpacingMenu = !showColumnSpacingMenu;
846
+ showRowHeightMenu = false;
847
+ showColumnPicker = false;
848
+ }}
849
+ type="button"
850
+ title="Column spacing"
851
+ >
852
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
853
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 4v16M15 4v16M4 9h16M4 15h16" />
854
+ </svg>
855
+ </button>
856
+ {#if showColumnSpacingMenu}
857
+ <div class="gridlite-view-dropdown">
858
+ <div class="gridlite-view-dropdown-title">Column Spacing</div>
859
+ {#each columnSpacingOptions as sp}
860
+ <button
861
+ class="gridlite-view-dropdown-item"
862
+ class:selected={columnSpacing === sp}
863
+ on:click={() => {
864
+ columnSpacing = sp;
865
+ showColumnSpacingMenu = false;
866
+ }}
867
+ type="button"
868
+ >
869
+ {sp.charAt(0).toUpperCase() + sp.slice(1)}
870
+ </button>
871
+ {/each}
872
+ </div>
873
+ {/if}
874
+ </div>
875
+ </div>
876
+
877
+ <!-- Search -->
878
+ {#if features.globalSearch}
879
+ <div class="gridlite-toolbar-search">
880
+ <div class="gridlite-search">
881
+ <svg class="gridlite-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
882
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
883
+ </svg>
884
+ <input
885
+ class="gridlite-search-input"
886
+ type="text"
887
+ placeholder="Search all columns..."
888
+ value={globalFilter}
889
+ on:input={handleGlobalSearchInput}
890
+ />
891
+ {#if globalFilter}
892
+ <button class="gridlite-search-clear" on:click={clearGlobalSearch} type="button" title="Clear search">
893
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
894
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
895
+ </svg>
896
+ </button>
897
+ {/if}
898
+ </div>
899
+ </div>
900
+ {/if}
901
+ </div>
902
+ {/if}
903
+
904
+ {#if table && toolbarLayout === 'aggrid'}
905
+ <!-- AG Grid layout: sidebar on right, minimal toolbar on top -->
906
+ <!-- TODO(#1): aggrid layout is experimental — not production-ready. Needs debugging. -->
907
+ <div class="gridlite-toolbar gridlite-toolbar-aggrid-top">
908
+ {#if features.globalSearch}
909
+ <div class="gridlite-toolbar-search">
910
+ <div class="gridlite-search">
911
+ <svg class="gridlite-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
912
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
913
+ </svg>
914
+ <input
915
+ class="gridlite-search-input"
916
+ type="text"
917
+ placeholder="Search all columns..."
918
+ value={globalFilter}
919
+ on:input={handleGlobalSearchInput}
920
+ />
921
+ {#if globalFilter}
922
+ <button class="gridlite-search-clear" on:click={clearGlobalSearch} type="button" title="Clear search">
923
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
924
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
925
+ </svg>
926
+ </button>
927
+ {/if}
928
+ </div>
929
+ </div>
930
+ {/if}
931
+ {#if features.sorting}
932
+ <div class="gridlite-toolbar-sort">
933
+ <SortBar
934
+ {columns}
935
+ columnConfigs={config?.columns ?? []}
936
+ {sorting}
937
+ onSortingChange={handleSortingChange}
938
+ isExpanded={sortExpanded}
939
+ onExpandedChange={(expanded) => (sortExpanded = expanded)}
940
+ />
941
+ </div>
942
+ {/if}
943
+ {#if features.grouping}
944
+ <div class="gridlite-toolbar-group">
945
+ <GroupBar
946
+ {columns}
947
+ columnConfigs={config?.columns ?? []}
948
+ {grouping}
949
+ onGroupingChange={handleGroupingChange}
950
+ isExpanded={groupExpanded}
951
+ onExpandedChange={(expanded) => (groupExpanded = expanded)}
952
+ />
953
+ </div>
954
+ {/if}
955
+ <div class="gridlite-toolbar-view gridlite-view-controls">
956
+ <div class="gridlite-view-control">
957
+ <button
958
+ class="gridlite-view-control-btn"
959
+ class:active={showRowHeightMenu}
960
+ on:click|stopPropagation={() => {
961
+ showRowHeightMenu = !showRowHeightMenu;
962
+ showColumnSpacingMenu = false;
963
+ }}
964
+ type="button"
965
+ title="Row height"
966
+ >
967
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
968
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
969
+ </svg>
970
+ </button>
971
+ {#if showRowHeightMenu}
972
+ <div class="gridlite-view-dropdown">
973
+ <div class="gridlite-view-dropdown-title">Row Height</div>
974
+ {#each rowHeightOptions as rh}
975
+ <button
976
+ class="gridlite-view-dropdown-item"
977
+ class:selected={rowHeight === rh}
978
+ on:click={() => { rowHeight = rh; showRowHeightMenu = false; }}
979
+ type="button"
980
+ >{rh === 'extra_tall' ? 'Extra Tall' : rh.charAt(0).toUpperCase() + rh.slice(1)}</button>
981
+ {/each}
982
+ </div>
983
+ {/if}
984
+ </div>
985
+ <div class="gridlite-view-control">
986
+ <button
987
+ class="gridlite-view-control-btn"
988
+ class:active={showColumnSpacingMenu}
989
+ on:click|stopPropagation={() => {
990
+ showColumnSpacingMenu = !showColumnSpacingMenu;
991
+ showRowHeightMenu = false;
992
+ }}
993
+ type="button"
994
+ title="Column spacing"
995
+ >
996
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
997
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 4v16M15 4v16M4 9h16M4 15h16" />
998
+ </svg>
999
+ </button>
1000
+ {#if showColumnSpacingMenu}
1001
+ <div class="gridlite-view-dropdown">
1002
+ <div class="gridlite-view-dropdown-title">Column Spacing</div>
1003
+ {#each columnSpacingOptions as sp}
1004
+ <button
1005
+ class="gridlite-view-dropdown-item"
1006
+ class:selected={columnSpacing === sp}
1007
+ on:click={() => { columnSpacing = sp; showColumnSpacingMenu = false; }}
1008
+ type="button"
1009
+ >{sp.charAt(0).toUpperCase() + sp.slice(1)}</button>
1010
+ {/each}
1011
+ </div>
1012
+ {/if}
1013
+ </div>
1014
+ </div>
1015
+ </div>
1016
+ {/if}
1017
+
1018
+ <div class="gridlite-body" class:gridlite-aggrid-body={toolbarLayout === 'aggrid'}>
1019
+ <div class="gridlite-table-wrap">
1020
+ <table
1021
+ class={`gridlite-table ${classNames.table ?? ''}`}
1022
+ style={features.columnResizing ? 'table-layout: fixed;' : ''}
1023
+ >
1024
+ <thead class={`gridlite-thead ${classNames.thead ?? ''}`}>
1025
+ <tr class={classNames.tr ?? ''}>
1026
+ {#each (isGrouped ? nonGroupedColumns : orderedColumns) as col}
1027
+ <th
1028
+ class={`gridlite-th gridlite-th-interactive ${classNames.th ?? ''}`}
1029
+ class:dragging={draggedColumnId === col.name}
1030
+ class:drag-over={dragOverColumnId === col.name && draggedColumnId !== col.name}
1031
+ style={features.columnResizing ? `width: ${getColumnWidth(col.name)}px;` : ''}
1032
+ on:dragover={(e) => handleDragOver(e, col.name)}
1033
+ on:drop={(e) => handleDrop(e, col.name)}
1034
+ >
1035
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
1036
+ <div
1037
+ class="gridlite-th-content"
1038
+ draggable={features.columnReordering ?? false}
1039
+ on:dragstart={(e) => handleDragStart(e, col.name)}
1040
+ on:dragend={handleDragEnd}
1041
+ style={features.columnReordering ? 'cursor: grab;' : ''}
1042
+ >
1043
+ <span class="gridlite-th-label">
1044
+ {#if config?.columns}
1045
+ {@const colConfig = config.columns.find((c) => c.name === col.name)}
1046
+ {colConfig?.label ?? col.name}
1047
+ {:else}
1048
+ {col.name}
1049
+ {/if}
1050
+ </span>
1051
+ {#if table}
1052
+ <button
1053
+ class="gridlite-th-menu-btn"
1054
+ on:click|stopPropagation={() =>
1055
+ (columnMenuOpen = columnMenuOpen === col.name ? null : col.name)}
1056
+ title="Column options"
1057
+ type="button"
1058
+ >
1059
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1060
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
1061
+ </svg>
1062
+ </button>
1063
+ <ColumnMenu
1064
+ columnName={col.name}
1065
+ isOpen={columnMenuOpen === col.name}
1066
+ {sorting}
1067
+ canSort={features.sorting ?? false}
1068
+ canFilter={features.filtering ?? false}
1069
+ canGroup={features.grouping ?? false}
1070
+ onSort={handleColumnMenuSort}
1071
+ onFilter={handleColumnMenuFilter}
1072
+ onGroup={handleColumnMenuGroup}
1073
+ onHide={handleColumnMenuHide}
1074
+ onClose={() => (columnMenuOpen = null)}
1075
+ />
1076
+ {/if}
1077
+ </div>
1078
+ {#if features.columnResizing}
1079
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
1080
+ <div
1081
+ class="gridlite-resize-handle"
1082
+ class:resizing={resizingColumn === col.name}
1083
+ on:mousedown={(e) => handleResizeStart(e, col.name)}
1084
+ on:touchstart={(e) => handleResizeStart(e, col.name)}
1085
+ />
1086
+ {/if}
1087
+ </th>
1088
+ {/each}
1089
+ </tr>
1090
+ </thead>
1091
+ <tbody class={`gridlite-tbody ${classNames.tbody ?? ''}`}>
1092
+ {#if isGrouped}
1093
+ <!-- Grouped view: flattened tree of group headers and child rows -->
1094
+ {#if flatGroupItems.length === 0}
1095
+ <tr>
1096
+ <td colspan={nonGroupedColumns.length} class="gridlite-empty">
1097
+ No data
1098
+ </td>
1099
+ </tr>
1100
+ {:else}
1101
+ {#each flatGroupItems as item}
1102
+ {#if item.type === 'group'}
1103
+ {@const group = item.group}
1104
+ {@const key = groupKey(group)}
1105
+ {@const expanded = expandedGroups.has(key)}
1106
+ {@const loading = groupLoading.has(key)}
1107
+ {@const aggs = getGroupAggregations(group)}
1108
+ <!-- Group header row -->
1109
+ <tr
1110
+ class="gridlite-group-row"
1111
+ on:click={() => toggleGroupExpand(group)}
1112
+ role="button"
1113
+ tabindex={0}
1114
+ on:keydown={(e) => {
1115
+ if (e.key === 'Enter' || e.key === ' ') {
1116
+ e.preventDefault();
1117
+ toggleGroupExpand(group);
1118
+ }
1119
+ }}
1120
+ >
1121
+ <td colspan={nonGroupedColumns.length} class="gridlite-group-td">
1122
+ <div class="gridlite-group-header gridlite-group-level-{Math.min(group.depth, 2)}">
1123
+ <svg
1124
+ class="gridlite-group-chevron"
1125
+ class:expanded
1126
+ width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"
1127
+ >
1128
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
1129
+ </svg>
1130
+ <span class="gridlite-group-label">{getGroupLabel(group)}</span>
1131
+ <span class="gridlite-group-count">{group.count}</span>
1132
+ {#each aggs as agg}
1133
+ <span class="gridlite-group-agg" title={agg.label}>{agg.label}: {agg.value}</span>
1134
+ {/each}
1135
+ {#if loading}
1136
+ <span class="gridlite-group-loading">Loading...</span>
1137
+ {/if}
1138
+ </div>
1139
+ </td>
1140
+ </tr>
1141
+ {:else}
1142
+ {@const row = item.row}
1143
+ <!-- Child data row -->
1144
+ <tr
1145
+ class={`gridlite-tr gridlite-group-child ${classNames.tr ?? ''}`}
1146
+ on:click={() => {
1147
+ onRowClick?.(row);
1148
+ }}
1149
+ role={onRowClick ? 'button' : undefined}
1150
+ tabindex={onRowClick ? 0 : undefined}
1151
+ on:keydown={(e) => {
1152
+ if ((e.key === 'Enter' || e.key === ' ')) {
1153
+ e.preventDefault();
1154
+ onRowClick?.(row);
1155
+ }
1156
+ }}
1157
+ >
1158
+ {#each nonGroupedColumns as col}
1159
+ <td
1160
+ class={`gridlite-td ${classNames.td ?? ''}`}
1161
+ on:contextmenu={(e) => handleCellContextMenu(e, row, col)}
1162
+ >
1163
+ {#if config?.columns}
1164
+ {@const colConfig = config.columns.find((c) => c.name === col.name)}
1165
+ {#if colConfig?.format}
1166
+ {colConfig.format(row[col.name])}
1167
+ {:else}
1168
+ {row[col.name] ?? ''}
1169
+ {/if}
1170
+ {:else}
1171
+ {row[col.name] ?? ''}
1172
+ {/if}
1173
+ </td>
1174
+ {/each}
1175
+ </tr>
1176
+ {/if}
1177
+ {/each}
1178
+ {/if}
1179
+ {:else}
1180
+ <!-- Flat (non-grouped) view -->
1181
+ {#if storeState.rows.length === 0}
1182
+ <tr>
1183
+ <td colspan={orderedColumns.length} class="gridlite-empty">
1184
+ No data
1185
+ </td>
1186
+ </tr>
1187
+ {:else}
1188
+ {#each storeState.rows as row, rowIndex}
1189
+ <tr
1190
+ class={`gridlite-tr ${classNames.tr ?? ''}`}
1191
+ on:click={() => {
1192
+ if (features.rowDetail) {
1193
+ openRowDetail(rowIndex);
1194
+ }
1195
+ onRowClick?.(row);
1196
+ }}
1197
+ role={onRowClick || features.rowDetail ? 'button' : undefined}
1198
+ tabindex={onRowClick || features.rowDetail ? 0 : undefined}
1199
+ on:keydown={(e) => {
1200
+ if ((e.key === 'Enter' || e.key === ' ')) {
1201
+ e.preventDefault();
1202
+ if (features.rowDetail) openRowDetail(rowIndex);
1203
+ onRowClick?.(row);
1204
+ }
1205
+ }}
1206
+ >
1207
+ {#each orderedColumns as col}
1208
+ <td
1209
+ class={`gridlite-td ${classNames.td ?? ''}`}
1210
+ on:contextmenu={(e) => handleCellContextMenu(e, row, col)}
1211
+ >
1212
+ {#if config?.columns}
1213
+ {@const colConfig = config.columns.find((c) => c.name === col.name)}
1214
+ {#if colConfig?.format}
1215
+ {colConfig.format(row[col.name])}
1216
+ {:else}
1217
+ {row[col.name] ?? ''}
1218
+ {/if}
1219
+ {:else}
1220
+ {row[col.name] ?? ''}
1221
+ {/if}
1222
+ </td>
1223
+ {/each}
1224
+ </tr>
1225
+ {/each}
1226
+ {/if}
1227
+ {/if}
1228
+ </tbody>
1229
+ </table>
1230
+ </div>
1231
+
1232
+ {#if features.pagination !== false && totalRows > 0}
1233
+ <div class={`gridlite-pagination ${classNames.pagination ?? ''}`}>
1234
+ <span>
1235
+ Page {page + 1} of {totalPages} ({totalRows} {isGrouped ? 'groups' : 'rows'})
1236
+ </span>
1237
+ <div class="gridlite-pagination-controls">
1238
+ <select
1239
+ class="gridlite-page-size-select"
1240
+ value={pageSize}
1241
+ on:change={(e) => setPageSize(Number(e.currentTarget.value))}
1242
+ >
1243
+ {#each config?.pagination?.pageSizeOptions ?? [10, 25, 50, 100] as size}
1244
+ <option value={size}>{size} / page</option>
1245
+ {/each}
1246
+ </select>
1247
+ <button disabled={page === 0} on:click={() => setPage(0)}>
1248
+ First
1249
+ </button>
1250
+ <button disabled={page === 0} on:click={() => setPage(page - 1)}>
1251
+ Prev
1252
+ </button>
1253
+ <button disabled={page >= totalPages - 1} on:click={() => setPage(page + 1)}>
1254
+ Next
1255
+ </button>
1256
+ <button disabled={page >= totalPages - 1} on:click={() => setPage(totalPages - 1)}>
1257
+ Last
1258
+ </button>
1259
+ </div>
1260
+ </div>
1261
+ {/if}
1262
+
1263
+ {#if toolbarLayout === 'aggrid'}
1264
+ <!-- AG Grid sidebar: columns + filters on right -->
1265
+ <!-- TODO(#1): aggrid sidebar is experimental — not production-ready. Needs debugging. -->
1266
+ <aside class="gridlite-aggrid-sidebar">
1267
+ {#if features.columnVisibility}
1268
+ <div class="gridlite-aggrid-sidebar-section">
1269
+ <div class="gridlite-aggrid-sidebar-header">Columns</div>
1270
+ <ColumnPicker
1271
+ {columns}
1272
+ columnConfigs={config?.columns ?? []}
1273
+ {columnVisibility}
1274
+ {columnOrder}
1275
+ isOpen={true}
1276
+ defaultVisibleColumns={config?.defaultVisibleColumns}
1277
+ onVisibilityChange={setColumnVisibility}
1278
+ onToggleAll={toggleAllColumns}
1279
+ onOrderChange={handleColumnOrderChange}
1280
+ />
1281
+ </div>
1282
+ {/if}
1283
+ {#if features.filtering}
1284
+ <div class="gridlite-aggrid-sidebar-section">
1285
+ <div class="gridlite-aggrid-sidebar-header">Filters</div>
1286
+ <FilterBar
1287
+ {db}
1288
+ table={table ?? ''}
1289
+ {columns}
1290
+ columnConfigs={config?.columns ?? []}
1291
+ {allowedColumns}
1292
+ conditions={filters}
1293
+ onConditionsChange={handleFiltersChange}
1294
+ logic={filterLogic}
1295
+ onLogicChange={handleLogicChange}
1296
+ isExpanded={true}
1297
+ onExpandedChange={() => {}}
1298
+ />
1299
+ </div>
1300
+ {/if}
1301
+ </aside>
1302
+ {/if}
1303
+ </div>
1304
+
1305
+ {#if contextMenu}
1306
+ <CellContextMenu
1307
+ x={contextMenu.x}
1308
+ y={contextMenu.y}
1309
+ value={contextMenu.value}
1310
+ columnName={contextMenu.columnName}
1311
+ columnLabel={contextMenu.columnLabel}
1312
+ isNumeric={contextMenu.isNumeric}
1313
+ onFilterEquals={handleContextFilterEquals}
1314
+ onFilterNotEquals={handleContextFilterNotEquals}
1315
+ onFilterGreaterThan={handleContextFilterGreaterThan}
1316
+ onFilterLessThan={handleContextFilterLessThan}
1317
+ onClose={() => (contextMenu = null)}
1318
+ />
1319
+ {/if}
1320
+
1321
+ {#if features.rowDetail}
1322
+ <RowDetailModal
1323
+ isOpen={rowDetailOpen}
1324
+ hasPrev={rowDetailIndex > 0}
1325
+ hasNext={rowDetailIndex < storeState.rows.length - 1}
1326
+ onClose={closeRowDetail}
1327
+ onPrev={prevRowDetail}
1328
+ onNext={nextRowDetail}
1329
+ >
1330
+ {#if rowDetailRow}
1331
+ <dl class="gridlite-row-detail">
1332
+ {#each orderedColumns as col}
1333
+ <div class="gridlite-row-detail-field">
1334
+ <dt>
1335
+ {#if config?.columns}
1336
+ {@const colConfig = config.columns.find((c) => c.name === col.name)}
1337
+ {colConfig?.label ?? col.name}
1338
+ {:else}
1339
+ {col.name}
1340
+ {/if}
1341
+ </dt>
1342
+ <dd>
1343
+ {#if config?.columns}
1344
+ {@const colConfig = config.columns.find((c) => c.name === col.name)}
1345
+ {#if colConfig?.format}
1346
+ {colConfig.format(rowDetailRow[col.name])}
1347
+ {:else}
1348
+ {rowDetailRow[col.name] ?? '—'}
1349
+ {/if}
1350
+ {:else}
1351
+ {rowDetailRow[col.name] ?? '—'}
1352
+ {/if}
1353
+ </dd>
1354
+ </div>
1355
+ {/each}
1356
+ </dl>
1357
+ {/if}
1358
+ </RowDetailModal>
1359
+ {/if}
1360
+ {/if}
1361
+ </div>