@marianmeres/stuic 3.111.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.
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.111.0",
3
+ "version": "3.112.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && pnpm run prepack",