@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.
@@ -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
@@ -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`).
@@ -24,7 +24,10 @@
24
24
  </script>
25
25
 
26
26
  <script lang="ts">
27
- import { innerHeight, innerWidth } from "svelte/reactivity/window";
27
+ import {
28
+ fixedContainingBlockAncestor,
29
+ fixedContainingBlockRect,
30
+ } from "../../utils/containing-block.js";
28
31
  import { DevicePointer } from "../../utils/device-pointer.svelte.js";
29
32
  import { waitForNextRepaint, waitForTransitionEnd } from "../../utils/paint.js";
30
33
  import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
@@ -82,12 +85,34 @@
82
85
  isExpanded = true;
83
86
  isExpanding = true;
84
87
 
88
+ // Pin the element in place: the inset values below resolve against the
89
+ // containing block once `position: fixed` is applied — the viewport,
90
+ // unless an ancestor (`transform`, `contain: layout|paint`, …)
91
+ // establishes one. Insets are LAYOUT px in the CB's content space, while
92
+ // rects are visual: divide out the accumulated ancestor scale, add the
93
+ // CB's scroll offsets (a fixed element whose CB is a scroll container
94
+ // behaves like an absolute one — it scrolls with the content), and
95
+ // derive bottom/right from the CB size so the top+height+bottom
96
+ // constraint stays exact. With no CB ancestor this reduces to the plain
97
+ // viewport-edge distances.
85
98
  box = el.getBoundingClientRect();
99
+ const cbEl = fixedContainingBlockAncestor(el);
100
+ const cb = fixedContainingBlockRect(el);
101
+ const sx =
102
+ el.offsetWidth && Math.abs(box.width - el.offsetWidth) > 1
103
+ ? box.width / el.offsetWidth
104
+ : 1;
105
+ const sy =
106
+ el.offsetHeight && Math.abs(box.height - el.offsetHeight) > 1
107
+ ? box.height / el.offsetHeight
108
+ : 1;
109
+ const top = (box.top - cb.top) / sy + (cbEl?.scrollTop ?? 0);
110
+ const left = (box.left - cb.left) / sx + (cbEl?.scrollLeft ?? 0);
86
111
  const pos = {
87
- top: box.top,
88
- bottom: (innerHeight.current ?? 0) - box.bottom,
89
- left: box.left,
90
- right: (innerWidth.current ?? 0) - box.right,
112
+ top,
113
+ left,
114
+ bottom: cb.height / sy - top - box.height / sy,
115
+ right: cb.width / sx - left - box.width / sx,
91
116
  };
92
117
 
93
118
  // <offset-x>, <offset-y>, <blur-radius>, <spread-radius>
@@ -19,7 +19,11 @@
19
19
  */
20
20
  export declare function buildPositionTryFallbacks(position: string): string;
21
21
  /**
22
- * Pull an element fully into the viewport with a corrective `transform`.
22
+ * Pull an element fully into its containing block with a corrective
23
+ * `transform`. For a fixed element that is the viewport — unless an ancestor
24
+ * (`transform`, `contain: layout|paint`, …) establishes a containing block, in
25
+ * which case the element is clamped into that ancestor's box instead (see
26
+ * {@link fixedContainingBlockRect}).
23
27
  *
24
28
  * This is the backstop for CSS Anchor Positioning: `position-try` can only swap
25
29
  * between discrete declared positions and cannot slide a centered annotation
@@ -36,6 +40,11 @@ export declare function buildPositionTryFallbacks(position: string): string;
36
40
  * correction applies instantly. The caller owns the element's `transform`.
37
41
  *
38
42
  * @param el - The (anchored, position:fixed) element to clamp
39
- * @param margin - Minimum gap from each viewport edge, in px (default 8)
43
+ * @param margin - Minimum gap from each containing-block edge, in px (default 8)
44
+ * @param cb - Optional explicit containing-block rect (viewport/visual
45
+ * coordinates). Callers that can measure the CB empirically (e.g. spotlight,
46
+ * via its own `inset: 0` backdrop) pass it to stay self-consistent even
47
+ * where the heuristic walker and the engine disagree (WebKit filter cases);
48
+ * defaults to {@link fixedContainingBlockRect}.
40
49
  */
41
- export declare function clampIntoViewport(el: HTMLElement, margin?: number): void;
50
+ export declare function clampIntoViewport(el: HTMLElement, margin?: number, cb?: DOMRectReadOnly): void;
@@ -2,6 +2,7 @@
2
2
  * Shared helpers for CSS Anchor Positioning based actions (spotlight, popover,
3
3
  * tooltip).
4
4
  */
5
+ import { fixedContainingBlockRect } from "./containing-block.js";
5
6
  /**
6
7
  * Builds the `position-try-fallbacks` value for an anchored element at a given
7
8
  * position.
@@ -28,7 +29,11 @@ export function buildPositionTryFallbacks(position) {
28
29
  return flips;
29
30
  }
30
31
  /**
31
- * Pull an element fully into the viewport with a corrective `transform`.
32
+ * Pull an element fully into its containing block with a corrective
33
+ * `transform`. For a fixed element that is the viewport — unless an ancestor
34
+ * (`transform`, `contain: layout|paint`, …) establishes a containing block, in
35
+ * which case the element is clamped into that ancestor's box instead (see
36
+ * {@link fixedContainingBlockRect}).
32
37
  *
33
38
  * This is the backstop for CSS Anchor Positioning: `position-try` can only swap
34
39
  * between discrete declared positions and cannot slide a centered annotation
@@ -45,25 +50,40 @@ export function buildPositionTryFallbacks(position) {
45
50
  * correction applies instantly. The caller owns the element's `transform`.
46
51
  *
47
52
  * @param el - The (anchored, position:fixed) element to clamp
48
- * @param margin - Minimum gap from each viewport edge, in px (default 8)
53
+ * @param margin - Minimum gap from each containing-block edge, in px (default 8)
54
+ * @param cb - Optional explicit containing-block rect (viewport/visual
55
+ * coordinates). Callers that can measure the CB empirically (e.g. spotlight,
56
+ * via its own `inset: 0` backdrop) pass it to stay self-consistent even
57
+ * where the heuristic walker and the engine disagree (WebKit filter cases);
58
+ * defaults to {@link fixedContainingBlockRect}.
49
59
  */
50
- export function clampIntoViewport(el, margin = 8) {
60
+ export function clampIntoViewport(el, margin = 8, cb = fixedContainingBlockRect(el)) {
51
61
  // Remove any prior correction so we measure the natural (anchored or
52
62
  // left/top) position, then recompute from scratch.
53
63
  el.style.transform = "";
54
64
  const a = el.getBoundingClientRect();
55
- const vw = window.innerWidth;
56
- const vh = window.innerHeight;
57
65
  let dx = 0;
58
66
  let dy = 0;
59
- if (a.left < margin)
60
- dx = margin - a.left;
61
- else if (a.right > vw - margin)
62
- dx = vw - margin - a.right;
63
- if (a.top < margin)
64
- dy = margin - a.top;
65
- else if (a.bottom > vh - margin)
66
- dy = vh - margin - a.bottom;
67
- if (dx || dy)
68
- el.style.transform = `translate(${dx}px, ${dy}px)`;
67
+ if (a.left < cb.left + margin)
68
+ dx = cb.left + margin - a.left;
69
+ else if (a.right > cb.right - margin)
70
+ dx = cb.right - margin - a.right;
71
+ if (a.top < cb.top + margin)
72
+ dy = cb.top + margin - a.top;
73
+ else if (a.bottom > cb.bottom - margin)
74
+ dy = cb.bottom - margin - a.bottom;
75
+ if (dx || dy) {
76
+ // The deltas above are visual (rect) px, but the translate applies in the
77
+ // element's local space — inside a scaled ancestor (`transform: scale()`
78
+ // zoom/preview wrapper) they differ by the accumulated scale factor.
79
+ // offsetWidth is integer-rounded, so treat sub-pixel differences as
80
+ // "unscaled" (a ratio threshold would misfire on small elements).
81
+ const sx = el.offsetWidth && Math.abs(a.width - el.offsetWidth) > 1
82
+ ? a.width / el.offsetWidth
83
+ : 1;
84
+ const sy = el.offsetHeight && Math.abs(a.height - el.offsetHeight) > 1
85
+ ? a.height / el.offsetHeight
86
+ : 1;
87
+ el.style.transform = `translate(${dx / sx}px, ${dy / sy}px)`;
88
+ }
69
89
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Helpers for working with the containing block (CB) of `position: fixed`
3
+ * elements.
4
+ *
5
+ * A fixed-positioned element resolves against the viewport UNLESS an ancestor
6
+ * establishes a fixed containing block — via `transform`/`translate`/`rotate`/
7
+ * `scale`/`perspective`/`filter`/`backdrop-filter`, a `will-change` naming any
8
+ * of those, or layout/paint containment (`contain: layout|paint|strict|content`,
9
+ * which `content-visibility: auto` also applies). Overlay code that measures
10
+ * "does this fixed element fit" must therefore compare against the CB rect,
11
+ * not `window.innerWidth/innerHeight` — the two only coincide in the (common)
12
+ * no-such-ancestor case.
13
+ */
14
+ /**
15
+ * Does this element establish a containing block for `position: fixed`
16
+ * descendants?
17
+ *
18
+ * Mirrors floating-ui's battle-tested `isContainingBlock`, with the same two
19
+ * deliberate omissions relative to a naive reading of MDN:
20
+ *
21
+ * - `filter`/`backdrop-filter` are ignored on WebKit: Safari historically does
22
+ * NOT form a fixed CB from them (plain `filter` was only fixed in Safari 26).
23
+ * Misdetecting a CB the browser doesn't honor would break correct layouts;
24
+ * missing one merely preserves the pre-CB-aware behavior.
25
+ * - `container-type` is NOT checked: the CSSWG removed layout containment from
26
+ * it (2024, csswg-drafts#10544) and Chrome 129+/Firefox/Safari all shipped
27
+ * the change, so container queries no longer re-parent fixed descendants.
28
+ */
29
+ export declare function isFixedContainingBlock(el: Element): boolean;
30
+ /**
31
+ * The nearest ancestor of `el` that establishes a containing block for
32
+ * `position: fixed` descendants, or `null` when fixed descendants resolve
33
+ * against the viewport. The walk stops (returning `null`) at top-layer
34
+ * elements — a modal `<dialog>`, an open `[popover]`, a fullscreen element —
35
+ * since the top layer escapes every ancestor containing block by design
36
+ * (unless such an element is itself CB-forming, e.g. a transformed dialog).
37
+ */
38
+ export declare function fixedContainingBlockAncestor(el: HTMLElement): HTMLElement | null;
39
+ /**
40
+ * The rect that `position: fixed` descendants of `el` actually resolve
41
+ * against, in viewport (visual) coordinates.
42
+ *
43
+ * Walks up from `el`'s parent looking for the nearest containing-block-forming
44
+ * ancestor (see {@link fixedContainingBlockAncestor}) and returns its padding
45
+ * box — per CSS, the CB is the padding box, not the border box. When no such
46
+ * ancestor exists (the overwhelmingly common case) it returns the viewport
47
+ * rect based on `window.innerWidth/innerHeight`, byte-identical to what the
48
+ * pre-CB-aware code measured.
49
+ *
50
+ * Known limitation: for a ROTATED CB ancestor the returned rect is the
51
+ * axis-aligned bounding box of the rotated element — consumers comparing
52
+ * rects (overflow checks, clamps) get an approximation there. Scaled
53
+ * ancestors are handled (border widths are converted to visual px).
54
+ */
55
+ export declare function fixedContainingBlockRect(el: HTMLElement): DOMRectReadOnly;