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