@marianmeres/stuic 3.110.0 → 3.112.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.
@@ -61,6 +61,17 @@ export function onSubmitValidityCheck(node) {
61
61
  // input (last radio input), which is not desired
62
62
  if (el.type === "radio")
63
63
  continue;
64
+ // File inputs: validate WITHOUT re-dispatching events. A synthetic
65
+ // `change` can't change a file input's value (it's read-only to
66
+ // script), so it adds nothing for validation — but it DOES re-trigger
67
+ // any dropzone/upload listener bound to the input. `FieldAssets`'
68
+ // hidden `<input type="file">` is exactly such a listener (wired via
69
+ // the `fileDropzone` action): re-firing `change` re-runs its
70
+ // `processFiles` with the file still sitting in `inputEl.files`,
71
+ // pushing a duplicate optimistic asset and kicking off a real
72
+ // re-upload on every submit. We still read `el.validity` below, so
73
+ // native `required` file inputs are honored as before.
74
+ const isFile = el.type === "file";
64
75
  // // [debug] kept commented for the next time Issue A regresses
65
76
  // // eslint-disable-next-line no-console
66
77
  // console.log(`[onSubmitValidityCheck] el#${i} ${el.name || el.type} BEFORE`, {
@@ -80,10 +91,12 @@ export function onSubmitValidityCheck(node) {
80
91
  // error, silently routing the form to `submit_invalid` and never calling
81
92
  // the consumer's onSubmit. The dispatched change event below re-runs the
82
93
  // per-field validator which re-applies a real customValidity if needed.
83
- if (typeof el.setCustomValidity === "function")
84
- el.setCustomValidity("");
85
- el.dispatchEvent(new Event("input", { bubbles: true }));
86
- el.dispatchEvent(new Event("change", { bubbles: true }));
94
+ if (!isFile) {
95
+ if (typeof el.setCustomValidity === "function")
96
+ el.setCustomValidity("");
97
+ el.dispatchEvent(new Event("input", { bubbles: true }));
98
+ el.dispatchEvent(new Event("change", { bubbles: true }));
99
+ }
87
100
  // // [debug] kept commented for the next time Issue A regresses
88
101
  // // eslint-disable-next-line no-console
89
102
  // console.log(`[onSubmitValidityCheck] el#${i} ${el.name || el.type} AFTER `, {
@@ -453,7 +453,27 @@
453
453
  processFiles(files: FileList | null, wasDrop?: boolean) {
454
454
  clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
455
455
 
456
- if (accept && [...(files ?? [])].some((f) => !is_accepted_type(accept, f.type))) {
456
+ // Copy the File objects into a stable array, then IMMEDIATELY release the
457
+ // hidden <input type="file">'s retained FileList. A native file input
458
+ // keeps its FileList after a selection until it is reset (form reset or
459
+ // `value = ""`), and its `change` event (wired by the `fileDropzone`
460
+ // action) calls back into this handler. Without this reset, any later
461
+ // stray `change` re-runs processFiles with the SAME file still in
462
+ // `inputEl.files`, pushing a duplicate optimistic asset and firing a real
463
+ // re-upload. `onSubmitValidityCheck` used to dispatch exactly such a
464
+ // synthetic `change` on submit (now fixed there too — belt and braces).
465
+ // Clearing also restores the ability to re-select the same file twice in
466
+ // a row (an unchanged value emits no `change`). The blob URLs we create
467
+ // below are independent of the input, so clearing here is safe.
468
+ const incoming = [...(files ?? [])];
469
+ if (inputEl) inputEl.value = "";
470
+
471
+ // Nothing to consume — a cancelled picker, or a stray/synthetic `change`
472
+ // on an already-cleared input. Bail before touching state or calling
473
+ // processAssets (which would otherwise run with an empty batch).
474
+ if (!incoming.length) return;
475
+
476
+ if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
457
477
  const msg = t("invalid_type", { accept });
458
478
  if (notifications) notifications.error(msg);
459
479
  else alert(msg);
@@ -461,8 +481,11 @@
461
481
  }
462
482
 
463
483
  const cardErrMsg = t("cardinality_reached", { max: cardinality });
464
- if (assets.length > cardinality) {
465
- // if (assets.length + (files?.length ?? 0) > cardinality) {
484
+ // `>=` (not `>`): refuse the moment the field already holds `cardinality`
485
+ // assets, instead of optimistically adding one past the limit and relying
486
+ // on the validator to reject it afterwards (the off-by-one that made the
487
+ // single-cardinality symptom loud).
488
+ if (assets.length >= cardinality) {
466
489
  if (notifications) notifications.error(cardErrMsg);
467
490
  else alert(cardErrMsg);
468
491
  return;
@@ -470,8 +493,8 @@
470
493
 
471
494
  const toBeProcessed: FieldAsset[] = [];
472
495
 
473
- for (const file of files ?? []) {
474
- if (assets.length > cardinality) {
496
+ for (const file of incoming) {
497
+ if (assets.length >= cardinality) {
475
498
  notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
476
499
  break;
477
500
  }
@@ -112,6 +112,23 @@
112
112
  * (any `window.prompt`-compatible sync/async function works).
113
113
  */
114
114
  prompt?: PromptFn;
115
+ /**
116
+ * Caps the editing surface height so a long document scrolls *inside* the
117
+ * surface instead of growing the editor (which would push the toolbar out
118
+ * of view). `number` → pixels; `string` → any CSS length (`"40rem"`,
119
+ * `"60vh"`, `"min(40rem,50vh)"`). Defaults to `32rem` (themeable via
120
+ * `--stuic-markdown-editor-max-height`). The surface is additionally capped
121
+ * to the parent's available height when {@link capToParent} is on — the
122
+ * smaller limit wins.
123
+ */
124
+ maxHeight?: number | string;
125
+ /**
126
+ * Also cap the editing surface to the height available in the parent
127
+ * container (measured at runtime), so the editor never overflows a
128
+ * height-constrained parent. Default `true`. Set `false` to rely solely on
129
+ * {@link maxHeight}.
130
+ */
131
+ capToParent?: boolean;
115
132
  /** Show the WYSIWYG/Source mode toggle. Default `true`. */
116
133
  showModeToggle?: boolean;
117
134
  /** Label for the toggle when in WYSIWYG mode (switches to source). */
@@ -165,6 +182,12 @@
165
182
  } from "../../icons/index.js";
166
183
  import InputWrap from "../Input/_internal/InputWrap.svelte";
167
184
  import type { EditorCommands, EditorHandle, PromptFn } from "./_internal/types.js";
185
+ import {
186
+ computeParentAvailable,
187
+ DEFAULT_MAX_HEIGHT_VAR,
188
+ maxHeightToCss,
189
+ surfaceMaxHeight,
190
+ } from "./_internal/max-height.js";
168
191
  import "./index.css";
169
192
 
170
193
  // Default URL prompt for link/image — the native `window.prompt`. Opt into
@@ -227,6 +250,8 @@
227
250
  autoSourceOnMobile = true,
228
251
  mobileQuery = "(pointer: coarse) and (max-width: 640px)",
229
252
  prompt = defaultPrompt,
253
+ maxHeight,
254
+ capToParent = true,
230
255
  showModeToggle = true,
231
256
  sourceLabel = "Source",
232
257
  previewLabel = "Preview",
@@ -285,6 +310,78 @@
285
310
  input = host;
286
311
  });
287
312
 
313
+ // --- Surface max-height ---------------------------------------------------
314
+ // Cap the editing surface so a long document scrolls internally instead of
315
+ // growing the editor (which scrolls the toolbar out of view). The base cap is
316
+ // the `maxHeight` prop (funneled into the CSS var so the surface rule + the
317
+ // min() below both see it) or `32rem`; `capToParent` further caps it to the
318
+ // parent's measured available height. `min()` picks the smaller limit.
319
+
320
+ // Funnel an explicit `maxHeight` into the themeable CSS var on the root.
321
+ const maxHeightVar = $derived.by(() => {
322
+ const css = maxHeightToCss(maxHeight);
323
+ return css ? `--stuic-markdown-editor-max-height: ${css};` : undefined;
324
+ });
325
+
326
+ // Measured parent-available height (px), or null when capping is off / not
327
+ // yet measured / the parent is unconstrained — then the CSS default applies.
328
+ let parentAvailable = $state<number | null>(null);
329
+
330
+ // Inline surface `max-height`; undefined falls back to the CSS rule default.
331
+ const surfaceMaxHeightCss = $derived(
332
+ capToParent && parentAvailable != null
333
+ ? surfaceMaxHeight(DEFAULT_MAX_HEIGHT_VAR, parentAvailable)
334
+ : undefined
335
+ );
336
+
337
+ $effect(() => {
338
+ if (!capToParent || !host || typeof ResizeObserver === "undefined") {
339
+ parentAvailable = null;
340
+ return;
341
+ }
342
+ const surface = host;
343
+ // Measure against the parent of the whole field (InputWrap's root), which
344
+ // is the consumer's container — that is the "parent" whose height we cap to.
345
+ const root = (surface.closest(".stuic-input") as HTMLElement | null) ?? surface;
346
+ const parent = root.parentElement;
347
+ if (!parent) {
348
+ parentAvailable = null;
349
+ return;
350
+ }
351
+
352
+ const measure = () => {
353
+ const cs = getComputedStyle(parent);
354
+ const next = computeParentAvailable({
355
+ fromTop: surface.getBoundingClientRect().top,
356
+ parentBottom: parent.getBoundingClientRect().bottom,
357
+ parentBorderBottom: parseFloat(cs.borderBottomWidth) || 0,
358
+ parentPaddingBottom: parseFloat(cs.paddingBottom) || 0,
359
+ });
360
+ // `untrack` the read of `parentAvailable`: the initial measure() runs
361
+ // inside this effect, and tracking its own output would tear down and
362
+ // rebuild the observer on every measurement. The threshold also guards
363
+ // against sub-pixel ResizeObserver feedback loops.
364
+ const prev = untrack(() => parentAvailable);
365
+ if (next == null) {
366
+ if (prev !== null) parentAvailable = null;
367
+ } else if (prev == null || Math.abs(next - prev) > 1) {
368
+ parentAvailable = next;
369
+ }
370
+ };
371
+
372
+ measure();
373
+ const ro = new ResizeObserver(measure);
374
+ ro.observe(parent);
375
+ // Observe the field root too, so the surface re-measures when content above
376
+ // it reflows (label wraps, description expands, validation message appears).
377
+ ro.observe(root);
378
+ window.addEventListener("resize", measure);
379
+ return () => {
380
+ ro.disconnect();
381
+ window.removeEventListener("resize", measure);
382
+ };
383
+ });
384
+
288
385
  // Handle to whichever backend is currently mounted (null while (re)loading).
289
386
  let activeHandle = $state<EditorHandle | undefined>();
290
387
 
@@ -464,6 +561,7 @@
464
561
  class:disabled
465
562
  data-size={renderSize}
466
563
  data-mode={mode}
564
+ style={maxHeightVar}
467
565
  >
468
566
  {#if toolbarItems.length || showModeToggle}
469
567
  <div class={twMerge("stuic-markdown-editor-bar", classToolbar)}>
@@ -511,6 +609,7 @@
511
609
  <div
512
610
  bind:this={host}
513
611
  class="stuic-markdown-editor-surface"
612
+ style:max-height={surfaceMaxHeightCss}
514
613
  onfocusout={handleFocusOut}
515
614
  role="textbox"
516
615
  aria-multiline="true"
@@ -54,6 +54,23 @@ export interface Props extends InputWrapClassProps {
54
54
  * (any `window.prompt`-compatible sync/async function works).
55
55
  */
56
56
  prompt?: PromptFn;
57
+ /**
58
+ * Caps the editing surface height so a long document scrolls *inside* the
59
+ * surface instead of growing the editor (which would push the toolbar out
60
+ * of view). `number` → pixels; `string` → any CSS length (`"40rem"`,
61
+ * `"60vh"`, `"min(40rem,50vh)"`). Defaults to `32rem` (themeable via
62
+ * `--stuic-markdown-editor-max-height`). The surface is additionally capped
63
+ * to the parent's available height when {@link capToParent} is on — the
64
+ * smaller limit wins.
65
+ */
66
+ maxHeight?: number | string;
67
+ /**
68
+ * Also cap the editing surface to the height available in the parent
69
+ * container (measured at runtime), so the editor never overflows a
70
+ * height-constrained parent. Default `true`. Set `false` to rely solely on
71
+ * {@link maxHeight}.
72
+ */
73
+ capToParent?: boolean;
57
74
  /** Show the WYSIWYG/Source mode toggle. Default `true`. */
58
75
  showModeToggle?: boolean;
59
76
  /** Label for the toggle when in WYSIWYG mode (switches to source). */
@@ -60,6 +60,8 @@ Bind the component instance to access:
60
60
  | `validate` | `boolean \| ValidateOptions` | – | Pass a `customValidator` to validate markdown |
61
61
  | `renderSize` | `"sm" \| "md" \| "lg"` | `"md"` | |
62
62
  | `toolbar` | `boolean \| ToolbarItem[]` | `true` | `true` = default toolbar, `false` = none, or an ordered button list |
63
+ | `maxHeight` | `number \| string` | `32rem` | Surface height cap (`number` = px). Long docs scroll internally |
64
+ | `capToParent` | `boolean` | `true` | Also cap the surface to the parent's available height |
63
65
  | `showModeToggle` | `boolean` | `true` | Show the WYSIWYG/source toggle |
64
66
  | `name` | `string` | – | Hidden input name for form submission |
65
67
 
@@ -95,6 +97,33 @@ Available items: `bold`, `italic`, `heading1`, `heading2`, `heading3`, `link`,
95
97
  `hardBreak`, `undo`, `redo`, and `"|"` (separator). The default layout is
96
98
  exported as `DEFAULT_TOOLBAR`.
97
99
 
100
+ ### Height & scrolling
101
+
102
+ A long document never grows the editor unbounded (which would scroll the toolbar
103
+ out of view). The editing **surface** has a finite max height and scrolls
104
+ internally instead:
105
+
106
+ - **Fixed cap** — `maxHeight` (`number` → px, or any CSS length string like
107
+ `"40rem"` / `"60vh"`), defaulting to `32rem` (themeable via
108
+ `--stuic-markdown-editor-max-height`).
109
+ - **Parent cap** — with `capToParent` on (default), the surface is _also_ capped
110
+ to the height available in the parent container, measured at runtime. The
111
+ **smaller** of the two limits wins, so the editor fills — but never overflows —
112
+ a height-constrained parent (e.g. a flex column, modal body, or split pane).
113
+
114
+ ```svelte
115
+ <!-- explicit fixed cap; ignore the parent -->
116
+ <MarkdownEditor bind:value maxHeight="20rem" capToParent={false} />
117
+
118
+ <!-- fill a height-constrained pane, scrolling internally past its available height -->
119
+ <div style="height: 60vh; display: flex; flex-direction: column;">
120
+ <MarkdownEditor bind:value maxHeight="100vh" />
121
+ </div>
122
+ ```
123
+
124
+ The minimum height (`--stuic-markdown-editor-min-height`, `12rem` for `md`) still
125
+ applies, so the surface never collapses below a usable editing area.
126
+
98
127
  ### Link / image URL prompt
99
128
 
100
129
  By default the link and image buttons ask for a URL via the native
@@ -146,7 +175,7 @@ input/global tokens:
146
175
  --stuic-markdown-editor-border-focus
147
176
  --stuic-markdown-editor-bg
148
177
  --stuic-markdown-editor-min-height /* default 12rem (9/16rem for sm/lg) */
149
- --stuic-markdown-editor-max-height
178
+ --stuic-markdown-editor-max-height /* default 32rem; see "Height & scrolling" */
150
179
  --stuic-markdown-editor-font-size
151
180
  --stuic-markdown-editor-font-mono
152
181
  --stuic-markdown-editor-code-bg
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure helpers for the MarkdownEditor's editing-surface max-height resolution.
3
+ *
4
+ * Deliberately framework-free (no DOM, no Svelte) so the height-capping logic is
5
+ * unit-testable without a browser/layout engine. `MarkdownEditor.svelte` does the
6
+ * actual DOM measurement and feeds the resulting numbers through these helpers.
7
+ */
8
+ /**
9
+ * Base cap applied to the editing surface, as a CSS expression. Reads the
10
+ * themeable `--stuic-markdown-editor-max-height` custom property, falling back
11
+ * to `32rem`. The `32rem` fallback is kept in sync with the same value in
12
+ * `index.css` (`.stuic-markdown-editor-surface`).
13
+ */
14
+ export declare const DEFAULT_MAX_HEIGHT_VAR = "var(--stuic-markdown-editor-max-height, 32rem)";
15
+ /**
16
+ * Normalize a `maxHeight` prop value into a CSS length.
17
+ * - `number` → pixels (`240` → `"240px"`)
18
+ * - `string` → used verbatim (any CSS length: `"40rem"`, `"60vh"`, `"min(40rem,50vh)"`)
19
+ * - `null` / `undefined` / empty / non-finite → `null` (caller uses the default)
20
+ */
21
+ export declare function maxHeightToCss(value: number | string | null | undefined): string | null;
22
+ /**
23
+ * Combine the configured cap (`base`, a CSS length/expression) with the measured
24
+ * parent-available height. When a positive `parentAvailablePx` is given the
25
+ * surface is capped to the smaller of the two via CSS `min()`, so it never
26
+ * overflows a height-constrained parent. Otherwise the base cap is returned
27
+ * unchanged.
28
+ */
29
+ export declare function surfaceMaxHeight(base: string, parentAvailablePx: number | null): string;
30
+ /** Inputs for {@link computeParentAvailable}, in CSS px (viewport coords for tops/bottoms). */
31
+ export interface ParentAvailableInput {
32
+ /** Viewport-relative top of the element we measure from (the editing surface). */
33
+ fromTop: number;
34
+ /** Viewport-relative bottom of the parent's border box (`getBoundingClientRect().bottom`). */
35
+ parentBottom: number;
36
+ /** Parent computed `border-bottom-width` in px. Default `0`. */
37
+ parentBorderBottom?: number;
38
+ /** Parent computed `padding-bottom` in px. Default `0`. */
39
+ parentPaddingBottom?: number;
40
+ /** Extra breathing room to leave below the editor, in px. Default `0`. */
41
+ bottomGap?: number;
42
+ }
43
+ /**
44
+ * Compute the vertical space available to the editor inside its parent: the
45
+ * distance from `fromTop` down to the bottom of the parent's content box, less
46
+ * an optional `bottomGap`.
47
+ *
48
+ * Returns `null` when the result is not positive — the parent is smaller than
49
+ * the editor's offset (e.g. not laid out yet), so no meaningful cap can be
50
+ * derived and the caller should fall back to the configured base.
51
+ *
52
+ * Note: in an auto-height (content-sized) parent this naturally returns roughly
53
+ * the editor's own height, so `surfaceMaxHeight(base, …)` resolves to the base
54
+ * cap and the parent constraint only bites when the parent is genuinely smaller.
55
+ */
56
+ export declare function computeParentAvailable(input: ParentAvailableInput): number | null;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pure helpers for the MarkdownEditor's editing-surface max-height resolution.
3
+ *
4
+ * Deliberately framework-free (no DOM, no Svelte) so the height-capping logic is
5
+ * unit-testable without a browser/layout engine. `MarkdownEditor.svelte` does the
6
+ * actual DOM measurement and feeds the resulting numbers through these helpers.
7
+ */
8
+ /**
9
+ * Base cap applied to the editing surface, as a CSS expression. Reads the
10
+ * themeable `--stuic-markdown-editor-max-height` custom property, falling back
11
+ * to `32rem`. The `32rem` fallback is kept in sync with the same value in
12
+ * `index.css` (`.stuic-markdown-editor-surface`).
13
+ */
14
+ export const DEFAULT_MAX_HEIGHT_VAR = "var(--stuic-markdown-editor-max-height, 32rem)";
15
+ /**
16
+ * Normalize a `maxHeight` prop value into a CSS length.
17
+ * - `number` → pixels (`240` → `"240px"`)
18
+ * - `string` → used verbatim (any CSS length: `"40rem"`, `"60vh"`, `"min(40rem,50vh)"`)
19
+ * - `null` / `undefined` / empty / non-finite → `null` (caller uses the default)
20
+ */
21
+ export function maxHeightToCss(value) {
22
+ if (value == null)
23
+ return null;
24
+ if (typeof value === "number")
25
+ return Number.isFinite(value) ? `${value}px` : null;
26
+ const trimmed = value.trim();
27
+ return trimmed.length ? trimmed : null;
28
+ }
29
+ /**
30
+ * Combine the configured cap (`base`, a CSS length/expression) with the measured
31
+ * parent-available height. When a positive `parentAvailablePx` is given the
32
+ * surface is capped to the smaller of the two via CSS `min()`, so it never
33
+ * overflows a height-constrained parent. Otherwise the base cap is returned
34
+ * unchanged.
35
+ */
36
+ export function surfaceMaxHeight(base, parentAvailablePx) {
37
+ if (parentAvailablePx != null && parentAvailablePx > 0) {
38
+ return `min(${base}, ${Math.round(parentAvailablePx)}px)`;
39
+ }
40
+ return base;
41
+ }
42
+ /**
43
+ * Compute the vertical space available to the editor inside its parent: the
44
+ * distance from `fromTop` down to the bottom of the parent's content box, less
45
+ * an optional `bottomGap`.
46
+ *
47
+ * Returns `null` when the result is not positive — the parent is smaller than
48
+ * the editor's offset (e.g. not laid out yet), so no meaningful cap can be
49
+ * derived and the caller should fall back to the configured base.
50
+ *
51
+ * Note: in an auto-height (content-sized) parent this naturally returns roughly
52
+ * the editor's own height, so `surfaceMaxHeight(base, …)` resolves to the base
53
+ * cap and the parent constraint only bites when the parent is genuinely smaller.
54
+ */
55
+ export function computeParentAvailable(input) {
56
+ const { fromTop, parentBottom, parentBorderBottom = 0, parentPaddingBottom = 0, bottomGap = 0, } = input;
57
+ const contentBottom = parentBottom - parentBorderBottom - parentPaddingBottom;
58
+ const available = contentBottom - fromTop - bottomGap;
59
+ return available > 0 ? Math.floor(available) : null;
60
+ }
@@ -157,7 +157,12 @@
157
157
  flex: 1;
158
158
  min-width: 0;
159
159
  min-height: var(--_min-height);
160
- max-height: var(--stuic-markdown-editor-max-height, none);
160
+ /* A finite default cap so a long document scrolls INSIDE the surface instead
161
+ of growing the editor unbounded (which scrolls the toolbar out of view).
162
+ `32rem` is kept in sync with DEFAULT_MAX_HEIGHT_VAR in _internal/max-height.ts.
163
+ The component may narrow this further (inline `max-height`) to the parent's
164
+ available height — see the `maxHeight` / `capToParent` props. */
165
+ max-height: var(--stuic-markdown-editor-max-height, 32rem);
161
166
  overflow: auto;
162
167
  padding: var(--_pad-y) var(--_pad-x);
163
168
  }
@@ -8,23 +8,23 @@
8
8
 
9
9
  ## Available Actions
10
10
 
11
- | Action | Purpose | File |
12
- | ----------------------- | ------------------------------------------------------------- | ------------------------------------ |
13
- | `validate` | Form field validation with i18n support | `validate.svelte.ts` |
14
- | `focusTrap` | Keyboard focus containment (modals/dialogs) | `focus-trap.ts` |
15
- | `autogrow` | Auto-resize textarea to content | `autogrow.svelte.ts` |
16
- | `autoscroll` | Auto-scroll container to bottom | `autoscroll.ts` |
17
- | `dimBehind` | Dim everything behind a target element (simplified spotlight) | `dim-behind/` |
18
- | `fileDropzone` | Drag-and-drop file handling | `file-dropzone.svelte.ts` |
19
- | `highlightDragover` | Visual feedback on drag-over | `highlight-dragover.svelte.ts` |
20
- | `resizableWidth` | Draggable width resizing | `resizable-width.svelte.ts` |
21
- | `trim` | Auto-trim whitespace from input | `trim.svelte.ts` |
22
- | `typeahead` | Advanced autocomplete behavior | `typeahead.svelte.ts` |
23
- | `onSubmitValidityCheck` | Form submit validation | `on-submit-validity-check.svelte.ts` |
24
- | `popover` | Popover positioning | `popover/` |
25
- | `spotlight` | Spotlight/coach mark overlay with cutout hole | `spotlight/` |
26
- | `tooltip` | Tooltip positioning and display | `tooltip/` |
27
- | `createTour` / `tourStep` | Multi-step onboarding tour (built on spotlight) | `onboarding/` |
11
+ | Action | Purpose | File |
12
+ | ------------------------- | ------------------------------------------------------------- | ------------------------------------ |
13
+ | `validate` | Form field validation with i18n support | `validate.svelte.ts` |
14
+ | `focusTrap` | Keyboard focus containment (modals/dialogs) | `focus-trap.ts` |
15
+ | `autogrow` | Auto-resize textarea to content | `autogrow.svelte.ts` |
16
+ | `autoscroll` | Auto-scroll container to bottom | `autoscroll.ts` |
17
+ | `dimBehind` | Dim everything behind a target element (simplified spotlight) | `dim-behind/` |
18
+ | `fileDropzone` | Drag-and-drop file handling | `file-dropzone.svelte.ts` |
19
+ | `highlightDragover` | Visual feedback on drag-over | `highlight-dragover.svelte.ts` |
20
+ | `resizableWidth` | Draggable width resizing | `resizable-width.svelte.ts` |
21
+ | `trim` | Auto-trim whitespace from input | `trim.svelte.ts` |
22
+ | `typeahead` | Advanced autocomplete behavior | `typeahead.svelte.ts` |
23
+ | `onSubmitValidityCheck` | Form submit validation | `on-submit-validity-check.svelte.ts` |
24
+ | `popover` | Popover positioning | `popover/` |
25
+ | `spotlight` | Spotlight/coach mark overlay with cutout hole | `spotlight/` |
26
+ | `tooltip` | Tooltip positioning and display | `tooltip/` |
27
+ | `createTour` / `tourStep` | Multi-step onboarding tour (built on spotlight) | `onboarding/` |
28
28
 
29
29
  ---
30
30
 
@@ -116,8 +116,8 @@ inline messages to render. They won't — the `validate` action's
116
116
 
117
117
  ### Hidden inputs and `required`
118
118
 
119
- Per the HTML spec, `<input type="hidden">` is *barred from constraint
120
- validation* — `validity.valueMissing` stays `false` regardless of the
119
+ Per the HTML spec, `<input type="hidden">` is _barred from constraint
120
+ validation_ — `validity.valueMissing` stays `false` regardless of the
121
121
  `required` attribute, and native browser submit blocking is skipped. Several
122
122
  STUIC field components (`FieldPhoneNumber`, `FieldCountry`, `FieldObject`,
123
123
  `FieldAssets`, `FieldInputLocalized`, `FieldKeyValues`, `FieldLikeButton`)
@@ -138,6 +138,29 @@ correctly fail validation when empty — both through the imperative
138
138
  `validate()` path and via `use:onSubmitValidityCheck`. Without this wrap
139
139
  they'd silently accept empty values.
140
140
 
141
+ ### `onSubmitValidityCheck` and synthetic events
142
+
143
+ On submit the action walks `form.elements` and synthetically dispatches
144
+ `input` + `change` on each control, so custom `validate` listeners run even on
145
+ fields the user never touched. `form.elements` includes CSS-hidden controls
146
+ (`display:none` does **not** remove an element — only `disabled` or being
147
+ outside the form does), so this fan-out reaches every wired input.
148
+
149
+ Two element types are deliberately exempt from the synthetic dispatch:
150
+
151
+ - **`type="radio"`** — dispatching `change` auto-selects the last radio in the
152
+ group, which is wrong.
153
+ - **`type="file"`** — a file input's value is read-only to script, so a
154
+ synthetic `change` can't re-validate it; worse, it re-triggers any dropzone /
155
+ upload listener bound to the input. `FieldAssets` wires its hidden
156
+ `<input type="file">` through `fileDropzone`, so a re-fired `change` would
157
+ re-run `processFiles` with the previously-picked file still in `inputEl.files`
158
+ — duplicating the asset and firing a real re-upload on every save. File inputs
159
+ are still read for native constraint validation (`required`); only the event
160
+ dispatch is skipped. (`FieldAssets` additionally clears its file input after
161
+ consuming a selection, both as defense-in-depth and to allow re-selecting the
162
+ same file twice in a row.)
163
+
141
164
  ### File Dropzone
142
165
 
143
166
  ```svelte
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.110.0",
3
+ "version": "3.112.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",