@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
package/AGENTS.md
CHANGED
|
@@ -28,6 +28,7 @@ src/lib/
|
|
|
28
28
|
├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
|
|
29
29
|
├── utils/ # 44 utility modules
|
|
30
30
|
├── icons/ # Icon re-exports from @marianmeres/icons-fns
|
|
31
|
+
├── css/ # CSS-only presets (ratio-locked frame / letterbox)
|
|
31
32
|
├── index.css # Centralized CSS imports
|
|
32
33
|
└── index.ts # Main exports
|
|
33
34
|
```
|
|
@@ -130,6 +131,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
|
|
|
130
131
|
|
|
131
132
|
- [Components](./docs/domains/components.md) — 63 component directories, Props pattern, snippets
|
|
132
133
|
- [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
|
|
134
|
+
- [CSS presets](./docs/domains/css-presets.md) — ratio-locked frame (letterbox), safe-area, scrollbar
|
|
133
135
|
- [Actions](./docs/domains/actions.md) — 15 Svelte directives
|
|
134
136
|
- [Attachments](./docs/domains/attachments.md) — `{@attach}` DOM helpers (preferred for new ones)
|
|
135
137
|
- [Utils](./docs/domains/utils.md) — 44 utility modules
|
|
@@ -138,6 +140,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
|
|
|
138
140
|
|
|
139
141
|
- [Design Tokens Manual](./docs/DESIGN_TOKENS_MANUAL.md) — Token philosophy
|
|
140
142
|
- [Tailwind v4 Variables](./docs/TAILWIND_V4_CSS_VARIABLES.md) — CSS variable reference
|
|
143
|
+
- [Ratio-Locked Frame](./docs/RATIO_LOCKED_FRAME.md) — Letterbox recipes + the measured gotcha list
|
|
141
144
|
|
|
142
145
|
---
|
|
143
146
|
|
package/API.md
CHANGED
|
@@ -2193,6 +2193,7 @@ Each component defines customization tokens. Override globally in `:root {}` or
|
|
|
2193
2193
|
| Cart | `--stuic-cart-*` | `gap`, `item-padding`, `item-radius`, `item-border-color`, `item-bg`, `thumbnail-size`, `quantity-border-color`, `remove-color`, `summary-border-color`, `compact-max-height`, `transition` |
|
|
2194
2194
|
| LoginForm | `--stuic-login-form-*` | `gap`, `gap-row`, `forgot-margin-y`, `forgot-margin-x`, `social-margin-top`, `social-gap`, `social-divider-color`, `social-divider-font-size`, `social-divider-margin-bottom` |
|
|
2195
2195
|
| Checkout | `--stuic-checkout-*` | `input-border`, `input-bg`, `input-focus-ring`, `input-radius`, `card-border`, `card-bg`, `card-radius`, `step-gap`, `progress-*`, `summary-*`, `guest-*`, `login-*`, `address-*`, `delivery-*`, `review-*`, `confirmation-*` |
|
|
2196
|
+
| Frame (CSS preset) | `--stuic-frame-*` | `aspect-ratio`, `width`, `height` — the ratio-locked frame / letterbox preset (see [CSS presets](docs/domains/css-presets.md)) |
|
|
2196
2197
|
|
|
2197
2198
|
### CSS Variable Naming Convention
|
|
2198
2199
|
|
package/README.md
CHANGED
|
@@ -248,6 +248,78 @@ All three are no-ops in a browser tab and need no prop.
|
|
|
248
248
|
|
|
249
249
|
**Not covered:** remaining fixed/edge-anchored components (e.g. `Float`, or a bare `ModalDialog` used directly) do not auto-handle insets — apply a `stuic-safe-area-*` class or the variables to their content as needed.
|
|
250
250
|
|
|
251
|
+
## Ratio-locked frame (letterbox)
|
|
252
|
+
|
|
253
|
+
Lock a box to an aspect ratio, size it to whichever axis binds first, centre it, and let the leftover space become letterboxing — a phone-proportioned column on a desktop, a portrait game board, a 16:9 scene nested under a header. The whole idea is one line:
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
width = min(available-width, available-height × ratio) /* aspect-ratio supplies the height */
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
That formula (plus two guards nobody remembers) is all stuic ships, because it is the only part Tailwind cannot express. The letterbox parent itself is plain utilities: `grid`, `overflow-hidden`, `fixed inset-0`, `bg-*`, and — where you need them — `contain-layout contain-paint`, `overflow-y-auto`, `@container-size`.
|
|
260
|
+
|
|
261
|
+
**Classes:**
|
|
262
|
+
|
|
263
|
+
- `.stuic-frame` — the ratio-locked box. Sized against the viewport (`100vw` / `100dvh`), centred with `margin: auto`, `overflow: hidden`.
|
|
264
|
+
- `.stuic-frame-cq` — the same box sized in container-query units, for a frame nested inside a layout rather than anchored to the window. Combine with `.stuic-frame`. **Requires** an ancestor with `container-type: size` (Tailwind `@container-size`); `inline-size` is not enough — `cqh` then falls through to the next container, or silently to the viewport, and you get a ratio-correct but wrongly-scaled frame that tracks the window as you resize.
|
|
265
|
+
- `.stuic-frame-col` — re-align a viewport-space element (a top-layer `<dialog>`, or an overlay portalled to `<body>`) onto the frame's column.
|
|
266
|
+
|
|
267
|
+
**Tokens** — all three are your inputs. stuic declares none of them anywhere; the defaults below live only as `var()` fallbacks at the usage sites, so a scoped override on the frame element or on any ancestor works:
|
|
268
|
+
|
|
269
|
+
| Token | Default | Meaning |
|
|
270
|
+
| ---------------------------- | ----------------------------------- | ---------------------------------------------------------------- |
|
|
271
|
+
| `--stuic-frame-aspect-ratio` | `1` | width ÷ height — anything `aspect-ratio:` accepts |
|
|
272
|
+
| `--stuic-frame-width` | `min(100vw, 100dvh × aspect-ratio)` | wholesale width override (bail-out value: `100vw`) |
|
|
273
|
+
| `--stuic-frame-height` | `auto` (⇒ ratio-locked) | wholesale height override (`100dvh` ⇒ fill height, derive width) |
|
|
274
|
+
|
|
275
|
+
**Viewport letterbox** — full screen, bars on exactly one axis:
|
|
276
|
+
|
|
277
|
+
```svelte
|
|
278
|
+
<div class="fixed inset-0 grid overflow-hidden bg-neutral-800">
|
|
279
|
+
<div
|
|
280
|
+
class="stuic-frame bg-[var(--stuic-color-surface)]"
|
|
281
|
+
style="--stuic-frame-aspect-ratio: 0.5"
|
|
282
|
+
>
|
|
283
|
+
…
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Nested frame** — sized against its parent box instead of the window:
|
|
289
|
+
|
|
290
|
+
```svelte
|
|
291
|
+
<div class="flex h-dvh flex-col">
|
|
292
|
+
<header>…</header>
|
|
293
|
+
<div class="@container-size grid min-h-0 grow overflow-hidden bg-neutral-800">
|
|
294
|
+
<div
|
|
295
|
+
class="stuic-frame stuic-frame-cq bg-[var(--stuic-color-surface)]"
|
|
296
|
+
style="--stuic-frame-aspect-ratio: calc(16 / 9)"
|
|
297
|
+
>
|
|
298
|
+
…
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
> ⚠️ **Don't reach for `max-width: 100%; max-height: 100%; aspect-ratio: R`** — the formulation everyone tries first. `max-*` never _grows_ a box, so in a centred grid/flex parent an empty frame measures **0×0**, and one with content shrink-wraps that content and overflows the parent. The ratio usually survives; the size is what's wrong.
|
|
305
|
+
|
|
306
|
+
> ⚠️ **Never make the frame both the fixed containing block and the scroll container.** `contain-layout contain-paint` together with `overflow-y-auto` on the same element demotes every `position: fixed` descendant to `absolute` against the scroll origin: at `scrollTop: 600` a `Drawer` and its backdrop render at `y = -600`, so the user taps and nothing appears — and `BodyScroll` cannot rescue it, because `document.body` has nothing to scroll in that layout. Scroll an inner element instead, or keep overlays in viewport space and reconcile them with `.stuic-frame-col`.
|
|
307
|
+
|
|
308
|
+
> ⚠️ **`--stuic-frame-height: 100dvh` is not ratio-locking.** An explicit height beats `aspect-ratio` unconditionally (which is exactly why the bail-out below needs no `!important`), so whenever `100vw < 100dvh × ratio` the frame degenerates to the raw viewport with zero bars on both axes. It looks perfect on a wide desktop and is wrong on the handset you were aiming at. Set it only inside a deliberate bail-out query, and gate that query on more than width:
|
|
309
|
+
|
|
310
|
+
```css
|
|
311
|
+
@media (max-width: 40rem) and (max-aspect-ratio: 3 / 5) {
|
|
312
|
+
:root {
|
|
313
|
+
--stuic-frame-width: 100vw;
|
|
314
|
+
--stuic-frame-height: 100dvh;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
See [CSS presets](docs/domains/css-presets.md) for the classes, the token contract and the decision tree, and [Ratio-Locked Frame](docs/RATIO_LOCKED_FRAME.md) for the full recipe set (including the unit-free `max-*` variant that _does_ work, given a positioned parent) and the measured gotcha list.
|
|
320
|
+
|
|
321
|
+
> **This preset does not make stuic's own overlays frame-aware.** `Backdrop`, `Modal`, `Drawer` and `Notifications` measure in viewport units — and top-layer geometry (`showModal()`, `popover`) ignores the frame outright, even for a DOM descendant of it. The portalled actions (`popover`, `spotlight`, `dimBehind`) additionally default to `document.body`, so they leave the frame entirely. Either way they fill the window, not the frame. Pass their `container` option where one exists, apply `.stuic-frame-col`, or tweak the affected call sites.
|
|
322
|
+
|
|
251
323
|
## TypeScript
|
|
252
324
|
|
|
253
325
|
All components export their Props types:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { dimBehind } from "./dim-behind.svelte.js";
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
aOpen = false,
|
|
6
|
+
bOpen = false,
|
|
7
|
+
useContainers = false,
|
|
8
|
+
sameContainer = false,
|
|
9
|
+
}: {
|
|
10
|
+
aOpen?: boolean;
|
|
11
|
+
bOpen?: boolean;
|
|
12
|
+
useContainers?: boolean;
|
|
13
|
+
sameContainer?: boolean;
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
let containerA = $state<HTMLDivElement>();
|
|
17
|
+
let containerB = $state<HTMLDivElement>();
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<!-- Two independent shells, each a fixed containing block + stacking context
|
|
21
|
+
(the framed-app case the `container` option exists for). -->
|
|
22
|
+
<div
|
|
23
|
+
bind:this={containerA}
|
|
24
|
+
data-testid="container-a"
|
|
25
|
+
style="contain: layout paint; width: 200px; height: 120px;"
|
|
26
|
+
>
|
|
27
|
+
<div
|
|
28
|
+
data-testid="target-a"
|
|
29
|
+
use:dimBehind={() => ({
|
|
30
|
+
open: aOpen,
|
|
31
|
+
container: useContainers ? () => containerA ?? null : undefined,
|
|
32
|
+
})}
|
|
33
|
+
>
|
|
34
|
+
A
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
bind:this={containerB}
|
|
40
|
+
data-testid="container-b"
|
|
41
|
+
style="contain: layout paint; width: 200px; height: 120px;"
|
|
42
|
+
>
|
|
43
|
+
<div
|
|
44
|
+
data-testid="target-b"
|
|
45
|
+
use:dimBehind={() => ({
|
|
46
|
+
open: bOpen,
|
|
47
|
+
container: useContainers
|
|
48
|
+
? () => (sameContainer ? (containerA ?? null) : (containerB ?? null))
|
|
49
|
+
: undefined,
|
|
50
|
+
})}
|
|
51
|
+
>
|
|
52
|
+
B
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
aOpen?: boolean;
|
|
3
|
+
bOpen?: boolean;
|
|
4
|
+
useContainers?: boolean;
|
|
5
|
+
sameContainer?: boolean;
|
|
6
|
+
};
|
|
7
|
+
declare const DimBehind: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type DimBehind = ReturnType<typeof DimBehind>;
|
|
9
|
+
export default DimBehind;
|
|
@@ -48,6 +48,16 @@ export interface DimBehindOptions {
|
|
|
48
48
|
onHide?: () => void;
|
|
49
49
|
/** Debug mode */
|
|
50
50
|
debug?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Where to append the backdrop. Defaults to the current behavior:
|
|
53
|
+
* `document.body`. Pass a bounded shell (framed app, embedded widget,
|
|
54
|
+
* dashboard pane) to keep the backdrop inside it — required when that shell
|
|
55
|
+
* is a stacking context, since the elevated target's z-index can then only
|
|
56
|
+
* compete with a backdrop living in the same context. Each distinct
|
|
57
|
+
* container gets its own ref-counted backdrop. A function returning `null`
|
|
58
|
+
* falls back to the default.
|
|
59
|
+
*/
|
|
60
|
+
container?: HTMLElement | (() => HTMLElement | null);
|
|
51
61
|
}
|
|
52
62
|
/**
|
|
53
63
|
* A Svelte action that dims everything behind a target element.
|
|
@@ -1,46 +1,60 @@
|
|
|
1
1
|
import { BodyScroll } from "../../utils/body-scroll-locker.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { resolveContainerOption } from "../../utils/overlay-container.js";
|
|
3
|
+
// --- Shared Backdrop Manager (one ref-counted backdrop per container) ---
|
|
4
|
+
const backdrops = new Map();
|
|
5
5
|
const TRANSITION_SAFETY_MARGIN = 50;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Actual transition duration of the backdrop element in ms — read from its
|
|
8
|
+
* computed style, which resolves the whole var chain
|
|
9
|
+
* (`--stuic-dim-behind-transition-duration`, `--stuic-transition`, …), unlike
|
|
10
|
+
* reading a single custom property off the root. Computed values serialize in
|
|
11
|
+
* seconds ("0.15s").
|
|
12
|
+
*/
|
|
13
|
+
function getTransitionDurationMs(el) {
|
|
14
|
+
const raw = getComputedStyle(el).transitionDuration;
|
|
15
|
+
const v = parseFloat(raw);
|
|
16
|
+
if (!Number.isFinite(v) || v < 0)
|
|
17
|
+
return 150;
|
|
18
|
+
return raw.trim().endsWith("ms") ? v : v * 1000;
|
|
11
19
|
}
|
|
12
20
|
function getElementZIndex() {
|
|
13
21
|
return (getComputedStyle(document.documentElement)
|
|
14
22
|
.getPropertyValue("--stuic-dim-behind-element-z-index")
|
|
15
23
|
.trim() || "41");
|
|
16
24
|
}
|
|
17
|
-
function showBackdrop(classBackdrop) {
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
function showBackdrop(container, classBackdrop) {
|
|
26
|
+
let entry = backdrops.get(container);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
const el = document.createElement("div");
|
|
29
|
+
el.classList.add("stuic-dim-behind-backdrop");
|
|
22
30
|
if (classBackdrop) {
|
|
23
|
-
|
|
31
|
+
el.classList.add(...classBackdrop.split(/\s+/).filter(Boolean));
|
|
24
32
|
}
|
|
25
|
-
|
|
33
|
+
container.appendChild(el);
|
|
26
34
|
// Force reflow for transition
|
|
27
|
-
void
|
|
28
|
-
|
|
35
|
+
void el.offsetHeight;
|
|
36
|
+
el.classList.add("dim-visible");
|
|
37
|
+
entry = { el, count: 0 };
|
|
38
|
+
backdrops.set(container, entry);
|
|
29
39
|
}
|
|
40
|
+
entry.count++;
|
|
41
|
+
return entry.el;
|
|
30
42
|
}
|
|
31
|
-
function hideBackdrop() {
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
43
|
+
function hideBackdrop(container) {
|
|
44
|
+
const entry = backdrops.get(container);
|
|
45
|
+
if (!entry)
|
|
46
|
+
return;
|
|
47
|
+
entry.count = Math.max(0, entry.count - 1);
|
|
48
|
+
if (entry.count === 0) {
|
|
49
|
+
// Drop the registry entry immediately so a show() during the fade-out
|
|
50
|
+
// creates a fresh backdrop instead of resurrecting the dying one.
|
|
51
|
+
backdrops.delete(container);
|
|
52
|
+
const el = entry.el;
|
|
35
53
|
el.classList.remove("dim-visible");
|
|
36
|
-
const cleanup = () =>
|
|
37
|
-
el.remove();
|
|
38
|
-
if (backdropEl === el)
|
|
39
|
-
backdropEl = null;
|
|
40
|
-
};
|
|
54
|
+
const cleanup = () => el.remove();
|
|
41
55
|
el.addEventListener("transitionend", cleanup, { once: true });
|
|
42
56
|
// Safety fallback in case transitionend doesn't fire
|
|
43
|
-
setTimeout(cleanup,
|
|
57
|
+
setTimeout(cleanup, getTransitionDurationMs(el) + TRANSITION_SAFETY_MARGIN);
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
// --- Registry ---
|
|
@@ -115,6 +129,12 @@ export function dimBehind(node, fn) {
|
|
|
115
129
|
let savedZIndex = "";
|
|
116
130
|
let currentOptions = {};
|
|
117
131
|
let do_debug = false;
|
|
132
|
+
// The container/backdrop/locks this instance acquired — captured at show()
|
|
133
|
+
// time so hide() releases exactly what was acquired even if options changed
|
|
134
|
+
// while visible.
|
|
135
|
+
let myContainer = null;
|
|
136
|
+
let myBackdropEl = null;
|
|
137
|
+
let myScrollLocked = false;
|
|
118
138
|
const debug = (...args) => {
|
|
119
139
|
if (do_debug)
|
|
120
140
|
console.debug("[dimBehind]", ...args);
|
|
@@ -128,7 +148,7 @@ export function dimBehind(node, fn) {
|
|
|
128
148
|
}
|
|
129
149
|
}
|
|
130
150
|
function onBackdropClick(e) {
|
|
131
|
-
if (e.target ===
|
|
151
|
+
if (e.target === myBackdropEl) {
|
|
132
152
|
hide();
|
|
133
153
|
}
|
|
134
154
|
}
|
|
@@ -151,18 +171,20 @@ export function dimBehind(node, fn) {
|
|
|
151
171
|
: getElementZIndex();
|
|
152
172
|
node.style.position = "relative";
|
|
153
173
|
node.style.zIndex = zIndex;
|
|
154
|
-
// Show
|
|
155
|
-
|
|
174
|
+
// Show the (per-container, ref-counted) shared backdrop
|
|
175
|
+
myContainer = resolveContainerOption(currentOptions.container) ?? document.body;
|
|
176
|
+
myBackdropEl = showBackdrop(myContainer, currentOptions.classBackdrop);
|
|
156
177
|
// Optional scroll lock
|
|
157
178
|
if (currentOptions.scrollLock) {
|
|
158
179
|
BodyScroll.lock();
|
|
180
|
+
myScrollLocked = true;
|
|
159
181
|
}
|
|
160
182
|
// Event listeners
|
|
161
183
|
if (currentOptions.closeOnEscape !== false) {
|
|
162
184
|
document.addEventListener("keydown", onEscape);
|
|
163
185
|
}
|
|
164
|
-
if (currentOptions.closeOnBackdropClick !== false
|
|
165
|
-
|
|
186
|
+
if (currentOptions.closeOnBackdropClick !== false) {
|
|
187
|
+
myBackdropEl.addEventListener("click", onBackdropClick);
|
|
166
188
|
}
|
|
167
189
|
currentOptions.onShow?.();
|
|
168
190
|
}
|
|
@@ -179,15 +201,18 @@ export function dimBehind(node, fn) {
|
|
|
179
201
|
node.style.zIndex = savedZIndex;
|
|
180
202
|
// Remove event listeners
|
|
181
203
|
document.removeEventListener("keydown", onEscape);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (currentOptions.scrollLock) {
|
|
204
|
+
myBackdropEl?.removeEventListener("click", onBackdropClick);
|
|
205
|
+
// Release the scroll lock iff THIS instance acquired one (the option may
|
|
206
|
+
// have changed while visible)
|
|
207
|
+
if (myScrollLocked) {
|
|
187
208
|
BodyScroll.unlock();
|
|
209
|
+
myScrollLocked = false;
|
|
188
210
|
}
|
|
189
|
-
//
|
|
190
|
-
|
|
211
|
+
// Release the shared backdrop
|
|
212
|
+
if (myContainer)
|
|
213
|
+
hideBackdrop(myContainer);
|
|
214
|
+
myContainer = null;
|
|
215
|
+
myBackdropEl = null;
|
|
191
216
|
currentOptions.onHide?.();
|
|
192
217
|
}
|
|
193
218
|
// Reactive params effect
|
|
@@ -205,6 +230,7 @@ export function dimBehind(node, fn) {
|
|
|
205
230
|
onShow: opts.onShow,
|
|
206
231
|
onHide: opts.onHide,
|
|
207
232
|
debug: opts.debug,
|
|
233
|
+
container: opts.container,
|
|
208
234
|
};
|
|
209
235
|
do_debug = !!opts.debug;
|
|
210
236
|
// Register in global registry if id provided
|
|
@@ -229,10 +255,15 @@ export function dimBehind(node, fn) {
|
|
|
229
255
|
if (isVisible) {
|
|
230
256
|
node.style.position = savedPosition;
|
|
231
257
|
node.style.zIndex = savedZIndex;
|
|
232
|
-
if (
|
|
258
|
+
if (myScrollLocked) {
|
|
233
259
|
BodyScroll.unlock();
|
|
260
|
+
myScrollLocked = false;
|
|
234
261
|
}
|
|
235
|
-
|
|
262
|
+
myBackdropEl?.removeEventListener("click", onBackdropClick);
|
|
263
|
+
if (myContainer)
|
|
264
|
+
hideBackdrop(myContainer);
|
|
265
|
+
myContainer = null;
|
|
266
|
+
myBackdropEl = null;
|
|
236
267
|
document.removeEventListener("keydown", onEscape);
|
|
237
268
|
}
|
|
238
269
|
// Unregister from registry
|
|
@@ -4,23 +4,24 @@ A Svelte action that displays an anchored popover using CSS Anchor Positioning,
|
|
|
4
4
|
|
|
5
5
|
## Options
|
|
6
6
|
|
|
7
|
-
| Option | Type
|
|
8
|
-
| --------------------- |
|
|
9
|
-
| `enabled` | `boolean`
|
|
10
|
-
| `content` | `THC \| null`
|
|
11
|
-
| `position` | `PopoverPosition`
|
|
12
|
-
| `trigger` | `"click" \| "hover"`
|
|
13
|
-
| `showDelay` | `number`
|
|
14
|
-
| `hideDelay` | `number`
|
|
15
|
-
| `class` | `string`
|
|
16
|
-
| `offset` | `string`
|
|
17
|
-
| `closeOthers` | `boolean`
|
|
18
|
-
| `closeOnClickOutside` | `boolean`
|
|
19
|
-
| `closeOnEscape` | `boolean`
|
|
20
|
-
| `showBackdrop` | `boolean`
|
|
21
|
-
| `forceFallback` | `boolean`
|
|
22
|
-
| `onShow` | `() => void`
|
|
23
|
-
| `onHide` | `() => void`
|
|
7
|
+
| Option | Type | Default | Description |
|
|
8
|
+
| --------------------- | -------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| `enabled` | `boolean` | `true` | Enable/disable popover |
|
|
10
|
+
| `content` | `THC \| null` | - | Popover content (string, HTML, component, or snippet) |
|
|
11
|
+
| `position` | `PopoverPosition` | `"bottom"` | Placement relative to anchor |
|
|
12
|
+
| `trigger` | `"click" \| "hover"` | `"click"` | Trigger mode |
|
|
13
|
+
| `showDelay` | `number` | `100` | Delay before showing (ms) |
|
|
14
|
+
| `hideDelay` | `number` | `200` | Delay before hiding (ms) |
|
|
15
|
+
| `class` | `string` | - | Custom CSS for popover container |
|
|
16
|
+
| `offset` | `string` | `"0.25rem"` | Margin from anchor (CSS value) |
|
|
17
|
+
| `closeOthers` | `boolean` | `false` | Close other open popovers |
|
|
18
|
+
| `closeOnClickOutside` | `boolean` | `true` | Close on outside click (click trigger) |
|
|
19
|
+
| `closeOnEscape` | `boolean` | `true` | Close on Escape key |
|
|
20
|
+
| `showBackdrop` | `boolean` | `true` | Show backdrop in fallback mode |
|
|
21
|
+
| `forceFallback` | `boolean` | `false` | Force centered modal mode |
|
|
22
|
+
| `onShow` | `() => void` | - | Callback when popover opens |
|
|
23
|
+
| `onHide` | `() => void` | - | Callback when popover closes |
|
|
24
|
+
| `container` | `HTMLElement \| (() => HTMLElement \| null)` | closest open `<dialog>`, else `document.body` | Where to append the popover — pass a bounded shell to keep the overlay inside its stacking context |
|
|
24
25
|
|
|
25
26
|
## Positions
|
|
26
27
|
|
|
@@ -139,6 +140,25 @@ bottom-left bottom bottom-right
|
|
|
139
140
|
</button>
|
|
140
141
|
```
|
|
141
142
|
|
|
143
|
+
### Custom Container (Bounded Shells)
|
|
144
|
+
|
|
145
|
+
When the app renders inside a bounded shell that is a stacking context and/or a
|
|
146
|
+
fixed containing block (e.g. a centered frame with `contain: layout paint`, an
|
|
147
|
+
embedded widget, a transformed wrapper), a body-level popover can only paint
|
|
148
|
+
entirely above or below that shell. Pass `container` to keep the overlay inside
|
|
149
|
+
it:
|
|
150
|
+
|
|
151
|
+
```svelte
|
|
152
|
+
<button
|
|
153
|
+
use:popover={() => ({
|
|
154
|
+
content: "Stays inside the frame",
|
|
155
|
+
container: () => document.querySelector(".app-frame"),
|
|
156
|
+
})}
|
|
157
|
+
>
|
|
158
|
+
Framed
|
|
159
|
+
</button>
|
|
160
|
+
```
|
|
161
|
+
|
|
142
162
|
## Helper Function
|
|
143
163
|
|
|
144
164
|
```ts
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { popover } from "./popover.svelte.js";
|
|
3
|
+
|
|
4
|
+
let { open = false, useContainer = false }: { open?: boolean; useContainer?: boolean } =
|
|
5
|
+
$props();
|
|
6
|
+
|
|
7
|
+
let shell = $state<HTMLDivElement>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<!-- A bounded shell that is a fixed containing block + stacking context. -->
|
|
11
|
+
<div
|
|
12
|
+
bind:this={shell}
|
|
13
|
+
data-testid="shell"
|
|
14
|
+
style="contain: layout paint; width: 300px; height: 200px;"
|
|
15
|
+
></div>
|
|
16
|
+
|
|
17
|
+
<button
|
|
18
|
+
data-testid="trigger"
|
|
19
|
+
use:popover={() => ({
|
|
20
|
+
content: "Hello",
|
|
21
|
+
open,
|
|
22
|
+
container: useContainer ? () => shell ?? null : undefined,
|
|
23
|
+
})}
|
|
24
|
+
>
|
|
25
|
+
trigger
|
|
26
|
+
</button>
|
|
@@ -101,6 +101,16 @@ export interface PopoverOptions {
|
|
|
101
101
|
open?: boolean;
|
|
102
102
|
/** Unique ID for registry-based programmatic control (use with openPopover/closePopover/togglePopover) */
|
|
103
103
|
id?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Where to append the popover (and its fallback backdrop/wrapper). Defaults
|
|
106
|
+
* to the current behavior: the closest open `<dialog>` ancestor, else
|
|
107
|
+
* `document.body`. Pass a bounded shell (framed app, embedded widget,
|
|
108
|
+
* dashboard pane) to keep the overlay inside it — required when that shell
|
|
109
|
+
* is a stacking context, since a body-level overlay can then only paint
|
|
110
|
+
* entirely above or entirely below it. A function returning `null` falls
|
|
111
|
+
* back to the default.
|
|
112
|
+
*/
|
|
113
|
+
container?: HTMLElement | (() => HTMLElement | null);
|
|
104
114
|
}
|
|
105
115
|
/**
|
|
106
116
|
* A Svelte action that displays a popover anchored to an element using CSS Anchor Positioning.
|
|
@@ -2,6 +2,8 @@ import { mount, unmount } from "svelte";
|
|
|
2
2
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
3
3
|
import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
|
|
4
4
|
import { clampIntoViewport } from "../../utils/anchor-position.js";
|
|
5
|
+
import { fixedContainingBlockRect } from "../../utils/containing-block.js";
|
|
6
|
+
import { resolveContainerOption } from "../../utils/overlay-container.js";
|
|
5
7
|
import { iconX } from "../../icons/index.js";
|
|
6
8
|
import { BodyScroll } from "../../utils/body-scroll-locker.js";
|
|
7
9
|
import PopoverContent from "./PopoverContent.svelte";
|
|
@@ -342,14 +344,19 @@ export function popover(anchorEl, fn) {
|
|
|
342
344
|
anchorEl.setAttribute("aria-expanded", "true");
|
|
343
345
|
const offsetValue = currentOptions.offset || "0.25rem";
|
|
344
346
|
const useAnchorPositioning = isSupported && !currentOptions.forceFallback;
|
|
345
|
-
// Get appropriate container (dialog if inside one,
|
|
346
|
-
// This ensures popover renders in same stacking
|
|
347
|
-
|
|
347
|
+
// Get appropriate container (explicit option, else dialog if inside one,
|
|
348
|
+
// otherwise body). This ensures popover renders in the same stacking
|
|
349
|
+
// context as modal dialogs / the consumer's bounded shell.
|
|
350
|
+
const container = resolveContainerOption(currentOptions.container) ?? getPopoverContainer(anchorEl);
|
|
348
351
|
if (useAnchorPositioning) {
|
|
349
352
|
// CSS Anchor Positioning mode
|
|
350
353
|
popoverEl = document.createElement("div");
|
|
351
354
|
popoverEl.setAttribute("id", id);
|
|
352
355
|
popoverEl.setAttribute("role", "dialog");
|
|
356
|
+
// NOTE: keep `vw`/`vh` here — this is the ANCHORED branch, where the
|
|
357
|
+
// element's containing block is the `position-area` region (a slice of
|
|
358
|
+
// the CB, often much smaller than it), so `%` would shrink the popover.
|
|
359
|
+
// Overflow is handled by @position-try + the CB-aware runtime check.
|
|
353
360
|
popoverEl.style.cssText = `
|
|
354
361
|
position: fixed;
|
|
355
362
|
position-anchor: ${anchorName};
|
|
@@ -390,10 +397,13 @@ export function popover(anchorEl, fn) {
|
|
|
390
397
|
popoverEl = document.createElement("div");
|
|
391
398
|
popoverEl.setAttribute("id", id);
|
|
392
399
|
popoverEl.setAttribute("role", "dialog");
|
|
400
|
+
// `90%` (not `90vw/90vh`): resolves against the inset-0 wrapper, which
|
|
401
|
+
// spans the containing block — identical to viewport units when the CB
|
|
402
|
+
// is the viewport, correct inside a contained/transformed shell.
|
|
393
403
|
popoverEl.style.cssText = `
|
|
394
404
|
position: relative;
|
|
395
|
-
max-width:
|
|
396
|
-
max-height:
|
|
405
|
+
max-width: 90%;
|
|
406
|
+
max-height: 90%;
|
|
397
407
|
overflow: auto;
|
|
398
408
|
transition-duration: ${TRANSITION}ms;
|
|
399
409
|
pointer-events: auto;
|
|
@@ -440,11 +450,13 @@ export function popover(anchorEl, fn) {
|
|
|
440
450
|
// sub-pixel/vertical cases); clamping keeps small edge-anchored
|
|
441
451
|
// popovers anchored instead of switching them to a modal.
|
|
442
452
|
clampIntoViewport(popoverEl);
|
|
453
|
+
// Compare against the containing block (the viewport, unless an
|
|
454
|
+
// ancestor with `transform`/`contain` establishes one).
|
|
443
455
|
const rect = popoverEl.getBoundingClientRect();
|
|
444
|
-
const
|
|
456
|
+
const cb = fixedContainingBlockRect(popoverEl);
|
|
445
457
|
// If it STILL overflows horizontally after clamping, the content
|
|
446
458
|
// is too wide to fit anchored — fall back to the centered modal.
|
|
447
|
-
if (rect.left <
|
|
459
|
+
if (rect.left < cb.left || rect.right > cb.right) {
|
|
448
460
|
debug("overflow detected, switching to fallback mode");
|
|
449
461
|
switchingToFallback = true;
|
|
450
462
|
// Quick cleanup (skip transition)
|
|
@@ -556,6 +568,7 @@ export function popover(anchorEl, fn) {
|
|
|
556
568
|
onHide: opts.onHide,
|
|
557
569
|
debug: opts.debug,
|
|
558
570
|
id: opts.id,
|
|
571
|
+
container: opts.container,
|
|
559
572
|
};
|
|
560
573
|
do_debug = !!opts.debug;
|
|
561
574
|
// Register in global registry if id provided
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { spotlight } from "./spotlight.svelte.js";
|
|
3
|
+
|
|
4
|
+
let { open = false, useContainer = false }: { open?: boolean; useContainer?: boolean } =
|
|
5
|
+
$props();
|
|
6
|
+
|
|
7
|
+
let shell = $state<HTMLDivElement>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<!-- A bounded shell that is a fixed containing block + stacking context. The
|
|
11
|
+
target sits at a known offset inside it so the clip-path hole coordinates can
|
|
12
|
+
be asserted container-locally. -->
|
|
13
|
+
<div
|
|
14
|
+
bind:this={shell}
|
|
15
|
+
data-testid="shell"
|
|
16
|
+
style="contain: layout paint; width: 240px; height: 180px; position: relative;"
|
|
17
|
+
>
|
|
18
|
+
<button
|
|
19
|
+
data-testid="target"
|
|
20
|
+
style="position: absolute; left: 40px; top: 30px; width: 60px; height: 20px;"
|
|
21
|
+
use:spotlight={() => ({
|
|
22
|
+
content: "hi",
|
|
23
|
+
open,
|
|
24
|
+
padding: 4,
|
|
25
|
+
borderRadius: 0,
|
|
26
|
+
scrollIntoView: false,
|
|
27
|
+
autoTrack: false,
|
|
28
|
+
container: useContainer ? () => shell ?? null : undefined,
|
|
29
|
+
})}
|
|
30
|
+
>
|
|
31
|
+
x
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
@@ -98,6 +98,15 @@ export interface SpotlightOptions {
|
|
|
98
98
|
autoTrack?: boolean;
|
|
99
99
|
/** Debug mode */
|
|
100
100
|
debug?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Where to append the overlay elements (backdrop, anchor, annotation).
|
|
103
|
+
* Defaults to the current behavior: `document.body`. Pass a bounded shell
|
|
104
|
+
* (framed app, embedded widget, dashboard pane) to keep the overlay inside
|
|
105
|
+
* it — required when that shell is a stacking context, since a body-level
|
|
106
|
+
* overlay can then only paint entirely above or entirely below it. A
|
|
107
|
+
* function returning `null` falls back to the default.
|
|
108
|
+
*/
|
|
109
|
+
container?: HTMLElement | (() => HTMLElement | null);
|
|
101
110
|
}
|
|
102
111
|
/**
|
|
103
112
|
* A Svelte action that highlights a target element with a spotlight effect.
|