@marianmeres/stuic 3.150.0 → 3.152.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/actions/dim-behind/dim-behind.fixture.svelte +54 -0
  2. package/dist/actions/dim-behind/dim-behind.fixture.svelte.d.ts +9 -0
  3. package/dist/actions/dim-behind/dim-behind.svelte.d.ts +10 -0
  4. package/dist/actions/dim-behind/dim-behind.svelte.js +72 -41
  5. package/dist/actions/popover/README.md +37 -17
  6. package/dist/actions/popover/popover.container.fixture.svelte +26 -0
  7. package/dist/actions/popover/popover.container.fixture.svelte.d.ts +7 -0
  8. package/dist/actions/popover/popover.svelte.d.ts +10 -0
  9. package/dist/actions/popover/popover.svelte.js +20 -7
  10. package/dist/actions/spotlight/spotlight.container.fixture.svelte +33 -0
  11. package/dist/actions/spotlight/spotlight.container.fixture.svelte.d.ts +7 -0
  12. package/dist/actions/spotlight/spotlight.svelte.d.ts +9 -0
  13. package/dist/actions/spotlight/spotlight.svelte.js +95 -37
  14. package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte +3 -2
  15. package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte.d.ts +3 -2
  16. package/dist/components/AlertConfirmPrompt/Current.svelte +44 -10
  17. package/dist/components/AlertConfirmPrompt/Current.svelte.d.ts +3 -2
  18. package/dist/components/AlertConfirmPrompt/README.md +29 -27
  19. package/dist/components/AlertConfirmPrompt/alert-confirm-prompt-stack.svelte.d.ts +8 -0
  20. package/dist/components/DropdownMenu/DropdownMenu.svelte +14 -7
  21. package/dist/components/DropdownMenu/README.md +1 -0
  22. package/dist/components/Float/Float.svelte +21 -0
  23. package/dist/components/Float/README.md +1 -1
  24. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +30 -5
  25. package/dist/utils/anchor-position.d.ts +12 -3
  26. package/dist/utils/anchor-position.js +35 -15
  27. package/dist/utils/containing-block.d.ts +55 -0
  28. package/dist/utils/containing-block.js +131 -0
  29. package/dist/utils/overlay-container.d.ts +16 -0
  30. package/dist/utils/overlay-container.js +12 -0
  31. package/package.json +12 -12
@@ -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;
@@ -0,0 +1,131 @@
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
+ * `will-change` values that force a fixed containing block: any property whose
16
+ * non-initial value would form one. (Same set floating-ui checks.)
17
+ */
18
+ const WILL_CHANGE_RE = /transform|translate|scale|rotate|perspective|filter/;
19
+ /** `contain` values that apply layout and/or paint containment. */
20
+ const CONTAIN_RE = /paint|layout|strict|content/;
21
+ let _isWebKit;
22
+ /**
23
+ * WebKit (Safari) detection — the same probe floating-ui uses. Only Safari
24
+ * supports the `-webkit-` prefixed backdrop-filter.
25
+ */
26
+ function isWebKit() {
27
+ if (_isWebKit === undefined) {
28
+ _isWebKit =
29
+ typeof CSS !== "undefined" &&
30
+ typeof CSS.supports === "function" &&
31
+ CSS.supports("-webkit-backdrop-filter", "none");
32
+ }
33
+ return _isWebKit;
34
+ }
35
+ function notNone(value) {
36
+ return !!value && value !== "none";
37
+ }
38
+ // Elements promoted to the TOP LAYER (modal dialogs via `showModal()`, open
39
+ // `[popover]`s, fullscreen elements) escape every ancestor containing block by
40
+ // design — their fixed descendants resolve against the viewport again. Each
41
+ // selector is probed separately: an engine that doesn't know one would
42
+ // otherwise reject the whole list.
43
+ const TOP_LAYER_SELECTORS = [":modal", ":popover-open", ":fullscreen"];
44
+ function isTopLayer(el) {
45
+ return TOP_LAYER_SELECTORS.some((s) => {
46
+ try {
47
+ return el.matches(s);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ });
53
+ }
54
+ /**
55
+ * Does this element establish a containing block for `position: fixed`
56
+ * descendants?
57
+ *
58
+ * Mirrors floating-ui's battle-tested `isContainingBlock`, with the same two
59
+ * deliberate omissions relative to a naive reading of MDN:
60
+ *
61
+ * - `filter`/`backdrop-filter` are ignored on WebKit: Safari historically does
62
+ * NOT form a fixed CB from them (plain `filter` was only fixed in Safari 26).
63
+ * Misdetecting a CB the browser doesn't honor would break correct layouts;
64
+ * missing one merely preserves the pre-CB-aware behavior.
65
+ * - `container-type` is NOT checked: the CSSWG removed layout containment from
66
+ * it (2024, csswg-drafts#10544) and Chrome 129+/Firefox/Safari all shipped
67
+ * the change, so container queries no longer re-parent fixed descendants.
68
+ */
69
+ export function isFixedContainingBlock(el) {
70
+ const s = getComputedStyle(el);
71
+ return (notNone(s.transform) ||
72
+ notNone(s.translate) ||
73
+ notNone(s.rotate) ||
74
+ notNone(s.scale) ||
75
+ notNone(s.perspective) ||
76
+ (!isWebKit() && (notNone(s.filter) || notNone(s.backdropFilter))) ||
77
+ s.contentVisibility === "auto" ||
78
+ WILL_CHANGE_RE.test(s.willChange) ||
79
+ CONTAIN_RE.test(s.contain));
80
+ }
81
+ /**
82
+ * The nearest ancestor of `el` that establishes a containing block for
83
+ * `position: fixed` descendants, or `null` when fixed descendants resolve
84
+ * against the viewport. The walk stops (returning `null`) at top-layer
85
+ * elements — a modal `<dialog>`, an open `[popover]`, a fullscreen element —
86
+ * since the top layer escapes every ancestor containing block by design
87
+ * (unless such an element is itself CB-forming, e.g. a transformed dialog).
88
+ */
89
+ export function fixedContainingBlockAncestor(el) {
90
+ for (let n = el.parentElement; n; n = n.parentElement) {
91
+ if (isFixedContainingBlock(n))
92
+ return n;
93
+ if (isTopLayer(n))
94
+ return null;
95
+ }
96
+ return null;
97
+ }
98
+ /**
99
+ * The rect that `position: fixed` descendants of `el` actually resolve
100
+ * against, in viewport (visual) coordinates.
101
+ *
102
+ * Walks up from `el`'s parent looking for the nearest containing-block-forming
103
+ * ancestor (see {@link fixedContainingBlockAncestor}) and returns its padding
104
+ * box — per CSS, the CB is the padding box, not the border box. When no such
105
+ * ancestor exists (the overwhelmingly common case) it returns the viewport
106
+ * rect based on `window.innerWidth/innerHeight`, byte-identical to what the
107
+ * pre-CB-aware code measured.
108
+ *
109
+ * Known limitation: for a ROTATED CB ancestor the returned rect is the
110
+ * axis-aligned bounding box of the rotated element — consumers comparing
111
+ * rects (overflow checks, clamps) get an approximation there. Scaled
112
+ * ancestors are handled (border widths are converted to visual px).
113
+ */
114
+ export function fixedContainingBlockRect(el) {
115
+ const n = fixedContainingBlockAncestor(el);
116
+ if (n) {
117
+ const s = getComputedStyle(n);
118
+ const r = n.getBoundingClientRect();
119
+ // computed border widths are layout px; the rect is visual (post-
120
+ // transform) px — convert via the ancestor's accumulated scale so the
121
+ // inset is correct inside scaled wrappers too
122
+ const sx = n.offsetWidth ? r.width / n.offsetWidth : 1;
123
+ const sy = n.offsetHeight ? r.height / n.offsetHeight : 1;
124
+ const bl = (parseFloat(s.borderLeftWidth) || 0) * sx;
125
+ const bt = (parseFloat(s.borderTopWidth) || 0) * sy;
126
+ const br = (parseFloat(s.borderRightWidth) || 0) * sx;
127
+ const bb = (parseFloat(s.borderBottomWidth) || 0) * sy;
128
+ return new DOMRectReadOnly(r.left + bl, r.top + bt, r.width - bl - br, r.height - bt - bb);
129
+ }
130
+ return new DOMRectReadOnly(0, 0, window.innerWidth, window.innerHeight);
131
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared bits for overlay actions (`popover`, `spotlight`, `dimBehind`) that
3
+ * can portal their DOM into a consumer-provided container instead of
4
+ * `document.body`.
5
+ */
6
+ /**
7
+ * The `container` option shape: a concrete element, or a lazy factory (useful
8
+ * when the element does not exist yet at action-setup time). A factory
9
+ * returning `null` means "use the default".
10
+ */
11
+ export type OverlayContainerOption = HTMLElement | (() => HTMLElement | null);
12
+ /**
13
+ * Resolve a `container` option to an element, or `null` when unset (callers
14
+ * then fall back to their default — typically `document.body`).
15
+ */
16
+ export declare function resolveContainerOption(option: OverlayContainerOption | undefined | null): HTMLElement | null;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared bits for overlay actions (`popover`, `spotlight`, `dimBehind`) that
3
+ * can portal their DOM into a consumer-provided container instead of
4
+ * `document.body`.
5
+ */
6
+ /**
7
+ * Resolve a `container` option to an element, or `null` when unset (callers
8
+ * then fall back to their default — typically `document.body`).
9
+ */
10
+ export function resolveContainerOption(option) {
11
+ return (typeof option === "function" ? option() : option) ?? null;
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.150.0",
3
+ "version": "3.152.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -136,15 +136,15 @@
136
136
  "@codemirror/view": "^6.43.8",
137
137
  "@eslint/js": "^9.39.5",
138
138
  "@marianmeres/random-human-readable": "^1.10.2",
139
- "@milkdown/core": "^7.22.0",
140
- "@milkdown/ctx": "^7.22.0",
141
- "@milkdown/plugin-history": "^7.22.0",
142
- "@milkdown/plugin-listener": "^7.22.0",
143
- "@milkdown/preset-commonmark": "^7.22.0",
144
- "@milkdown/preset-gfm": "^7.22.0",
145
- "@milkdown/prose": "^7.22.0",
146
- "@milkdown/transformer": "^7.22.0",
147
- "@milkdown/utils": "^7.22.0",
139
+ "@milkdown/core": "^7.22.1",
140
+ "@milkdown/ctx": "^7.22.1",
141
+ "@milkdown/plugin-history": "^7.22.1",
142
+ "@milkdown/plugin-listener": "^7.22.1",
143
+ "@milkdown/preset-commonmark": "^7.22.1",
144
+ "@milkdown/preset-gfm": "^7.22.1",
145
+ "@milkdown/prose": "^7.22.1",
146
+ "@milkdown/transformer": "^7.22.1",
147
+ "@milkdown/utils": "^7.22.1",
148
148
  "@sveltejs/adapter-auto": "^4.0.0",
149
149
  "@sveltejs/kit": "^2.70.2",
150
150
  "@sveltejs/package": "^2.5.8",
@@ -162,8 +162,8 @@
162
162
  "prettier": "^3.9.6",
163
163
  "prettier-plugin-svelte": "^3.5.2",
164
164
  "publint": "^0.3.23",
165
- "svelte": "^5.56.8",
166
- "svelte-check": "^4.7.5",
165
+ "svelte": "^5.56.9",
166
+ "svelte-check": "^4.7.6",
167
167
  "tailwindcss": "^4.3.3",
168
168
  "tsx": "^4.23.12",
169
169
  "typescript": "^5.9.3",