@lavalogic/scoria 0.37.42 → 0.37.44

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.
@@ -15,6 +15,7 @@
15
15
  selected,
16
16
  noRadius = false,
17
17
  grow = false,
18
+ dense = false,
18
19
  filterable = false,
19
20
  filterFn,
20
21
  }: HorizontalTabGroupProps<CommonDenominator> = $props();
@@ -42,6 +43,7 @@
42
43
  class="wrapper"
43
44
  class:no-radius={noRadius}
44
45
  class:grow
46
+ class:dense
45
47
  >
46
48
  <div class="tabs">
47
49
  <div class="sticky">
@@ -104,6 +106,9 @@
104
106
  .wrapper.grow {
105
107
  flex-grow: 1;
106
108
  }
109
+ .wrapper.dense {
110
+ align-items: flex-start;
111
+ }
107
112
  .wrapper:not(.grow) .tabs {
108
113
  padding-top: 1px;
109
114
  }
@@ -15,6 +15,18 @@ interface HorizontalTabGroupPropsBase<CommonDenominator> {
15
15
  noRadius?: boolean;
16
16
  /** Stretch to fill the parent's flex axis. */
17
17
  grow?: boolean;
18
+ /**
19
+ * Anchor the tab strip to the top of the group rather than letting
20
+ * it stretch to the height of the active tab's content. In the
21
+ * default `align-items: stretch` row layout, the tabs strip and the
22
+ * content pane stretch each other to the tallest natural height -
23
+ * which is fine for full-page tables, but leaves a tall column of
24
+ * empty `.ui-blue-2` background next to a short tab body inside a
25
+ * dialog. With `dense`, the wrapper switches to `align-items:
26
+ * flex-start` so both children sit at their natural heights and
27
+ * the group collapses onto whichever is taller.
28
+ */
29
+ dense?: boolean;
18
30
  /** The tabs to render. Keyed by `option.label`. */
19
31
  tabs: ReadonlyArray<TabOption<CommonDenominator>>;
20
32
  /** Currently selected tab, matched by reference. */
@@ -288,23 +288,31 @@
288
288
  let showFromDateModal = $state(false);
289
289
  let showToDateModal = $state(false);
290
290
 
291
- function generateOnChangeObject(label: string) {
291
+ function generateOnChangeObject(_label: string) {
292
292
  return (newValue: SelectOption<unknown> | null) => {
293
293
  tableContext.paginationRepo?.filterBy(def.id, newValue?.value ?? null);
294
- tableContext.dataRepo.__queryValues.set(label, newValue);
294
+ // Cache by `def.id` (which equals `customFilter.id`) so the
295
+ // `$effect` lookup `__queryValues.get(customFilter.id)`
296
+ // finds it on the next reactive pass. Previously we keyed
297
+ // the cache by the human-readable `label`, so the effect's
298
+ // lookup returned `undefined` and the QuerySelect was
299
+ // immediately reset to the placeholder.
300
+ tableContext.dataRepo.__queryValues.set(def.id, newValue);
295
301
  assertNotPromise(newValue);
296
302
  value = newValue;
297
303
  };
298
304
  }
299
305
 
300
- function generateOnChangePrimitive(label: string) {
306
+ function generateOnChangePrimitive(_label: string) {
301
307
  // Phase 7.6: AsPrimitive QuerySelect now hands us the primitive
302
308
  // directly (previously typed as `SelectOption` with a hidden
303
309
  // `.value` dig). The discriminated typing makes the flow
304
310
  // straightforward.
305
311
  return (newValue: unknown) => {
306
312
  tableContext.paginationRepo?.filterBy(def.id, newValue ?? null);
307
- tableContext.dataRepo.__queryValues.set(label, newValue ?? null);
313
+ // See `generateOnChangeObject` - cache key matches the
314
+ // `$effect` lookup above.
315
+ tableContext.dataRepo.__queryValues.set(def.id, newValue ?? null);
308
316
  assertNotPromise(newValue);
309
317
  value = newValue ?? null;
310
318
  };
@@ -257,22 +257,68 @@
257
257
  let showFromDateModal = $state(false);
258
258
  let showToDateModal = $state(false);
259
259
 
260
- function generateOnChangeObject(label: string) {
260
+ /**
261
+ * Debounce window for typed filter inputs (Text / Number / Between
262
+ * min+max). Discrete selections (Svelecte selects, date pickers,
263
+ * filter-mode dropdowns) bypass this and fire `filterBy` immediately.
264
+ * Combined with the 400 ms debounce in `createRemoteRowSource`, the
265
+ * server-side lookup lands roughly one second after the last keystroke,
266
+ * which matches the "wait a couple of seconds" expectation users have
267
+ * when typing into a free-text filter.
268
+ */
269
+ const FILTER_INPUT_DEBOUNCE_MS = 500;
270
+
271
+ let typingDebounceTimer: ReturnType<typeof setTimeout> | undefined;
272
+
273
+ /** Fire the table's `filterBy` after the user stops typing for
274
+ * `FILTER_INPUT_DEBOUNCE_MS`. Repeated calls coalesce; the most
275
+ * recent value wins. */
276
+ function debouncedFilterBy(id: string, newValue: unknown): void {
277
+ if (typingDebounceTimer !== undefined) {
278
+ clearTimeout(typingDebounceTimer);
279
+ }
280
+ typingDebounceTimer = setTimeout(() => {
281
+ typingDebounceTimer = undefined;
282
+ tableContext.paginationRepo?.filterBy(id, newValue);
283
+ }, FILTER_INPUT_DEBOUNCE_MS);
284
+ }
285
+
286
+ // Clear any pending debounce when the row unmounts so we do not
287
+ // fire `filterBy` against a torn-down `paginationRepo`.
288
+ $effect(() => {
289
+ return () => {
290
+ if (typingDebounceTimer !== undefined) {
291
+ clearTimeout(typingDebounceTimer);
292
+ typingDebounceTimer = undefined;
293
+ }
294
+ };
295
+ });
296
+
297
+ function generateOnChangeObject(_label: string) {
261
298
  return (newValue: SelectOption<unknown> | null) => {
262
299
  tableContext.paginationRepo?.filterBy(def.id, newValue?.value ?? null);
263
- tableContext.dataRepo.__queryValues.set(label, newValue ?? null);
300
+ // Cache by the column id (which is what the `$effect` above
301
+ // reads back with `__queryValues.get(customFilter.id)`).
302
+ // Previously we cached by the human-readable `label`, so the
303
+ // effect's lookup returned `undefined` and immediately
304
+ // overwrote `value` with `null` - the QuerySelect would
305
+ // briefly flash the chosen option then snap back to the
306
+ // "Please Select an Item" placeholder.
307
+ tableContext.dataRepo.__queryValues.set(def.id, newValue ?? null);
264
308
  value = newValue ?? null;
265
309
  };
266
310
  }
267
311
 
268
- function generateOnChangePrimitive(label: string) {
312
+ function generateOnChangePrimitive(_label: string) {
269
313
  // Phase 7.6: AsPrimitive QuerySelect now hands us the primitive
270
314
  // directly. The previous code typed this as `SelectOption<unknown>`
271
315
  // and read `.value` off it; with the discriminated typing the
272
316
  // primitive flows straight through.
273
317
  return (newValue: unknown) => {
274
318
  tableContext.paginationRepo?.filterBy(def.id, newValue ?? null);
275
- tableContext.dataRepo.__queryValues.set(label, newValue ?? null);
319
+ // See note in `generateOnChangeObject` - keyed by `def.id`
320
+ // to match the `$effect` lookup.
321
+ tableContext.dataRepo.__queryValues.set(def.id, newValue ?? null);
276
322
  value = newValue ?? null;
277
323
  };
278
324
  }
@@ -406,6 +452,24 @@
406
452
  />
407
453
  {/if}
408
454
  {:else}
455
+ {@const selectedPrimitives = selectedOptions
456
+ .map((it) => it.value)
457
+ .filter(
458
+ (v): v is string | number => typeof v === 'string' || typeof v === 'number'
459
+ )}
460
+ <!--
461
+ MultiSelect with `valueAsObject={false}` matches
462
+ options against the primitive value, not the whole
463
+ SelectOption shape. Previously the remote-select
464
+ branch passed the SelectOption objects directly
465
+ (the `selectedOptions` $state), meaning the inner
466
+ `selectedKeys` derived saw
467
+ `[{label, value}, ...]` rows where it expected
468
+ `[id1, id2, ...]`, so no chips ever rendered even
469
+ though the filter was active server-side. Pull the
470
+ primitive `value` off each option to match the
471
+ local-select branch above.
472
+ -->
409
473
  <MultiSelect
410
474
  label={displayLabel}
411
475
  labelProp="label"
@@ -418,7 +482,7 @@
418
482
  // primitives directly.
419
483
  tableContext.paginationRepo?.filterBy(def.id, e);
420
484
  }}
421
- selectedValues={selectedOptions}
485
+ selectedValues={selectedPrimitives}
422
486
  collapseSelection
423
487
  />
424
488
  {/if}
@@ -501,7 +565,7 @@
501
565
  id={inputId}
502
566
  bind:value={valueMin}
503
567
  oninput={() => {
504
- tableContext.paginationRepo?.filterBy(def.id, [valueMin, valueMax]);
568
+ debouncedFilterBy(def.id, [valueMin, valueMax]);
505
569
  }}
506
570
  onkeydown={(e: KeyboardEvent) => {
507
571
  e.stopPropagation();
@@ -512,7 +576,7 @@
512
576
  id={inputId}
513
577
  bind:value={valueMax}
514
578
  oninput={() => {
515
- tableContext.paginationRepo?.filterBy(def.id, [valueMin, valueMax]);
579
+ debouncedFilterBy(def.id, [valueMin, valueMax]);
516
580
  }}
517
581
  onkeydown={(e: KeyboardEvent) => {
518
582
  e.stopPropagation();
@@ -523,7 +587,7 @@
523
587
  id={inputId}
524
588
  bind:value
525
589
  oninput={() => {
526
- tableContext.paginationRepo?.filterBy(def.id, [value]);
590
+ debouncedFilterBy(def.id, [value]);
527
591
  }}
528
592
  onkeydown={(e: KeyboardEvent) => {
529
593
  e.stopPropagation();
@@ -561,7 +625,7 @@
561
625
  id={inputId}
562
626
  bind:value
563
627
  oninput={() => {
564
- tableContext.paginationRepo?.filterBy(def.id, value);
628
+ debouncedFilterBy(def.id, value);
565
629
  }}
566
630
  onkeydown={(e: KeyboardEvent) => {
567
631
  e.stopPropagation();
@@ -893,6 +893,4 @@ div.sidebar-wrapper .sidebar.toolbar-right {
893
893
  text-align: left;
894
894
  min-width: max-content;
895
895
  isolation: isolate;
896
- position: relative;
897
- z-index: 20;
898
896
  }</style>
@@ -16,6 +16,7 @@
16
16
  noscroll,
17
17
  noTabMessage,
18
18
  nohistory = false,
19
+ dense = false,
19
20
  }: VerticalTabGroupProps<CommonDenominator> = $props();
20
21
 
21
22
  const isNonEmptyString = (raw: unknown): raw is string =>
@@ -48,6 +49,7 @@
48
49
  <div
49
50
  class="wrapper"
50
51
  class:noscroll
52
+ class:dense
51
53
  {style}
52
54
  >
53
55
  <div class="tabs">
@@ -62,6 +64,7 @@
62
64
  <div
63
65
  class="content"
64
66
  class:noscroll
67
+ class:dense
65
68
  >
66
69
  {#if selected}
67
70
  {@render selected.content(selected.args)}
@@ -88,6 +91,10 @@
88
91
  .wrapper.noscroll {
89
92
  overflow: visible;
90
93
  }
94
+ .wrapper.dense {
95
+ flex: 0 0 auto;
96
+ overflow: visible;
97
+ }
91
98
  .wrapper .tabs {
92
99
  border-radius: 0 4px 0 0;
93
100
  display: flex;
@@ -113,4 +120,8 @@
113
120
  }
114
121
  .wrapper .content.noscroll {
115
122
  overflow: visible;
123
+ }
124
+ .wrapper .content.dense {
125
+ flex-grow: 0;
126
+ overflow: visible;
116
127
  }</style>
@@ -20,4 +20,16 @@ export interface VerticalTabGroupProps<CommonDenominator> {
20
20
  noTabMessage?: string;
21
21
  /** Suppress on-mount history restore even when `name` is set. */
22
22
  nohistory?: boolean;
23
+ /**
24
+ * Size the group to the height of the selected tab's content rather
25
+ * than filling whatever vertical space the parent offers. Use this
26
+ * inside dialogs / forms where the tab body is a short, content-
27
+ * driven panel and the default `flex-grow: 1` would leave a
28
+ * stretch of empty background below the rendered content.
29
+ *
30
+ * Concretely this disables the `flex: 1` on the wrapper and the
31
+ * `flex-grow: 1` on the inner `.content`, so the group collapses
32
+ * vertically onto whatever the active tab renders.
33
+ */
34
+ dense?: boolean;
23
35
  }
@@ -32,14 +32,19 @@
32
32
  * 3. Listens for capture-phase `scroll` events and for viewport
33
33
  * `resize` events so the position tracks the trigger while
34
34
  * the dropdown is open. Repositioning is rAF-throttled.
35
- * 4. Applies a very large `z-index` to the dropdown so it wins
36
- * against the table's sticky-footer (`z-index: 10`) and
37
- * pinned-column (`z-index: 5-11`) stacking layers. Note that
38
- * `z-index` only competes within a shared stacking context;
39
- * the dropdown's `position: fixed` + `z-index` makes it its
40
- * own stacking context, but ancestor stacking contexts can
41
- * still constrain it. See `_mixins.scss` `table-cell-svelecte`
42
- * for the per-cell rules that avoid creating such ancestors.
35
+ * 4. Applies a very large `z-index` to the dropdown AND
36
+ * temporarily lifts the dropdown's nearest stacking-context
37
+ * ancestor (the sticky / isolated table strip that contains
38
+ * the trigger) so the dropdown's z-index can actually win
39
+ * against other peer stacking contexts in the same parent.
40
+ * Without the lift the dropdown's z-index was trapped inside
41
+ * its ancestor's own context and could be hidden by an
42
+ * adjacent sibling stacking context with even a small
43
+ * z-index - which is exactly what happened with the table's
44
+ * sticky footer covering open cell dropdowns (or, after the
45
+ * initial fix, the table body covering open footer
46
+ * dropdowns). The lift is ref-counted so two dropdowns that
47
+ * share an ancestor coordinate cleanly.
43
48
  */
44
49
  export interface DropdownPortalOptions {
45
50
  /** The Svelecte root wrapper `<div class="svelecte">`. Used as the
@@ -32,14 +32,19 @@
32
32
  * 3. Listens for capture-phase `scroll` events and for viewport
33
33
  * `resize` events so the position tracks the trigger while
34
34
  * the dropdown is open. Repositioning is rAF-throttled.
35
- * 4. Applies a very large `z-index` to the dropdown so it wins
36
- * against the table's sticky-footer (`z-index: 10`) and
37
- * pinned-column (`z-index: 5-11`) stacking layers. Note that
38
- * `z-index` only competes within a shared stacking context;
39
- * the dropdown's `position: fixed` + `z-index` makes it its
40
- * own stacking context, but ancestor stacking contexts can
41
- * still constrain it. See `_mixins.scss` `table-cell-svelecte`
42
- * for the per-cell rules that avoid creating such ancestors.
35
+ * 4. Applies a very large `z-index` to the dropdown AND
36
+ * temporarily lifts the dropdown's nearest stacking-context
37
+ * ancestor (the sticky / isolated table strip that contains
38
+ * the trigger) so the dropdown's z-index can actually win
39
+ * against other peer stacking contexts in the same parent.
40
+ * Without the lift the dropdown's z-index was trapped inside
41
+ * its ancestor's own context and could be hidden by an
42
+ * adjacent sibling stacking context with even a small
43
+ * z-index - which is exactly what happened with the table's
44
+ * sticky footer covering open cell dropdowns (or, after the
45
+ * initial fix, the table body covering open footer
46
+ * dropdowns). The lift is ref-counted so two dropdowns that
47
+ * share an ancestor coordinate cleanly.
43
48
  */
44
49
  /**
45
50
  * Z-index applied to the open dropdown. Picked very high so the
@@ -48,6 +53,108 @@
48
53
  * stays below modal overlays (`z 99999` in `Action`-based popovers).
49
54
  */
50
55
  const PORTAL_Z_INDEX = 99998;
56
+ /**
57
+ * Z-index applied to the dropdown's nearest stacking-context ancestor
58
+ * while the dropdown is open. One below `PORTAL_Z_INDEX` so the
59
+ * dropdown still wins inside its own stacking context, but high
60
+ * enough that the whole ancestor block paints above its peer stacking
61
+ * contexts (e.g. the table footer's `position: sticky; z-index: 10`
62
+ * row, or the table body's `isolation: isolate; z-index: 20`
63
+ * container). Without this lift, the dropdown's `z-index: 99998` was
64
+ * trapped inside the ancestor's own stacking context and could not
65
+ * paint above a sibling stacking context with even a small z-index.
66
+ *
67
+ * The previous workaround pinned `.datatable-grid` at `z-index: 20`
68
+ * so its cell dropdowns sat above the footer; that fixed the cell
69
+ * case but reversed the failure for the footer's `Records-per-Page`
70
+ * select, whose dropdown opens upwards into the rows. Lifting the
71
+ * dropdown's actual ancestor at open time means both cases work
72
+ * without either side needing a hard-coded z-index bias.
73
+ */
74
+ const STACKING_LIFT_Z_INDEX = 99997;
75
+ /**
76
+ * Walk up the DOM from `el` and return the first ancestor that
77
+ * establishes a new CSS stacking context. The dropdown's z-index
78
+ * only competes inside this ancestor's context, so lifting this
79
+ * element is what determines whether the dropdown can paint above a
80
+ * sibling sticky / isolated block. Stops at `document.body` (which
81
+ * doesn't form a useful pivot for the lift).
82
+ */
83
+ function findStackingContextAncestor(el) {
84
+ let current = el.parentElement;
85
+ while (current && current !== document.body) {
86
+ if (createsStackingContext(current)) {
87
+ return current;
88
+ }
89
+ current = current.parentElement;
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * True when the computed style of `el` causes the browser to
95
+ * establish a new stacking context. Covers the cases that actually
96
+ * appear in scoria today (sticky / isolated table strips, fixed
97
+ * popover ancestors). Not exhaustive against every CSS feature that
98
+ * triggers a stacking context, but tuned for our tree.
99
+ */
100
+ function createsStackingContext(el) {
101
+ const cs = getComputedStyle(el);
102
+ if (cs.position === 'fixed' || cs.position === 'sticky') {
103
+ return true;
104
+ }
105
+ if ((cs.position === 'relative' || cs.position === 'absolute') &&
106
+ cs.zIndex !== 'auto') {
107
+ return true;
108
+ }
109
+ if (cs.isolation === 'isolate') {
110
+ return true;
111
+ }
112
+ if (cs.transform !== 'none') {
113
+ return true;
114
+ }
115
+ if (cs.filter !== 'none') {
116
+ return true;
117
+ }
118
+ if (cs.perspective !== 'none') {
119
+ return true;
120
+ }
121
+ if (cs.mixBlendMode !== 'normal') {
122
+ return true;
123
+ }
124
+ if (Number.parseFloat(cs.opacity) < 1) {
125
+ return true;
126
+ }
127
+ if (cs.willChange && cs.willChange !== 'auto') {
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+ const raiseCounts = new WeakMap();
133
+ function liftStackingContext(el) {
134
+ let info = raiseCounts.get(el);
135
+ if (!info) {
136
+ info = { count: 0, originalInlineZIndex: el.style.zIndex };
137
+ raiseCounts.set(el, info);
138
+ el.style.zIndex = String(STACKING_LIFT_Z_INDEX);
139
+ }
140
+ info.count++;
141
+ let released = false;
142
+ return () => {
143
+ if (released) {
144
+ return;
145
+ }
146
+ released = true;
147
+ const current = raiseCounts.get(el);
148
+ if (!current) {
149
+ return;
150
+ }
151
+ current.count--;
152
+ if (current.count <= 0) {
153
+ el.style.zIndex = current.originalInlineZIndex;
154
+ raiseCounts.delete(el);
155
+ }
156
+ };
157
+ }
51
158
  /**
52
159
  * Minimum free vertical space below the trigger before the dropdown
53
160
  * is flipped to open upwards. Picked to fit the default Svelecte
@@ -109,6 +216,7 @@ export function attachDropdownPortal(options) {
109
216
  };
110
217
  }
111
218
  let isOpen = false;
219
+ let releaseStackingLift = null;
112
220
  const reposition = () => {
113
221
  if (!isOpen) {
114
222
  return;
@@ -175,6 +283,18 @@ export function attachDropdownPortal(options) {
175
283
  return;
176
284
  }
177
285
  isOpen = true;
286
+ // Lift the dropdown's nearest stacking-context ancestor so the
287
+ // whole block paints above any sibling stacking contexts that
288
+ // would otherwise hide it. Without this, the dropdown's high
289
+ // z-index is trapped inside the ancestor's own stacking context.
290
+ // Resolved from `trigger` rather than `dropdown` because the
291
+ // dropdown's `position: fixed` makes it its own stacking
292
+ // context; we want to lift the surrounding sticky / isolated
293
+ // table strip, not the dropdown itself.
294
+ const stackingAncestor = findStackingContextAncestor(trigger);
295
+ if (stackingAncestor) {
296
+ releaseStackingLift = liftStackingContext(stackingAncestor);
297
+ }
178
298
  dropdown.style.display = '';
179
299
  dropdown.setAttribute('data-scoria-portal-dropdown', '');
180
300
  reposition();
@@ -189,6 +309,10 @@ export function attachDropdownPortal(options) {
189
309
  throttled.cancel();
190
310
  window.removeEventListener('scroll', handleScroll, { capture: true });
191
311
  window.removeEventListener('resize', handleResize);
312
+ if (releaseStackingLift) {
313
+ releaseStackingLift();
314
+ releaseStackingLift = null;
315
+ }
192
316
  dropdown.style.position = '';
193
317
  dropdown.style.top = '';
194
318
  dropdown.style.left = '';
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.37.42",
4
+ "version": "0.37.44",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },