@lavalogic/scoria 0.37.36 → 0.37.38

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.
@@ -323,7 +323,6 @@
323
323
  text-align: center;
324
324
  --sv-border: none;
325
325
  min-width: 0;
326
- overflow: hidden;
327
326
  }
328
327
  .select-cell :global(.select-wrapper) {
329
328
  --sv-min-height: 0px;
@@ -295,7 +295,6 @@
295
295
  text-align: center;
296
296
  --sv-border: none;
297
297
  min-width: 0;
298
- overflow: hidden;
299
298
  }
300
299
  .select-cell :global(.select-wrapper) {
301
300
  --sv-min-height: 0px;
@@ -8,33 +8,42 @@
8
8
  * `transform` / `filter` / `will-change: transform` on the way up to
9
9
  * the body.
10
10
  *
11
- * This helper sidesteps all of that:
11
+ * This helper does NOT move the dropdown in the DOM. Moving the
12
+ * `.sv_dropdown` element to `document.body` (the original strategy)
13
+ * broke Svelte 5's per-component event delegation: inline
14
+ * `onmousedown` / `onclick` handlers declared in Svelecte's template
15
+ * are dispatched by the app's root-level delegated listener, which
16
+ * walks the live `parentNode` chain at dispatch time. Once the
17
+ * dropdown was a sibling of the app root rather than a descendant,
18
+ * the chain no longer reached the selection handler, so mouse clicks
19
+ * silently did nothing (keyboard Enter still worked because that
20
+ * path is wired on the input, not the dropdown).
12
21
  *
13
- * 1. On open, it reparents the dropdown to `document.body` (saving
14
- * the original parent + next-sibling so it can be restored on
15
- * close).
16
- * 2. It switches the dropdown to `position: fixed` and sets
17
- * `top` / `left` / `width` / `bottom` from the trigger's
18
- * `getBoundingClientRect()`.
19
- * 3. It picks `top` vs `bottom` based on available space below vs
20
- * above the trigger, so the dropdown auto-flips upwards when the
21
- * trigger sits near the bottom of the viewport.
22
- * 4. It listens for capture-phase `scroll` events and for viewport
23
- * `resize` events so the position tracks the trigger while the
24
- * dropdown is open. Repositioning is rAF-throttled to keep the
25
- * cost off the scroll-event hot path.
26
- * 5. On close, the dropdown is moved back to its original DOM slot
27
- * and the inline positioning styles are cleared, so Svelecte's
28
- * next open re-runs the same flow on a clean element.
22
+ * Instead, the helper:
29
23
  *
30
- * The helper deliberately does NOT add the `is-open` class or fight
31
- * Svelecte's own state machine. Svelecte still owns open / closed
32
- * lifecycle; the portal just intercepts the moment the class flips
33
- * `is-open` on / off via a `MutationObserver`.
24
+ * 1. Switches the dropdown to `position: fixed` and sets
25
+ * `top` / `left` / `width` / `bottom` from the trigger's
26
+ * `getBoundingClientRect()`, so it escapes ancestor
27
+ * `overflow: hidden` / `overflow: auto` clipping (which was
28
+ * the original motivation for portaling).
29
+ * 2. Picks `top` vs `bottom` based on available space below vs
30
+ * above the trigger, so the dropdown auto-flips upwards when
31
+ * the trigger sits near the bottom of the viewport.
32
+ * 3. Listens for capture-phase `scroll` events and for viewport
33
+ * `resize` events so the position tracks the trigger while
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.
34
43
  */
35
44
  export interface DropdownPortalOptions {
36
45
  /** The Svelecte root wrapper `<div class="svelecte">`. Used as the
37
- * anchor for positioning the teleported dropdown. */
46
+ * anchor for positioning the dropdown. */
38
47
  getTrigger: () => HTMLElement | null | undefined;
39
48
  /** The `<div class="sv_dropdown">` Svelecte renders. The helper
40
49
  * watches this element's `class` attribute for `is-open` /
@@ -8,37 +8,46 @@
8
8
  * `transform` / `filter` / `will-change: transform` on the way up to
9
9
  * the body.
10
10
  *
11
- * This helper sidesteps all of that:
11
+ * This helper does NOT move the dropdown in the DOM. Moving the
12
+ * `.sv_dropdown` element to `document.body` (the original strategy)
13
+ * broke Svelte 5's per-component event delegation: inline
14
+ * `onmousedown` / `onclick` handlers declared in Svelecte's template
15
+ * are dispatched by the app's root-level delegated listener, which
16
+ * walks the live `parentNode` chain at dispatch time. Once the
17
+ * dropdown was a sibling of the app root rather than a descendant,
18
+ * the chain no longer reached the selection handler, so mouse clicks
19
+ * silently did nothing (keyboard Enter still worked because that
20
+ * path is wired on the input, not the dropdown).
12
21
  *
13
- * 1. On open, it reparents the dropdown to `document.body` (saving
14
- * the original parent + next-sibling so it can be restored on
15
- * close).
16
- * 2. It switches the dropdown to `position: fixed` and sets
17
- * `top` / `left` / `width` / `bottom` from the trigger's
18
- * `getBoundingClientRect()`.
19
- * 3. It picks `top` vs `bottom` based on available space below vs
20
- * above the trigger, so the dropdown auto-flips upwards when the
21
- * trigger sits near the bottom of the viewport.
22
- * 4. It listens for capture-phase `scroll` events and for viewport
23
- * `resize` events so the position tracks the trigger while the
24
- * dropdown is open. Repositioning is rAF-throttled to keep the
25
- * cost off the scroll-event hot path.
26
- * 5. On close, the dropdown is moved back to its original DOM slot
27
- * and the inline positioning styles are cleared, so Svelecte's
28
- * next open re-runs the same flow on a clean element.
22
+ * Instead, the helper:
29
23
  *
30
- * The helper deliberately does NOT add the `is-open` class or fight
31
- * Svelecte's own state machine. Svelecte still owns open / closed
32
- * lifecycle; the portal just intercepts the moment the class flips
33
- * `is-open` on / off via a `MutationObserver`.
24
+ * 1. Switches the dropdown to `position: fixed` and sets
25
+ * `top` / `left` / `width` / `bottom` from the trigger's
26
+ * `getBoundingClientRect()`, so it escapes ancestor
27
+ * `overflow: hidden` / `overflow: auto` clipping (which was
28
+ * the original motivation for portaling).
29
+ * 2. Picks `top` vs `bottom` based on available space below vs
30
+ * above the trigger, so the dropdown auto-flips upwards when
31
+ * the trigger sits near the bottom of the viewport.
32
+ * 3. Listens for capture-phase `scroll` events and for viewport
33
+ * `resize` events so the position tracks the trigger while
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.
34
43
  */
35
44
  /**
36
- * Z-index applied to the teleported dropdown. Chosen so the dropdown
37
- * sits above all sticky table strips (`z 5-11` in the table grid) and
38
- * the side-panel chrome, but stays below modal overlays (`z 99999`
39
- * in `Action`-based popovers).
45
+ * Z-index applied to the open dropdown. Picked very high so the
46
+ * dropdown sits above sticky table strips (`z 5-11` in the table
47
+ * grid), the table footer (`z 10`), and the side-panel chrome, but
48
+ * stays below modal overlays (`z 99999` in `Action`-based popovers).
40
49
  */
41
- const PORTAL_Z_INDEX = 10000;
50
+ const PORTAL_Z_INDEX = 99998;
42
51
  /**
43
52
  * Minimum free vertical space below the trigger before the dropdown
44
53
  * is flipped to open upwards. Picked to fit the default Svelecte
@@ -90,16 +99,12 @@ export function attachDropdownPortal(options) {
90
99
  /* nothing wired up */
91
100
  };
92
101
  }
93
- let isPortalled = false;
102
+ let isOpen = false;
94
103
  const reposition = () => {
95
- if (!isPortalled) {
104
+ if (!isOpen) {
96
105
  return;
97
106
  }
98
107
  const tr = trigger.getBoundingClientRect();
99
- // `offsetHeight` only reflects the actual rendered height once
100
- // Svelecte has laid out the option list, so the first frame
101
- // after open may read 0; fall back to the flip threshold so
102
- // we still pick a sensible orientation in that case.
103
108
  const dropdownHeight = dropdown.offsetHeight || FLIP_THRESHOLD_PX;
104
109
  const viewportHeight = window.innerHeight;
105
110
  const spaceBelow = viewportHeight - tr.bottom;
@@ -108,18 +113,9 @@ export function attachDropdownPortal(options) {
108
113
  dropdown.style.position = 'fixed';
109
114
  dropdown.style.left = `${tr.left}px`;
110
115
  dropdown.style.width = `${tr.width}px`;
111
- // Override Svelecte's own `min-width: 100%` on `.sv_dropdown`.
112
- // With `position: fixed` the percentage resolves against the
113
- // initial containing block (the viewport), which would otherwise
114
- // stretch the dropdown across the whole page. Pin the
115
- // minimum to the trigger width so the dropdown stays the same
116
- // width as the select that opened it.
117
116
  dropdown.style.minWidth = `${tr.width}px`;
118
117
  dropdown.style.maxWidth = `${tr.width}px`;
119
118
  dropdown.style.zIndex = String(PORTAL_Z_INDEX);
120
- // Bound the dropdown's max height to the available space so
121
- // long option lists scroll internally rather than disappear
122
- // past the viewport edge.
123
119
  if (openUp) {
124
120
  dropdown.style.top = '';
125
121
  dropdown.style.bottom = `${viewportHeight - tr.top}px`;
@@ -138,77 +134,25 @@ export function attachDropdownPortal(options) {
138
134
  const handleResize = () => {
139
135
  throttled.schedule();
140
136
  };
141
- // Track whether the dropdown has been moved to the portal target
142
- // at least once. We move it AT MOUNT and leave it there for the
143
- // lifetime of the SingleSelect, regardless of open / close. This
144
- // avoids two bugs the older "move on every open, move back on every
145
- // close" implementation suffered from:
146
- // 1. Svelte 5's per-component event delegation walks the live
147
- // DOM parentNode chain at dispatch time; moving the dropdown
148
- // mid-click left the chain pointing at the old parent and
149
- // Svelecte's `on_click` selection handler never ran, so mouse
150
- // clicks did not select an option (keyboard Enter did,
151
- // because keydown is wired on the input, not the dropdown).
152
- // 2. The MutationObserver that toggled the move based on the
153
- // `is-open` class fired between `mousedown` and `click`. The
154
- // observer's microtask moved the dropdown back to its
155
- // original parent before the `click` event reached an option,
156
- // so the click resolved against `body` instead of the
157
- // dropdown item.
158
- // Keeping the element in the portal target the whole time also
159
- // puts the dropdown in the body's stacking context unconditionally,
160
- // which means a `z-index: 10000` actually wins against the table's
161
- // own sticky-footer / pinned-column stacking contexts (the
162
- // `position: fixed`-only variant suffered from that footer being
163
- // painted ABOVE the open option list).
164
- let didMoveToPortalTarget = false;
165
- const moveToPortalTargetOnce = () => {
166
- if (didMoveToPortalTarget) {
137
+ const onOpen = () => {
138
+ if (isOpen) {
167
139
  return;
168
140
  }
169
- // Append to the nearest open `<dialog>` ancestor if one exists
170
- // (so the dropdown ends up in the dialog's top-layer stacking
171
- // context and is not painted behind the dialog's backdrop /
172
- // content), otherwise to `document.body`.
173
- const dialogHost = trigger.closest('dialog');
174
- const portalTarget = dialogHost && dialogHost.open ? dialogHost : document.body;
175
- portalTarget.appendChild(dropdown);
176
- didMoveToPortalTarget = true;
177
- };
178
- const portalOut = () => {
179
- if (isPortalled) {
180
- return;
181
- }
182
- moveToPortalTargetOnce();
183
- // Clear the defensive `display: none` that `portalIn` may have
184
- // applied; the dropdown is opening again now.
141
+ isOpen = true;
185
142
  dropdown.style.display = '';
186
- // Marker so consumers (e.g. the TableSidebar "click-outside"
187
- // handler) can recognise clicks landing inside the dropdown as
188
- // part of the trigger, not as outside-clicks.
189
143
  dropdown.setAttribute('data-scoria-portal-dropdown', '');
190
- isPortalled = true;
191
- // First-frame measurement: lay out, then reposition again on
192
- // the next frame so we measure the dropdown's real
193
- // `offsetHeight` once it has options rendered.
194
144
  reposition();
195
145
  requestAnimationFrame(reposition);
196
- // `capture: true` so scroll events on any ancestor (the
197
- // filter-panel side pane, the page itself, the .datatable
198
- // scroll container) reach this listener; without capture the
199
- // listener would only fire when the document element scrolls.
200
146
  window.addEventListener('scroll', handleScroll, { capture: true, passive: true });
201
147
  window.addEventListener('resize', handleResize, { passive: true });
202
148
  };
203
- const portalIn = () => {
204
- if (!isPortalled) {
149
+ const onClose = () => {
150
+ if (!isOpen) {
205
151
  return;
206
152
  }
207
153
  throttled.cancel();
208
154
  window.removeEventListener('scroll', handleScroll, { capture: true });
209
155
  window.removeEventListener('resize', handleResize);
210
- // Strip the inline overrides so Svelecte's next open starts
211
- // from a clean slate.
212
156
  dropdown.style.position = '';
213
157
  dropdown.style.top = '';
214
158
  dropdown.style.left = '';
@@ -219,88 +163,30 @@ export function attachDropdownPortal(options) {
219
163
  dropdown.style.maxHeight = '';
220
164
  dropdown.style.zIndex = '';
221
165
  dropdown.removeAttribute('data-scoria-portal-dropdown');
222
- // Defensive: if Svelecte left the dropdown without its own
223
- // `display: none` (transition-cancel race paths where the
224
- // open/close class flip and the inline style update arrive
225
- // out of order), the dropdown would otherwise stay `position:
226
- // static; display: block` in `document.body` and contribute
227
- // its option-list height to the body scroll size, surfacing
228
- // as a stray page-level scrollbar after toggling a select.
229
166
  if (!dropdown.classList.contains('is-open')) {
230
167
  dropdown.style.display = 'none';
231
168
  }
232
- // Note: we do NOT move the dropdown back to its original
233
- // parent. Moving mid-click races with the click event (see the
234
- // `moveToPortalTargetOnce` comment above). The dropdown stays
235
- // in the portal target for the rest of its lifetime and is
236
- // removed on component unmount.
237
- isPortalled = false;
169
+ isOpen = false;
238
170
  };
239
- // Apply / unapply the portal whenever Svelecte toggles the
240
- // `is-open` class on the dropdown. We watch class only because
241
- // Svelecte's open state is exposed purely as a class flip.
242
171
  const observer = new MutationObserver((mutations) => {
243
172
  for (const mutation of mutations) {
244
173
  if (mutation.attributeName !== 'class') {
245
174
  continue;
246
175
  }
247
176
  if (dropdown.classList.contains('is-open')) {
248
- portalOut();
177
+ onOpen();
249
178
  }
250
179
  else {
251
- portalIn();
180
+ onClose();
252
181
  }
253
182
  }
254
183
  });
255
184
  observer.observe(dropdown, { attributes: true, attributeFilter: ['class'] });
256
- // Honour the dropdown's initial state in case it mounted already
257
- // open (e.g. `focusOnLoad`).
258
185
  if (dropdown.classList.contains('is-open')) {
259
- portalOut();
186
+ onOpen();
260
187
  }
261
188
  return () => {
262
189
  observer.disconnect();
263
- portalIn();
264
- // Final cleanup: the dropdown was moved to the portal target
265
- // at first open and stayed there for the component's lifetime
266
- // (see `moveToPortalTargetOnce`). When the SingleSelect
267
- // unmounts, take the dropdown out of the portal target so it
268
- // is not orphaned in the document.
269
- if (didMoveToPortalTarget && dropdown.parentNode) {
270
- dropdown.parentNode.removeChild(dropdown);
271
- }
190
+ onClose();
272
191
  };
273
192
  }
274
- tion;
275
- of;
276
- mutations;
277
- {
278
- if (mutation.attributeName !== 'class') {
279
- continue;
280
- }
281
- if (dropdown.classList.contains('is-open')) {
282
- portalOut();
283
- }
284
- else {
285
- portalIn();
286
- }
287
- }
288
- ;
289
- observer.observe(dropdown, { attributes: true, attributeFilter: ['class'] });
290
- // Honour the dropdown's initial state in case it mounted already
291
- // open (e.g. `focusOnLoad`).
292
- if (dropdown.classList.contains('is-open')) {
293
- portalOut();
294
- }
295
- return () => {
296
- observer.disconnect();
297
- portalIn();
298
- // Final cleanup: the dropdown was moved to the portal target
299
- // at first open and stayed there for the component's lifetime
300
- // (see `moveToPortalTargetOnce`). When the SingleSelect
301
- // unmounts, take the dropdown out of the portal target so it
302
- // is not orphaned in the document.
303
- if (didMoveToPortalTarget && dropdown.parentNode) {
304
- dropdown.parentNode.removeChild(dropdown);
305
- }
306
- };
@@ -1757,10 +1757,16 @@
1757
1757
  // row that already has a value" symptom). `min-width: 0` makes
1758
1758
  // the cell honour the column's declared minimum instead.
1759
1759
  min-width: 0;
1760
- // Pair with `overflow: hidden` so the input inside cannot
1761
- // visually overflow the cell once it is forced narrower than
1762
- // its content - the label clips with ellipsis instead.
1763
- overflow: hidden;
1760
+ // `overflow: hidden` is intentionally NOT applied here:
1761
+ // it would clip the open dropdown (the dropdown lives inside
1762
+ // the cell as a Svelecte-owned sibling of the control, and
1763
+ // `position: fixed` should escape it, but a containing-block
1764
+ // ancestor with `transform` / `filter` would still let
1765
+ // `overflow: hidden` clip it). The leaf `.focusable` /
1766
+ // `.select-cell` rules already include `overflow-x: clip`
1767
+ // from `table-cell-basic` for the cell's text content; that
1768
+ // is enough to ellipsise the in-input label when the cell is
1769
+ // forced narrower than its rendered text.
1764
1770
 
1765
1771
  :global(.select-wrapper) {
1766
1772
  --sv-min-height: 0px;
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.36",
4
+ "version": "0.37.38",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },