@marianmeres/stuic 3.151.0 → 3.153.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/AGENTS.md +3 -0
- package/API.md +1 -0
- package/README.md +72 -0
- 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/css/frame.css +109 -0
- package/dist/index.css +3 -0
- 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/docs/RATIO_LOCKED_FRAME.md +466 -0
- package/docs/architecture.md +6 -0
- package/docs/domains/css-presets.md +304 -0
- package/docs/domains/theming.md +2 -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>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
RATIO-LOCKED FRAME (letterbox)
|
|
3
|
+
|
|
4
|
+
Lock a box to an aspect ratio, size it to whichever axis binds first, centre
|
|
5
|
+
it, and let the leftover space become letterboxing.
|
|
6
|
+
|
|
7
|
+
See docs/domains/css-presets.md for the classes, the token contract and the
|
|
8
|
+
decision tree, and docs/RATIO_LOCKED_FRAME.md for the recipes and the measured
|
|
9
|
+
gotcha list — the CSS here is nine declarations; the knowledge is the deliverable.
|
|
10
|
+
|
|
11
|
+
LAYERED (unlike the `.scrollbar-thin` / `.stuic-safe-area-*` utilities at the
|
|
12
|
+
end of index.css) because these are opt-in layout presets a consumer puts on
|
|
13
|
+
their OWN element and WILL tweak: Tailwind emits
|
|
14
|
+
`@layer theme, base, components, utilities`, so a utility always wins —
|
|
15
|
+
`class="stuic-frame h-dvh overflow-y-auto bg-white"` overrides everything
|
|
16
|
+
below. That is the escape hatch, by design.
|
|
17
|
+
|
|
18
|
+
This preset deliberately ships NO background, NO containment, NO scroll
|
|
19
|
+
container and NO letterbox-bars class: `grid`, `overflow-hidden`,
|
|
20
|
+
`fixed inset-0`, `bg-*`, `contain-layout`, `contain-paint`, `@container-size`
|
|
21
|
+
and `overflow-y-auto` are all Tailwind v4 utilities (stuic already requires
|
|
22
|
+
Tailwind v4). Only the sizing formula is ours, because it is the only part
|
|
23
|
+
Tailwind cannot express.
|
|
24
|
+
============================================================================ */
|
|
25
|
+
|
|
26
|
+
@layer components {
|
|
27
|
+
/* The ratio-locked box. Centres itself in a block, grid or flex parent
|
|
28
|
+
(`margin: auto` centres both axes in grid/flex; in normal flow the block-
|
|
29
|
+
axis autos compute to 0 and it behaves as `margin-inline: auto`).
|
|
30
|
+
|
|
31
|
+
One parent shape is NOT safe: a flex COLUMN. The ratio-derived height becomes
|
|
32
|
+
the flex base size and `min-height: 0` below removes the floor that would stop
|
|
33
|
+
it shrinking, so the frame silently goes off-ratio (measured 400x740, r=0.5405,
|
|
34
|
+
where a grid parent gives 400x800, r=0.5). Add `shrink-0`. See G22.
|
|
35
|
+
|
|
36
|
+
Why min() and not `max-width:100%; max-height:100%; aspect-ratio:R` — the
|
|
37
|
+
formulation everyone tries first: `max-*` never GROWS a box, so in a
|
|
38
|
+
centred grid/flex parent an empty frame measures 0x0, and one with content
|
|
39
|
+
shrink-wraps that content and overflows the parent. The ratio usually
|
|
40
|
+
survives; the SIZE is what's wrong. (There is a working `max-*` variant —
|
|
41
|
+
it needs a positioned parent — see Recipe D in the docs.)
|
|
42
|
+
|
|
43
|
+
`aspect-ratio` supplies the height, so the frame is ratio-locked at every
|
|
44
|
+
viewport, with bars on exactly one axis. Deriving the width from the
|
|
45
|
+
height and then letting `aspect-ratio` derive the height back is not
|
|
46
|
+
circular: `width` is resolved first, `aspect-ratio` only ever fills an
|
|
47
|
+
`auto` axis.
|
|
48
|
+
|
|
49
|
+
`min-height: 0` and `overflow: hidden` are load-bearing for the ratio, not
|
|
50
|
+
cosmetics: a grid/flex item's automatic minimum size overrides
|
|
51
|
+
`aspect-ratio` outright, so a tall child stretches an otherwise correct
|
|
52
|
+
frame off-ratio. Either one alone fixes it; both are set so a consumer's
|
|
53
|
+
`overflow-visible` stays survivable. */
|
|
54
|
+
.stuic-frame {
|
|
55
|
+
width: var(
|
|
56
|
+
--stuic-frame-width,
|
|
57
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/* `auto` = ratio-locked (the default). Any explicit length here WINS over
|
|
61
|
+
`aspect-ratio` unconditionally — that is the supported way to opt into
|
|
62
|
+
"fill the height, derive the width" inside a bail-out media query, with
|
|
63
|
+
no `!important` and no specificity game. */
|
|
64
|
+
height: var(--stuic-frame-height, auto);
|
|
65
|
+
|
|
66
|
+
aspect-ratio: var(--stuic-frame-aspect-ratio, 1);
|
|
67
|
+
min-height: 0;
|
|
68
|
+
margin: auto;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Opt-in: size against the nearest ANCESTOR query container instead of the
|
|
73
|
+
viewport — the nested case (a frame under a header, inside a flex column).
|
|
74
|
+
Combine with `.stuic-frame`; this rule must stay AFTER it in source order,
|
|
75
|
+
since both declare `width` at equal specificity.
|
|
76
|
+
|
|
77
|
+
REQUIRES an ancestor with `container-type: size` (Tailwind:
|
|
78
|
+
`@container-size`). `inline-size` is NOT enough: `cqh` then falls THROUGH
|
|
79
|
+
to the next container, or silently to the small viewport, and you get a
|
|
80
|
+
ratio-correct but wrongly-scaled frame that overflows its parent and
|
|
81
|
+
tracks the window as you resize. Same failure with no container ancestor
|
|
82
|
+
at all. Size containment is safe here precisely because this frame's own
|
|
83
|
+
height is always determined. */
|
|
84
|
+
.stuic-frame-cq {
|
|
85
|
+
width: var(
|
|
86
|
+
--stuic-frame-width,
|
|
87
|
+
min(100cqw, calc(100cqh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Re-align a VIEWPORT-space element onto the frame's column: a top-layer
|
|
92
|
+
`<dialog>`, or an overlay portalled to `<body>` (stuic's popover /
|
|
93
|
+
spotlight / dimBehind default to `document.body` — pass their `container`
|
|
94
|
+
option instead where you can).
|
|
95
|
+
|
|
96
|
+
The whole fallback expression is repeated on purpose. Nothing declares
|
|
97
|
+
`--stuic-frame-width`, so a bare `var(--stuic-frame-width)` would be
|
|
98
|
+
invalid-at-computed-value-time -> `width: auto` -> silently full-bleed. */
|
|
99
|
+
.stuic-frame-col {
|
|
100
|
+
width: min(
|
|
101
|
+
100%,
|
|
102
|
+
var(
|
|
103
|
+
--stuic-frame-width,
|
|
104
|
+
min(100vw, calc(100dvh * (var(--stuic-frame-aspect-ratio, 1))))
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
margin-inline: auto;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/index.css
CHANGED
|
@@ -118,6 +118,9 @@ In practice:
|
|
|
118
118
|
@import "./actions/spotlight/index.css";
|
|
119
119
|
@import "./actions/tooltip/index.css";
|
|
120
120
|
|
|
121
|
+
/* Layout preset CSS (classes only, no component) */
|
|
122
|
+
@import "./css/frame.css";
|
|
123
|
+
|
|
121
124
|
/* Base styles for STUIC components */
|
|
122
125
|
@layer base {
|
|
123
126
|
button:not(:disabled),
|
|
@@ -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
|
}
|