@marianmeres/stuic 3.112.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.
@@ -33,7 +33,14 @@ export function autogrow(el, fn) {
33
33
  // console.log(123, el.value);
34
34
  if (enabled) {
35
35
  el.style.height = "auto"; // Reset height to auto to correctly calculate scrollHeight
36
- el.style.height = Math.max(min, Math.min(el.scrollHeight, max)) + "px";
36
+ // `scrollHeight` excludes the border, but with `box-sizing: border-box` the
37
+ // height we set *includes* it — so without adding the vertical border back
38
+ // we undershoot by a couple px and a scrollbar lingers.
39
+ const cs = getComputedStyle(el);
40
+ const borderY = cs.boxSizing === "border-box"
41
+ ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
42
+ : 0;
43
+ el.style.height = Math.max(min, Math.min(el.scrollHeight + borderY, max)) + "px";
37
44
  }
38
45
  }
39
46
  // eventlistener strategy (we're not passing value)
@@ -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);
@@ -42,13 +42,23 @@
42
42
  addLabel?: string;
43
43
  emptyMessage?: string;
44
44
  onChange?: (value: string) => void;
45
+ /**
46
+ * When `true`, a value that *looks like* JSON (starts with `{`/`[`) but
47
+ * fails to parse becomes a blocking validation error on submit.
48
+ *
49
+ * Defaults to `false`: the component detects/parses JSON for convenience
50
+ * (pretty-print + the inline indicator) but does **not** enforce validity —
51
+ * whether a value must be valid JSON is a business rule the consumer owns.
52
+ * Unparseable values are simply stored as plain strings. Opt in to `true`
53
+ * only for a strictly JSON-only field.
54
+ */
45
55
  strictJsonValidation?: boolean;
46
56
  t?: TranslateFn;
47
57
  }
48
58
  </script>
49
59
 
50
60
  <script lang="ts">
51
- import { iconPlus, iconTrash } from "../../icons/index.js";
61
+ import { iconAlertWarning, iconCode, iconPlus, iconTrash } from "../../icons/index.js";
52
62
  import { tick } from "svelte";
53
63
  import { autogrow } from "../../actions/autogrow.svelte.js";
54
64
  import { validate as validateAction } from "../../actions/validate.svelte.js";
@@ -75,6 +85,7 @@
75
85
  remove_entry: "Remove entry",
76
86
  duplicate_keys: "Duplicate keys are not allowed",
77
87
  invalid_json_syntax: "Invalid JSON syntax. Check for missing quotes or brackets.",
88
+ json_detected: "Valid JSON",
78
89
  };
79
90
  let out = m[k] ?? fallback ?? k;
80
91
  return isPlainObject(values) ? replaceMap(out, values as any) : out;
@@ -118,7 +129,7 @@
118
129
  addLabel,
119
130
  emptyMessage,
120
131
  onChange,
121
- strictJsonValidation = true,
132
+ strictJsonValidation = false,
122
133
  t = t_default,
123
134
  }: Props = $props();
124
135
 
@@ -159,7 +170,7 @@
159
170
  if (!isPlainObject(parsed)) return [];
160
171
  return Object.entries(parsed).map(([key, val]) => ({
161
172
  key,
162
- value: typeof val === "string" ? val : JSON.stringify(val),
173
+ value: typeof val === "string" ? val : JSON.stringify(val, null, 2),
163
174
  parsedValue: val,
164
175
  }));
165
176
  } catch (e) {
@@ -232,6 +243,35 @@
232
243
  syncToValue();
233
244
  }
234
245
 
246
+ // Pretty-print structured JSON (objects/arrays) on blur. Display-only: the
247
+ // parsed value is unchanged, so the serialized external `value` is identical
248
+ // and `syncToValue()` is intentionally not called. Primitives, plain strings,
249
+ // and invalid JSON are left exactly as typed (no surprising normalization).
250
+ function formatValueEntry(idx: number) {
251
+ const raw = entries[idx].value;
252
+ const trimmed = raw.trim();
253
+ if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return;
254
+ try {
255
+ const parsed = JSON.parse(trimmed);
256
+ const pretty = JSON.stringify(parsed, null, 2);
257
+ if (pretty !== raw) {
258
+ entries[idx].value = pretty;
259
+ entries[idx].parsedValue = parsed;
260
+ entryJsonErrors[idx] = false;
261
+ }
262
+ } catch {
263
+ // invalid JSON: leave as typed; indicator + validation handle it
264
+ }
265
+ }
266
+
267
+ // Per-entry JSON signal for the subtle inline indicator.
268
+ function jsonState(entry: KeyValueEntry, idx: number): "valid" | "error" | "none" {
269
+ if (entryJsonErrors[idx]) return "error";
270
+ const v = entry.parsedValue;
271
+ if (isPlainObject(v) || Array.isArray(v)) return "valid";
272
+ return "none";
273
+ }
274
+
235
275
  // Validation
236
276
  let validation: ValidationResult | undefined = $state();
237
277
  const setValidationResult = (res: ValidationResult) => (validation = res);
@@ -314,6 +354,7 @@
314
354
 
315
355
  const INPUT_CLS = [
316
356
  "rounded bg-(--stuic-color-input)",
357
+ "font-mono",
317
358
  "focus:outline-none focus:ring-0",
318
359
  "border border-(--stuic-color-border)",
319
360
  "focus:border-(--stuic-color-border-hover)",
@@ -355,6 +396,7 @@
355
396
  {:else}
356
397
  <div class="p-2">
357
398
  {#each entries as entry, idx (idx)}
399
+ {@const st = jsonState(entry, idx)}
358
400
  <div
359
401
  class={twMerge(
360
402
  "flex gap-2 items-start py-2",
@@ -382,15 +424,42 @@
382
424
  />
383
425
 
384
426
  <!-- Value textarea -->
385
- <textarea
386
- value={entry.value}
387
- oninput={(e) => updateEntry(idx, "value", e.currentTarget.value)}
388
- placeholder={valuePlaceholder ?? t("value_placeholder")}
389
- class={twMerge(INPUT_CLS, "min-h-10 flex-none", classValueInput)}
390
- {disabled}
391
- {tabindex}
392
- use:autogrow={() => ({ enabled: true, value: entry.value })}
393
- ></textarea>
427
+ <div class="relative">
428
+ <textarea
429
+ value={entry.value}
430
+ oninput={(e) => updateEntry(idx, "value", e.currentTarget.value)}
431
+ onblur={() => formatValueEntry(idx)}
432
+ placeholder={valuePlaceholder ?? t("value_placeholder")}
433
+ class={twMerge(
434
+ INPUT_CLS,
435
+ "w-full min-h-10 flex-none pr-6",
436
+ classValueInput
437
+ )}
438
+ {disabled}
439
+ {tabindex}
440
+ use:autogrow={() => ({ enabled: true, value: entry.value })}
441
+ ></textarea>
442
+
443
+ <!-- Subtle JSON state indicator -->
444
+ {#if st !== "none"}
445
+ <span
446
+ class={twMerge(
447
+ "pointer-events-none absolute top-1.5 right-1.5",
448
+ st === "valid"
449
+ ? "opacity-40"
450
+ : "text-amber-500 opacity-80"
451
+ )}
452
+ title={st === "valid"
453
+ ? t("json_detected")
454
+ : t("invalid_json_syntax")}
455
+ aria-hidden="true"
456
+ >
457
+ {@html st === "valid"
458
+ ? iconCode({ size: 14 })
459
+ : iconAlertWarning({ size: 14 })}
460
+ </span>
461
+ {/if}
462
+ </div>
394
463
  </div>
395
464
 
396
465
  <!-- Delete button -->
@@ -37,6 +37,16 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
37
37
  addLabel?: string;
38
38
  emptyMessage?: string;
39
39
  onChange?: (value: string) => void;
40
+ /**
41
+ * When `true`, a value that *looks like* JSON (starts with `{`/`[`) but
42
+ * fails to parse becomes a blocking validation error on submit.
43
+ *
44
+ * Defaults to `false`: the component detects/parses JSON for convenience
45
+ * (pretty-print + the inline indicator) but does **not** enforce validity —
46
+ * whether a value must be valid JSON is a business rule the consumer owns.
47
+ * Unparseable values are simply stored as plain strings. Opt in to `true`
48
+ * only for a strictly JSON-only field.
49
+ */
40
50
  strictJsonValidation?: boolean;
41
51
  t?: TranslateFn;
42
52
  }
@@ -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.112.0",
3
+ "version": "3.114.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",