@warkypublic/svelix 0.1.34 → 0.1.36

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.
@@ -9,14 +9,14 @@ type GlobalStateStoreApi = {
9
9
  declare const createGlobalStateStore: () => GlobalStateStoreApi;
10
10
  declare const GlobalStateStore: GlobalStateStoreApi;
11
11
  declare const languageStore: Readable<SupportedLanguage>;
12
+ declare const isLoggedInStore: Readable<boolean>;
12
13
  declare function useGlobalStateStore(): Readable<GlobalStateStoreType>;
13
14
  declare function useGlobalStateStore<T>(selector: (state: GlobalStateStoreType) => T): Readable<T>;
14
15
  declare const setApiURL: (url: string) => void;
15
16
  declare const getApiURL: () => string;
16
17
  declare const getAuthToken: () => string;
17
- declare const isLoggedIn: () => boolean;
18
18
  declare const setAuthToken: (token: string) => void;
19
19
  declare const GetGlobalState: () => GlobalStateStoreType;
20
20
  declare const setLanguage: (lang: SupportedLanguage) => void;
21
21
  export type { GlobalStateStoreApi };
22
- export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedIn, languageStore, setApiURL, setAuthToken, setLanguage, useGlobalStateStore, };
22
+ export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedInStore, languageStore, setApiURL, setAuthToken, setLanguage, useGlobalStateStore, };
@@ -102,12 +102,29 @@ const hasSessionValidationSignal = (session, user) => {
102
102
  user?.guid ||
103
103
  user?.username);
104
104
  };
105
+ const computeIsLoggedIn = (session) => {
106
+ if (!session.validated || !session.loggedIn || !session.authToken) {
107
+ return false;
108
+ }
109
+ return !isSessionExpired(session);
110
+ };
105
111
  const createGlobalStateStore = () => {
106
112
  const initialState = createInitialState();
107
113
  let isStorageInitialized = false;
108
114
  let initializationPromise = null;
109
115
  let operationLock = Promise.resolve();
110
116
  let hasAutoValidated = false;
117
+ let validationScheduled = false;
118
+ const scheduleValidation = () => {
119
+ if (validationScheduled)
120
+ return;
121
+ validationScheduled = true;
122
+ waitForInitialization()
123
+ .then(() => withOperationLock(() => fetchDataInternal()))
124
+ .finally(() => {
125
+ validationScheduled = false;
126
+ });
127
+ };
111
128
  const store = writable(undefined);
112
129
  const getState = () => get(store);
113
130
  const setState = (partial, replace = false) => {
@@ -126,7 +143,7 @@ const createGlobalStateStore = () => {
126
143
  hasAutoValidated = true;
127
144
  const session = after.session;
128
145
  if (hasSessionCredentials(session) && !session.validated) {
129
- waitForInitialization().then(() => withOperationLock(() => fetchDataInternal()));
146
+ scheduleValidation();
130
147
  }
131
148
  }
132
149
  };
@@ -258,16 +275,7 @@ const createGlobalStateStore = () => {
258
275
  await waitForInitialization();
259
276
  return withOperationLock(() => fetchDataInternal(url));
260
277
  },
261
- isLoggedIn: () => {
262
- const session = getState().session;
263
- if (!session.validated || !session.loggedIn || !session.authToken) {
264
- return false;
265
- }
266
- if (isSessionExpired(session)) {
267
- return false;
268
- }
269
- return true;
270
- },
278
+ isLoggedIn: () => computeIsLoggedIn(getState().session),
271
279
  login: async (authToken, user) => {
272
280
  await waitForInitialization();
273
281
  return withOperationLock(async () => {
@@ -463,9 +471,6 @@ const createGlobalStateStore = () => {
463
471
  ...loadedState.session,
464
472
  connected: true,
465
473
  loading: false,
466
- loggedIn: hasPersistedSession
467
- ? false
468
- : loadedState.session?.loggedIn ?? current.session.loggedIn,
469
474
  validated: hasPersistedSession
470
475
  ? false
471
476
  : loadedState.session?.validated ?? current.session.validated,
@@ -484,6 +489,12 @@ const createGlobalStateStore = () => {
484
489
  if (!isStorageInitialized) {
485
490
  return;
486
491
  }
492
+ if (hasSessionCredentials(state.session) &&
493
+ !state.session.validated &&
494
+ !computeIsLoggedIn(state.session) &&
495
+ typeof state.onFetchSession === "function") {
496
+ scheduleValidation();
497
+ }
487
498
  saveStorage(toPersistedState(state)).catch((e) => {
488
499
  console.error("Error saving storage:", e);
489
500
  });
@@ -499,6 +510,7 @@ const GlobalStateStoreReadable = {
499
510
  subscribe: GlobalStateStore.subscribe,
500
511
  };
501
512
  const languageStore = derived(GlobalStateStoreReadable, (state) => state.user.language ?? "en");
513
+ const isLoggedInStore = derived(GlobalStateStoreReadable, (state) => computeIsLoggedIn(state.session));
502
514
  initTranslation(languageStore);
503
515
  function useGlobalStateStore(selector) {
504
516
  if (!selector) {
@@ -515,9 +527,6 @@ const getApiURL = () => {
515
527
  const getAuthToken = () => {
516
528
  return GlobalStateStore.getState().session.authToken ?? "";
517
529
  };
518
- const isLoggedIn = () => {
519
- return GlobalStateStore.getState().isLoggedIn();
520
- };
521
530
  const setAuthToken = (token) => {
522
531
  GlobalStateStore.getState().setAuthToken(token);
523
532
  };
@@ -527,4 +536,4 @@ const GetGlobalState = () => {
527
536
  const setLanguage = (lang) => {
528
537
  GlobalStateStore.getState().setLanguage(lang);
529
538
  };
530
- export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedIn, languageStore, setApiURL, setAuthToken, setLanguage, useGlobalStateStore, };
539
+ export { createGlobalStateStore, getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, isLoggedInStore, languageStore, setApiURL, setAuthToken, setLanguage, useGlobalStateStore, };
@@ -0,0 +1,209 @@
1
+ # GlobalStateStore
2
+
3
+ Singleton Svelte store for app-wide authenticated state. Persists all slices to `localStorage` and re-validates credentials on every page load.
4
+
5
+ ---
6
+
7
+ ## Setup
8
+
9
+ Wrap your app with `GlobalStateStoreProvider` and wire up the three server callbacks:
10
+
11
+ ```svelte
12
+ <script lang="ts">
13
+ import { GlobalStateStoreProvider } from '@warkypublic/svelix';
14
+
15
+ const onLogin = async (state) => {
16
+ const res = await api.post('/auth/login', { token: state.session.authToken });
17
+ return { session: res.session, user: res.user };
18
+ };
19
+
20
+ const onLogout = async () => {
21
+ await api.post('/auth/logout');
22
+ };
23
+
24
+ const onFetchSession = async (state) => {
25
+ const res = await api.get('/app/bootstrap');
26
+ return { session: res.session, user: res.user, navigation: res.nav, program: res.program };
27
+ };
28
+ </script>
29
+
30
+ <GlobalStateStoreProvider {onLogin} {onLogout} {onFetchSession}>
31
+ {@render children()}
32
+ </GlobalStateStoreProvider>
33
+ ```
34
+
35
+ ### Provider props
36
+
37
+ | Prop | Type | Default | Description |
38
+ |---|---|---|---|
39
+ | `apiURL` | `string` | — | Sets `session.apiURL` on mount |
40
+ | `autoFetch` | `boolean` | `true` | Enables automatic `fetchData` calls |
41
+ | `fetchOnMount` | `boolean` | `true` | Calls `fetchData` once after mount |
42
+ | `onFetchSession` | `fn` | — | Called by `fetchData`; must return state slice or void |
43
+ | `onLogin` | `fn` | — | Called after credentials are set; return confirms the session |
44
+ | `onLogout` | `fn` | — | Called during logout |
45
+ | `program` | `Partial<ProgramState>` | — | Merged into `program` slice on mount |
46
+ | `throttleMs` | `number` | `0` | Minimum ms between `fetchData` calls |
47
+
48
+ ---
49
+
50
+ ## State slices
51
+
52
+ ### `session`
53
+
54
+ | Field | Type | Description |
55
+ |---|---|---|
56
+ | `apiURL` | `string` | Base API URL |
57
+ | `authToken` | `string` | Bearer token |
58
+ | `loggedIn` | `boolean` | Persisted login intent |
59
+ | `validated` | `boolean` | Server has confirmed the session this load |
60
+ | `expiryDate` | `string` | ISO date; `isLoggedIn` returns false after this |
61
+ | `connected` | `boolean` | Last fetch succeeded |
62
+ | `loading` | `boolean` | Fetch in progress |
63
+ | `error` | `string` | Last fetch error message |
64
+
65
+ ### `user`
66
+
67
+ `guid`, `username`, `email`, `fullNames`, `language`, `isAdmin`, `avatarUrl`, `theme`, `parameters`
68
+
69
+ ### `owner`
70
+
71
+ `id`, `guid`, `name`, `logo`, `settings`, `theme`
72
+
73
+ ### `program`
74
+
75
+ `guid`, `name`, `slug`, `environment`, `version`, `controls`, `globals`, `database`, `meta`
76
+
77
+ ### `layout`
78
+
79
+ `topBar`, `leftBar`, `rightBar`, `bottomBar` — each is `{ open, pinned, size, collapsed, menuItems }`
80
+
81
+ ### `navigation`
82
+
83
+ `menu: MenuItem[]`, `currentPage: { title, path, breadcrumbs, meta }`
84
+
85
+ ---
86
+
87
+ ## Auth lifecycle
88
+
89
+ ```
90
+ login(token)
91
+ → sets authToken, loggedIn:false, validated:false
92
+ → calls onLogin ──► server confirms → loggedIn:true, validated:true
93
+ → calls fetchData (onFetchSession) → merges server state
94
+ ```
95
+
96
+ ```
97
+ logout()
98
+ → clears all auth state, loading:true
99
+ → calls onLogout
100
+ → calls fetchData (onFetchSession)
101
+ ```
102
+
103
+ ```
104
+ fetchData(url?)
105
+ → no-op if onFetchSession not registered
106
+ → calls onFetchSession
107
+ → validates or clears session (see Session Validation below)
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Session validation
113
+
114
+ Credentials from `localStorage` start each page load as `loggedIn: true, validated: false`. `isLoggedInStore` emits `false` until validated.
115
+
116
+ Validation is triggered automatically — no manual call needed — in priority order:
117
+
118
+ 1. **`onFetchSession` registered** while unvalidated credentials exist (`setState` detects handler transition)
119
+ 2. **Any state change** with unvalidated credentials + handler present (`store.subscribe`)
120
+
121
+ `fetchData` clears the session when the server response indicates invalidity:
122
+
123
+ | Condition | Result |
124
+ |---|---|
125
+ | `onFetchSession` not registered | No-op, session unchanged |
126
+ | Server returns no session/user fields | Session preserved, `validated` stays `false` |
127
+ | Server returns `loggedIn: false` | Session cleared |
128
+ | Server returns a different `authToken` | Session cleared |
129
+ | Server returns a different `user.guid` or `username` | Session cleared |
130
+ | `expiryDate` is in the past | Session cleared |
131
+ | Server confirms credentials | `validated: true`, session kept |
132
+
133
+ ---
134
+
135
+ ## Reactive usage (Svelte components)
136
+
137
+ ```svelte
138
+ <script lang="ts">
139
+ import { isLoggedInStore, useGlobalStateStore } from '@warkypublic/svelix';
140
+
141
+ const username = useGlobalStateStore((s) => s.user.username);
142
+ </script>
143
+
144
+ {#if $isLoggedInStore}
145
+ <p>Welcome, {$username}</p>
146
+ {/if}
147
+ ```
148
+
149
+ ### Reactive stores
150
+
151
+ | Export | Type | Value |
152
+ |---|---|---|
153
+ | `isLoggedInStore` | `Readable<boolean>` | `true` only when `loggedIn && validated && !expired` |
154
+ | `languageStore` | `Readable<'en' \| 'af'>` | `user.language` |
155
+ | `useGlobalStateStore(selector?)` | `Readable<T>` | Derived slice of the full store |
156
+
157
+ ---
158
+
159
+ ## Imperative usage (outside components)
160
+
161
+ ```ts
162
+ import { GlobalStateStore, getAuthToken, getApiURL, setAuthToken, setApiURL } from '@warkypublic/svelix';
163
+
164
+ const state = GlobalStateStore.getState();
165
+
166
+ await state.login('my-token');
167
+ await state.logout();
168
+ await state.fetchData();
169
+
170
+ state.isLoggedIn(); // boolean — checks validated + loggedIn + authToken + expiry
171
+ state.setAuthToken('token');
172
+ state.setApiURL('https://api.example.com');
173
+ state.setUser({ username: 'alice' });
174
+ state.setSession({ expiryDate: '2099-01-01T00:00:00.000Z' });
175
+
176
+ getAuthToken(); // shorthand
177
+ getApiURL();
178
+ setAuthToken('token');
179
+ setApiURL('https://api.example.com');
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Persistence
185
+
186
+ All slices are written to `localStorage` under `APP_GLO:<slice>` on every state change.
187
+
188
+ **Not persisted:** `session.connected`, `session.error`, `session.loading`, `session.validated`
189
+
190
+ On load, `validated` is forced to `false` when credentials exist. `loggedIn` is preserved as-is so the UI can optimistically render the logged-in shell while re-validation completes in the background.
191
+
192
+ ---
193
+
194
+ ## Callback return shapes
195
+
196
+ ```ts
197
+ // onLogin / onLogout
198
+ Promise<{
199
+ session?: Partial<SessionState>;
200
+ user?: Partial<UserState>;
201
+ owner?: Partial<OwnerState>;
202
+ program?: Partial<ProgramState>;
203
+ } | void>
204
+
205
+ // onFetchSession (superset — also accepts layout + navigation)
206
+ Promise<Partial<GlobalState>>
207
+ ```
208
+
209
+ Return only the fields that changed. Omitted fields are left as-is.
@@ -123,8 +123,13 @@
123
123
 
124
124
  // ── Selection / editing state ────────────────────────────────────────────────
125
125
 
126
- let currentSelection = $state<Selection>(untrack(() => selection));
126
+ let currentSelection = $state<Selection>({ type: "none" });
127
127
  let focusedCell = $state<Item | null>(null);
128
+
129
+ $effect(() => {
130
+ if (!selection || selection.type === "none") return;
131
+ currentSelection = selection;
132
+ });
128
133
  let isEditing = $state(false);
129
134
  let editingCell = $state<Item | null>(null);
130
135
 
@@ -211,10 +216,14 @@
211
216
  }
212
217
 
213
218
  if (lines.length > 0) {
214
- await navigator.clipboard.writeText(lines.join("\n"));
215
- canvasComponent?.setAnnouncement(
216
- `Copied ${lines.length} row${lines.length > 1 ? "s" : ""} to clipboard`,
217
- );
219
+ try {
220
+ await navigator.clipboard.writeText(lines.join("\n"));
221
+ canvasComponent?.setAnnouncement(
222
+ `Copied ${lines.length} row${lines.length > 1 ? "s" : ""} to clipboard`,
223
+ );
224
+ } catch {
225
+ canvasComponent?.setAnnouncement("Clipboard access denied");
226
+ }
218
227
  }
219
228
  }
220
229
 
@@ -222,7 +231,13 @@
222
231
  if (resolvedReadonly) return;
223
232
  const fc = canvasComponent?.getFocusedCell() ?? focusedCell;
224
233
  if (!fc) return;
225
- const text = await navigator.clipboard.readText();
234
+ let text: string;
235
+ try {
236
+ text = await navigator.clipboard.readText();
237
+ } catch {
238
+ canvasComponent?.setAnnouncement("Clipboard access denied");
239
+ return;
240
+ }
226
241
  const pastedRows = text.split("\n").map((line) => line.split("\t"));
227
242
  const [startCol, startRow] = fc;
228
243
 
@@ -413,6 +428,7 @@
413
428
  width={typeof width === "number" ? `${width}px` : (width ?? "100%")}
414
429
  height={typeof height === "number" ? `${height}px` : (height ?? "400px")}
415
430
  {mergedTheme}
431
+ {isDarkMode}
416
432
  {headerHeight}
417
433
  {rowHeight}
418
434
  readonly={resolvedReadonly}
@@ -460,8 +476,10 @@
460
476
  onRowContextMenu={(row) => {
461
477
  onRowContextMenu?.(row);
462
478
  }}
463
- onColumnResized={(col, width) => {
464
- onGridEvent?.("column_resized", undefined, columns[col], undefined, { width });
479
+ onColumnResized={(col, newWidth) => {
480
+ columns[col].width = newWidth;
481
+ columns[col].grow = 0;
482
+ onGridEvent?.("column_resized", undefined, columns[col], undefined, { width: newWidth });
465
483
  }}
466
484
  onGridResize={(width, height) => {
467
485
  onGridEvent?.("resize", undefined, undefined, undefined, { width, height });
@@ -37,6 +37,7 @@
37
37
  width: string;
38
38
  height: string;
39
39
  mergedTheme: GridlerTheme;
40
+ isDarkMode?: boolean;
40
41
  headerHeight?: number;
41
42
  rowHeight?: number;
42
43
  readonly?: boolean;
@@ -92,6 +93,7 @@
92
93
  width,
93
94
  height,
94
95
  mergedTheme,
96
+ isDarkMode: isDarkModeProp = false,
95
97
  headerHeight = 36,
96
98
  rowHeight = 34,
97
99
  readonly = false,
@@ -135,7 +137,6 @@
135
137
 
136
138
  // ── Internal state ───────────────────────────────────────────────────────────
137
139
 
138
- let isDarkMode = $state(false);
139
140
  let scrollX = $state(0);
140
141
  let scrollY = $state(0);
141
142
  let currentSelection = $state<Selection>({ type: "none" });
@@ -143,8 +144,8 @@
143
144
  let isDragging = $state(false);
144
145
  let dragStart = $state<Item | null>(null);
145
146
  let resizingColumn = $state<number | null>(null);
147
+ let resizeWidth = $state<number | null>(null);
146
148
  let hoverResizeCol = $state<number | null>(null);
147
- let resizeVersion = $state(0);
148
149
  let draggingHeaderCol = $state<number | null>(null);
149
150
  let dragHeaderCurrentX = $state(0);
150
151
  let announcement = $state("");
@@ -194,13 +195,17 @@
194
195
  );
195
196
 
196
197
  const effectiveColumns: GridlerColumn[] = $derived.by(() => {
197
- void resizeVersion;
198
- return computeEffectiveColumns(columns, containerWidth, rowMarkerWidth);
198
+ const cols = resizingColumn !== null && resizeWidth !== null
199
+ ? columns.map((c, i) => i === resizingColumn ? { ...c, width: resizeWidth, grow: 0 } : c)
200
+ : columns;
201
+ return computeEffectiveColumns(cols, containerWidth, rowMarkerWidth);
199
202
  });
200
203
 
201
204
  const totalWidth = $derived.by(() => {
202
- void resizeVersion;
203
- return columns.reduce((sum, col) => sum + col.width, 0) + rowMarkerWidth;
205
+ const cols = resizingColumn !== null && resizeWidth !== null
206
+ ? columns.map((c, i) => i === resizingColumn ? { ...c, width: resizeWidth } : c)
207
+ : columns;
208
+ return cols.reduce((sum, col) => sum + col.width, 0) + rowMarkerWidth;
204
209
  });
205
210
 
206
211
  const totalHeight = $derived(getTotalHeight(rows, rowHeight));
@@ -257,45 +262,6 @@
257
262
  }
258
263
  });
259
264
 
260
- // ── Dark mode detection ───────────────────────────────────────────────────────
261
-
262
- function computeIsDarkMode(): boolean {
263
- const root = containerRef;
264
- if (root?.closest('.dark, [data-theme="dark"]')) return true;
265
- if (document?.documentElement?.classList.contains("dark")) return true;
266
- if (document?.body?.classList.contains("dark")) return true;
267
- if (document?.documentElement?.getAttribute("data-theme") === "dark")
268
- return true;
269
- if (document?.body?.getAttribute("data-theme") === "dark") return true;
270
- return false;
271
- }
272
-
273
- $effect(() => {
274
- if (!containerRef) return;
275
- const update = () => {
276
- isDarkMode = computeIsDarkMode();
277
- };
278
- update();
279
-
280
- const observed: HTMLElement[] = [];
281
- let el: HTMLElement | null = containerRef;
282
- while (el) {
283
- observed.push(el);
284
- el = el.parentElement;
285
- }
286
- if (document.documentElement) observed.push(document.documentElement);
287
- if (document.body) observed.push(document.body);
288
-
289
- const mo = new MutationObserver(update);
290
- for (const node of observed) {
291
- mo.observe(node, {
292
- attributes: true,
293
- attributeFilter: ["class", "data-theme"],
294
- });
295
- }
296
- return () => mo.disconnect();
297
- });
298
-
299
265
  // ── Resize observer ───────────────────────────────────────────────────────────
300
266
 
301
267
  $effect(() => {
@@ -320,16 +286,20 @@
320
286
  return () => document.removeEventListener(IMAGE_LOADED_EVENT, handler);
321
287
  });
322
288
 
289
+ // ── Document listener cleanup on destroy ─────────────────────────────────────
290
+ $effect(() => {
291
+ return () => {
292
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
293
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
294
+ };
295
+ });
296
+
323
297
  // ── Render loop ───────────────────────────────────────────────────────────────
324
298
 
325
299
  $effect(() => {
326
- void visibleRange;
327
- void searchValue;
328
- void currentSelection;
329
- void getCellContent;
330
- void mergedTheme;
331
- void isDarkMode;
332
- void sortOrder;
300
+ // Track reactive dependencies that should trigger a redraw.
301
+ const _deps = [visibleRange, searchValue, currentSelection, getCellContent, mergedTheme, isDarkModeProp, sortOrder];
302
+ void _deps;
333
303
  if (ctx && containerWidth > 0 && containerHeight > 0) {
334
304
  scheduleDraw();
335
305
  }
@@ -367,7 +337,7 @@
367
337
  totalHeight,
368
338
  currentSelection,
369
339
  mergedTheme,
370
- isDarkMode,
340
+ isDarkMode: isDarkModeProp,
371
341
  rowMarkers: rowMarkers ?? "none",
372
342
  draggingHeaderCol,
373
343
  dragHeaderCurrentX,
@@ -849,9 +819,7 @@
849
819
  const scrollXForResize =
850
820
  fixedColumns > 0 && resizingColumn < fixedColumns ? 0 : scrollX;
851
821
  const newWidth = Math.max(50, e.offsetX - baseX + scrollXForResize);
852
- columns[resizingColumn].width = newWidth;
853
- columns[resizingColumn].grow = 0;
854
- resizeVersion++;
822
+ resizeWidth = newWidth;
855
823
  scheduleDraw();
856
824
  return;
857
825
  }
@@ -955,12 +923,13 @@
955
923
  scheduleDraw();
956
924
  }
957
925
 
958
- if (resizingColumn !== null) {
959
- onColumnResized?.(resizingColumn, columns[resizingColumn].width);
926
+ if (resizingColumn !== null && resizeWidth !== null) {
927
+ onColumnResized?.(resizingColumn, resizeWidth);
960
928
  }
961
929
  isDragging = false;
962
930
  dragStart = null;
963
931
  resizingColumn = null;
932
+ resizeWidth = null;
964
933
  }
965
934
 
966
935
  function handleMouseLeave(e: MouseEvent) {
@@ -969,6 +938,7 @@
969
938
  hoverResizeCol = null;
970
939
  hoverMenuButton = false;
971
940
  hoverSortCol = null;
941
+ pendingGridMenuOpen = false;
972
942
  if (hoveredCell) {
973
943
  onCellLeave?.(hoveredCell);
974
944
  hoveredCell = null;
@@ -981,7 +951,6 @@
981
951
  }
982
952
 
983
953
  function handleWheel(e: WheelEvent) {
984
- if (!hasFocus) return;
985
954
  if (scrollRef) {
986
955
  e.preventDefault();
987
956
  scrollRef.scrollLeft += e.deltaX;
@@ -1079,7 +1048,7 @@
1079
1048
 
1080
1049
  if (rowMarkers !== "none" && offsetX < rowMarkerWidth) {
1081
1050
  const row = getRowFromOffsetY(offsetY);
1082
- if (row >= 0 && row < rows) {
1051
+ if (row !== null && row >= 0 && row < rows) {
1083
1052
  onRowToggle?.(row);
1084
1053
  }
1085
1054
  return;
@@ -1207,7 +1176,7 @@
1207
1176
  <GridlerEditor
1208
1177
  cell={getCellContent(editingCell)}
1209
1178
  x={getColumnX(effectiveColumns, editingCell[0]) -
1210
- scrollX +
1179
+ (editingCell[0] < fixedColumns ? 0 : scrollX) +
1211
1180
  rowMarkerWidth}
1212
1181
  y={editingCell[1] * rowHeight - scrollY + headerHeight}
1213
1182
  width={effectiveColumns[editingCell[0]]?.width ?? 0}
@@ -8,6 +8,7 @@ interface Props {
8
8
  width: string;
9
9
  height: string;
10
10
  mergedTheme: GridlerTheme;
11
+ isDarkMode?: boolean;
11
12
  headerHeight?: number;
12
13
  rowHeight?: number;
13
14
  readonly?: boolean;
@@ -163,7 +163,7 @@
163
163
  // gridler pass-through
164
164
  fixedColumns = 0,
165
165
  rowMarkers,
166
- showSearch = false,
166
+ showSearch,
167
167
  height = 500,
168
168
  onColumnMoved,
169
169
  onCellEdited,
@@ -211,10 +211,15 @@
211
211
  if (data === untrack(() => lastDataRef)) return;
212
212
  lastDataRef = data;
213
213
  if (typeof data === "function") {
214
- data().then((result) => {
215
- dataState = result;
216
- onGridEvent?.("load");
217
- });
214
+ data()
215
+ .then((result) => {
216
+ dataState = result;
217
+ onGridEvent?.("load");
218
+ })
219
+ .catch((e) => {
220
+ const msg = e instanceof Error ? e.message : "Failed to load data";
221
+ onLoadError?.(msg);
222
+ });
218
223
  } else {
219
224
  dataState = data.slice();
220
225
  onGridEvent?.("load");
@@ -292,9 +297,14 @@
292
297
  if (isServerMode) {
293
298
  refreshTrigger++;
294
299
  } else if (typeof data === "function") {
295
- data().then((result) => {
296
- dataState = result;
297
- });
300
+ data()
301
+ .then((result) => {
302
+ dataState = result;
303
+ })
304
+ .catch((e) => {
305
+ const msg = e instanceof Error ? e.message : "Failed to load data";
306
+ onLoadError?.(msg);
307
+ });
298
308
  }
299
309
  }
300
310
 
@@ -355,6 +365,7 @@
355
365
  let filterPanelColTitle = $state("");
356
366
  let filterPanelOp = $state<GridFilterOp>("contains");
357
367
  let filterPanelValue = $state("");
368
+ let filterPanelValue2 = $state("");
358
369
 
359
370
  // Snippet reference captured from the template — assigned via {(filterRenderer ??= filterForm, '')}
360
371
  let filterRenderer = $state<Snippet<
@@ -372,6 +383,7 @@
372
383
  filterPanelColTitle = colTitle;
373
384
  filterPanelOp = existing?.op ?? "contains";
374
385
  filterPanelValue = existing?.value ?? "";
386
+ filterPanelValue2 = existing?.value2 ?? "";
375
387
  menu?.show("gridler-filter-panel", {
376
388
  x,
377
389
  y,
@@ -387,9 +399,13 @@
387
399
  delete next[filterPanelColId];
388
400
  handleFilterChange(next);
389
401
  } else {
402
+ const entry: GridColumnFilters[string] = { value: filterPanelValue, op: filterPanelOp };
403
+ if ((filterPanelOp === "between" || filterPanelOp === "notBetween") && filterPanelValue2) {
404
+ entry.value2 = filterPanelValue2;
405
+ }
390
406
  handleFilterChange({
391
407
  ...resolvedFilters,
392
- [filterPanelColId]: { value: filterPanelValue, op: filterPanelOp },
408
+ [filterPanelColId]: entry,
393
409
  });
394
410
  }
395
411
  menu?.hide("gridler-filter-panel");
@@ -503,6 +519,7 @@
503
519
  let serverData = $state<Record<string, unknown>[]>([]);
504
520
  let serverTotal = $state(0);
505
521
  let serverCursor = $state<string | undefined>(undefined);
522
+ let serverFetchVersion = $state(0);
506
523
  let serverLoading = $state(false);
507
524
  let serverAllLoaded = $state(false);
508
525
  let serverError = $state<string | null>(null);
@@ -570,15 +587,16 @@
570
587
  const _pageSize = pageSize;
571
588
 
572
589
  // Track resolvedFilters so internal filter changes also trigger a re-fetch.
573
-
574
- void resolvedFilters;
590
+ const _filters = resolvedFilters;
575
591
  // Track manual refresh trigger.
576
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
577
- refreshTrigger;
592
+ const _refresh = refreshTrigger;
578
593
 
594
+ // _refresh is tracked to trigger re-fetch on manual refresh.
595
+ void _refresh;
579
596
  if (!_adapter) return;
580
597
 
581
598
  let cancelled = false;
599
+ serverFetchVersion++;
582
600
  serverLoading = true;
583
601
  serverError = null;
584
602
  serverData = [];
@@ -586,7 +604,13 @@
586
604
  serverCursor = undefined;
587
605
  serverAllLoaded = false;
588
606
 
589
- const allFilters = buildAllFilters(_search);
607
+ const columnFilters = Object.keys(_filters).length
608
+ ? gridColumnFiltersToFilterOptions(_filters)
609
+ : [];
610
+ const searchFilters = _search?.trim()
611
+ ? buildSearchFilters(_search, columnsState, searchColumns)
612
+ : [];
613
+ const allFilters = [...columnFilters, ...searchFilters];
590
614
 
591
615
  _adapter
592
616
  .readPage(_pageSize, undefined, _sort, allFilters, resolvedFields)
@@ -596,7 +620,7 @@
596
620
  serverTotal = result.total;
597
621
  serverCursor = result.nextCursor;
598
622
  serverAllLoaded = result.data.length < _pageSize || !result.nextCursor;
599
- onGridEvent?.("load", { row: 0, column: 0 });
623
+ onGridEvent?.("load");
600
624
  })
601
625
  .catch((e) => {
602
626
  if (cancelled) return;
@@ -619,7 +643,7 @@
619
643
  if (!_adapter || serverLoading || serverAllLoaded || !serverCursor) return;
620
644
 
621
645
  const cursorSnapshot = serverCursor;
622
- const lengthSnapshot = serverData.length;
646
+ const versionSnapshot = serverFetchVersion;
623
647
  serverLoading = true;
624
648
 
625
649
  const allFilters = buildAllFilters(
@@ -634,13 +658,14 @@
634
658
  allFilters,
635
659
  resolvedFields,
636
660
  );
637
- if (serverData.length !== lengthSnapshot) return;
661
+ if (serverFetchVersion !== versionSnapshot) return;
638
662
  serverData = [...serverData, ...result.data];
639
663
  serverCursor = result.nextCursor;
640
664
  serverAllLoaded = result.data.length < pageSize || !result.nextCursor;
641
665
  onGridEvent?.("page_loaded", undefined, undefined, undefined, { data: result.data, total: serverData.length });
642
- } catch {
643
- // Silent don't clobber existing rows with an error on append.
666
+ } catch (e) {
667
+ const msg = e instanceof Error ? e.message : "Failed to load page";
668
+ onLoadError?.(msg);
644
669
  } finally {
645
670
  serverLoading = false;
646
671
  }
@@ -682,6 +707,11 @@
682
707
 
683
708
  let internalSelectedItems = $state<Record<string, unknown>[]>([]);
684
709
 
710
+ $effect(() => {
711
+ if (_selectedItems === undefined) return;
712
+ internalSelectedItems = Array.isArray(_selectedItems) ? _selectedItems : [];
713
+ });
714
+
685
715
  function handleSelectionChange(sel: Selection) {
686
716
  let items: Record<string, unknown>[] = [];
687
717
  if (sel.type === "row") {
@@ -706,7 +736,7 @@
706
736
 
707
737
  // ── Settings change event ─────────────────────────────────────────────────
708
738
 
709
- let lastSettings = $state(settings);
739
+ let lastSettings = $state<typeof settings>(undefined);
710
740
  $effect(() => {
711
741
  if (settings === untrack(() => lastSettings)) return;
712
742
  lastSettings = settings;
@@ -804,7 +834,6 @@
804
834
  selectedItems={internalSelectedItems}
805
835
  getRowData={resolveRowData}
806
836
  onSelectionChange={handleSelectionChange}
807
- {onMenuClick}
808
837
  onGridMenuOpen={handleGridMenuClick}
809
838
  sortOrder={resolvedSortOrder}
810
839
  onSortOrderChange={handleSortOrderChange}
@@ -842,8 +871,10 @@
842
871
  role="dialog"
843
872
  aria-modal="true"
844
873
  aria-labelledby="gf-filter-title"
874
+ tabindex="-1"
875
+ onkeydown={(e) => { if (e.key === "Escape") onClose(); }}
845
876
  >
846
- <div class="gf-filter-title">{filterPanelColTitle}</div>
877
+ <div id="gf-filter-title" class="gf-filter-title">{filterPanelColTitle}</div>
847
878
  <select bind:value={filterPanelOp} class="gf-filter-select">
848
879
  <option value="contains">Contains</option>
849
880
  <option value="equals">Equals</option>
@@ -854,6 +885,10 @@
854
885
  <option value="lessThan">Less than</option>
855
886
  <option value="greaterThanOrEqual">Greater than or equal</option>
856
887
  <option value="lessThanOrEqual">Less than or equal</option>
888
+ <option value="in">In (comma-separated)</option>
889
+ <option value="notIn">Not in (comma-separated)</option>
890
+ <option value="between">Between</option>
891
+ <option value="notBetween">Not between</option>
857
892
  <option value="isNull">Is empty / null</option>
858
893
  <option value="isNotNull">Is not empty / null</option>
859
894
  </select>
@@ -862,11 +897,20 @@
862
897
  class="gf-filter-input"
863
898
  type="text"
864
899
  bind:value={filterPanelValue}
865
- placeholder="Filter value…"
900
+ placeholder={filterPanelOp === "between" || filterPanelOp === "notBetween" ? "From value…" : "Filter value…"}
901
+ onkeydown={(e) => {
902
+ if (e.key === "Enter") applyFilterPanel();
903
+ }}
904
+ />
905
+ {/if}
906
+ {#if filterPanelOp === "between" || filterPanelOp === "notBetween"}
907
+ <input
908
+ class="gf-filter-input"
909
+ type="text"
910
+ bind:value={filterPanelValue2}
911
+ placeholder="To value…"
866
912
  onkeydown={(e) => {
867
- if (e.key === "Enter") {
868
- applyFilterPanel();
869
- }
913
+ if (e.key === "Enter") applyFilterPanel();
870
914
  }}
871
915
  />
872
916
  {/if}
@@ -1,7 +1,17 @@
1
- /** Loaded images keyed by URL. 'loading' prevents duplicate requests. */
1
+ const IMAGE_CACHE_MAX = 500;
2
+ /** Loaded images keyed by URL. 'loading' prevents duplicate requests. Map insertion order acts as LRU. */
2
3
  const imageCache = new Map();
3
4
  /** Fired on document when a cached image finishes loading so the canvas repaints. */
4
5
  export const IMAGE_LOADED_EVENT = 'gridler:image-loaded';
6
+ function touchCache(url, value) {
7
+ imageCache.delete(url);
8
+ imageCache.set(url, value);
9
+ if (imageCache.size > IMAGE_CACHE_MAX) {
10
+ const oldest = imageCache.keys().next().value;
11
+ if (oldest !== undefined)
12
+ imageCache.delete(oldest);
13
+ }
14
+ }
5
15
  /**
6
16
  * Draws an image cell. Supported formats: PNG, JPG, SVG (anything <img> can load).
7
17
  * cell.data (or cell.displayData) should be a URL or data URI.
@@ -35,16 +45,16 @@ export function drawImageCell(ctx, cell, x, y, size) {
35
45
  // Placeholder while loading.
36
46
  drawPlaceholder(ctx, x, y, size);
37
47
  if (cached !== 'loading') {
38
- imageCache.set(url, 'loading');
48
+ touchCache(url, 'loading');
39
49
  const img = new Image();
40
50
  if (!isDataUri(url))
41
51
  img.crossOrigin = 'anonymous';
42
52
  img.onload = () => {
43
- imageCache.set(url, img);
53
+ touchCache(url, img);
44
54
  document.dispatchEvent(new CustomEvent(IMAGE_LOADED_EVENT));
45
55
  };
46
56
  img.onerror = () => {
47
- imageCache.set(url, 'error');
57
+ touchCache(url, 'error');
48
58
  document.dispatchEvent(new CustomEvent(IMAGE_LOADED_EVENT));
49
59
  };
50
60
  img.src = url;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Strips basic markdown syntax characters and renders as plain text.
3
- * For rich markdown rendering, replace this with a custom overlay cell.
2
+ * Strips markdown syntax patterns and renders as plain text.
3
+ * Targets structural markdown (headings, bold, italic, code, links)
4
+ * while preserving those characters when they appear in normal content.
4
5
  */
5
6
  export declare function drawMarkdownCell(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number): void;
@@ -1,10 +1,19 @@
1
1
  import { truncateText } from './shared';
2
2
  /**
3
- * Strips basic markdown syntax characters and renders as plain text.
4
- * For rich markdown rendering, replace this with a custom overlay cell.
3
+ * Strips markdown syntax patterns and renders as plain text.
4
+ * Targets structural markdown (headings, bold, italic, code, links)
5
+ * while preserving those characters when they appear in normal content.
5
6
  */
6
7
  export function drawMarkdownCell(ctx, text, x, y, maxWidth) {
7
- const cleanText = text.replace(/[#*`_~[\]]/g, '');
8
+ const cleanText = text
9
+ .replace(/^#{1,6}\s+/gm, '') // headings: "## title" → "title"
10
+ .replace(/\*\*(.+?)\*\*/g, '$1') // bold: **text** → text
11
+ .replace(/__(.+?)__/g, '$1') // bold: __text__ → text
12
+ .replace(/\*(.+?)\*/g, '$1') // italic: *text* → text
13
+ .replace(/_(.+?)_/g, '$1') // italic: _text_ → text
14
+ .replace(/~~(.+?)~~/g, '$1') // strikethrough: ~~text~~ → text
15
+ .replace(/`(.+?)`/g, '$1') // inline code: `code` → code
16
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // links: [text](url) → text
8
17
  const truncated = truncateText(ctx, cleanText, maxWidth);
9
18
  ctx.fillText(truncated, x, y);
10
19
  }
@@ -7,12 +7,17 @@ import { truncateText } from './shared';
7
7
  export function drawPercentageCell(ctx, cell, x, y, maxWidth, fractional = true, decimals = 2, locale) {
8
8
  let text;
9
9
  if (typeof cell.data === 'number') {
10
- const value = fractional ? cell.data : cell.data / 100;
11
- text = new Intl.NumberFormat(locale, {
12
- style: 'percent',
13
- minimumFractionDigits: 0,
14
- maximumFractionDigits: decimals,
15
- }).format(value);
10
+ try {
11
+ const value = fractional ? cell.data : cell.data / 100;
12
+ text = new Intl.NumberFormat(locale, {
13
+ style: 'percent',
14
+ minimumFractionDigits: 0,
15
+ maximumFractionDigits: decimals,
16
+ }).format(value);
17
+ }
18
+ catch {
19
+ text = cell.displayData;
20
+ }
16
21
  }
17
22
  else {
18
23
  text = cell.displayData;
@@ -90,13 +90,16 @@ export function drawCell(ctx, x, y, width, height, cell, theme, isSelected, isHe
90
90
  drawDrilldownCell(ctx, cell, textX, textY, maxWidth, theme);
91
91
  break;
92
92
  case 'currency':
93
- drawCurrencyCell(ctx, cell, textX, textY, maxWidth);
93
+ drawCurrencyCell(ctx, cell, textX, textY, maxWidth, cell.formatOptions?.currency, cell.formatOptions?.locale);
94
94
  break;
95
95
  case 'percentage':
96
- drawPercentageCell(ctx, cell, textX, textY, maxWidth);
96
+ drawPercentageCell(ctx, cell, textX, textY, maxWidth, cell.formatOptions?.fractional, cell.formatOptions?.decimals, cell.formatOptions?.locale);
97
97
  break;
98
98
  case 'datetime':
99
- drawDatetimeCell(ctx, cell, textX, textY, maxWidth);
99
+ drawDatetimeCell(ctx, cell, textX, textY, maxWidth, cell.formatOptions?.dateStyle, cell.formatOptions?.timeStyle, cell.formatOptions?.locale);
100
+ break;
101
+ default:
102
+ drawTextCell(ctx, cell.displayData, textX, textY, maxWidth);
100
103
  break;
101
104
  }
102
105
  ctx.restore();
@@ -111,7 +114,12 @@ export function drawHeaderCell(ctx, x, y, width, height, title, theme, isSelecte
111
114
  ctx.fillRect(x, y, width, height);
112
115
  ctx.strokeStyle = theme.borderColor;
113
116
  ctx.lineWidth = 1;
114
- ctx.strokeRect(x, y, width, height);
117
+ ctx.beginPath();
118
+ ctx.moveTo(x + width, y);
119
+ ctx.lineTo(x + width, y + height);
120
+ ctx.moveTo(x, y + height);
121
+ ctx.lineTo(x + width, y + height);
122
+ ctx.stroke();
115
123
  if (isSelected) {
116
124
  const dark = isDarkBg(theme.bgCell);
117
125
  ctx.strokeStyle = theme.accentColor;
@@ -13,6 +13,11 @@ export declare function relativeLuminance({ r, g, b }: {
13
13
  g: number;
14
14
  b: number;
15
15
  }): number;
16
+ export declare function parseRgb(color: string): {
17
+ r: number;
18
+ g: number;
19
+ b: number;
20
+ } | null;
16
21
  export declare function isDarkBg(color: string): boolean;
17
22
  /** Binary-search truncation — keeps the longest prefix that fits within maxWidth. */
18
23
  export declare function truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string;
@@ -26,25 +26,35 @@ export function relativeLuminance({ r, g, b }) {
26
26
  const lin = srgb.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
27
27
  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
28
28
  }
29
+ export function parseRgb(color) {
30
+ const match = color.match(/rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/);
31
+ if (!match)
32
+ return null;
33
+ return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) };
34
+ }
29
35
  export function isDarkBg(color) {
30
- const rgb = hexToRgb(color);
36
+ const rgb = hexToRgb(color) ?? parseRgb(color);
31
37
  if (!rgb)
32
38
  return false;
33
39
  return relativeLuminance(rgb) < 0.35;
34
40
  }
35
41
  /** Binary-search truncation — keeps the longest prefix that fits within maxWidth. */
36
42
  export function truncateText(ctx, text, maxWidth) {
37
- if (text.length === 0)
38
- return text;
43
+ if (text.length === 0 || maxWidth <= 0)
44
+ return '';
39
45
  const metrics = ctx.measureText(text);
40
46
  if (metrics.width <= maxWidth)
41
47
  return text;
48
+ // If even "..." doesn't fit, return empty.
49
+ const ellipsis = '...';
50
+ if (ctx.measureText(ellipsis).width > maxWidth)
51
+ return '';
42
52
  let low = 0;
43
53
  let high = text.length;
44
- let result = text;
54
+ let result = ellipsis;
45
55
  while (low <= high) {
46
56
  const mid = Math.floor((low + high) / 2);
47
- const truncated = text.slice(0, mid) + '...';
57
+ const truncated = text.slice(0, mid) + ellipsis;
48
58
  const width = ctx.measureText(truncated).width;
49
59
  if (width <= maxWidth) {
50
60
  result = truncated;
@@ -45,6 +45,20 @@ export interface GridlerColumn extends GridColumn<Record<string, unknown>> {
45
45
  /** Show a context-menu trigger button on this column's header. */
46
46
  hasMenu?: boolean;
47
47
  }
48
+ export interface GridlerCellFormatOptions {
49
+ /** Currency code for 'currency' cells (default: 'ZAR'). */
50
+ currency?: string;
51
+ /** Locale for number/date formatting. */
52
+ locale?: string;
53
+ /** For 'percentage' cells: true if data is 0–1 fractional (default), false if 0–100. */
54
+ fractional?: boolean;
55
+ /** Decimal places for 'percentage' cells (default: 2). */
56
+ decimals?: number;
57
+ /** Date style for 'datetime' cells (default: 'short'). */
58
+ dateStyle?: Intl.DateTimeFormatOptions['dateStyle'];
59
+ /** Time style for 'datetime' cells. */
60
+ timeStyle?: Intl.DateTimeFormatOptions['timeStyle'];
61
+ }
48
62
  export interface GridlerCell {
49
63
  kind: GridlerCellKind;
50
64
  data: unknown;
@@ -53,6 +67,8 @@ export interface GridlerCell {
53
67
  allowEditing?: boolean;
54
68
  readonly?: boolean;
55
69
  span?: [startRow: number, endRow: number];
70
+ /** Per-cell format options for currency, percentage, and datetime renderers. */
71
+ formatOptions?: GridlerCellFormatOptions;
56
72
  }
57
73
  export type Selection = {
58
74
  type: "none";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warkypublic/svelix",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Svelte 5 component library with Skeleton UI and Tailwind CSS",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {