@shotleybuilder/svelte-gridlite-kit 0.2.0 → 0.3.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/README.md CHANGED
@@ -156,12 +156,13 @@ The component accepts a **PGLite instance + table name** (or a raw SQL query), n
156
156
  | Numeric range hints | Scan all rows for min/max | `SELECT MIN(), MAX()` |
157
157
  | Column type detection | Sample rows and guess | Schema introspection |
158
158
  | Config persistence | localStorage JSON | PGLite tables with IndexedDB backing |
159
+ | Column labels | Static config only | User-editable, persisted in PGLite |
159
160
  | Global search | String matching across columns | `ILIKE` or full-text search |
160
161
 
161
162
  ### Key Design Decisions
162
163
 
163
164
  - **No TanStack Table dependency.** The SQL engine IS the table engine.
164
- - **PGLite is the state store.** Table configs, view presets, column visibility, sort/filter state — all stored in PGLite tables, persisted automatically via IndexedDB.
165
+ - **PGLite is the state store.** Table configs, view presets, column visibility, custom labels, sort/filter state — all stored in PGLite tables, persisted automatically via IndexedDB.
165
166
  - **FilterBar emits SQL.** Postgres operators (regex, `ILIKE`, date math, JSON paths, FTS) are available natively.
166
167
  - **Live queries drive reactivity.** PGLite `live.query()` replaces Svelte writable stores for data. UI auto-updates when underlying data changes.
167
168
  - **Column types come from schema introspection**, not data sampling.
@@ -11,6 +11,7 @@ import {
11
11
  createLiveQueryStore
12
12
  } from "./query/live.js";
13
13
  import { runMigrations } from "./state/migrations.js";
14
+ import { loadColumnState, saveColumnState } from "./state/views.js";
14
15
  import FilterBar from "./components/FilterBar.svelte";
15
16
  import SortBar from "./components/SortBar.svelte";
16
17
  import GroupBar from "./components/GroupBar.svelte";
@@ -63,6 +64,9 @@ let resizeStartWidth = 0;
63
64
  const COL_MIN_WIDTH = 62;
64
65
  const COL_MAX_WIDTH = 1e3;
65
66
  const COL_DEFAULT_WIDTH = 180;
67
+ let customLabels = {};
68
+ let editingColumnLabel = null;
69
+ let editingLabelValue = "";
66
70
  let groupData = [];
67
71
  let expandedGroups = /* @__PURE__ */ new Set();
68
72
  let totalGroups = 0;
@@ -110,6 +114,20 @@ $: orderedColumns = (() => {
110
114
  $: validGrouping = grouping.filter((g) => g.column !== "");
111
115
  $: isGrouped = validGrouping.length > 0;
112
116
  $: nonGroupedColumns = isGrouped ? orderedColumns.filter((col) => !validGrouping.some((g) => g.column === col.name)) : orderedColumns;
117
+ $: mergedColumnConfigs = (() => {
118
+ const base = config?.columns ?? [];
119
+ if (Object.keys(customLabels).length === 0) return base;
120
+ const configMap = new Map(base.map((c) => [c.name, c]));
121
+ for (const [name, label] of Object.entries(customLabels)) {
122
+ const existing = configMap.get(name);
123
+ if (existing) {
124
+ configMap.set(name, { ...existing, label });
125
+ } else {
126
+ configMap.set(name, { name, label });
127
+ }
128
+ }
129
+ return [...configMap.values()];
130
+ })();
113
131
  function groupKey(group) {
114
132
  return Object.entries(group.values).map(([col, val]) => `${col}=${val === null || val === void 0 ? "__null__" : String(val)}`).join("::");
115
133
  }
@@ -124,6 +142,14 @@ async function init() {
124
142
  return;
125
143
  }
126
144
  }
145
+ if (config?.id) {
146
+ const savedState = await loadColumnState(db, config.id);
147
+ const labels = {};
148
+ for (const col of savedState) {
149
+ if (col.label) labels[col.name] = col.label;
150
+ }
151
+ if (Object.keys(labels).length > 0) customLabels = labels;
152
+ }
127
153
  initialized = true;
128
154
  await rebuildQuery();
129
155
  } catch (err) {
@@ -478,13 +504,12 @@ function clearGlobalSearch() {
478
504
  }
479
505
  function handleCellContextMenu(event, row, col) {
480
506
  event.preventDefault();
481
- const colConfig = config?.columns?.find((c) => c.name === col.name);
482
507
  contextMenu = {
483
508
  x: event.clientX,
484
509
  y: event.clientY,
485
510
  value: row[col.name],
486
511
  columnName: col.name,
487
- columnLabel: colConfig?.label ?? col.name,
512
+ columnLabel: getColumnLabel(col),
488
513
  isNumeric: col.dataType === "number"
489
514
  };
490
515
  }
@@ -559,9 +584,60 @@ function handleColumnOrderChange(newOrder) {
559
584
  notifyStateChange();
560
585
  }
561
586
  function getColumnLabel(col) {
587
+ if (col.name in customLabels) return customLabels[col.name];
562
588
  const cfg = config?.columns?.find((c) => c.name === col.name);
563
589
  return cfg?.label ?? col.name;
564
590
  }
591
+ function startEditingLabel(columnName) {
592
+ const col = columns.find((c) => c.name === columnName);
593
+ if (!col) return;
594
+ editingColumnLabel = columnName;
595
+ editingLabelValue = getColumnLabel(col);
596
+ }
597
+ function commitLabelEdit() {
598
+ if (!editingColumnLabel) return;
599
+ const columnName = editingColumnLabel;
600
+ const newLabel = editingLabelValue.trim();
601
+ const cfg = config?.columns?.find((c) => c.name === columnName);
602
+ const defaultLabel = cfg?.label ?? columnName;
603
+ if (newLabel && newLabel !== defaultLabel) {
604
+ customLabels = { ...customLabels, [columnName]: newLabel };
605
+ } else {
606
+ const { [columnName]: _, ...rest } = customLabels;
607
+ customLabels = rest;
608
+ }
609
+ editingColumnLabel = null;
610
+ editingLabelValue = "";
611
+ persistColumnLabels();
612
+ notifyStateChange();
613
+ }
614
+ function cancelLabelEdit() {
615
+ editingColumnLabel = null;
616
+ editingLabelValue = "";
617
+ }
618
+ function handleLabelKeydown(event) {
619
+ if (event.key === "Enter") {
620
+ event.preventDefault();
621
+ commitLabelEdit();
622
+ } else if (event.key === "Escape") {
623
+ cancelLabelEdit();
624
+ }
625
+ }
626
+ async function persistColumnLabels() {
627
+ if (!config?.id) return;
628
+ try {
629
+ const colState = columns.map((col, i) => ({
630
+ name: col.name,
631
+ visible: isColumnVisible(col.name),
632
+ width: columnSizing[col.name] ?? void 0,
633
+ position: columnOrder.indexOf(col.name) >= 0 ? columnOrder.indexOf(col.name) : i,
634
+ label: customLabels[col.name] ?? null
635
+ }));
636
+ await saveColumnState(db, config.id, colState);
637
+ } catch (err) {
638
+ console.error("Failed to persist column labels:", err);
639
+ }
640
+ }
565
641
  function isColumnVisible(columnName) {
566
642
  if (columnName in columnVisibility) {
567
643
  return columnVisibility[columnName];
@@ -744,7 +820,7 @@ onDestroy(() => {
744
820
  </button>
745
821
  <ColumnPicker
746
822
  {columns}
747
- columnConfigs={config?.columns ?? []}
823
+ columnConfigs={mergedColumnConfigs}
748
824
  {columnVisibility}
749
825
  {columnOrder}
750
826
  isOpen={showColumnPicker}
@@ -763,7 +839,7 @@ onDestroy(() => {
763
839
  {db}
764
840
  {table}
765
841
  {columns}
766
- columnConfigs={config?.columns ?? []}
842
+ columnConfigs={mergedColumnConfigs}
767
843
  {allowedColumns}
768
844
  conditions={filters}
769
845
  onConditionsChange={handleFiltersChange}
@@ -780,7 +856,7 @@ onDestroy(() => {
780
856
  <div class="gridlite-toolbar-group">
781
857
  <GroupBar
782
858
  {columns}
783
- columnConfigs={config?.columns ?? []}
859
+ columnConfigs={mergedColumnConfigs}
784
860
  {grouping}
785
861
  onGroupingChange={handleGroupingChange}
786
862
  isExpanded={groupExpanded}
@@ -794,7 +870,7 @@ onDestroy(() => {
794
870
  <div class="gridlite-toolbar-sort">
795
871
  <SortBar
796
872
  {columns}
797
- columnConfigs={config?.columns ?? []}
873
+ columnConfigs={mergedColumnConfigs}
798
874
  {sorting}
799
875
  onSortingChange={handleSortingChange}
800
876
  isExpanded={sortExpanded}
@@ -941,7 +1017,7 @@ onDestroy(() => {
941
1017
  <div class="gridlite-toolbar-sort">
942
1018
  <SortBar
943
1019
  {columns}
944
- columnConfigs={config?.columns ?? []}
1020
+ columnConfigs={mergedColumnConfigs}
945
1021
  {sorting}
946
1022
  onSortingChange={handleSortingChange}
947
1023
  isExpanded={sortExpanded}
@@ -953,7 +1029,7 @@ onDestroy(() => {
953
1029
  <div class="gridlite-toolbar-group">
954
1030
  <GroupBar
955
1031
  {columns}
956
- columnConfigs={config?.columns ?? []}
1032
+ columnConfigs={mergedColumnConfigs}
957
1033
  {grouping}
958
1034
  onGroupingChange={handleGroupingChange}
959
1035
  isExpanded={groupExpanded}
@@ -1052,14 +1128,27 @@ onDestroy(() => {
1052
1128
  on:dragend={handleDragEnd}
1053
1129
  style={features.columnReordering ? 'cursor: grab;' : ''}
1054
1130
  >
1055
- <span class="gridlite-th-label">
1056
- {#if config?.columns}
1057
- {@const colConfig = config.columns.find((c) => c.name === col.name)}
1058
- {colConfig?.label ?? col.name}
1059
- {:else}
1060
- {col.name}
1061
- {/if}
1062
- </span>
1131
+ {#if editingColumnLabel === col.name}
1132
+ <!-- svelte-ignore a11y-autofocus -->
1133
+ <input
1134
+ class="gridlite-th-label-input"
1135
+ type="text"
1136
+ bind:value={editingLabelValue}
1137
+ on:blur={commitLabelEdit}
1138
+ on:keydown={handleLabelKeydown}
1139
+ on:click|stopPropagation
1140
+ autofocus
1141
+ />
1142
+ {:else}
1143
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
1144
+ <span
1145
+ class="gridlite-th-label"
1146
+ on:dblclick|stopPropagation={() => startEditingLabel(col.name)}
1147
+ title="Double-click to rename"
1148
+ >
1149
+ {getColumnLabel(col)}
1150
+ </span>
1151
+ {/if}
1063
1152
  {#if table}
1064
1153
  <button
1065
1154
  class="gridlite-th-menu-btn"
@@ -1285,7 +1374,7 @@ onDestroy(() => {
1285
1374
  <div class="gridlite-aggrid-sidebar-header">Columns</div>
1286
1375
  <ColumnPicker
1287
1376
  {columns}
1288
- columnConfigs={config?.columns ?? []}
1377
+ columnConfigs={mergedColumnConfigs}
1289
1378
  {columnVisibility}
1290
1379
  {columnOrder}
1291
1380
  isOpen={true}
@@ -1303,7 +1392,7 @@ onDestroy(() => {
1303
1392
  {db}
1304
1393
  table={table ?? ''}
1305
1394
  {columns}
1306
- columnConfigs={config?.columns ?? []}
1395
+ columnConfigs={mergedColumnConfigs}
1307
1396
  {allowedColumns}
1308
1397
  conditions={filters}
1309
1398
  onConditionsChange={handleFiltersChange}
@@ -1350,17 +1439,10 @@ onDestroy(() => {
1350
1439
  <dl class="gridlite-row-detail">
1351
1440
  {#each orderedColumns as col}
1352
1441
  <div class="gridlite-row-detail-field">
1353
- <dt>
1354
- {#if config?.columns}
1355
- {@const colConfig = config.columns.find((c) => c.name === col.name)}
1356
- {colConfig?.label ?? col.name}
1357
- {:else}
1358
- {col.name}
1359
- {/if}
1360
- </dt>
1442
+ <dt>{getColumnLabel(col)}</dt>
1361
1443
  <dd>
1362
- {#if config?.columns}
1363
- {@const colConfig = config.columns.find((c) => c.name === col.name)}
1444
+ {#if mergedColumnConfigs.length > 0}
1445
+ {@const colConfig = mergedColumnConfigs.find((c) => c.name === col.name)}
1364
1446
  {#if colConfig?.format}
1365
1447
  {colConfig.format(rowDetailRow[col.name])}
1366
1448
  {:else}
@@ -22,6 +22,8 @@ declare const __propDef: {
22
22
  setGlobalFilter?: (search: string) => void;
23
23
  };
24
24
  events: {
25
+ click: PointerEvent;
26
+ } & {
25
27
  [evt: string]: CustomEvent<any>;
26
28
  };
27
29
  slots: {
@@ -51,6 +51,13 @@ const MIGRATIONS = [
51
51
  ON _gridlite_column_state (grid_id);
52
52
  `,
53
53
  },
54
+ {
55
+ version: 2,
56
+ description: "Add label column to column_state for user-editable column names",
57
+ sql: `
58
+ ALTER TABLE _gridlite_column_state ADD COLUMN IF NOT EXISTS label TEXT;
59
+ `,
60
+ },
54
61
  ];
55
62
  // ─── Migration Runner ───────────────────────────────────────────────────────
56
63
  /**
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Requires `runMigrations()` to have been called first.
9
9
  */
10
- import type { PGlite } from '@electric-sql/pglite';
11
- import type { ViewPreset } from '../types.js';
10
+ import type { PGlite } from "@electric-sql/pglite";
11
+ import type { ViewPreset } from "../types.js";
12
12
  /**
13
13
  * Save a view configuration. Creates or updates (upserts).
14
14
  */
@@ -42,6 +42,7 @@ export declare function saveColumnState(db: PGlite, gridId: string, columns: {
42
42
  visible?: boolean;
43
43
  width?: number;
44
44
  position?: number;
45
+ label?: string | null;
45
46
  }[], viewId?: string): Promise<void>;
46
47
  /**
47
48
  * Load column state for a grid (optionally scoped to a view).
@@ -51,4 +52,5 @@ export declare function loadColumnState(db: PGlite, gridId: string, viewId?: str
51
52
  visible: boolean;
52
53
  width: number | null;
53
54
  position: number | null;
55
+ label: string | null;
54
56
  }[]>;
@@ -30,11 +30,11 @@ export async function saveView(db, gridId, view) {
30
30
  view.name,
31
31
  view.description ?? null,
32
32
  JSON.stringify(view.filters ?? []),
33
- view.filterLogic ?? 'and',
33
+ view.filterLogic ?? "and",
34
34
  JSON.stringify(view.sorting ?? []),
35
35
  JSON.stringify(view.grouping ?? []),
36
36
  JSON.stringify(view.columnVisibility ?? {}),
37
- JSON.stringify(view.columnOrder ?? [])
37
+ JSON.stringify(view.columnOrder ?? []),
38
38
  ]);
39
39
  }
40
40
  /**
@@ -76,7 +76,9 @@ export async function setDefaultView(db, gridId, viewId) {
76
76
  */
77
77
  export async function deleteView(db, viewId) {
78
78
  // Also clean up associated column state
79
- await db.query(`DELETE FROM _gridlite_column_state WHERE view_id = $1`, [viewId]);
79
+ await db.query(`DELETE FROM _gridlite_column_state WHERE view_id = $1`, [
80
+ viewId,
81
+ ]);
80
82
  await db.query(`DELETE FROM _gridlite_views WHERE id = $1`, [viewId]);
81
83
  }
82
84
  // ─── Column State CRUD ──────────────────────────────────────────────────────
@@ -84,26 +86,27 @@ export async function deleteView(db, viewId) {
84
86
  * Save column state for a grid (optionally scoped to a view).
85
87
  * Replaces all existing column state for the given grid+view.
86
88
  */
87
- export async function saveColumnState(db, gridId, columns, viewId = '__default__') {
89
+ export async function saveColumnState(db, gridId, columns, viewId = "__default__") {
88
90
  // Clear existing state for this grid+view
89
91
  await db.query(`DELETE FROM _gridlite_column_state WHERE grid_id = $1 AND view_id = $2`, [gridId, viewId]);
90
92
  // Insert new state
91
93
  for (const col of columns) {
92
- await db.query(`INSERT INTO _gridlite_column_state (grid_id, view_id, column_name, visible, width, position)
93
- VALUES ($1, $2, $3, $4, $5, $6)`, [
94
+ await db.query(`INSERT INTO _gridlite_column_state (grid_id, view_id, column_name, visible, width, position, label)
95
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
94
96
  gridId,
95
97
  viewId,
96
98
  col.name,
97
99
  col.visible ?? true,
98
100
  col.width ?? null,
99
- col.position ?? null
101
+ col.position ?? null,
102
+ col.label ?? null,
100
103
  ]);
101
104
  }
102
105
  }
103
106
  /**
104
107
  * Load column state for a grid (optionally scoped to a view).
105
108
  */
106
- export async function loadColumnState(db, gridId, viewId = '__default__') {
109
+ export async function loadColumnState(db, gridId, viewId = "__default__") {
107
110
  const result = await db.query(`SELECT * FROM _gridlite_column_state
108
111
  WHERE grid_id = $1 AND view_id = $2
109
112
  ORDER BY position NULLS LAST, column_name`, [gridId, viewId]);
@@ -111,7 +114,8 @@ export async function loadColumnState(db, gridId, viewId = '__default__') {
111
114
  name: row.column_name,
112
115
  visible: row.visible,
113
116
  width: row.width,
114
- position: row.position
117
+ position: row.position,
118
+ label: row.label,
115
119
  }));
116
120
  }
117
121
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -125,6 +129,6 @@ function rowToViewPreset(row) {
125
129
  sorting: row.sorting,
126
130
  grouping: row.grouping,
127
131
  columnVisibility: row.column_visibility,
128
- columnOrder: row.column_order
132
+ columnOrder: row.column_order,
129
133
  };
130
134
  }
@@ -752,6 +752,21 @@
752
752
 
753
753
  .gridlite-th-label {
754
754
  flex: 1;
755
+ cursor: default;
756
+ }
757
+
758
+ .gridlite-th-label-input {
759
+ flex: 1;
760
+ font: inherit;
761
+ font-weight: inherit;
762
+ font-size: inherit;
763
+ padding: 0 2px;
764
+ margin: -1px 0;
765
+ border: 1px solid #3b82f6;
766
+ border-radius: 3px;
767
+ outline: none;
768
+ background: #fff;
769
+ min-width: 40px;
755
770
  }
756
771
 
757
772
  .gridlite-th-menu-btn {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotleybuilder/svelte-gridlite-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A SQL-native data grid component for Svelte and SvelteKit, powered by PGLite",
5
5
  "author": "Sertantai",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "peerDependencies": {
53
53
  "svelte": "^4.0.0 || ^5.0.0",
54
- "@electric-sql/pglite": "^0.2.0"
54
+ "@electric-sql/pglite": ">=0.2.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@electric-sql/pglite": "^0.2.0",