@marianmeres/stuic 3.150.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.
Files changed (31) hide show
  1. package/dist/actions/dim-behind/dim-behind.fixture.svelte +54 -0
  2. package/dist/actions/dim-behind/dim-behind.fixture.svelte.d.ts +9 -0
  3. package/dist/actions/dim-behind/dim-behind.svelte.d.ts +10 -0
  4. package/dist/actions/dim-behind/dim-behind.svelte.js +72 -41
  5. package/dist/actions/popover/README.md +37 -17
  6. package/dist/actions/popover/popover.container.fixture.svelte +26 -0
  7. package/dist/actions/popover/popover.container.fixture.svelte.d.ts +7 -0
  8. package/dist/actions/popover/popover.svelte.d.ts +10 -0
  9. package/dist/actions/popover/popover.svelte.js +20 -7
  10. package/dist/actions/spotlight/spotlight.container.fixture.svelte +33 -0
  11. package/dist/actions/spotlight/spotlight.container.fixture.svelte.d.ts +7 -0
  12. package/dist/actions/spotlight/spotlight.svelte.d.ts +9 -0
  13. package/dist/actions/spotlight/spotlight.svelte.js +95 -37
  14. package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte +3 -2
  15. package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte.d.ts +3 -2
  16. package/dist/components/AlertConfirmPrompt/Current.svelte +44 -10
  17. package/dist/components/AlertConfirmPrompt/Current.svelte.d.ts +3 -2
  18. package/dist/components/AlertConfirmPrompt/README.md +29 -27
  19. package/dist/components/AlertConfirmPrompt/alert-confirm-prompt-stack.svelte.d.ts +8 -0
  20. package/dist/components/DropdownMenu/DropdownMenu.svelte +14 -7
  21. package/dist/components/DropdownMenu/README.md +1 -0
  22. package/dist/components/Float/Float.svelte +21 -0
  23. package/dist/components/Float/README.md +1 -1
  24. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +30 -5
  25. package/dist/utils/anchor-position.d.ts +12 -3
  26. package/dist/utils/anchor-position.js +35 -15
  27. package/dist/utils/containing-block.d.ts +55 -0
  28. package/dist/utils/containing-block.js +131 -0
  29. package/dist/utils/overlay-container.d.ts +16 -0
  30. package/dist/utils/overlay-container.js +12 -0
  31. package/package.json +12 -12
@@ -3,6 +3,7 @@ import { twMerge } from "../../utils/tw-merge.js";
3
3
  import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
4
4
  import { buildPositionTryFallbacks, clampIntoViewport, } from "../../utils/anchor-position.js";
5
5
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
6
+ import { resolveContainerOption } from "../../utils/overlay-container.js";
6
7
  import SpotlightContent from "./SpotlightContent.svelte";
7
8
  //
8
9
  const TRANSITION = 200;
@@ -85,23 +86,35 @@ const _classAnnotation = `
85
86
  border border-(--stuic-spotlight-annotation-border)
86
87
  z-50
87
88
  `;
89
+ const VIEWPORT_ORIGIN = () => ({
90
+ left: 0,
91
+ top: 0,
92
+ width: window.innerWidth,
93
+ height: window.innerHeight,
94
+ scaleX: 1,
95
+ scaleY: 1,
96
+ });
88
97
  /**
89
- * Builds the clip-path value for the backdrop overlay with a rounded-rectangle hole.
98
+ * Builds the clip-path value for the backdrop overlay with a rounded-rectangle
99
+ * hole. `rect` is the target's viewport rect; `o` is the backdrop's own
100
+ * coordinate space (see {@link OverlayOrigin}) — clip-path coordinates are
101
+ * local to the backdrop element, so the hole is translated by the origin and
102
+ * the outer box is the backdrop's layout size.
90
103
  */
91
- function buildClipPath(rect, padding, borderRadius) {
92
- const vw = window.innerWidth;
93
- const vh = window.innerHeight;
94
- const x = rect.left - padding;
95
- const y = rect.top - padding;
96
- const w = rect.width + padding * 2;
97
- const h = rect.height + padding * 2;
104
+ function buildClipPath(rect, padding, borderRadius, o) {
105
+ const ow = o.width;
106
+ const oh = o.height;
107
+ const x = (rect.left - o.left) / o.scaleX - padding;
108
+ const y = (rect.top - o.top) / o.scaleY - padding;
109
+ const w = rect.width / o.scaleX + padding * 2;
110
+ const h = rect.height / o.scaleY + padding * 2;
98
111
  const r = Math.min(borderRadius, w / 2, h / 2);
99
112
  if (r <= 0) {
100
113
  // Simple rectangular hole (no rounding)
101
- return `polygon(evenodd, 0 0, ${vw}px 0, ${vw}px ${vh}px, 0 ${vh}px, 0 0, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`;
114
+ return `polygon(evenodd, 0 0, ${ow}px 0, ${ow}px ${oh}px, 0 ${oh}px, 0 0, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`;
102
115
  }
103
116
  // Rounded rectangular hole using SVG path syntax
104
- return `path(evenodd, "M 0 0 L ${vw} 0 L ${vw} ${vh} L 0 ${vh} Z M ${x + r} ${y} L ${x + w - r} ${y} A ${r} ${r} 0 0 1 ${x + w} ${y + r} L ${x + w} ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} L ${x + r} ${y + h} A ${r} ${r} 0 0 1 ${x} ${y + h - r} L ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z")`;
117
+ return `path(evenodd, "M 0 0 L ${ow} 0 L ${ow} ${oh} L 0 ${oh} Z M ${x + r} ${y} L ${x + w - r} ${y} A ${r} ${r} 0 0 1 ${x + w} ${y + r} L ${x + w} ${y + h - r} A ${r} ${r} 0 0 1 ${x + w - r} ${y + h} L ${x + r} ${y + h} A ${r} ${r} 0 0 1 ${x} ${y + h - r} L ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z")`;
105
118
  }
106
119
  /**
107
120
  * Checks if content is simple (string/html) vs complex (component/snippet).
@@ -216,6 +229,35 @@ export function spotlight(targetEl, fn) {
216
229
  hide();
217
230
  }
218
231
  }
232
+ /**
233
+ * The coordinate space the overlay's fixed elements resolve against. The
234
+ * backdrop is `position: fixed; inset: 0`, so its own box IS that space —
235
+ * the containing block's padding box when a CB-forming ancestor exists
236
+ * (overlay portalled into a contained/transformed shell via `container`),
237
+ * the viewport otherwise. Measuring it handles both with no special-casing.
238
+ */
239
+ function overlayOrigin() {
240
+ if (!backdropEl)
241
+ return VIEWPORT_ORIGIN();
242
+ const r = backdropEl.getBoundingClientRect();
243
+ // offsetWidth/Height are layout-space; the rect is visual — the ratio is
244
+ // the accumulated ancestor scale. offset* are integer-rounded, so treat
245
+ // sub-pixel differences as "unscaled" to avoid noise on the default path.
246
+ const w = backdropEl.offsetWidth || r.width;
247
+ const h = backdropEl.offsetHeight || r.height;
248
+ const sx = w && Math.abs(r.width - w) > 1 ? r.width / w : 1;
249
+ const sy = h && Math.abs(r.height - h) > 1 ? r.height / h : 1;
250
+ return { left: r.left, top: r.top, width: w, height: h, scaleX: sx, scaleY: sy };
251
+ }
252
+ /** Place the invisible anchor element over the (padded) hole. */
253
+ function positionAnchor(rect, padding, o) {
254
+ if (!anchorEl)
255
+ return;
256
+ anchorEl.style.left = `${(rect.left - o.left) / o.scaleX - padding}px`;
257
+ anchorEl.style.top = `${(rect.top - o.top) / o.scaleY - padding}px`;
258
+ anchorEl.style.width = `${rect.width / o.scaleX + padding * 2}px`;
259
+ anchorEl.style.height = `${rect.height / o.scaleY + padding * 2}px`;
260
+ }
219
261
  /**
220
262
  * Update the clip-path hole position to match the current target rect.
221
263
  */
@@ -225,15 +267,11 @@ export function spotlight(targetEl, fn) {
225
267
  const rect = targetEl.getBoundingClientRect();
226
268
  const padding = currentOptions.padding ?? 8;
227
269
  const borderRadius = currentOptions.borderRadius ?? 8;
270
+ const o = overlayOrigin();
228
271
  debug("updateHolePosition()", rect);
229
- backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius);
272
+ backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius, o);
230
273
  // Update the invisible anchor element position
231
- if (anchorEl) {
232
- anchorEl.style.left = `${rect.left - padding}px`;
233
- anchorEl.style.top = `${rect.top - padding}px`;
234
- anchorEl.style.width = `${rect.width + padding * 2}px`;
235
- anchorEl.style.height = `${rect.height + padding * 2}px`;
236
- }
274
+ positionAnchor(rect, padding, o);
237
275
  // Reposition / re-clamp the annotation. The fallback path recomputes its
238
276
  // base left/top here; the anchor path is re-placed by the browser. Either
239
277
  // way we re-clamp so an edge-anchored annotation stays on-screen as the
@@ -281,17 +319,21 @@ export function spotlight(targetEl, fn) {
281
319
  lastRect = null;
282
320
  }
283
321
  /**
284
- * Position annotation without CSS Anchor Positioning (fallback).
322
+ * Position annotation without CSS Anchor Positioning (fallback). All values
323
+ * are expressed in the overlay's coordinate space (see {@link overlayOrigin})
324
+ * — for a fixed element, `left/top/right/bottom` resolve against the
325
+ * containing block, which is the viewport only in the un-portalled default.
285
326
  */
286
327
  function positionAnnotationFallback(rect, padding) {
287
328
  if (!annotationEl)
288
329
  return;
289
330
  const pos = currentOptions.position || "bottom";
290
331
  const offset = 8; // px fallback offset
291
- const x = rect.left - padding;
292
- const y = rect.top - padding;
293
- const w = rect.width + padding * 2;
294
- const h = rect.height + padding * 2;
332
+ const o = overlayOrigin();
333
+ const x = (rect.left - o.left) / o.scaleX - padding;
334
+ const y = (rect.top - o.top) / o.scaleY - padding;
335
+ const w = rect.width / o.scaleX + padding * 2;
336
+ const h = rect.height / o.scaleY + padding * 2;
295
337
  // Reset position
296
338
  annotationEl.style.left = "";
297
339
  annotationEl.style.top = "";
@@ -300,14 +342,14 @@ export function spotlight(targetEl, fn) {
300
342
  annotationEl.style.transform = "";
301
343
  if (pos.startsWith("top")) {
302
344
  annotationEl.style.left = `${x}px`;
303
- annotationEl.style.bottom = `${window.innerHeight - y + offset}px`;
345
+ annotationEl.style.bottom = `${o.height - y + offset}px`;
304
346
  }
305
347
  else if (pos.startsWith("bottom")) {
306
348
  annotationEl.style.left = `${x}px`;
307
349
  annotationEl.style.top = `${y + h + offset}px`;
308
350
  }
309
351
  else if (pos === "left") {
310
- annotationEl.style.right = `${window.innerWidth - x + offset}px`;
352
+ annotationEl.style.right = `${o.width - x + offset}px`;
311
353
  annotationEl.style.top = `${y}px`;
312
354
  }
313
355
  else if (pos === "right") {
@@ -328,7 +370,11 @@ export function spotlight(targetEl, fn) {
328
370
  function clampAnnotationIntoViewport() {
329
371
  if (!annotationEl || !annotationShown)
330
372
  return;
331
- clampIntoViewport(annotationEl);
373
+ // Pass the empirically measured CB rect (the inset-0 backdrop's box) so
374
+ // the clamp uses the SAME coordinate space as the hole/anchor math even
375
+ // where the heuristic ancestor walk and the engine disagree (WebKit
376
+ // filter shells).
377
+ clampIntoViewport(annotationEl, undefined, backdropEl ? backdropEl.getBoundingClientRect() : undefined);
332
378
  }
333
379
  function renderContent() {
334
380
  if (!annotationEl || !currentOptions.content)
@@ -374,8 +420,12 @@ export function spotlight(targetEl, fn) {
374
420
  requestAnimationFrame(() => {
375
421
  if (!isVisible)
376
422
  return; // may have been hidden in the meantime
423
+ const container = resolveContainerOption(currentOptions.container) ?? document.body;
377
424
  const rect = targetEl.getBoundingClientRect();
378
- // 1. Create backdrop overlay
425
+ // 1. Create backdrop overlay. Append BEFORE building the clip-path:
426
+ // the hole coordinates are relative to the backdrop's own box, which
427
+ // is only measurable once it is in the DOM (same JS turn — nothing
428
+ // paints unclipped).
379
429
  backdropEl = document.createElement("div");
380
430
  backdropEl.style.cssText = `
381
431
  position: fixed;
@@ -385,26 +435,29 @@ export function spotlight(targetEl, fn) {
385
435
  transition-duration: ${TRANSITION}ms;
386
436
  `;
387
437
  backdropEl.classList.add(...twMerge("stuic-spotlight-backdrop", currentOptions.classBackdrop).split(/\s/));
388
- backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius);
389
- document.body.appendChild(backdropEl);
438
+ container.appendChild(backdropEl);
439
+ const o = overlayOrigin();
440
+ backdropEl.style.clipPath = buildClipPath(rect, padding, borderRadius, o);
390
441
  // 2. Create invisible anchor element for CSS Anchor Positioning
391
442
  anchorEl = document.createElement("div");
392
443
  anchorEl.style.cssText = `
393
444
  position: fixed;
394
- left: ${rect.left - padding}px;
395
- top: ${rect.top - padding}px;
396
- width: ${rect.width + padding * 2}px;
397
- height: ${rect.height + padding * 2}px;
398
445
  pointer-events: none;
399
446
  z-index: -1;
400
447
  `;
448
+ positionAnchor(rect, padding, o);
401
449
  addAnchorName(anchorEl, anchorName);
402
- document.body.appendChild(anchorEl);
450
+ container.appendChild(anchorEl);
403
451
  // 3. Create annotation element (if content provided)
404
452
  if (currentOptions.content) {
405
453
  annotationEl = document.createElement("div");
406
454
  annotationEl.setAttribute("role", "dialog");
407
455
  if (isSupported) {
456
+ // NOTE: keep `vw`/`vh` here — this is the ANCHORED branch, where
457
+ // the element's containing block is the `position-area` region (a
458
+ // slice of the CB, often much smaller), so `%` would shrink the
459
+ // annotation. Overflow is handled by position-try + the CB-aware
460
+ // clamp.
408
461
  annotationEl.style.cssText = `
409
462
  position: fixed;
410
463
  position-anchor: ${anchorName};
@@ -420,17 +473,18 @@ export function spotlight(targetEl, fn) {
420
473
  annotationEl.classList.add(...twMerge("stuic-spotlight-annotation", _classAnnotation, currentOptions.class).split(/\s/));
421
474
  }
422
475
  else {
423
- // Fallback positioning
476
+ // Fallback positioning. `90%` (not `90vw`): the left/top values
477
+ // are containing-block-relative, so the size must be too.
424
478
  annotationEl.style.cssText = `
425
479
  position: fixed;
426
480
  transition-duration: ${TRANSITION}ms;
427
481
  z-index: 50;
428
- max-width: 90vw;
482
+ max-width: 90%;
429
483
  `;
430
484
  annotationEl.classList.add(...twMerge("stuic-spotlight-annotation-fallback", _classAnnotation, currentOptions.class).split(/\s/));
431
485
  positionAnnotationFallback(rect, padding);
432
486
  }
433
- document.body.appendChild(annotationEl);
487
+ container.appendChild(annotationEl);
434
488
  renderContent();
435
489
  }
436
490
  // 4. Lock body scroll
@@ -457,9 +511,12 @@ export function spotlight(targetEl, fn) {
457
511
  if (currentOptions.closeOnBackdropClick !== false) {
458
512
  backdropEl.addEventListener("click", onBackdropClick);
459
513
  }
460
- // 7. Watch for target position changes
514
+ // 7. Watch for target position changes — and for the overlay's own
515
+ // coordinate space changing size (the backdrop tracks its container,
516
+ // which resize/scroll listeners and target-rect comparison won't see)
461
517
  resizeObserver = new ResizeObserver(updateHolePosition);
462
518
  resizeObserver.observe(targetEl);
519
+ resizeObserver.observe(backdropEl);
463
520
  window.addEventListener("resize", updateHolePosition);
464
521
  window.addEventListener("scroll", updateHolePosition, true);
465
522
  // 8. Per-frame compare-loop to catch layout shifts (sibling collapses,
@@ -527,6 +584,7 @@ export function spotlight(targetEl, fn) {
527
584
  onHide: opts.onHide,
528
585
  debug: opts.debug,
529
586
  id: opts.id,
587
+ container: opts.container,
530
588
  };
531
589
  do_debug = !!opts.debug;
532
590
  // Register in global registry if id provided
@@ -24,8 +24,9 @@
24
24
  classButtonPrimary?: string;
25
25
  intentButtonCancel?: IntentColorKey;
26
26
  intentButtonCustom?: IntentColorKey;
27
- /** Intent of the primary (OK) button. Defaults to `"primary"`, except for the
28
- * `"warn"` variant, which defaults to `"destructive"`. */
27
+ /** Intent of the primary (OK) button. Defaults to `"primary"`, except for
28
+ * `"warn"` confirm/prompt dialogs, which default to `"destructive"`. A dialog
29
+ * option of the same name wins over this. */
29
30
  intentButtonPrimary?: IntentColorKey;
30
31
  classSpinnerBox?: string;
31
32
  defaultIcons?: Partial<
@@ -22,8 +22,9 @@ export interface Props {
22
22
  classButtonPrimary?: string;
23
23
  intentButtonCancel?: IntentColorKey;
24
24
  intentButtonCustom?: IntentColorKey;
25
- /** Intent of the primary (OK) button. Defaults to `"primary"`, except for the
26
- * `"warn"` variant, which defaults to `"destructive"`. */
25
+ /** Intent of the primary (OK) button. Defaults to `"primary"`, except for
26
+ * `"warn"` confirm/prompt dialogs, which default to `"destructive"`. A dialog
27
+ * option of the same name wins over this. */
27
28
  intentButtonPrimary?: IntentColorKey;
28
29
  classSpinnerBox?: string;
29
30
  defaultIcons?: Partial<Record<"info" | "success" | "warn" | "error" | "spinner", () => string | undefined>>;
@@ -39,8 +39,9 @@
39
39
  classButtonPrimary?: string;
40
40
  intentButtonCancel?: IntentColorKey;
41
41
  intentButtonCustom?: IntentColorKey;
42
- /** Intent of the primary (OK) button. Defaults to `"primary"`, except for the
43
- * `"warn"` variant, which defaults to `"destructive"`. */
42
+ /** Intent of the primary (OK) button. Defaults to `"primary"`, except for
43
+ * `"warn"` confirm/prompt dialogs, which default to `"destructive"`. A dialog
44
+ * option of the same name wins over this. */
44
45
  intentButtonPrimary?: IntentColorKey;
45
46
  classSpinnerBox?: string;
46
47
  defaultIcons?: Partial<
@@ -77,12 +78,24 @@
77
78
 
78
79
  let current = $derived(acp?.current!);
79
80
 
80
- // the "warn" variant hints a destructive action, so unless explicitly configured
81
- // otherwise, render the primary button accordingly
81
+ // Button config is layered: the dialog object's own value (most specific) wins over
82
+ // the component level prop - same as CmpButtonOk/Cancel/Custom below.
83
+
84
+ // The "warn" variant hints a destructive action, so unless explicitly configured
85
+ // otherwise, render the primary button accordingly. Not for ALERT though: its ok
86
+ // button only dismisses the dialog (onOk defaults to `shift`), so there is no
87
+ // destructive action to signal - a warning-severity *message* is not the same
88
+ // thing as a destructive *action*.
82
89
  let _intentButtonPrimary = $derived(
83
- intentButtonPrimary ?? (current?.variant === "warn" ? "destructive" : "primary")
90
+ current?.intentButtonPrimary ??
91
+ intentButtonPrimary ??
92
+ (current?.variant === "warn" && current?.type !== ALERT ? "destructive" : "primary")
84
93
  );
85
94
 
95
+ let _intentButtonCancel = $derived(current?.intentButtonCancel ?? intentButtonCancel);
96
+
97
+ let _intentButtonCustom = $derived(current?.intentButtonCustom ?? intentButtonCustom);
98
+
86
99
  let iconHtml = $derived.by(() => {
87
100
  let fn = current.iconFn as any;
88
101
  if (current.iconFn === true) {
@@ -219,8 +232,15 @@
219
232
  {#if current.type !== ALERT}
220
233
  <li class={twMerge(_classMenuLi, classMenuLi, hasCustom && classMenuLiCustom)}>
221
234
  <CmpButtonCancel
222
- class={twMerge("cancel", _classButton, classButton, classButtonCancel)}
223
- intent={intentButtonCancel}
235
+ class={twMerge(
236
+ "cancel",
237
+ _classButton,
238
+ classButton,
239
+ classButtonCancel,
240
+ current.classButton,
241
+ current.classButtonCancel
242
+ )}
243
+ intent={_intentButtonCancel}
224
244
  disabled={isPending}
225
245
  onclick={createOnClick("cancel", current.onCancel)}
226
246
  >
@@ -231,8 +251,15 @@
231
251
  {#if hasCustom}
232
252
  <li class={twMerge(_classMenuLi, classMenuLi, classMenuLiCustom)}>
233
253
  <CmpButtonCustom
234
- class={twMerge("custom", _classButton, classButton, classButtonCustom)}
235
- intent={intentButtonCustom}
254
+ class={twMerge(
255
+ "custom",
256
+ _classButton,
257
+ classButton,
258
+ classButtonCustom,
259
+ current.classButton,
260
+ current.classButtonCustom
261
+ )}
262
+ intent={_intentButtonCustom}
236
263
  disabled={isPending}
237
264
  onclick={createOnClick("custom", current.onCustom!)}
238
265
  >
@@ -242,7 +269,14 @@
242
269
  {/if}
243
270
  <li class={twMerge(_classMenuLi, classMenuLi, hasCustom && classMenuLiCustom)}>
244
271
  <CmpButtonOk
245
- class={twMerge("ok", _classButton, classButton, classButtonPrimary)}
272
+ class={twMerge(
273
+ "ok",
274
+ _classButton,
275
+ classButton,
276
+ classButtonPrimary,
277
+ current.classButton,
278
+ current.classButtonPrimary
279
+ )}
246
280
  intent={_intentButtonPrimary}
247
281
  disabled={isPending}
248
282
  onclick={createOnClick("ok", current.onOk)}
@@ -22,8 +22,9 @@ interface Props {
22
22
  classButtonPrimary?: string;
23
23
  intentButtonCancel?: IntentColorKey;
24
24
  intentButtonCustom?: IntentColorKey;
25
- /** Intent of the primary (OK) button. Defaults to `"primary"`, except for the
26
- * `"warn"` variant, which defaults to `"destructive"`. */
25
+ /** Intent of the primary (OK) button. Defaults to `"primary"`, except for
26
+ * `"warn"` confirm/prompt dialogs, which default to `"destructive"`. A dialog
27
+ * option of the same name wins over this. */
27
28
  intentButtonPrimary?: IntentColorKey;
28
29
  classSpinnerBox?: string;
29
30
  defaultIcons?: Partial<Record<"info" | "success" | "warn" | "error" | "spinner", () => string | undefined>>;
@@ -4,23 +4,23 @@ A modern, customizable replacement for native browser `alert()`, `confirm()`, an
4
4
 
5
5
  ## Props
6
6
 
7
- | Prop | Type | Default | Description |
8
- | --------------------- | ------------------------------- | ------- | --------------------------------------------------------------------------------------------- |
9
- | `acp` | `AlertConfirmPromptStack` | - | Stack instance managing the dialog queue |
10
- | `forceAsHtml` | `boolean` | `false` | Render all content as HTML |
11
- | `class` | `string` | - | CSS classes for the modal dialog |
12
- | `classWrap` | `string` | - | CSS for outer wrapper |
13
- | `classIconBox` | `string` | - | CSS for icon container |
14
- | `classTitle` | `string` | - | CSS for title text |
15
- | `classContent` | `string` | - | CSS for content area |
16
- | `classInput` | `string` | - | CSS for prompt input field |
17
- | `classButton` | `string` | - | CSS for all buttons |
18
- | `classButtonPrimary` | `string` | - | CSS for OK button |
19
- | `classButtonCancel` | `string` | - | CSS for Cancel button |
20
- | `intentButtonPrimary` | `IntentColorKey` | - | Intent of the OK button; defaults to `"primary"`, or `"destructive"` for the `"warn"` variant |
21
- | `intentButtonCancel` | `IntentColorKey` | - | Intent of the Cancel button |
22
- | `intentButtonCustom` | `IntentColorKey` | - | Intent of the custom button |
23
- | `defaultIcons` | `Record<variant, () => string>` | - | Custom icon functions per variant |
7
+ | Prop | Type | Default | Description |
8
+ | --------------------- | ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
9
+ | `acp` | `AlertConfirmPromptStack` | - | Stack instance managing the dialog queue |
10
+ | `forceAsHtml` | `boolean` | `false` | Render all content as HTML |
11
+ | `class` | `string` | - | CSS classes for the modal dialog |
12
+ | `classWrap` | `string` | - | CSS for outer wrapper |
13
+ | `classIconBox` | `string` | - | CSS for icon container |
14
+ | `classTitle` | `string` | - | CSS for title text |
15
+ | `classContent` | `string` | - | CSS for content area |
16
+ | `classInput` | `string` | - | CSS for prompt input field |
17
+ | `classButton` | `string` | - | CSS for all buttons |
18
+ | `classButtonPrimary` | `string` | - | CSS for OK button |
19
+ | `classButtonCancel` | `string` | - | CSS for Cancel button |
20
+ | `intentButtonPrimary` | `IntentColorKey` | - | Intent of the OK button; defaults to `"primary"`, or `"destructive"` for `"warn"` confirm/prompt dialogs (a dialog option of the same name wins) |
21
+ | `intentButtonCancel` | `IntentColorKey` | - | Intent of the Cancel button |
22
+ | `intentButtonCustom` | `IntentColorKey` | - | Intent of the custom button |
23
+ | `defaultIcons` | `Record<variant, () => string>` | - | Custom icon functions per variant |
24
24
 
25
25
  ## AlertConfirmPromptStack API
26
26
 
@@ -42,16 +42,18 @@ A modern, customizable replacement for native browser `alert()`, `confirm()`, an
42
42
 
43
43
  ### Dialog Options
44
44
 
45
- | Option | Type | Description |
46
- | ------------- | ------------------------------------------ | ------------------------------------------------------------------- |
47
- | `title` | `THC` | Dialog title |
48
- | `content` | `THC` | Dialog message/content |
49
- | `variant` | `"info" \| "success" \| "warn" \| "error"` | Visual style (`"warn"` also renders the OK button as `destructive`) |
50
- | `value` | `any` | Initial value for prompt |
51
- | `labelOk` | `THC` | Custom OK button label |
52
- | `labelCancel` | `THC` | Custom Cancel button label |
53
- | `labelCustom` | `THC` | Optional third button label |
54
- | `onCustom` | `(value) => void` | Custom button callback |
45
+ | Option | Type | Description |
46
+ | ----------------------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------- |
47
+ | `title` | `THC` | Dialog title |
48
+ | `content` | `THC` | Dialog message/content |
49
+ | `variant` | `"info" \| "success" \| "warn" \| "error"` | Visual style (for confirm/prompt, `"warn"` also renders the OK button as `destructive`) |
50
+ | `value` | `any` | Initial value for prompt |
51
+ | `labelOk` | `THC` | Custom OK button label |
52
+ | `labelCancel` | `THC` | Custom Cancel button label |
53
+ | `labelCustom` | `THC` | Optional third button label |
54
+ | `onCustom` | `(value) => void` | Custom button callback |
55
+ | `classButton`, `classButtonPrimary`, `classButtonCancel`, `classButtonCustom` | `string` | Per dialog button classes, merged on top of the same named component props |
56
+ | `intentButtonPrimary`, `intentButtonCancel`, `intentButtonCustom` | `IntentColorKey` | Per dialog button intents, overriding the same named component props (and the `"warn"` default) |
55
57
 
56
58
  ## Usage
57
59
 
@@ -1,5 +1,6 @@
1
1
  import type { Component } from "svelte";
2
2
  import type { THC } from "../Thc/Thc.svelte";
3
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
3
4
  /**
4
5
  * Types of alert/confirm/prompt dialogs.
5
6
  */
@@ -42,6 +43,13 @@ export interface AlertConfirmPromptObj extends Record<string, any> {
42
43
  CmpButtonOk?: Component;
43
44
  CmpButtonCancel?: Component;
44
45
  CmpButtonCustom?: Component;
46
+ classButton?: string;
47
+ classButtonPrimary?: string;
48
+ classButtonCancel?: string;
49
+ classButtonCustom?: string;
50
+ intentButtonPrimary?: IntentColorKey;
51
+ intentButtonCancel?: IntentColorKey;
52
+ intentButtonCustom?: IntentColorKey;
45
53
  }
46
54
  /**
47
55
  * A reactive stack manager for alert, confirm, and prompt dialogs.
@@ -266,6 +266,7 @@
266
266
  import Thc from "../Thc/Thc.svelte";
267
267
  import ListItemButton from "../ListItemButton/ListItemButton.svelte";
268
268
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
269
+ import { fixedContainingBlockRect } from "../../utils/containing-block.js";
269
270
  import { waitForTwoRepaints } from "../../utils/paint.js";
270
271
  import {
271
272
  extractSearchableItems,
@@ -542,15 +543,17 @@
542
543
  await waitForTwoRepaints();
543
544
  if (!dropdownEl || !isOpen) return;
544
545
 
546
+ // Measure against the dropdown's containing block, not the viewport —
547
+ // an ancestor with e.g. `transform` or `contain: layout|paint` is what
548
+ // the fixed dropdown actually resolves (and gets clipped) against.
545
549
  const rect = dropdownEl.getBoundingClientRect();
546
- const viewportWidth = window.innerWidth;
547
- const viewportHeight = window.innerHeight;
550
+ const cb = fixedContainingBlockRect(dropdownEl);
548
551
 
549
552
  if (
550
- rect.left < 0 ||
551
- rect.right > viewportWidth ||
552
- rect.top < 0 ||
553
- rect.bottom > viewportHeight
553
+ rect.left < cb.left ||
554
+ rect.right > cb.right ||
555
+ rect.top < cb.top ||
556
+ rect.bottom > cb.bottom
554
557
  ) {
555
558
  switchingToFallback = true;
556
559
  runtimeFallback = true;
@@ -707,12 +710,16 @@
707
710
  const heightStyle = searchConfig
708
711
  ? `height: ${maxHeight};`
709
712
  : `max-height: ${maxHeight};`;
713
+ // `90%` (not `90vw`): the top/left/transform centering is relative to
714
+ // the containing block, so the size must be too — `%` resolves against
715
+ // the CB (identical to `vw` when the CB is the viewport, correct when
716
+ // an ancestor with `transform`/`contain` establishes one).
710
717
  return `
711
718
  position: fixed;
712
719
  top: 50%;
713
720
  left: 50%;
714
721
  transform: translate(-50%, -50%);
715
- max-width: 90vw;
722
+ max-width: 90%;
716
723
  ${heightStyle}
717
724
  ${gutterStyle}
718
725
  z-index: 50;
@@ -361,6 +361,7 @@ Use `contentBefore` for leading content (icons) and `contentAfter` for trailing
361
361
  ## Features
362
362
 
363
363
  - **CSS Anchor Positioning**: Uses modern CSS anchor positioning with automatic fallback for unsupported browsers
364
+ - **Containing-block aware**: Overflow detection and the fallback modal measure against the dropdown's actual containing block — inside a `transform`ed or `contain: layout|paint` shell they use the shell's box, not the viewport
364
365
  - **Full Keyboard Navigation**: Complete arrow key navigation with Home/End support
365
366
  - **Expandable Sections**: Collapsible groups with independent toggle state
366
367
  - **ARIA Compliant**: Proper menu roles and keyboard interaction
@@ -67,6 +67,7 @@
67
67
  <script lang="ts">
68
68
  import { untrack } from "svelte";
69
69
  import { twMerge } from "../../utils/tw-merge.js";
70
+ import { fixedContainingBlockRect } from "../../utils/containing-block.js";
70
71
  import { localStorageState } from "../../utils/persistent-state.svelte.js";
71
72
  import { draggable as draggableAction } from "../../actions/draggable.svelte.js";
72
73
  import { iconChevronDown, iconX } from "../../icons/index.js";
@@ -130,6 +131,26 @@
130
131
 
131
132
  function viewport(): FloatSize {
132
133
  if (typeof window === "undefined") return { width: 0, height: 0 };
134
+ // The panel is `position: fixed`, so its `left`/`top` (and therefore all
135
+ // placement/clamping math) resolve against its containing block — the
136
+ // viewport, unless an ancestor (`transform`, `contain: layout|paint`, …)
137
+ // establishes one. `x`/`y` are written as `left`/`top`, i.e. LAYOUT px,
138
+ // while the CB rect is visual — divide out the accumulated ancestor
139
+ // scale (measured on the panel itself; sub-pixel offset* rounding is
140
+ // treated as unscaled).
141
+ if (el) {
142
+ const cb = fixedContainingBlockRect(el);
143
+ const r = el.getBoundingClientRect();
144
+ const sx =
145
+ el.offsetWidth && Math.abs(r.width - el.offsetWidth) > 1
146
+ ? r.width / el.offsetWidth
147
+ : 1;
148
+ const sy =
149
+ el.offsetHeight && Math.abs(r.height - el.offsetHeight) > 1
150
+ ? r.height / el.offsetHeight
151
+ : 1;
152
+ return { width: cb.width / sx, height: cb.height / sy };
153
+ }
133
154
  return { width: window.innerWidth, height: window.innerHeight };
134
155
  }
135
156
 
@@ -5,7 +5,7 @@ dev/inspector tweak panel (dat.GUI / Tweakpane style). It has a header (optional
5
5
  icon, a `THC` title, an actions slot, and minimize/close buttons) and an arbitrary body.
6
6
 
7
7
  - **Positioned by params**: numeric `x`/`y` **or** a named `placement` preset (corners / edges / center).
8
- - **`position: fixed`** relative to the viewport, with drag **clamped** so it never leaves the screen.
8
+ - **`position: fixed`** relative to the viewport — or to the nearest containing-block ancestor (`transform`, `contain: layout|paint`, …) when one exists — with drag **clamped** so it never leaves that box.
9
9
  - **Draggable** by the whole header (buttons excepted).
10
10
  - **Minimizable** to just the title bar (header button, double-click header, or methods).
11
11
  - **Imperative control** via a `bind:this` ref (mirrors `Modal`/`ModalDialog`).