@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.
- package/dist/actions/autogrow.svelte.js +8 -1
- 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/components/Input/FieldKeyValues.svelte +81 -12
- package/dist/components/Input/FieldKeyValues.svelte.d.ts +10 -0
- package/dist/utils/anchor-position.d.ts +41 -0
- package/dist/utils/anchor-position.js +69 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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 =
|
|
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
|
-
<
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
}
|