@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.
- package/dist/actions/dim-behind/dim-behind.fixture.svelte +54 -0
- package/dist/actions/dim-behind/dim-behind.fixture.svelte.d.ts +9 -0
- package/dist/actions/dim-behind/dim-behind.svelte.d.ts +10 -0
- package/dist/actions/dim-behind/dim-behind.svelte.js +72 -41
- package/dist/actions/popover/README.md +37 -17
- package/dist/actions/popover/popover.container.fixture.svelte +26 -0
- package/dist/actions/popover/popover.container.fixture.svelte.d.ts +7 -0
- package/dist/actions/popover/popover.svelte.d.ts +10 -0
- package/dist/actions/popover/popover.svelte.js +20 -7
- package/dist/actions/spotlight/spotlight.container.fixture.svelte +33 -0
- package/dist/actions/spotlight/spotlight.container.fixture.svelte.d.ts +7 -0
- package/dist/actions/spotlight/spotlight.svelte.d.ts +9 -0
- package/dist/actions/spotlight/spotlight.svelte.js +95 -37
- package/dist/components/DropdownMenu/DropdownMenu.svelte +14 -7
- package/dist/components/DropdownMenu/README.md +1 -0
- package/dist/components/Float/Float.svelte +21 -0
- package/dist/components/Float/README.md +1 -1
- package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +30 -5
- package/dist/utils/anchor-position.d.ts +12 -3
- package/dist/utils/anchor-position.js +35 -15
- package/dist/utils/containing-block.d.ts +55 -0
- package/dist/utils/containing-block.js +131 -0
- package/dist/utils/overlay-container.d.ts +16 -0
- package/dist/utils/overlay-container.js +12 -0
- 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
|
|
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
|
|
93
|
-
const
|
|
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, ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
const
|
|
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 = `${
|
|
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 = `${
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
547
|
-
const viewportHeight = window.innerHeight;
|
|
550
|
+
const cb = fixedContainingBlockRect(dropdownEl);
|
|
548
551
|
|
|
549
552
|
if (
|
|
550
|
-
rect.left <
|
|
551
|
-
rect.right >
|
|
552
|
-
rect.top <
|
|
553
|
-
rect.bottom >
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
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
|
|
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
|
|
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
|
|
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
|
|
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 >
|
|
62
|
-
dx =
|
|
63
|
-
if (a.top < margin)
|
|
64
|
-
dy = margin - a.top;
|
|
65
|
-
else if (a.bottom >
|
|
66
|
-
dy =
|
|
67
|
-
if (dx || dy)
|
|
68
|
-
|
|
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;
|