@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.
- package/dist/actions/on-submit-validity-check.svelte.js +17 -4
- package/dist/components/Input/FieldAssets.svelte +28 -5
- package/dist/components/MarkdownEditor/MarkdownEditor.svelte +99 -0
- package/dist/components/MarkdownEditor/MarkdownEditor.svelte.d.ts +17 -0
- package/dist/components/MarkdownEditor/README.md +30 -1
- package/dist/components/MarkdownEditor/_internal/max-height.d.ts +56 -0
- package/dist/components/MarkdownEditor/_internal/max-height.js +60 -0
- package/dist/components/MarkdownEditor/index.css +6 -1
- package/docs/domains/actions.md +42 -19
- package/package.json +1 -1
|
@@ -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 (
|
|
84
|
-
el.setCustomValidity
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
|
474
|
-
if (assets.length
|
|
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
|
-
|
|
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
|
}
|
package/docs/domains/actions.md
CHANGED
|
@@ -8,23 +8,23 @@
|
|
|
8
8
|
|
|
9
9
|
## Available Actions
|
|
10
10
|
|
|
11
|
-
| Action
|
|
12
|
-
|
|
|
13
|
-
| `validate`
|
|
14
|
-
| `focusTrap`
|
|
15
|
-
| `autogrow`
|
|
16
|
-
| `autoscroll`
|
|
17
|
-
| `dimBehind`
|
|
18
|
-
| `fileDropzone`
|
|
19
|
-
| `highlightDragover`
|
|
20
|
-
| `resizableWidth`
|
|
21
|
-
| `trim`
|
|
22
|
-
| `typeahead`
|
|
23
|
-
| `onSubmitValidityCheck`
|
|
24
|
-
| `popover`
|
|
25
|
-
| `spotlight`
|
|
26
|
-
| `tooltip`
|
|
27
|
-
| `createTour` / `tourStep` | Multi-step onboarding tour (built on spotlight)
|
|
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
|
|
120
|
-
|
|
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
|