@lavalogic/scoria 0.38.13 → 0.38.14

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.
@@ -229,12 +229,7 @@
229
229
  let dragStartSlotIndex: number | null = $state(null);
230
230
  let dragEndSlotIndex: number | null = $state(null);
231
231
 
232
- function onSlotMouseDown(doorId: number, slotIndex: number, e: MouseEvent): void {
233
- // Only start a drag-create on the primary (left) button. Otherwise a
234
- // middle-click (autoscroll) or right-click would open the create modal.
235
- if (e.button !== 0) {
236
- return;
237
- }
232
+ function onSlotMouseDown(doorId: number, slotIndex: number): void {
238
233
  if (!ondragcreate || appointmentDragStarted) {
239
234
  return;
240
235
  }
@@ -376,11 +371,6 @@
376
371
  let moveTargetSlotIndex: number | null = $state(null);
377
372
 
378
373
  function onAppointmentMouseDown(apt: CalendarAppointment, e: MouseEvent): void {
379
- // Only react to the primary (left) button so middle/right clicks don't
380
- // start a move or register as an appointment click.
381
- if (e.button !== 0) {
382
- return;
383
- }
384
374
  if (!onappointmentmove) {
385
375
  return;
386
376
  }
@@ -674,8 +664,8 @@
674
664
  class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
675
665
  class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
676
666
  class:row-hover={hoveredSlotIndex === slotIdx}
677
- onmousedown={(e) => {
678
- onSlotMouseDown(door.id, slotIdx, e);
667
+ onmousedown={() => {
668
+ onSlotMouseDown(door.id, slotIdx);
679
669
  }}
680
670
  onmouseenter={() => {
681
671
  hoveredSlotIndex = slotIdx;
@@ -30,6 +30,13 @@ export interface JSONTableLayout {
30
30
  * `userResized` so the grid renders the fixed track.
31
31
  */
32
32
  sizing: ColumnSizingState;
33
+ /**
34
+ * Records-per-page selection that was active when the layout was
35
+ * captured. Optional for backwards compatibility - older snapshots
36
+ * predate this field and `_applyLayout` simply leaves the live
37
+ * pagination unchanged when it is absent.
38
+ */
39
+ pageSize?: number;
33
40
  }
34
41
  /**
35
42
  * Schema version for the persisted `JSONTableLayout` envelope. Bump when
@@ -30,6 +30,12 @@ export function isJSONTableLayout(raw) {
30
30
  if (!isPrimitiveRecord(candidate.sizing, (v) => typeof v === 'number')) {
31
31
  return false;
32
32
  }
33
+ if (candidate.pageSize !== undefined &&
34
+ (typeof candidate.pageSize !== 'number' ||
35
+ !Number.isFinite(candidate.pageSize) ||
36
+ candidate.pageSize <= 0)) {
37
+ return false;
38
+ }
33
39
  return isJSONPinningState(candidate.pinning);
34
40
  }
35
41
  /** Validates a plain (non-array) object whose values all satisfy `valueGuard`. */
@@ -313,14 +313,12 @@ export class SelectionState {
313
313
  : Math.min(pageEndIndexRaw, sortedDataLength);
314
314
  return { pageStartIndex, pageEndIndexExclusive };
315
315
  }
316
- allSelectedBySelectableMethod(pageStartIndex, pageEndIndexExclusive, selectableMethod) {
317
- const sortedData = this._deps.getSortedData();
316
+ allSelectedBySelectableMethod(pageItems, selectableMethod) {
318
317
  const emptyPromise = new Promise(() => undefined);
319
318
  if (selectableMethod) {
320
319
  const pending = [];
321
- for (let i = pageStartIndex; i < pageEndIndexExclusive; i++) {
322
- const item = sortedData[i];
323
- const relIdx = i - pageStartIndex;
320
+ for (let relIdx = 0; relIdx < pageItems.length; relIdx++) {
321
+ const item = pageItems[relIdx];
324
322
  const raw = this.cachedKey(item);
325
323
  const checkUnselected = (key) => {
326
324
  const selectable = selectableMethod(item, relIdx);
@@ -349,8 +347,8 @@ export class SelectionState {
349
347
  }
350
348
  else {
351
349
  const pending = [];
352
- for (let i = pageStartIndex; i < pageEndIndexExclusive; i++) {
353
- const raw = this.cachedKey(sortedData[i]);
350
+ for (let i = 0; i < pageItems.length; i++) {
351
+ const raw = this.cachedKey(pageItems[i]);
354
352
  if (raw instanceof Promise) {
355
353
  pending.push(raw.then((key) => !this._selectedItems.has(key)));
356
354
  }
@@ -378,17 +376,22 @@ export class SelectionState {
378
376
  if (this._selectedItems.size === 0) {
379
377
  return false;
380
378
  }
381
- // Subscribe to sortedData + pagination window so the derived
382
- // recomputes when the page shifts (mirrors the pre-extraction
383
- // `void this.paginationRepo?.pageStartIndex` reads).
384
- void this._deps.getSortedData();
385
- void this._deps.getPageStartIndex();
386
- void this._deps.getPageEndIndexExclusive();
387
- const { pageStartIndex, pageEndIndexExclusive } = this.getPageWindow();
379
+ // Subscribe to currentPageData so the derived recomputes when the
380
+ // page shifts. `currentPageData` already accounts for the active
381
+ // pagination mode: for Local pagination it's the sortedData slice
382
+ // for the current page; for Remote pagination it's the
383
+ // repository's data array (which IS only the current page).
384
+ // Iterating it directly avoids the page-2+ remote-pagination bug
385
+ // where absolute `pageStartIndex` indices read undefined entries
386
+ // from the page-sized `sortedData` array.
387
+ const pageItems = this._deps.getCurrentPageData();
388
+ if (pageItems.length === 0) {
389
+ return false;
390
+ }
388
391
  const selectableMethod = this._deps
389
392
  .getColumnDefs()
390
393
  .find((it) => it instanceof RowSelectionDef)?.isSelectable;
391
- const result = this.allSelectedBySelectableMethod(pageStartIndex, pageEndIndexExclusive, selectableMethod);
394
+ const result = this.allSelectedBySelectableMethod(pageItems, selectableMethod);
392
395
  if (result instanceof Promise) {
393
396
  result
394
397
  .then((flag) => {
@@ -406,9 +409,14 @@ export class SelectionState {
406
409
  * `isSelectable` guard is added to the selection map.
407
410
  */
408
411
  selectAllInCurrentPage = async () => {
409
- void this._deps.getPageStartIndex();
410
- const sortedData = this._deps.getSortedData();
411
- const { pageStartIndex, pageEndIndexExclusive } = this.getPageWindow();
412
+ // Use `currentPageData` rather than absolute indices into
413
+ // `sortedData`: for Remote pagination `sortedData` only contains
414
+ // the current page's rows but `pageStartIndex` is still the
415
+ // absolute index (e.g. 20 for page 2), so iterating from
416
+ // `pageStartIndex` would read undefined entries and silently no-op
417
+ // for page 2 onwards. `currentPageData` is always the visible
418
+ // page, regardless of pagination mode.
419
+ const pageItems = this._deps.getCurrentPageData();
412
420
  // Mutate in place to preserve SvelteMap per-key reactivity AND
413
421
  // to honour `setSelectionMap()` - if the consumer replaced the
414
422
  // slot wholesale, we mutate THAT map rather than allocating a
@@ -416,8 +424,8 @@ export class SelectionState {
416
424
  const sel = this._selectedItems;
417
425
  const pending = [];
418
426
  if (this.allSelectedInCurrentPage) {
419
- for (let i = pageStartIndex; i < pageEndIndexExclusive; i++) {
420
- const raw = this.cachedKey(sortedData[i]);
427
+ for (let i = 0; i < pageItems.length; i++) {
428
+ const raw = this.cachedKey(pageItems[i]);
421
429
  if (raw instanceof Promise) {
422
430
  pending.push(raw.then((key) => {
423
431
  sel.delete(key);
@@ -433,9 +441,8 @@ export class SelectionState {
433
441
  .getColumnDefs()
434
442
  .find((it) => it instanceof RowSelectionDef)?.isSelectable;
435
443
  if (selectableMethod) {
436
- for (let i = pageStartIndex; i < pageEndIndexExclusive; i++) {
437
- const item = sortedData[i];
438
- const relIdx = i - pageStartIndex;
444
+ for (let relIdx = 0; relIdx < pageItems.length; relIdx++) {
445
+ const item = pageItems[relIdx];
439
446
  const s = selectableMethod(item, relIdx);
440
447
  const doSet = (ok) => {
441
448
  if (!ok) {
@@ -456,8 +463,8 @@ export class SelectionState {
456
463
  }
457
464
  }
458
465
  else {
459
- for (let i = pageStartIndex; i < pageEndIndexExclusive; i++) {
460
- const item = sortedData[i];
466
+ for (let i = 0; i < pageItems.length; i++) {
467
+ const item = pageItems[i];
461
468
  const raw = this.cachedKey(item);
462
469
  if (raw instanceof Promise) {
463
470
  pending.push(raw.then((key) => {
@@ -484,14 +491,16 @@ export class SelectionState {
484
491
  if (this._selectedItems.size === 0) {
485
492
  return;
486
493
  }
487
- const sortedData = this._deps.getSortedData();
488
- const { pageStartIndex, pageEndIndexExclusive } = this.getPageWindow();
494
+ // Use `currentPageData` for the same reason as
495
+ // `selectAllInCurrentPage`: it is always the visible page
496
+ // regardless of pagination mode.
497
+ const pageItems = this._deps.getCurrentPageData();
489
498
  // Build key -> item map for the current page (O(page_size) extractions)
490
499
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
491
500
  const pageKeyMap = new Map();
492
501
  const pending = [];
493
- for (let i = pageStartIndex; i < pageEndIndexExclusive; i++) {
494
- const item = sortedData[i];
502
+ for (let i = 0; i < pageItems.length; i++) {
503
+ const item = pageItems[i];
495
504
  const raw = this.cachedKey(item);
496
505
  if (raw instanceof Promise) {
497
506
  pending.push(raw.then((key) => {
@@ -116,10 +116,11 @@ export declare class TableContext<T extends object, RowIdType extends Primitive>
116
116
  */
117
117
  private _layoutPersistTimer;
118
118
  /**
119
- * Pending debounce timer for the filter auto-persist. The filter
120
- * counterpart of `_layoutPersistTimer`; deliberately not cleared on
121
- * teardown for the same reason - a still-pending `persistFilter`
122
- * write should flush, and it only touches `localStorage`.
119
+ * Pending debounce timer for the filter dirty-tracking pass. Filters
120
+ * do NOT persist across page refresh (see the boot chain) so the
121
+ * trailing callback only updates the in-memory `viewState` dirty
122
+ * flag; no localStorage write happens here. Name retained for parity
123
+ * with `_layoutPersistTimer`.
123
124
  */
124
125
  private _filterPersistTimer;
125
126
  private _paginationRepo;
@@ -812,24 +813,6 @@ export declare class TableContext<T extends object, RowIdType extends Primitive>
812
813
  * corresponding step rather than crashing.
813
814
  */
814
815
  private _applyFilter;
815
- /**
816
- * Boot-time hydration. Reads the auto-saved live-filter snapshot and
817
- * applies it, restoring the user's last per-column filter values /
818
- * modes. No-op when no snapshot is stored. The filter counterpart of
819
- * `_hydrateLayout`.
820
- */
821
- private _hydrateFilter;
822
- /**
823
- * Boot-time hydration for the FPM 403 filter view-state machine.
824
- * Reads the persisted *filter* `JSONActiveView` envelope and restores
825
- * the filter active-view selection on top of whatever `_hydrateFilter`
826
- * produced. The filter counterpart of `_hydrateActiveView`; runs
827
- * independently and never touches the layout slice.
828
- *
829
- * Runs before the `_layoutHydrated` gate opens so the filter
830
- * auto-persist effect does not clobber the restored selection.
831
- */
832
- private _hydrateActiveFilterView;
833
816
  /**
834
817
  * Re-point `_filterBaseline` at the active filter view's saved
835
818
  * filter. The filter counterpart of `_reconcileBaselineForActiveView`:
@@ -449,20 +449,17 @@ export class TableContext {
449
449
  this._persistActiveView();
450
450
  }, LAYOUT_PERSIST_DEBOUNCE_MS);
451
451
  });
452
- // Auto-persist the live filter state (per-column values + modes)
453
- // to localStorage on every change - the filter counterpart of the
454
- // layout auto-persist effect above. A user's quick-filter / advanced
455
- // filter tweaks survive navigation and reload without saving a named
456
- // filter view. Gated on the SAME `_layoutHydrated` boot gate (the
457
- // boot chain hydrates the filter working copy alongside the layout,
458
- // so a single gate covers both) and debounced so per-keystroke
459
- // typing collapses to one write.
452
+ // Track FILTER dirtiness against the active-filter-view baseline
453
+ // without persisting anything. Filters are intentionally
454
+ // session-scoped: they reset on page navigation / refresh so the
455
+ // user always starts from the Default filter view and an empty
456
+ // quick-filter row, matching the user's mental model of "filters
457
+ // are an in-page action". Only the dirty-flag is updated so the
458
+ // view-picker chip still surfaces an unsaved active filter.
460
459
  //
461
- // Fully independent of the layout effect: it reads only the repo's
462
- // filter state, compares against the FILTER baseline, sets the
463
- // FILTER dirty flag, and persists the `active-filter-view`
464
- // envelope. Editing a layout never re-runs this effect and vice
465
- // versa.
460
+ // (We deliberately do NOT call `persistFilter` /
461
+ // `persistActiveFilterView` here; layouts persist, filters do
462
+ // not.)
466
463
  $effect(() => {
467
464
  const snapshot = this._captureFilter();
468
465
  if (!this._layoutHydrated) {
@@ -473,7 +470,6 @@ export class TableContext {
473
470
  }
474
471
  this._filterPersistTimer = setTimeout(() => {
475
472
  this._filterPersistTimer = undefined;
476
- this._preferences.persistFilter(snapshot);
477
473
  // Dirty tracking: the filter is dirty when it diverges from
478
474
  // the active filter view's baseline. The baseline can be a
479
475
  // filter parsed from a backend JSON column (arbitrary key
@@ -483,7 +479,6 @@ export class TableContext {
483
479
  const differs = this._filterBaseline !== undefined &&
484
480
  stableStringify(snapshot) !== stableStringify(this._filterBaseline);
485
481
  this._viewState.markDirty('filter', differs);
486
- this._persistActiveFilterView();
487
482
  }, FILTER_PERSIST_DEBOUNCE_MS);
488
483
  });
489
484
  if (browser) {
@@ -520,10 +515,15 @@ export class TableContext {
520
515
  // Restore the auto-saved live layout on top of the
521
516
  // factory Default.
522
517
  this._hydrateLayout();
523
- // Restore the auto-saved live filter working copy. The
524
- // filter counterpart of `_hydrateLayout`; fully
525
- // independent of the layout restore.
526
- this._hydrateFilter();
518
+ // Filters intentionally do NOT survive page refresh /
519
+ // navigation - the live working copy is reset to the
520
+ // Default filter view on every mount. Drop any
521
+ // stale persisted slot left by older builds so an
522
+ // older deploy never resurfaces filters the user
523
+ // thought were cleared. Layout persistence is
524
+ // untouched.
525
+ this._preferences.clearFilter();
526
+ this._preferences.clearActiveFilterView();
527
527
  // Restore the persisted active-view selection on top of
528
528
  // the working copy. The working copy that `_hydrateLayout`
529
529
  // applied stays the live state; this only re-points
@@ -531,9 +531,6 @@ export class TableContext {
531
531
  // flag. Done before the `_layoutHydrated` gate opens so
532
532
  // the auto-persist effect does not clobber it.
533
533
  this._hydrateActiveView();
534
- // Restore the persisted filter active-view selection,
535
- // independently of the layout one.
536
- this._hydrateActiveFilterView();
537
534
  this._layoutHydrated = true;
538
535
  // Fire-and-forget: pull the saved views from the backend.
539
536
  // Deliberately not awaited so a slow / failing network
@@ -566,10 +563,11 @@ export class TableContext {
566
563
  */
567
564
  _layoutPersistTimer = undefined;
568
565
  /**
569
- * Pending debounce timer for the filter auto-persist. The filter
570
- * counterpart of `_layoutPersistTimer`; deliberately not cleared on
571
- * teardown for the same reason - a still-pending `persistFilter`
572
- * write should flush, and it only touches `localStorage`.
566
+ * Pending debounce timer for the filter dirty-tracking pass. Filters
567
+ * do NOT persist across page refresh (see the boot chain) so the
568
+ * trailing callback only updates the in-memory `viewState` dirty
569
+ * flag; no localStorage write happens here. Name retained for parity
570
+ * with `_layoutPersistTimer`.
573
571
  */
574
572
  _filterPersistTimer = undefined;
575
573
  _paginationRepo = $state();
@@ -1964,11 +1962,20 @@ export class TableContext {
1964
1962
  sizing[def.id] = def.width;
1965
1963
  }
1966
1964
  }
1965
+ // Capture the current records-per-page selection so applying a
1966
+ // saved layout also restores its page size, and so a drag-resize
1967
+ // or visibility change auto-persists a layout that still reflects
1968
+ // the user's current page size. Reading `paginationState.pageSize`
1969
+ // also subscribes the auto-persist effect to page-size changes -
1970
+ // without it the new size would only land in localStorage on the
1971
+ // next layout mutation.
1972
+ const pageSize = this.paginationRepo?.paginationState.pageSize;
1967
1973
  return {
1968
1974
  columnOrder,
1969
1975
  visibility: { ...this.columnVisibility },
1970
1976
  pinning,
1971
1977
  sizing,
1978
+ ...(pageSize !== undefined ? { pageSize } : {}),
1972
1979
  };
1973
1980
  }
1974
1981
  /**
@@ -2038,6 +2045,18 @@ export class TableContext {
2038
2045
  }
2039
2046
  }
2040
2047
  this.columnPinning = adaptJSONPinningState(layout.pinning);
2048
+ // Restore the saved records-per-page selection. Optional in the
2049
+ // snapshot for backwards compatibility - older saves predate the
2050
+ // field; leave the live pagination untouched in that case.
2051
+ // Guarded against a no-op set (the setter is idempotent but
2052
+ // the localStorage write-through inside `paginationState =` would
2053
+ // still fire) so a hydration that matches the live value stays
2054
+ // silent.
2055
+ if (layout.pageSize !== undefined &&
2056
+ this.paginationRepo &&
2057
+ this.paginationRepo.paginationState.pageSize !== layout.pageSize) {
2058
+ this.paginationRepo.setPageSize(layout.pageSize);
2059
+ }
2041
2060
  }
2042
2061
  /**
2043
2062
  * Boot-time hydration. Reads the auto-saved live-layout snapshot and
@@ -2235,39 +2254,13 @@ export class TableContext {
2235
2254
  }
2236
2255
  }
2237
2256
  }
2238
- /**
2239
- * Boot-time hydration. Reads the auto-saved live-filter snapshot and
2240
- * applies it, restoring the user's last per-column filter values /
2241
- * modes. No-op when no snapshot is stored. The filter counterpart of
2242
- * `_hydrateLayout`.
2243
- */
2244
- _hydrateFilter() {
2245
- const filter = this._preferences.loadFilter();
2246
- if (filter) {
2247
- this._applyFilter(filter);
2248
- }
2249
- }
2250
- /**
2251
- * Boot-time hydration for the FPM 403 filter view-state machine.
2252
- * Reads the persisted *filter* `JSONActiveView` envelope and restores
2253
- * the filter active-view selection on top of whatever `_hydrateFilter`
2254
- * produced. The filter counterpart of `_hydrateActiveView`; runs
2255
- * independently and never touches the layout slice.
2256
- *
2257
- * Runs before the `_layoutHydrated` gate opens so the filter
2258
- * auto-persist effect does not clobber the restored selection.
2259
- */
2260
- _hydrateActiveFilterView() {
2261
- const persisted = this._preferences.loadActiveFilterView();
2262
- if (persisted === null) {
2263
- return;
2264
- }
2265
- if (persisted.active.kind === 'saved') {
2266
- this._viewState.setActiveView('filter', persisted.active);
2267
- this._reconcileBaselineForActiveFilterView();
2268
- }
2269
- this._viewState.markDirty('filter', persisted.dirty);
2270
- }
2257
+ // Boot-time filter hydration helpers used to live here as
2258
+ // `_hydrateFilter` / `_hydrateActiveFilterView`. Filters now reset to
2259
+ // the Default view on every mount (see the boot chain in the
2260
+ // constructor, which calls `clearFilter` / `clearActiveFilterView`),
2261
+ // so neither helper is called anymore. The reconcile/apply machinery
2262
+ // below is still used by `applyDatatableView` and `resetToDefault`,
2263
+ // which run during a live session - they have not been removed.
2271
2264
  /**
2272
2265
  * Re-point `_filterBaseline` at the active filter view's saved
2273
2266
  * filter. The filter counterpart of `_reconcileBaselineForActiveView`:
@@ -68,6 +68,13 @@ export declare class BuiltInRemoteRepository<TRow extends object> extends TableD
68
68
  readonly setFilterMode: (id: string, mode: FilterMode | undefined) => void;
69
69
  readonly filterBy: (id: string, value: unknown) => void;
70
70
  readonly resetFilters: () => void;
71
+ /**
72
+ * Reset the current page to page 1 without touching `pageSize`. Used
73
+ * by every filter-mutation entrypoint so a fresh filter never leaves
74
+ * the user stranded on a page index that no longer exists once the
75
+ * remote re-query returns a smaller result set.
76
+ */
77
+ private resetToFirstPage;
71
78
  /** Jumps to the given 1-based page number, clamped at >= 1. */
72
79
  readonly setPage: (page: number) => void;
73
80
  /** Advances to the next page. */
@@ -74,17 +74,14 @@ export class BuiltInRemoteRepository extends TableDataRepository {
74
74
  constructor(_options) {
75
75
  super(_options.tableName, _options.getUserId);
76
76
  this._options = _options;
77
- // Restore persisted page size if present.
78
- if (browser) {
79
- const userId = this.getUserId() ?? 'anon';
80
- const saved = localStorage.getItem(`scoria-table:${userId}:${_options.tableName}:page-size`);
81
- if (saved !== null) {
82
- const parsed = Number.parseInt(saved, 10);
83
- if (Number.isFinite(parsed) && parsed > 0) {
84
- this.paginationState = { pageIndex: 0, pageSize: parsed };
85
- }
86
- }
87
- else if (_options.pageSize) {
77
+ // The base-class constructor already restores the persisted
78
+ // records-per-page selection. Apply the caller-supplied default
79
+ // ONLY when nothing is persisted; otherwise an explicit
80
+ // `pageSize` option would silently overwrite the user's saved
81
+ // choice on every mount.
82
+ if (browser && _options.pageSize) {
83
+ const saved = localStorage.getItem(`scoria-table:${this.getUserId() ?? 'anon'}:${_options.tableName}:page-size`);
84
+ if (saved === null) {
88
85
  this.paginationState = { pageIndex: 0, pageSize: _options.pageSize };
89
86
  }
90
87
  }
@@ -101,6 +98,7 @@ export class BuiltInRemoteRepository extends TableDataRepository {
101
98
  else {
102
99
  this._filterModes.set(id, mode);
103
100
  }
101
+ this.resetToFirstPage();
104
102
  this.scheduleReload();
105
103
  };
106
104
  filterBy = (id, value) => {
@@ -121,12 +119,32 @@ export class BuiltInRemoteRepository extends TableDataRepository {
121
119
  }
122
120
  }
123
121
  this._filtering = next;
122
+ // Any filter change should drop the user back on page 1; otherwise
123
+ // when typing in a filter while on page 2+ the new (smaller) result
124
+ // set leaves the pagination on an out-of-range page index and the
125
+ // table renders empty until the user manually navigates back.
126
+ this.resetToFirstPage();
124
127
  this.scheduleReload();
125
128
  };
126
129
  resetFilters = () => {
127
130
  this._filtering = [];
131
+ this.resetToFirstPage();
128
132
  this.scheduleReload();
129
133
  };
134
+ /**
135
+ * Reset the current page to page 1 without touching `pageSize`. Used
136
+ * by every filter-mutation entrypoint so a fresh filter never leaves
137
+ * the user stranded on a page index that no longer exists once the
138
+ * remote re-query returns a smaller result set.
139
+ */
140
+ resetToFirstPage() {
141
+ if (this.paginationState.pageIndex !== 0) {
142
+ this.paginationState = {
143
+ ...this.paginationState,
144
+ pageIndex: 0,
145
+ };
146
+ }
147
+ }
130
148
  /** Jumps to the given 1-based page number, clamped at >= 1. */
131
149
  setPage = (page) => {
132
150
  this.paginationState = {
@@ -76,6 +76,12 @@ export declare class LocalTableDataRepository<T extends object> extends TableDat
76
76
  * `filterByDebounced` helper here.
77
77
  */
78
78
  readonly filterBy: (id: string, value: unknown) => void;
79
+ /**
80
+ * Reset the current page to page 1 without touching `pageSize`. Used
81
+ * by every filter-mutation entrypoint so a fresh filter never leaves
82
+ * the user stranded on a page that no longer exists.
83
+ */
84
+ private resetToFirstPage;
79
85
  private _pageStartIndex;
80
86
  /** Index of the first row on the current page. */
81
87
  get pageStartIndex(): number;
@@ -133,6 +133,10 @@ export class LocalTableDataRepository extends TableDataRepository {
133
133
  }
134
134
  this._filterModes.delete(id);
135
135
  }
136
+ // Changing the active filter mode can change which rows are
137
+ // included; reset to page 1 so the user is not stranded on a
138
+ // now-empty page.
139
+ this.resetToFirstPage();
136
140
  };
137
141
  /**
138
142
  * Sets or updates the filter value for a column. Empty values are
@@ -180,7 +184,25 @@ export class LocalTableDataRepository extends TableDataRepository {
180
184
  }
181
185
  }
182
186
  this.filtering = newFiltering;
187
+ // Any filter change should drop the user back on page 1; otherwise
188
+ // when typing in a filter while on page 2+ the new (smaller) result
189
+ // set leaves the pagination on an out-of-range page index and the
190
+ // table renders empty until the user manually navigates back.
191
+ this.resetToFirstPage();
183
192
  };
193
+ /**
194
+ * Reset the current page to page 1 without touching `pageSize`. Used
195
+ * by every filter-mutation entrypoint so a fresh filter never leaves
196
+ * the user stranded on a page that no longer exists.
197
+ */
198
+ resetToFirstPage() {
199
+ if (this.paginationState.pageIndex !== 0) {
200
+ this.paginationState = {
201
+ ...this.paginationState,
202
+ pageIndex: 0,
203
+ };
204
+ }
205
+ }
184
206
  _pageStartIndex = $derived(this.paginationState.pageIndex * this.paginationState.pageSize);
185
207
  /** Index of the first row on the current page. */
186
208
  get pageStartIndex() {
@@ -194,6 +216,7 @@ export class LocalTableDataRepository extends TableDataRepository {
194
216
  /** Clears every active filter value (keeps filter modes intact). */
195
217
  resetFilters = () => {
196
218
  this.filtering = [];
219
+ this.resetToFirstPage();
197
220
  };
198
221
  /**
199
222
  * Sets the page size and clamps the current page index so the new
@@ -13,6 +13,22 @@ export class TableDataRepository {
13
13
  constructor(tableName, getUserId) {
14
14
  this.tableName = tableName;
15
15
  this.getUserId = getUserId;
16
+ // Restore the persisted records-per-page selection. The setter
17
+ // writes through to localStorage on every change, but without this
18
+ // load the next mount would always snap back to the hardcoded 20
19
+ // default. Done in the base class so every concrete repository
20
+ // (local + every remote variant) gets the same behaviour for free.
21
+ // Browser-only: SSR has no localStorage and the setter's
22
+ // write-through is similarly guarded.
23
+ if (browser) {
24
+ const saved = localStorage.getItem(`scoria-table:${this.getUserId() ?? 'anon'}:${this.tableName}:page-size`);
25
+ if (saved !== null) {
26
+ const parsed = Number.parseInt(saved, 10);
27
+ if (Number.isFinite(parsed) && parsed > 0) {
28
+ this._paginationState = { pageIndex: 0, pageSize: parsed };
29
+ }
30
+ }
31
+ }
16
32
  }
17
33
  _paginationState = $state({
18
34
  pageIndex: 0,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.38.13",
4
+ "version": "0.38.14",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },