@marianmeres/stuic 3.151.0 → 3.152.0

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.
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { dimBehind } from "./dim-behind.svelte.js";
3
+
4
+ let {
5
+ aOpen = false,
6
+ bOpen = false,
7
+ useContainers = false,
8
+ sameContainer = false,
9
+ }: {
10
+ aOpen?: boolean;
11
+ bOpen?: boolean;
12
+ useContainers?: boolean;
13
+ sameContainer?: boolean;
14
+ } = $props();
15
+
16
+ let containerA = $state<HTMLDivElement>();
17
+ let containerB = $state<HTMLDivElement>();
18
+ </script>
19
+
20
+ <!-- Two independent shells, each a fixed containing block + stacking context
21
+ (the framed-app case the `container` option exists for). -->
22
+ <div
23
+ bind:this={containerA}
24
+ data-testid="container-a"
25
+ style="contain: layout paint; width: 200px; height: 120px;"
26
+ >
27
+ <div
28
+ data-testid="target-a"
29
+ use:dimBehind={() => ({
30
+ open: aOpen,
31
+ container: useContainers ? () => containerA ?? null : undefined,
32
+ })}
33
+ >
34
+ A
35
+ </div>
36
+ </div>
37
+
38
+ <div
39
+ bind:this={containerB}
40
+ data-testid="container-b"
41
+ style="contain: layout paint; width: 200px; height: 120px;"
42
+ >
43
+ <div
44
+ data-testid="target-b"
45
+ use:dimBehind={() => ({
46
+ open: bOpen,
47
+ container: useContainers
48
+ ? () => (sameContainer ? (containerA ?? null) : (containerB ?? null))
49
+ : undefined,
50
+ })}
51
+ >
52
+ B
53
+ </div>
54
+ </div>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ aOpen?: boolean;
3
+ bOpen?: boolean;
4
+ useContainers?: boolean;
5
+ sameContainer?: boolean;
6
+ };
7
+ declare const DimBehind: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type DimBehind = ReturnType<typeof DimBehind>;
9
+ export default DimBehind;
@@ -48,6 +48,16 @@ export interface DimBehindOptions {
48
48
  onHide?: () => void;
49
49
  /** Debug mode */
50
50
  debug?: boolean;
51
+ /**
52
+ * Where to append the backdrop. Defaults to the current behavior:
53
+ * `document.body`. Pass a bounded shell (framed app, embedded widget,
54
+ * dashboard pane) to keep the backdrop inside it — required when that shell
55
+ * is a stacking context, since the elevated target's z-index can then only
56
+ * compete with a backdrop living in the same context. Each distinct
57
+ * container gets its own ref-counted backdrop. A function returning `null`
58
+ * falls back to the default.
59
+ */
60
+ container?: HTMLElement | (() => HTMLElement | null);
51
61
  }
52
62
  /**
53
63
  * A Svelte action that dims everything behind a target element.
@@ -1,46 +1,60 @@
1
1
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
2
- // --- Singleton Backdrop Manager ---
3
- let backdropEl = null;
4
- let activeCount = 0;
2
+ import { resolveContainerOption } from "../../utils/overlay-container.js";
3
+ // --- Shared Backdrop Manager (one ref-counted backdrop per container) ---
4
+ const backdrops = new Map();
5
5
  const TRANSITION_SAFETY_MARGIN = 50;
6
- function getTransitionDuration() {
7
- const raw = getComputedStyle(document.documentElement)
8
- .getPropertyValue("--stuic-dim-behind-transition-duration")
9
- .trim();
10
- return parseFloat(raw) || 150;
6
+ /**
7
+ * Actual transition duration of the backdrop element in ms — read from its
8
+ * computed style, which resolves the whole var chain
9
+ * (`--stuic-dim-behind-transition-duration`, `--stuic-transition`, …), unlike
10
+ * reading a single custom property off the root. Computed values serialize in
11
+ * seconds ("0.15s").
12
+ */
13
+ function getTransitionDurationMs(el) {
14
+ const raw = getComputedStyle(el).transitionDuration;
15
+ const v = parseFloat(raw);
16
+ if (!Number.isFinite(v) || v < 0)
17
+ return 150;
18
+ return raw.trim().endsWith("ms") ? v : v * 1000;
11
19
  }
12
20
  function getElementZIndex() {
13
21
  return (getComputedStyle(document.documentElement)
14
22
  .getPropertyValue("--stuic-dim-behind-element-z-index")
15
23
  .trim() || "41");
16
24
  }
17
- function showBackdrop(classBackdrop) {
18
- activeCount++;
19
- if (activeCount === 1) {
20
- backdropEl = document.createElement("div");
21
- backdropEl.classList.add("stuic-dim-behind-backdrop");
25
+ function showBackdrop(container, classBackdrop) {
26
+ let entry = backdrops.get(container);
27
+ if (!entry) {
28
+ const el = document.createElement("div");
29
+ el.classList.add("stuic-dim-behind-backdrop");
22
30
  if (classBackdrop) {
23
- backdropEl.classList.add(...classBackdrop.split(/\s+/).filter(Boolean));
31
+ el.classList.add(...classBackdrop.split(/\s+/).filter(Boolean));
24
32
  }
25
- document.body.appendChild(backdropEl);
33
+ container.appendChild(el);
26
34
  // Force reflow for transition
27
- void backdropEl.offsetHeight;
28
- backdropEl.classList.add("dim-visible");
35
+ void el.offsetHeight;
36
+ el.classList.add("dim-visible");
37
+ entry = { el, count: 0 };
38
+ backdrops.set(container, entry);
29
39
  }
40
+ entry.count++;
41
+ return entry.el;
30
42
  }
31
- function hideBackdrop() {
32
- activeCount = Math.max(0, activeCount - 1);
33
- if (activeCount === 0 && backdropEl) {
34
- const el = backdropEl;
43
+ function hideBackdrop(container) {
44
+ const entry = backdrops.get(container);
45
+ if (!entry)
46
+ return;
47
+ entry.count = Math.max(0, entry.count - 1);
48
+ if (entry.count === 0) {
49
+ // Drop the registry entry immediately so a show() during the fade-out
50
+ // creates a fresh backdrop instead of resurrecting the dying one.
51
+ backdrops.delete(container);
52
+ const el = entry.el;
35
53
  el.classList.remove("dim-visible");
36
- const cleanup = () => {
37
- el.remove();
38
- if (backdropEl === el)
39
- backdropEl = null;
40
- };
54
+ const cleanup = () => el.remove();
41
55
  el.addEventListener("transitionend", cleanup, { once: true });
42
56
  // Safety fallback in case transitionend doesn't fire
43
- setTimeout(cleanup, getTransitionDuration() + TRANSITION_SAFETY_MARGIN);
57
+ setTimeout(cleanup, getTransitionDurationMs(el) + TRANSITION_SAFETY_MARGIN);
44
58
  }
45
59
  }
46
60
  // --- Registry ---
@@ -115,6 +129,12 @@ export function dimBehind(node, fn) {
115
129
  let savedZIndex = "";
116
130
  let currentOptions = {};
117
131
  let do_debug = false;
132
+ // The container/backdrop/locks this instance acquired — captured at show()
133
+ // time so hide() releases exactly what was acquired even if options changed
134
+ // while visible.
135
+ let myContainer = null;
136
+ let myBackdropEl = null;
137
+ let myScrollLocked = false;
118
138
  const debug = (...args) => {
119
139
  if (do_debug)
120
140
  console.debug("[dimBehind]", ...args);
@@ -128,7 +148,7 @@ export function dimBehind(node, fn) {
128
148
  }
129
149
  }
130
150
  function onBackdropClick(e) {
131
- if (e.target === backdropEl) {
151
+ if (e.target === myBackdropEl) {
132
152
  hide();
133
153
  }
134
154
  }
@@ -151,18 +171,20 @@ export function dimBehind(node, fn) {
151
171
  : getElementZIndex();
152
172
  node.style.position = "relative";
153
173
  node.style.zIndex = zIndex;
154
- // Show singleton backdrop
155
- showBackdrop(currentOptions.classBackdrop);
174
+ // Show the (per-container, ref-counted) shared backdrop
175
+ myContainer = resolveContainerOption(currentOptions.container) ?? document.body;
176
+ myBackdropEl = showBackdrop(myContainer, currentOptions.classBackdrop);
156
177
  // Optional scroll lock
157
178
  if (currentOptions.scrollLock) {
158
179
  BodyScroll.lock();
180
+ myScrollLocked = true;
159
181
  }
160
182
  // Event listeners
161
183
  if (currentOptions.closeOnEscape !== false) {
162
184
  document.addEventListener("keydown", onEscape);
163
185
  }
164
- if (currentOptions.closeOnBackdropClick !== false && backdropEl) {
165
- backdropEl.addEventListener("click", onBackdropClick);
186
+ if (currentOptions.closeOnBackdropClick !== false) {
187
+ myBackdropEl.addEventListener("click", onBackdropClick);
166
188
  }
167
189
  currentOptions.onShow?.();
168
190
  }
@@ -179,15 +201,18 @@ export function dimBehind(node, fn) {
179
201
  node.style.zIndex = savedZIndex;
180
202
  // Remove event listeners
181
203
  document.removeEventListener("keydown", onEscape);
182
- if (backdropEl) {
183
- backdropEl.removeEventListener("click", onBackdropClick);
184
- }
185
- // Optional scroll unlock
186
- if (currentOptions.scrollLock) {
204
+ myBackdropEl?.removeEventListener("click", onBackdropClick);
205
+ // Release the scroll lock iff THIS instance acquired one (the option may
206
+ // have changed while visible)
207
+ if (myScrollLocked) {
187
208
  BodyScroll.unlock();
209
+ myScrollLocked = false;
188
210
  }
189
- // Hide singleton backdrop
190
- hideBackdrop();
211
+ // Release the shared backdrop
212
+ if (myContainer)
213
+ hideBackdrop(myContainer);
214
+ myContainer = null;
215
+ myBackdropEl = null;
191
216
  currentOptions.onHide?.();
192
217
  }
193
218
  // Reactive params effect
@@ -205,6 +230,7 @@ export function dimBehind(node, fn) {
205
230
  onShow: opts.onShow,
206
231
  onHide: opts.onHide,
207
232
  debug: opts.debug,
233
+ container: opts.container,
208
234
  };
209
235
  do_debug = !!opts.debug;
210
236
  // Register in global registry if id provided
@@ -229,10 +255,15 @@ export function dimBehind(node, fn) {
229
255
  if (isVisible) {
230
256
  node.style.position = savedPosition;
231
257
  node.style.zIndex = savedZIndex;
232
- if (currentOptions.scrollLock) {
258
+ if (myScrollLocked) {
233
259
  BodyScroll.unlock();
260
+ myScrollLocked = false;
234
261
  }
235
- hideBackdrop();
262
+ myBackdropEl?.removeEventListener("click", onBackdropClick);
263
+ if (myContainer)
264
+ hideBackdrop(myContainer);
265
+ myContainer = null;
266
+ myBackdropEl = null;
236
267
  document.removeEventListener("keydown", onEscape);
237
268
  }
238
269
  // Unregister from registry
@@ -4,23 +4,24 @@ A Svelte action that displays an anchored popover using CSS Anchor Positioning,
4
4
 
5
5
  ## Options
6
6
 
7
- | Option | Type | Default | Description |
8
- | --------------------- | -------------------- | ----------- | ----------------------------------------------------- |
9
- | `enabled` | `boolean` | `true` | Enable/disable popover |
10
- | `content` | `THC \| null` | - | Popover content (string, HTML, component, or snippet) |
11
- | `position` | `PopoverPosition` | `"bottom"` | Placement relative to anchor |
12
- | `trigger` | `"click" \| "hover"` | `"click"` | Trigger mode |
13
- | `showDelay` | `number` | `100` | Delay before showing (ms) |
14
- | `hideDelay` | `number` | `200` | Delay before hiding (ms) |
15
- | `class` | `string` | - | Custom CSS for popover container |
16
- | `offset` | `string` | `"0.25rem"` | Margin from anchor (CSS value) |
17
- | `closeOthers` | `boolean` | `false` | Close other open popovers |
18
- | `closeOnClickOutside` | `boolean` | `true` | Close on outside click (click trigger) |
19
- | `closeOnEscape` | `boolean` | `true` | Close on Escape key |
20
- | `showBackdrop` | `boolean` | `true` | Show backdrop in fallback mode |
21
- | `forceFallback` | `boolean` | `false` | Force centered modal mode |
22
- | `onShow` | `() => void` | - | Callback when popover opens |
23
- | `onHide` | `() => void` | - | Callback when popover closes |
7
+ | Option | Type | Default | Description |
8
+ | --------------------- | -------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- |
9
+ | `enabled` | `boolean` | `true` | Enable/disable popover |
10
+ | `content` | `THC \| null` | - | Popover content (string, HTML, component, or snippet) |
11
+ | `position` | `PopoverPosition` | `"bottom"` | Placement relative to anchor |
12
+ | `trigger` | `"click" \| "hover"` | `"click"` | Trigger mode |
13
+ | `showDelay` | `number` | `100` | Delay before showing (ms) |
14
+ | `hideDelay` | `number` | `200` | Delay before hiding (ms) |
15
+ | `class` | `string` | - | Custom CSS for popover container |
16
+ | `offset` | `string` | `"0.25rem"` | Margin from anchor (CSS value) |
17
+ | `closeOthers` | `boolean` | `false` | Close other open popovers |
18
+ | `closeOnClickOutside` | `boolean` | `true` | Close on outside click (click trigger) |
19
+ | `closeOnEscape` | `boolean` | `true` | Close on Escape key |
20
+ | `showBackdrop` | `boolean` | `true` | Show backdrop in fallback mode |
21
+ | `forceFallback` | `boolean` | `false` | Force centered modal mode |
22
+ | `onShow` | `() => void` | - | Callback when popover opens |
23
+ | `onHide` | `() => void` | - | Callback when popover closes |
24
+ | `container` | `HTMLElement \| (() => HTMLElement \| null)` | closest open `<dialog>`, else `document.body` | Where to append the popover — pass a bounded shell to keep the overlay inside its stacking context |
24
25
 
25
26
  ## Positions
26
27
 
@@ -139,6 +140,25 @@ bottom-left bottom bottom-right
139
140
  </button>
140
141
  ```
141
142
 
143
+ ### Custom Container (Bounded Shells)
144
+
145
+ When the app renders inside a bounded shell that is a stacking context and/or a
146
+ fixed containing block (e.g. a centered frame with `contain: layout paint`, an
147
+ embedded widget, a transformed wrapper), a body-level popover can only paint
148
+ entirely above or below that shell. Pass `container` to keep the overlay inside
149
+ it:
150
+
151
+ ```svelte
152
+ <button
153
+ use:popover={() => ({
154
+ content: "Stays inside the frame",
155
+ container: () => document.querySelector(".app-frame"),
156
+ })}
157
+ >
158
+ Framed
159
+ </button>
160
+ ```
161
+
142
162
  ## Helper Function
143
163
 
144
164
  ```ts
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { popover } from "./popover.svelte.js";
3
+
4
+ let { open = false, useContainer = false }: { open?: boolean; useContainer?: boolean } =
5
+ $props();
6
+
7
+ let shell = $state<HTMLDivElement>();
8
+ </script>
9
+
10
+ <!-- A bounded shell that is a fixed containing block + stacking context. -->
11
+ <div
12
+ bind:this={shell}
13
+ data-testid="shell"
14
+ style="contain: layout paint; width: 300px; height: 200px;"
15
+ ></div>
16
+
17
+ <button
18
+ data-testid="trigger"
19
+ use:popover={() => ({
20
+ content: "Hello",
21
+ open,
22
+ container: useContainer ? () => shell ?? null : undefined,
23
+ })}
24
+ >
25
+ trigger
26
+ </button>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ open?: boolean;
3
+ useContainer?: boolean;
4
+ };
5
+ declare const Popover: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Popover = ReturnType<typeof Popover>;
7
+ export default Popover;
@@ -101,6 +101,16 @@ export interface PopoverOptions {
101
101
  open?: boolean;
102
102
  /** Unique ID for registry-based programmatic control (use with openPopover/closePopover/togglePopover) */
103
103
  id?: string;
104
+ /**
105
+ * Where to append the popover (and its fallback backdrop/wrapper). Defaults
106
+ * to the current behavior: the closest open `<dialog>` ancestor, else
107
+ * `document.body`. Pass a bounded shell (framed app, embedded widget,
108
+ * dashboard pane) to keep the overlay inside it — required when that shell
109
+ * is a stacking context, since a body-level overlay can then only paint
110
+ * entirely above or entirely below it. A function returning `null` falls
111
+ * back to the default.
112
+ */
113
+ container?: HTMLElement | (() => HTMLElement | null);
104
114
  }
105
115
  /**
106
116
  * A Svelte action that displays a popover anchored to an element using CSS Anchor Positioning.
@@ -2,6 +2,8 @@ import { mount, unmount } from "svelte";
2
2
  import { twMerge } from "../../utils/tw-merge.js";
3
3
  import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
4
4
  import { clampIntoViewport } from "../../utils/anchor-position.js";
5
+ import { fixedContainingBlockRect } from "../../utils/containing-block.js";
6
+ import { resolveContainerOption } from "../../utils/overlay-container.js";
5
7
  import { iconX } from "../../icons/index.js";
6
8
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
7
9
  import PopoverContent from "./PopoverContent.svelte";
@@ -342,14 +344,19 @@ export function popover(anchorEl, fn) {
342
344
  anchorEl.setAttribute("aria-expanded", "true");
343
345
  const offsetValue = currentOptions.offset || "0.25rem";
344
346
  const useAnchorPositioning = isSupported && !currentOptions.forceFallback;
345
- // Get appropriate container (dialog if inside one, otherwise body)
346
- // This ensures popover renders in same stacking context as modal dialogs
347
- const container = getPopoverContainer(anchorEl);
347
+ // Get appropriate container (explicit option, else dialog if inside one,
348
+ // otherwise body). This ensures popover renders in the same stacking
349
+ // context as modal dialogs / the consumer's bounded shell.
350
+ const container = resolveContainerOption(currentOptions.container) ?? getPopoverContainer(anchorEl);
348
351
  if (useAnchorPositioning) {
349
352
  // CSS Anchor Positioning mode
350
353
  popoverEl = document.createElement("div");
351
354
  popoverEl.setAttribute("id", id);
352
355
  popoverEl.setAttribute("role", "dialog");
356
+ // NOTE: keep `vw`/`vh` here — this is the ANCHORED branch, where the
357
+ // element's containing block is the `position-area` region (a slice of
358
+ // the CB, often much smaller than it), so `%` would shrink the popover.
359
+ // Overflow is handled by @position-try + the CB-aware runtime check.
353
360
  popoverEl.style.cssText = `
354
361
  position: fixed;
355
362
  position-anchor: ${anchorName};
@@ -390,10 +397,13 @@ export function popover(anchorEl, fn) {
390
397
  popoverEl = document.createElement("div");
391
398
  popoverEl.setAttribute("id", id);
392
399
  popoverEl.setAttribute("role", "dialog");
400
+ // `90%` (not `90vw/90vh`): resolves against the inset-0 wrapper, which
401
+ // spans the containing block — identical to viewport units when the CB
402
+ // is the viewport, correct inside a contained/transformed shell.
393
403
  popoverEl.style.cssText = `
394
404
  position: relative;
395
- max-width: 90vw;
396
- max-height: 90vh;
405
+ max-width: 90%;
406
+ max-height: 90%;
397
407
  overflow: auto;
398
408
  transition-duration: ${TRANSITION}ms;
399
409
  pointer-events: auto;
@@ -440,11 +450,13 @@ export function popover(anchorEl, fn) {
440
450
  // sub-pixel/vertical cases); clamping keeps small edge-anchored
441
451
  // popovers anchored instead of switching them to a modal.
442
452
  clampIntoViewport(popoverEl);
453
+ // Compare against the containing block (the viewport, unless an
454
+ // ancestor with `transform`/`contain` establishes one).
443
455
  const rect = popoverEl.getBoundingClientRect();
444
- const viewportWidth = window.innerWidth;
456
+ const cb = fixedContainingBlockRect(popoverEl);
445
457
  // If it STILL overflows horizontally after clamping, the content
446
458
  // is too wide to fit anchored — fall back to the centered modal.
447
- if (rect.left < 0 || rect.right > viewportWidth) {
459
+ if (rect.left < cb.left || rect.right > cb.right) {
448
460
  debug("overflow detected, switching to fallback mode");
449
461
  switchingToFallback = true;
450
462
  // Quick cleanup (skip transition)
@@ -556,6 +568,7 @@ export function popover(anchorEl, fn) {
556
568
  onHide: opts.onHide,
557
569
  debug: opts.debug,
558
570
  id: opts.id,
571
+ container: opts.container,
559
572
  };
560
573
  do_debug = !!opts.debug;
561
574
  // Register in global registry if id provided
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import { spotlight } from "./spotlight.svelte.js";
3
+
4
+ let { open = false, useContainer = false }: { open?: boolean; useContainer?: boolean } =
5
+ $props();
6
+
7
+ let shell = $state<HTMLDivElement>();
8
+ </script>
9
+
10
+ <!-- A bounded shell that is a fixed containing block + stacking context. The
11
+ target sits at a known offset inside it so the clip-path hole coordinates can
12
+ be asserted container-locally. -->
13
+ <div
14
+ bind:this={shell}
15
+ data-testid="shell"
16
+ style="contain: layout paint; width: 240px; height: 180px; position: relative;"
17
+ >
18
+ <button
19
+ data-testid="target"
20
+ style="position: absolute; left: 40px; top: 30px; width: 60px; height: 20px;"
21
+ use:spotlight={() => ({
22
+ content: "hi",
23
+ open,
24
+ padding: 4,
25
+ borderRadius: 0,
26
+ scrollIntoView: false,
27
+ autoTrack: false,
28
+ container: useContainer ? () => shell ?? null : undefined,
29
+ })}
30
+ >
31
+ x
32
+ </button>
33
+ </div>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ open?: boolean;
3
+ useContainer?: boolean;
4
+ };
5
+ declare const Spotlight: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Spotlight = ReturnType<typeof Spotlight>;
7
+ export default Spotlight;
@@ -98,6 +98,15 @@ export interface SpotlightOptions {
98
98
  autoTrack?: boolean;
99
99
  /** Debug mode */
100
100
  debug?: boolean;
101
+ /**
102
+ * Where to append the overlay elements (backdrop, anchor, annotation).
103
+ * Defaults to the current behavior: `document.body`. Pass a bounded shell
104
+ * (framed app, embedded widget, dashboard pane) to keep the overlay inside
105
+ * it — required when that shell is a stacking context, since a body-level
106
+ * overlay can then only paint entirely above or entirely below it. A
107
+ * function returning `null` falls back to the default.
108
+ */
109
+ container?: HTMLElement | (() => HTMLElement | null);
101
110
  }
102
111
  /**
103
112
  * A Svelte action that highlights a target element with a spotlight effect.