@lavalogic/scoria 0.38.5 → 0.38.7

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.
@@ -111,17 +111,27 @@
111
111
  $effect(() => {
112
112
  if (tableContext.paginationRepo?.paginationState.pageIndex == null) {
113
113
  pageButtons = [1];
114
- } else {
115
- const startingPage = Math.min(
116
- tableContext.lastPage - 2,
117
- tableContext.paginationRepo.paginationState.pageIndex - 1
118
- );
119
- const pageToStartWith = Math.max(1, Math.min(startingPage, tableContext.lastPage - 4));
120
-
121
- pageButtons = new Array<number>(Math.min(tableContext.lastPage, 5))
122
- .fill(pageToStartWith)
123
- .map((item, index) => item + index);
114
+ return;
124
115
  }
116
+ const current = tableContext.paginationRepo.paginationState.pageIndex + 1;
117
+ const last = tableContext.lastPage;
118
+ // `pages` accumulates the dedup'd, sorted button set. The minimum
119
+ // the user always sees between the Previous and Next arrows is the
120
+ // current page, the next page (when one exists), and the last
121
+ // page. On top of that we still surface a small window around the
122
+ // current page for visual context.
123
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
124
+ const pages = new Set<number>();
125
+ const windowStart = Math.max(1, Math.min(current - 2, last - 4));
126
+ for (let i = windowStart; i < windowStart + 5 && i <= last; i++) {
127
+ pages.add(i);
128
+ }
129
+ pages.add(current);
130
+ if (current + 1 <= last) {
131
+ pages.add(current + 1);
132
+ }
133
+ pages.add(last);
134
+ pageButtons = [...pages].sort((a, b) => a - b);
125
135
  });
126
136
 
127
137
  let hasNoSelectedItems = $derived(tableContext.selectedItems.size == 0);
@@ -250,6 +260,11 @@
250
260
  id="{tableContext.tableName}-jump-to-page"
251
261
  value={pageValue}
252
262
  oninput={onNumberInput}
263
+ onfocus={(e) => {
264
+ // Select the existing value so typing replaces it, instead of
265
+ // appending to it (typing "5" on a "4" otherwise yields "45").
266
+ (e.target as HTMLInputElement).select();
267
+ }}
253
268
  disabled={tableContext.showSelectedOnly}
254
269
  />
255
270
  </div>
@@ -32,18 +32,9 @@
32
32
  // strings) and from `UNSAVED_OPTION`.
33
33
  const DEFAULT_OPTION = ' default';
34
34
 
35
- // Sentinel value for the *transient* "…(unsaved)" option shown while
36
- // the table state is dirty. It is deliberately distinct from
37
- // `DEFAULT_OPTION` and from every real saved-view id so the transient
38
- // option never coincides with a selectable option: selecting any real
39
- // option (Default included) from a dirty state is then always seen as
40
- // a genuine change by the underlying select, so the handler fires.
41
- const UNSAVED_OPTION = ' unsaved';
42
-
43
35
  // The view-state slice for `kind`, read live from the context.
44
36
  const activeView = $derived(tableContext._viewState.activeView(kind));
45
37
  const savedViews = $derived(tableContext._viewState.savedViews(kind));
46
- const isDirty = $derived(tableContext._viewState.isDirty(kind));
47
38
 
48
39
  // Dropdown label: 'Default' / 'Custom (unsaved)' / '<name>' / '<name>
49
40
  // (unsaved)'. Used as the displayed value of the select so it reflects
@@ -68,19 +59,18 @@
68
59
  ...savedViews.map((view) => ({ label: view.name, value: view.id })),
69
60
  ]);
70
61
 
71
- // The value handed to `SingleSelect`. `SingleSelect` displays
72
- // `value.label` directly (via its `selection` snippet), so when the
73
- // state is dirty we feed it a transient option carrying the
74
- // "…(unsaved)" `dropdownLabel` and the `UNSAVED_OPTION` sentinel
75
- // value - this shows the dirty label WITHOUT having to inject it into
76
- // `options`, so Default + saved views all stay selectable. When the
77
- // state is clean the value is the matching real option so it
78
- // highlights correctly in the list.
79
- const selectedOption: SelectOption<string> = $derived(
80
- isDirty
81
- ? { label: dropdownLabel, value: UNSAVED_OPTION }
82
- : (options.find((o) => o.value === activeValue) ?? options[0])
83
- );
62
+ // The value handed to `SingleSelect`. The `value` is always the active
63
+ // option's real value (the Default sentinel or a saved-view id), so it
64
+ // matches an entry in `options` and `SingleSelect` keeps the selection
65
+ // rendered. The `label` is `dropdownLabel`, which already encodes the
66
+ // dirty state ("…(unsaved)"); `SingleSelect` shows it verbatim via its
67
+ // `selection` snippet. An earlier approach fed a transient value that
68
+ // was absent from `options`, which made svelecte drop the selection
69
+ // and fall back to its placeholder.
70
+ const selectedOption: SelectOption<string> = $derived({
71
+ label: dropdownLabel,
72
+ value: activeValue,
73
+ });
84
74
 
85
75
  /** Apply the option the user picked from the dropdown. */
86
76
  function selectOption(option: SelectOption<string> | null): void {
@@ -37,6 +37,31 @@ const LAYOUT_PERSIST_DEBOUNCE_MS = 250;
37
37
  * auto-persist effects behave consistently.
38
38
  */
39
39
  const FILTER_PERSIST_DEBOUNCE_MS = 250;
40
+ /**
41
+ * `JSON.stringify` with object keys sorted recursively, so two
42
+ * structurally-equal values produce an identical string regardless of
43
+ * key insertion order. Array element order is preserved (it is
44
+ * significant for `columnOrder` and pinning entries).
45
+ *
46
+ * Used for the layout / filter dirty comparison: a baseline parsed from a
47
+ * backend JSON column has arbitrary key order, while `_captureLayout` /
48
+ * `_captureFilter` build their objects in a fixed order - a plain
49
+ * `JSON.stringify` compare would then report a false "dirty" the moment a
50
+ * saved view is applied.
51
+ */
52
+ function stableStringify(value) {
53
+ if (value === null || typeof value !== 'object') {
54
+ return JSON.stringify(value) ?? 'null';
55
+ }
56
+ if (Array.isArray(value)) {
57
+ return `[${value.map(stableStringify).join(',')}]`;
58
+ }
59
+ const record = value;
60
+ return `{${Object.keys(record)
61
+ .sort()
62
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
63
+ .join(',')}}`;
64
+ }
40
65
  /**
41
66
  * The complete set of filter-mode operator strings scoria recognises -
42
67
  * the union of every `StringFilterMode` and `NumberDateFilterMode`
@@ -412,13 +437,14 @@ export class TableContext {
412
437
  this._layoutPersistTimer = undefined;
413
438
  this._preferences.persistLayout(snapshot);
414
439
  // Dirty tracking: the layout is dirty when it diverges
415
- // from the active view's baseline. `_captureLayout`
416
- // builds its object deterministically, so a stable
417
- // `JSON.stringify` comparison is sufficient. When no
418
- // baseline has been captured yet (should not happen
419
- // past boot) treat the layout as clean.
440
+ // from the active view's baseline. The baseline can be a
441
+ // layout parsed from a backend JSON column (arbitrary key
442
+ // order), so the comparison must be key-order-insensitive
443
+ // - a plain `JSON.stringify` compare flagged every applied
444
+ // saved view as dirty. When no baseline has been captured
445
+ // yet (should not happen past boot) treat it as clean.
420
446
  const differs = this._viewBaseline !== undefined &&
421
- JSON.stringify(snapshot) !== JSON.stringify(this._viewBaseline);
447
+ stableStringify(snapshot) !== stableStringify(this._viewBaseline);
422
448
  this._viewState.markDirty('layout', differs);
423
449
  this._persistActiveView();
424
450
  }, LAYOUT_PERSIST_DEBOUNCE_MS);
@@ -449,13 +475,13 @@ export class TableContext {
449
475
  this._filterPersistTimer = undefined;
450
476
  this._preferences.persistFilter(snapshot);
451
477
  // Dirty tracking: the filter is dirty when it diverges from
452
- // the active filter view's baseline. `_captureFilter` builds
453
- // its object deterministically (sorted entries / sorted mode
454
- // keys), so a stable `JSON.stringify` comparison is
455
- // sufficient. When no baseline has been captured yet (should
456
- // not happen past boot) treat the filter as clean.
478
+ // the active filter view's baseline. The baseline can be a
479
+ // filter parsed from a backend JSON column (arbitrary key
480
+ // order), so the comparison is key-order-insensitive via
481
+ // `stableStringify`. When no baseline has been captured yet
482
+ // (should not happen past boot) treat the filter as clean.
457
483
  const differs = this._filterBaseline !== undefined &&
458
- JSON.stringify(snapshot) !== JSON.stringify(this._filterBaseline);
484
+ stableStringify(snapshot) !== stableStringify(this._filterBaseline);
459
485
  this._viewState.markDirty('filter', differs);
460
486
  this._persistActiveFilterView();
461
487
  }, FILTER_PERSIST_DEBOUNCE_MS);
@@ -68,8 +68,11 @@ export declare class BuiltInRemoteRepository<TRow extends object> extends TableD
68
68
  readonly setFilterMode: (id: string, mode: FilterMode) => void;
69
69
  readonly filterBy: (id: string, value: unknown) => void;
70
70
  readonly resetFilters: () => void;
71
+ /** Jumps to the given 1-based page number, clamped at >= 1. */
71
72
  readonly setPage: (page: number) => void;
73
+ /** Advances to the next page. */
72
74
  readonly nextPage: () => void;
75
+ /** Returns to the previous page, clamped at >= 1. */
73
76
  readonly prevPage: () => void;
74
77
  readonly setPageSize: (size: number | string) => void;
75
78
  readonly updateSorting: (next: SortingState | ((prev: SortingState) => SortingState)) => void;
@@ -121,8 +124,11 @@ export declare class CustomRemoteRepository<TRow extends object> extends TableDa
121
124
  readonly setFilterMode: (id: string, mode: FilterMode) => void;
122
125
  readonly filterBy: (id: string, value: unknown) => void;
123
126
  readonly resetFilters: () => void;
127
+ /** Jumps to the given 1-based page number, clamped at >= 1. */
124
128
  readonly setPage: (page: number) => void;
129
+ /** Advances to the next page. */
125
130
  readonly nextPage: () => void;
131
+ /** Returns to the previous page, clamped at >= 1. */
126
132
  readonly prevPage: () => void;
127
133
  readonly setPageSize: (size: number | string) => void;
128
134
  readonly updateSorting: (next: SortingState | ((prev: SortingState) => SortingState)) => void;
@@ -118,15 +118,29 @@ export class BuiltInRemoteRepository extends TableDataRepository {
118
118
  this._filtering = [];
119
119
  this.scheduleReload();
120
120
  };
121
+ /** Jumps to the given 1-based page number, clamped at >= 1. */
121
122
  setPage = (page) => {
122
- this.paginationState = { ...this.paginationState, pageIndex: page };
123
+ this.paginationState = {
124
+ ...this.paginationState,
125
+ pageIndex: Math.max(0, page - 1),
126
+ };
123
127
  this.scheduleReload({ immediate: true });
124
128
  };
129
+ /** Advances to the next page. */
125
130
  nextPage = () => {
126
- this.setPage(this.paginationState.pageIndex + 1);
131
+ this.paginationState = {
132
+ ...this.paginationState,
133
+ pageIndex: this.paginationState.pageIndex + 1,
134
+ };
135
+ this.scheduleReload({ immediate: true });
127
136
  };
137
+ /** Returns to the previous page, clamped at >= 1. */
128
138
  prevPage = () => {
129
- this.setPage(Math.max(0, this.paginationState.pageIndex - 1));
139
+ this.paginationState = {
140
+ ...this.paginationState,
141
+ pageIndex: Math.max(0, this.paginationState.pageIndex - 1),
142
+ };
143
+ this.scheduleReload({ immediate: true });
130
144
  };
131
145
  setPageSize = (size) => {
132
146
  const parsed = typeof size === 'number' ? size : Number.parseInt(size, 10);
@@ -379,15 +393,29 @@ export class CustomRemoteRepository extends TableDataRepository {
379
393
  this._filtering = [];
380
394
  void this.reload();
381
395
  };
396
+ /** Jumps to the given 1-based page number, clamped at >= 1. */
382
397
  setPage = (page) => {
383
- this.paginationState = { ...this.paginationState, pageIndex: page };
398
+ this.paginationState = {
399
+ ...this.paginationState,
400
+ pageIndex: Math.max(0, page - 1),
401
+ };
384
402
  void this.reload();
385
403
  };
404
+ /** Advances to the next page. */
386
405
  nextPage = () => {
387
- this.setPage(this.paginationState.pageIndex + 1);
406
+ this.paginationState = {
407
+ ...this.paginationState,
408
+ pageIndex: this.paginationState.pageIndex + 1,
409
+ };
410
+ void this.reload();
388
411
  };
412
+ /** Returns to the previous page, clamped at >= 1. */
389
413
  prevPage = () => {
390
- this.setPage(Math.max(0, this.paginationState.pageIndex - 1));
414
+ this.paginationState = {
415
+ ...this.paginationState,
416
+ pageIndex: Math.max(0, this.paginationState.pageIndex - 1),
417
+ };
418
+ void this.reload();
391
419
  };
392
420
  setPageSize = (size) => {
393
421
  const parsed = typeof size === 'number' ? size : Number.parseInt(size, 10);
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.5",
4
+ "version": "0.38.7",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },