@marianmeres/stuic 3.113.0 → 3.114.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/popover/popover.svelte.js +8 -0
- package/dist/actions/spotlight/index.css +9 -2
- package/dist/actions/spotlight/spotlight.svelte.js +35 -26
- package/dist/actions/tooltip/tooltip.svelte.js +5 -0
- package/dist/actions/validate.svelte.js +37 -3
- package/dist/utils/anchor-position.d.ts +41 -0
- package/dist/utils/anchor-position.js +69 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { clampIntoViewport } from "../../utils/anchor-position.js";
|
|
4
5
|
import { iconX } from "../../icons/index.js";
|
|
5
6
|
import { BodyScroll } from "../../utils/body-scroll-locker.js";
|
|
6
7
|
import PopoverContent from "./PopoverContent.svelte";
|
|
@@ -434,8 +435,15 @@ export function popover(anchorEl, fn) {
|
|
|
434
435
|
requestAnimationFrame(() => {
|
|
435
436
|
if (!popoverEl)
|
|
436
437
|
return;
|
|
438
|
+
// Clamp into the viewport first. The discrete @position-try
|
|
439
|
+
// fallbacks can leave a residual overflow (and don't cover
|
|
440
|
+
// sub-pixel/vertical cases); clamping keeps small edge-anchored
|
|
441
|
+
// popovers anchored instead of switching them to a modal.
|
|
442
|
+
clampIntoViewport(popoverEl);
|
|
437
443
|
const rect = popoverEl.getBoundingClientRect();
|
|
438
444
|
const viewportWidth = window.innerWidth;
|
|
445
|
+
// If it STILL overflows horizontally after clamping, the content
|
|
446
|
+
// is too wide to fit anchored — fall back to the centered modal.
|
|
439
447
|
if (rect.left < 0 || rect.right > viewportWidth) {
|
|
440
448
|
debug("overflow detected, switching to fallback mode");
|
|
441
449
|
switchingToFallback = true;
|
|
@@ -26,11 +26,18 @@
|
|
|
26
26
|
|
|
27
27
|
@supports (anchor-name: --anchor) {
|
|
28
28
|
.stuic-spotlight-annotation {
|
|
29
|
-
|
|
29
|
+
/* The spotlight action overrides these inline per-instance, tailoring the
|
|
30
|
+
fallbacks to each annotation's position (see buildPositionTryFallbacks).
|
|
31
|
+
These are a sane default for the centered `bottom` placement. `normal`
|
|
32
|
+
order keeps the base position when it fits and only engages the span
|
|
33
|
+
fallbacks on overflow; the JS clamp is the final backstop. */
|
|
34
|
+
position-try-order: normal;
|
|
30
35
|
position-try-fallbacks:
|
|
31
36
|
flip-block,
|
|
32
37
|
flip-inline,
|
|
33
|
-
flip-block flip-inline
|
|
38
|
+
flip-block flip-inline,
|
|
39
|
+
bottom span-left,
|
|
40
|
+
bottom span-right;
|
|
34
41
|
|
|
35
42
|
&.spot-block {
|
|
36
43
|
display: block;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { buildPositionTryFallbacks, clampIntoViewport, } from "../../utils/anchor-position.js";
|
|
4
5
|
import { BodyScroll } from "../../utils/body-scroll-locker.js";
|
|
5
6
|
import SpotlightContent from "./SpotlightContent.svelte";
|
|
6
7
|
//
|
|
@@ -189,6 +190,9 @@ export function spotlight(targetEl, fn) {
|
|
|
189
190
|
let resizeObserver = null;
|
|
190
191
|
let rafId = null;
|
|
191
192
|
let lastRect = null;
|
|
193
|
+
// True once the annotation is display:block and laid out — gates the
|
|
194
|
+
// viewport clamp so it never measures a `display:none` element (zeros).
|
|
195
|
+
let annotationShown = false;
|
|
192
196
|
// Unique identifiers
|
|
193
197
|
const rnd = Math.random().toString(36).slice(2);
|
|
194
198
|
const anchorName = `--anchor-spotlight-${rnd}`;
|
|
@@ -230,9 +234,14 @@ export function spotlight(targetEl, fn) {
|
|
|
230
234
|
anchorEl.style.width = `${rect.width + padding * 2}px`;
|
|
231
235
|
anchorEl.style.height = `${rect.height + padding * 2}px`;
|
|
232
236
|
}
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
237
|
+
// Reposition / re-clamp the annotation. The fallback path recomputes its
|
|
238
|
+
// base left/top here; the anchor path is re-placed by the browser. Either
|
|
239
|
+
// way we re-clamp so an edge-anchored annotation stays on-screen as the
|
|
240
|
+
// target moves (the anchor path has no built-in viewport clamping).
|
|
241
|
+
if (annotationEl) {
|
|
242
|
+
if (!isSupported)
|
|
243
|
+
positionAnnotationFallback(rect, padding);
|
|
244
|
+
clampAnnotationIntoViewport();
|
|
236
245
|
}
|
|
237
246
|
}
|
|
238
247
|
/**
|
|
@@ -305,27 +314,21 @@ export function spotlight(targetEl, fn) {
|
|
|
305
314
|
annotationEl.style.left = `${x + w + offset}px`;
|
|
306
315
|
annotationEl.style.top = `${y}px`;
|
|
307
316
|
}
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (a.top < m)
|
|
324
|
-
dy = m - a.top;
|
|
325
|
-
else if (a.bottom > vh - m)
|
|
326
|
-
dy = vh - m - a.bottom;
|
|
327
|
-
annotationEl.style.transform = dx || dy ? `translate(${dx}px, ${dy}px)` : "";
|
|
328
|
-
});
|
|
317
|
+
// Viewport clamping is handled by clampAnnotationIntoViewport(), called by
|
|
318
|
+
// the caller once the annotation is laid out (and on every reposition).
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Clamp the annotation into the viewport, on BOTH positioning paths. The CSS
|
|
322
|
+
* Anchor Positioning path has no built-in way to slide a centered annotation
|
|
323
|
+
* back on-screen when the target is near a viewport edge (visible on Android
|
|
324
|
+
* Chrome, which supports anchor positioning; iOS Safari ≤18 takes the JS
|
|
325
|
+
* fallback path). Gated on `annotationShown` so it never measures a
|
|
326
|
+
* `display:none` element. See {@link clampIntoViewport}.
|
|
327
|
+
*/
|
|
328
|
+
function clampAnnotationIntoViewport() {
|
|
329
|
+
if (!annotationEl || !annotationShown)
|
|
330
|
+
return;
|
|
331
|
+
clampIntoViewport(annotationEl);
|
|
329
332
|
}
|
|
330
333
|
function renderContent() {
|
|
331
334
|
if (!annotationEl || !currentOptions.content)
|
|
@@ -406,8 +409,8 @@ export function spotlight(targetEl, fn) {
|
|
|
406
409
|
position: fixed;
|
|
407
410
|
position-anchor: ${anchorName};
|
|
408
411
|
position-area: ${POSITION_MAP[currentOptions.position || "bottom"] || "bottom"};
|
|
409
|
-
position-try-fallbacks:
|
|
410
|
-
position-try-order:
|
|
412
|
+
position-try-fallbacks: ${buildPositionTryFallbacks(currentOptions.position || "bottom")};
|
|
413
|
+
position-try-order: normal;
|
|
411
414
|
max-width: calc(100vw - 1rem);
|
|
412
415
|
max-height: calc(100vh - 1rem);
|
|
413
416
|
transition-duration: ${TRANSITION}ms;
|
|
@@ -437,6 +440,10 @@ export function spotlight(targetEl, fn) {
|
|
|
437
440
|
backdropEl?.classList.add("spot-visible");
|
|
438
441
|
if (annotationEl) {
|
|
439
442
|
annotationEl.classList.add("spot-block");
|
|
443
|
+
// Now display:block and laid out — clamp into the viewport before
|
|
444
|
+
// it fades in. Applies to both the anchor and fallback paths.
|
|
445
|
+
annotationShown = true;
|
|
446
|
+
clampAnnotationIntoViewport();
|
|
440
447
|
requestAnimationFrame(() => {
|
|
441
448
|
annotationEl?.classList.add("spot-visible");
|
|
442
449
|
});
|
|
@@ -467,6 +474,7 @@ export function spotlight(targetEl, fn) {
|
|
|
467
474
|
if (!isVisible)
|
|
468
475
|
return;
|
|
469
476
|
isVisible = false;
|
|
477
|
+
annotationShown = false;
|
|
470
478
|
if (currentOptions.id) {
|
|
471
479
|
spotlightOpenStates[currentOptions.id] = false;
|
|
472
480
|
}
|
|
@@ -555,6 +563,7 @@ export function spotlight(targetEl, fn) {
|
|
|
555
563
|
$effect(() => {
|
|
556
564
|
return () => {
|
|
557
565
|
// Cleanup on unmount
|
|
566
|
+
annotationShown = false;
|
|
558
567
|
if (mountedComponent) {
|
|
559
568
|
unmount(mountedComponent);
|
|
560
569
|
mountedComponent = null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
2
2
|
import { addAnchorName, removeAnchorName } from "../../utils/anchor-name.js";
|
|
3
|
+
import { clampIntoViewport } from "../../utils/anchor-position.js";
|
|
3
4
|
const TIMEOUT = 200;
|
|
4
5
|
const TRANSITION = 200;
|
|
5
6
|
/**
|
|
@@ -224,6 +225,10 @@ export function tooltip(anchorEl, fn) {
|
|
|
224
225
|
anchorEl.setAttribute("aria-expanded", "true");
|
|
225
226
|
//
|
|
226
227
|
tooltipEl.classList.add("tt-block");
|
|
228
|
+
// Backstop: the CSS @position-try fallbacks handle most edge cases,
|
|
229
|
+
// but can leave a residual overflow (and don't cover the "no fallback
|
|
230
|
+
// fits" case) — clamp fully on-screen now that it's laid out.
|
|
231
|
+
clampIntoViewport(tooltipEl);
|
|
227
232
|
requestAnimationFrame(() => {
|
|
228
233
|
tooltipEl.classList.add("tt-visible");
|
|
229
234
|
on_show?.();
|
|
@@ -170,9 +170,20 @@ export function validate(el, fn) {
|
|
|
170
170
|
// }">`,
|
|
171
171
|
// { enabled, on, hasCustomValidator: typeof customValidator === "function" }
|
|
172
172
|
// );
|
|
173
|
+
// Flipped to `true` in this $effect's cleanup. Guards the deferred blur
|
|
174
|
+
// validation (and any other stray late event) from running after the action
|
|
175
|
+
// is torn down — see the guard in `_doValidate` and the `onBlur` deferral.
|
|
176
|
+
let destroyed = false;
|
|
173
177
|
const _doValidate = () => {
|
|
174
178
|
if (!enabled)
|
|
175
179
|
return;
|
|
180
|
+
// Bail if the action has already been torn down. Together with the
|
|
181
|
+
// deferral in `onBlur`, this makes the deferred validation a guaranteed
|
|
182
|
+
// no-op after unmount even in the rare case the node stays connected —
|
|
183
|
+
// e.g. a keyed `{#each}` move that destroys this effect while the DOM
|
|
184
|
+
// node persists, where the `isConnected` check below would still pass.
|
|
185
|
+
if (destroyed)
|
|
186
|
+
return;
|
|
176
187
|
// A focused, dirty field torn down by a route change fires a final
|
|
177
188
|
// synchronous `change`/`blur` while being removed from the DOM. That
|
|
178
189
|
// removal runs inside Svelte's flush, so writing `validation` state here
|
|
@@ -241,13 +252,36 @@ export function validate(el, fn) {
|
|
|
241
252
|
let _touchCount = 0;
|
|
242
253
|
const onFocus = () => _touchCount++;
|
|
243
254
|
el.addEventListener("focus", onFocus);
|
|
244
|
-
// also validate on first blur
|
|
255
|
+
// also validate on first blur — but DEFERRED out of the current task.
|
|
256
|
+
//
|
|
257
|
+
// When a focused, touched field is unmounted (e.g. a successful submit
|
|
258
|
+
// navigates away and tears down the form), the browser fires a final
|
|
259
|
+
// synchronous `blur` *during* Svelte's destroy flush, while the node is
|
|
260
|
+
// still connected. Running `_doValidate` there reads any consumer `$derived`
|
|
261
|
+
// belonging to the now-destroyed effect (`derived_inert` warning) and writes
|
|
262
|
+
// the parent's `validation` `$state` (`state_unsafe_mutation`, uncaught) —
|
|
263
|
+
// the existing `isConnected` guard misses it because the node hasn't been
|
|
264
|
+
// detached yet at blur time.
|
|
265
|
+
//
|
|
266
|
+
// A microtask runs *after* the synchronous flush completes: by then a
|
|
267
|
+
// torn-down node is detached and this $effect's cleanup has set `destroyed`,
|
|
268
|
+
// so `_doValidate`'s guards bail (no derived read, no state write). On a real
|
|
269
|
+
// user blur the field is still connected and alive, so validation runs as
|
|
270
|
+
// before — just one microtask later (imperceptible).
|
|
271
|
+
//
|
|
272
|
+
// Only `blur` is deferred. The `change`/`input` listener and the imperative
|
|
273
|
+
// `setDoValidate` path must stay synchronous: `onSubmitValidityCheck`
|
|
274
|
+
// dispatches synthetic `input`/`change` and reads `el.validity` immediately,
|
|
275
|
+
// and `FieldInput.validate()` reads `validation` right after invoking the
|
|
276
|
+
// exposed validator — deferring either would break submit-time validation.
|
|
245
277
|
const onBlur = () => {
|
|
246
|
-
if (_touchCount
|
|
247
|
-
|
|
278
|
+
if (_touchCount !== 1)
|
|
279
|
+
return;
|
|
280
|
+
queueMicrotask(_doValidate);
|
|
248
281
|
};
|
|
249
282
|
el.addEventListener("blur", onBlur);
|
|
250
283
|
return () => {
|
|
284
|
+
destroyed = true;
|
|
251
285
|
el.removeEventListener(on, _doValidate);
|
|
252
286
|
el.removeEventListener("focus", onFocus);
|
|
253
287
|
el.removeEventListener("blur", onBlur);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for CSS Anchor Positioning based actions (spotlight, popover,
|
|
3
|
+
* tooltip).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Builds the `position-try-fallbacks` value for an anchored element at a given
|
|
7
|
+
* position.
|
|
8
|
+
*
|
|
9
|
+
* For the centered positions (`top`/`bottom` are inline-centered; `left`/`right`
|
|
10
|
+
* are block-centered) a `flip-inline`/`flip-block` is a no-op on the centered
|
|
11
|
+
* axis, so the browser cannot slide the element back on-screen when the target
|
|
12
|
+
* sits near a viewport edge. We append `span-*` variants that give it an edge to
|
|
13
|
+
* align to. A JS clamp (see {@link clampIntoViewport}) should still be used as
|
|
14
|
+
* the ultimate backstop; these fallbacks just yield nicer native placement.
|
|
15
|
+
*
|
|
16
|
+
* Note: tooltip/popover declare their fallbacks via `@position-try` named rules
|
|
17
|
+
* in CSS instead and don't use this; it's primarily for the spotlight action,
|
|
18
|
+
* which sets `position-try-fallbacks` inline.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildPositionTryFallbacks(position: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Pull an element fully into the viewport with a corrective `transform`.
|
|
23
|
+
*
|
|
24
|
+
* This is the backstop for CSS Anchor Positioning: `position-try` can only swap
|
|
25
|
+
* between discrete declared positions and cannot slide a centered annotation
|
|
26
|
+
* back on-screen when the target is near a viewport edge — so without this an
|
|
27
|
+
* anchored element can render off-screen on browsers that support anchor
|
|
28
|
+
* positioning (e.g. Android Chrome).
|
|
29
|
+
*
|
|
30
|
+
* Synchronous and flicker-free: it clears any prior transform, force-measures
|
|
31
|
+
* the natural rect (`getBoundingClientRect` triggers a synchronous layout), then
|
|
32
|
+
* applies a single translate — all within one JS turn, so the browser only
|
|
33
|
+
* paints the final, clamped position. The element MUST be laid out
|
|
34
|
+
* (`display: block`) when called, and `transform` MUST NOT be in its
|
|
35
|
+
* `transition-property` (callers use `transition-property: opacity`) so the
|
|
36
|
+
* correction applies instantly. The caller owns the element's `transform`.
|
|
37
|
+
*
|
|
38
|
+
* @param el - The (anchored, position:fixed) element to clamp
|
|
39
|
+
* @param margin - Minimum gap from each viewport edge, in px (default 8)
|
|
40
|
+
*/
|
|
41
|
+
export declare function clampIntoViewport(el: HTMLElement, margin?: number): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for CSS Anchor Positioning based actions (spotlight, popover,
|
|
3
|
+
* tooltip).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Builds the `position-try-fallbacks` value for an anchored element at a given
|
|
7
|
+
* position.
|
|
8
|
+
*
|
|
9
|
+
* For the centered positions (`top`/`bottom` are inline-centered; `left`/`right`
|
|
10
|
+
* are block-centered) a `flip-inline`/`flip-block` is a no-op on the centered
|
|
11
|
+
* axis, so the browser cannot slide the element back on-screen when the target
|
|
12
|
+
* sits near a viewport edge. We append `span-*` variants that give it an edge to
|
|
13
|
+
* align to. A JS clamp (see {@link clampIntoViewport}) should still be used as
|
|
14
|
+
* the ultimate backstop; these fallbacks just yield nicer native placement.
|
|
15
|
+
*
|
|
16
|
+
* Note: tooltip/popover declare their fallbacks via `@position-try` named rules
|
|
17
|
+
* in CSS instead and don't use this; it's primarily for the spotlight action,
|
|
18
|
+
* which sets `position-try-fallbacks` inline.
|
|
19
|
+
*/
|
|
20
|
+
export function buildPositionTryFallbacks(position) {
|
|
21
|
+
const flips = "flip-block, flip-inline, flip-block flip-inline";
|
|
22
|
+
if (position === "top" || position === "bottom") {
|
|
23
|
+
return `${flips}, ${position} span-left, ${position} span-right`;
|
|
24
|
+
}
|
|
25
|
+
if (position === "left" || position === "right") {
|
|
26
|
+
return `${flips}, ${position} span-top, ${position} span-bottom`;
|
|
27
|
+
}
|
|
28
|
+
return flips;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pull an element fully into the viewport with a corrective `transform`.
|
|
32
|
+
*
|
|
33
|
+
* This is the backstop for CSS Anchor Positioning: `position-try` can only swap
|
|
34
|
+
* between discrete declared positions and cannot slide a centered annotation
|
|
35
|
+
* back on-screen when the target is near a viewport edge — so without this an
|
|
36
|
+
* anchored element can render off-screen on browsers that support anchor
|
|
37
|
+
* positioning (e.g. Android Chrome).
|
|
38
|
+
*
|
|
39
|
+
* Synchronous and flicker-free: it clears any prior transform, force-measures
|
|
40
|
+
* the natural rect (`getBoundingClientRect` triggers a synchronous layout), then
|
|
41
|
+
* applies a single translate — all within one JS turn, so the browser only
|
|
42
|
+
* paints the final, clamped position. The element MUST be laid out
|
|
43
|
+
* (`display: block`) when called, and `transform` MUST NOT be in its
|
|
44
|
+
* `transition-property` (callers use `transition-property: opacity`) so the
|
|
45
|
+
* correction applies instantly. The caller owns the element's `transform`.
|
|
46
|
+
*
|
|
47
|
+
* @param el - The (anchored, position:fixed) element to clamp
|
|
48
|
+
* @param margin - Minimum gap from each viewport edge, in px (default 8)
|
|
49
|
+
*/
|
|
50
|
+
export function clampIntoViewport(el, margin = 8) {
|
|
51
|
+
// Remove any prior correction so we measure the natural (anchored or
|
|
52
|
+
// left/top) position, then recompute from scratch.
|
|
53
|
+
el.style.transform = "";
|
|
54
|
+
const a = el.getBoundingClientRect();
|
|
55
|
+
const vw = window.innerWidth;
|
|
56
|
+
const vh = window.innerHeight;
|
|
57
|
+
let dx = 0;
|
|
58
|
+
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)`;
|
|
69
|
+
}
|