@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.
@@ -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
- position-try-order: most-width;
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
- // Update fallback annotation position
234
- if (annotationEl && !isSupported) {
235
- positionAnnotationFallback(rect, padding);
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
- // Clamp into viewport after layout settles. Using transform keeps the
309
- // underlying left/top intent intact so the next call recomputes cleanly.
310
- requestAnimationFrame(() => {
311
- if (!annotationEl)
312
- return;
313
- const a = annotationEl.getBoundingClientRect();
314
- const m = 8;
315
- const vw = window.innerWidth;
316
- const vh = window.innerHeight;
317
- let dx = 0;
318
- let dy = 0;
319
- if (a.left < m)
320
- dx = m - a.left;
321
- else if (a.right > vw - m)
322
- dx = vw - m - a.right;
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: flip-block, flip-inline, flip-block flip-inline;
410
- position-try-order: most-width;
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 === 1)
247
- _doValidate();
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.113.0",
3
+ "version": "3.114.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",