@marianmeres/stuic 3.132.0 → 3.133.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.
@@ -0,0 +1,547 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLTextareaAttributes } from "svelte/elements";
4
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
5
+ import type { THC } from "../Thc/Thc.svelte";
6
+ import type { InputWrapClassProps } from "../Input/types.js";
7
+ import type { MarkdownRenderer } from "./_internal/render.js";
8
+
9
+ type SnippetWithId = Snippet<[{ id: string }]>;
10
+
11
+ /** Active tab of the comment box. */
12
+ export type CommentInputMode = "write" | "preview";
13
+
14
+ export interface Props
15
+ extends
16
+ Omit<HTMLTextareaAttributes, "value" | "class" | "style">,
17
+ InputWrapClassProps {
18
+ /** Comment text (raw markdown). Bindable. */
19
+ value?: string;
20
+ /** Active tab. Bindable. Default `"write"`. */
21
+ mode?: CommentInputMode;
22
+ /** The underlying `<textarea>` element. Bindable. */
23
+ input?: HTMLTextAreaElement;
24
+ /** Name attribute of the textarea, for native form submission. */
25
+ name?: string;
26
+ //
27
+ label?: SnippetWithId | THC;
28
+ description?: SnippetWithId | THC;
29
+ class?: string;
30
+ id?: string;
31
+ tabindex?: number;
32
+ renderSize?: "sm" | "md" | "lg" | string;
33
+ required?: boolean;
34
+ disabled?: boolean;
35
+ placeholder?: string;
36
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
37
+ /** Trim surrounding whitespace on blur. Default `true`. */
38
+ useTrim?: boolean;
39
+ /** Grow the textarea with its content (up to `max`). Default `true`. */
40
+ useAutogrow?: boolean | { enabled?: boolean; max?: number };
41
+ //
42
+ /** Show the Write/Preview tabs. Default `true`. Set `false` for a plain markdown textarea. */
43
+ showTabs?: boolean;
44
+ /** Label for the "write" tab. Default `"Write"`. */
45
+ writeLabel?: string;
46
+ /** Label for the "preview" tab. Default `"Preview"`. */
47
+ previewLabel?: string;
48
+ /** Show the subtle "Markdown supported" hint in the tab bar. Default `true`. */
49
+ showMarkdownHint?: boolean;
50
+ /** Text of the markdown hint. Default `"Markdown supported"`. */
51
+ markdownHintLabel?: string;
52
+ /** Placeholder shown in the Preview tab when there is nothing to render. */
53
+ previewEmptyLabel?: string;
54
+ /** Text shown in the Preview tab while the renderer is (lazy-)loading. */
55
+ previewLoadingLabel?: string;
56
+ /**
57
+ * Override the Preview renderer. Receives the raw markdown, returns (or
58
+ * resolves to) an HTML string that is rendered as-is — **you are responsible
59
+ * for sanitizing it**. When omitted, the bundled marked + DOMPurify pipeline
60
+ * is used (lazy-loaded; requires the optional `marked` / `dompurify` peers).
61
+ */
62
+ renderMarkdown?: MarkdownRenderer;
63
+ //
64
+ /**
65
+ * Submit handler. Receives the current value. May be async — the submit
66
+ * button shows a spinner and the box is disabled while it resolves.
67
+ */
68
+ onSubmit?: (value: string) => void | Promise<void>;
69
+ /** Cancel handler. When set, a Cancel button is shown by default. */
70
+ onCancel?: () => void;
71
+ /** Fired on every edit with the new value. */
72
+ onChange?: (value: string) => void;
73
+ /** Submit button label. Default `"Comment"`. */
74
+ submitLabel?: string;
75
+ /** Cancel button label. Default `"Cancel"`. */
76
+ cancelLabel?: string;
77
+ /** Show the submit button. Defaults to `true` when `onSubmit` is set. */
78
+ showSubmit?: boolean;
79
+ /** Show the cancel button. Defaults to `true` when `onCancel` is set. */
80
+ showCancel?: boolean;
81
+ /** Submit on ⌘/Ctrl+Enter while the textarea is focused. Default `true`. */
82
+ submitOnModEnter?: boolean;
83
+ /** Clear the value after a successful submit. Default `true`. */
84
+ clearOnSubmit?: boolean;
85
+ /** Disable the submit button when the value is empty/whitespace. Default `true`. */
86
+ submitDisabledWhenEmpty?: boolean;
87
+ /** Externally-controlled busy state (disables the box + submit). */
88
+ busy?: boolean;
89
+ //
90
+ labelAfter?: SnippetWithId | THC;
91
+ below?: SnippetWithId | THC;
92
+ labelLeft?: boolean;
93
+ labelLeftWidth?: "normal" | "wide";
94
+ labelLeftBreakpoint?: number;
95
+ //
96
+ /** Optional avatar/gutter rendered to the left of the box. */
97
+ avatar?: SnippetWithId | THC;
98
+ /** Extra content rendered in the footer, left of the buttons (e.g. attach). */
99
+ footer?: Snippet;
100
+ //
101
+ /** Classes for the `<textarea>` element. */
102
+ classInput?: string;
103
+ /** Classes for the tab bar. */
104
+ classBar?: string;
105
+ /** Classes for the preview panel. */
106
+ classPreview?: string;
107
+ /** Classes for the footer (button row). */
108
+ classFooter?: string;
109
+ style?: string;
110
+ }
111
+ </script>
112
+
113
+ <script lang="ts">
114
+ import { autogrow } from "../../actions/autogrow.svelte.js";
115
+ import { trim } from "../../actions/trim.svelte.js";
116
+ import {
117
+ validate as validateAction,
118
+ type ValidationResult,
119
+ } from "../../actions/validate.svelte.js";
120
+ import { getId } from "../../utils/get-id.js";
121
+ import { twMerge } from "../../utils/tw-merge.js";
122
+ import { Button } from "../Button/index.js";
123
+ import Thc from "../Thc/Thc.svelte";
124
+ import InputWrap from "../Input/_internal/InputWrap.svelte";
125
+ import { renderMarkdownSafe } from "./_internal/render.js";
126
+
127
+ let {
128
+ value = $bindable(""),
129
+ mode = $bindable("write"),
130
+ input = $bindable(),
131
+ name,
132
+ //
133
+ label = "",
134
+ description,
135
+ class: classProp,
136
+ id = getId(),
137
+ tabindex = 0,
138
+ renderSize = "md",
139
+ required = false,
140
+ disabled = false,
141
+ placeholder,
142
+ // Renamed to avoid collision with the exported `validate()` below.
143
+ validate: validateProp,
144
+ useTrim = true,
145
+ useAutogrow = true,
146
+ //
147
+ showTabs = true,
148
+ writeLabel = "Write",
149
+ previewLabel = "Preview",
150
+ showMarkdownHint = true,
151
+ markdownHintLabel = "Markdown supported",
152
+ previewEmptyLabel = "Nothing to preview",
153
+ previewLoadingLabel = "Loading preview…",
154
+ renderMarkdown,
155
+ //
156
+ onSubmit,
157
+ onCancel,
158
+ onChange,
159
+ submitLabel = "Comment",
160
+ cancelLabel = "Cancel",
161
+ showSubmit,
162
+ showCancel,
163
+ submitOnModEnter = true,
164
+ clearOnSubmit = true,
165
+ submitDisabledWhenEmpty = true,
166
+ busy = false,
167
+ //
168
+ labelAfter,
169
+ below,
170
+ labelLeft = false,
171
+ labelLeftWidth = "normal",
172
+ labelLeftBreakpoint = 480,
173
+ //
174
+ avatar,
175
+ footer,
176
+ //
177
+ classInput,
178
+ classBar,
179
+ classPreview,
180
+ classFooter,
181
+ classLabel,
182
+ classLabelBox,
183
+ classInputBox,
184
+ classInputBoxWrap,
185
+ classInputBoxWrapInvalid,
186
+ classDescBox,
187
+ classDescBoxToggle,
188
+ classBelowBox,
189
+ classValidationBox,
190
+ style,
191
+ //
192
+ ...rest
193
+ }: Props = $props();
194
+
195
+ // --- Validation (mirrors FieldTextarea: action lives on the real textarea) ---
196
+ let validation: ValidationResult | undefined = $state();
197
+ const setValidationResult = (res: ValidationResult) => (validation = res);
198
+ let _doValidate: (() => void) | undefined = $state();
199
+
200
+ // --- Submit state ---------------------------------------------------------
201
+ let submitting = $state(false);
202
+ const isEmpty = $derived(!(value ?? "").trim());
203
+ // Resolve the show* defaults from the presence of their handlers.
204
+ const _showSubmit = $derived(showSubmit ?? !!onSubmit);
205
+ const _showCancel = $derived(showCancel ?? !!onCancel);
206
+ const _busy = $derived(disabled || busy || submitting);
207
+ const canSubmit = $derived(!_busy && !(submitDisabledWhenEmpty && isEmpty));
208
+
209
+ // --- Preview rendering ----------------------------------------------------
210
+ let previewHtml = $state("");
211
+ let previewError = $state(false);
212
+ let previewLoading = $state(false);
213
+
214
+ $effect(() => {
215
+ if (mode !== "preview" || !showTabs) return;
216
+ const md = value ?? "";
217
+ if (!md.trim()) {
218
+ previewHtml = "";
219
+ previewError = false;
220
+ previewLoading = false;
221
+ return;
222
+ }
223
+ const render = renderMarkdown ?? renderMarkdownSafe;
224
+ let cancelled = false;
225
+ previewLoading = true;
226
+ // Drop the previous value's HTML so the loading placeholder shows instead of
227
+ // briefly rendering content that no longer matches `value`.
228
+ previewHtml = "";
229
+ previewError = false;
230
+ (async () => {
231
+ try {
232
+ const html = await render(md);
233
+ if (cancelled) return;
234
+ previewHtml = html;
235
+ previewError = false;
236
+ } catch (err) {
237
+ if (cancelled) return;
238
+ previewHtml = "";
239
+ previewError = true;
240
+ // eslint-disable-next-line no-console
241
+ console.warn(
242
+ "[CommentInput] preview renderer failed. When using the default " +
243
+ "renderer, make sure the optional peers `marked` and `dompurify` " +
244
+ "are installed, or pass a `renderMarkdown` function.",
245
+ err
246
+ );
247
+ } finally {
248
+ if (!cancelled) previewLoading = false;
249
+ }
250
+ })();
251
+ return () => {
252
+ cancelled = true;
253
+ };
254
+ });
255
+
256
+ // Returning to the Write tab: re-measure the textarea. autogrow can't size a
257
+ // `display:none` element, so an external value change made while previewing
258
+ // would otherwise leave the textarea at a stale height.
259
+ $effect(() => {
260
+ if (mode !== "write" || !input || !useAutogrow) return;
261
+ const el = input;
262
+ const max =
263
+ typeof useAutogrow === "object" && useAutogrow.max ? useAutogrow.max : 250;
264
+ requestAnimationFrame(() => {
265
+ el.style.height = "auto";
266
+ const cs = getComputedStyle(el);
267
+ const borderY =
268
+ cs.boxSizing === "border-box"
269
+ ? parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
270
+ : 0;
271
+ el.style.height = Math.min(el.scrollHeight + borderY, max) + "px";
272
+ });
273
+ });
274
+
275
+ // --- Handlers -------------------------------------------------------------
276
+ async function handleSubmit() {
277
+ if (!canSubmit) return;
278
+ // Validate before invoking the handler — the submit button bypasses any
279
+ // form-level submit check, so enforce it here (surfaces the inline message).
280
+ const res = validate();
281
+ if (res && !res.valid) {
282
+ mode = "write";
283
+ return;
284
+ }
285
+ try {
286
+ submitting = true;
287
+ await onSubmit?.(value ?? "");
288
+ if (clearOnSubmit) {
289
+ value = "";
290
+ // Back to Write so the (now empty) box is editable again.
291
+ if (mode === "preview") mode = "write";
292
+ validation = undefined;
293
+ input?.setCustomValidity?.("");
294
+ }
295
+ } finally {
296
+ submitting = false;
297
+ }
298
+ }
299
+
300
+ function handleCancel() {
301
+ onCancel?.();
302
+ }
303
+
304
+ function handleKeydown(e: KeyboardEvent) {
305
+ if (submitOnModEnter && (e.metaKey || e.ctrlKey) && e.key === "Enter") {
306
+ e.preventDefault();
307
+ handleSubmit();
308
+ }
309
+ }
310
+
311
+ function setMode(next: CommentInputMode) {
312
+ if (disabled) return;
313
+ mode = next;
314
+ }
315
+
316
+ // Roving-tabindex keyboard nav for the Write/Preview tablist (automatic
317
+ // activation — Arrow/Home/End move selection and focus together).
318
+ function handleTabKeydown(e: KeyboardEvent) {
319
+ const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"];
320
+ if (!keys.includes(e.key)) return;
321
+ e.preventDefault();
322
+ const next: CommentInputMode =
323
+ e.key === "Home"
324
+ ? "write"
325
+ : e.key === "End"
326
+ ? "preview"
327
+ : mode === "write"
328
+ ? "preview"
329
+ : "write";
330
+ setMode(next);
331
+ if (disabled) return;
332
+ document.getElementById(`${id}-tab-${next}`)?.focus();
333
+ }
334
+
335
+ // --- Imperative API (mirrors FieldTextarea) -------------------------------
336
+ /** Trigger validation now. Renders the inline message if invalid. */
337
+ export function validate(): ValidationResult | undefined {
338
+ _doValidate?.();
339
+ return validation;
340
+ }
341
+ /** Clear the inline validation message and reset `setCustomValidity`. */
342
+ export function clearValidation(): void {
343
+ validation = undefined;
344
+ input?.setCustomValidity?.("");
345
+ }
346
+ /** Current validation state, or undefined if the validator has never run. */
347
+ export function getValidation(): ValidationResult | undefined {
348
+ return validation;
349
+ }
350
+ /** Focus the underlying `<textarea>` (switches to the Write tab first). */
351
+ export function focus(): void {
352
+ mode = "write";
353
+ input?.focus?.();
354
+ }
355
+ /** Scroll the field into view. Defaults to smooth + center. */
356
+ export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
357
+ input?.scrollIntoView?.({ behavior: "smooth", block: "center", ...opts });
358
+ }
359
+ /** Programmatically run the submit flow (respects the empty/busy guards). */
360
+ export function submit(): void {
361
+ handleSubmit();
362
+ }
363
+ </script>
364
+
365
+ {#snippet thcOrSnippet(value?: SnippetWithId | THC)}
366
+ {#if typeof value === "function"}
367
+ {@render value({ id })}
368
+ {:else if value}
369
+ <Thc thc={value} forceAsHtml />
370
+ {/if}
371
+ {/snippet}
372
+
373
+ <InputWrap
374
+ {description}
375
+ class={classProp}
376
+ size={renderSize}
377
+ {id}
378
+ {label}
379
+ {labelAfter}
380
+ {below}
381
+ {required}
382
+ {disabled}
383
+ {labelLeft}
384
+ {labelLeftWidth}
385
+ {labelLeftBreakpoint}
386
+ {classLabel}
387
+ {classLabelBox}
388
+ {classInputBox}
389
+ {classInputBoxWrap}
390
+ {classInputBoxWrapInvalid}
391
+ {classDescBox}
392
+ {classDescBoxToggle}
393
+ {classBelowBox}
394
+ {classValidationBox}
395
+ {validation}
396
+ {style}
397
+ >
398
+ <div class="stuic-comment-input" data-size={renderSize} class:disabled>
399
+ {#if avatar}
400
+ <div class="stuic-comment-input-avatar">
401
+ {@render thcOrSnippet(avatar)}
402
+ </div>
403
+ {/if}
404
+
405
+ <div class="stuic-comment-input-editor" data-mode={mode}>
406
+ {#if showTabs || showMarkdownHint}
407
+ <div class={twMerge("stuic-comment-input-bar", classBar)}>
408
+ {#if showTabs}
409
+ <div
410
+ class="stuic-comment-input-tabs"
411
+ role="tablist"
412
+ aria-label="Comment editor"
413
+ >
414
+ <button
415
+ id={`${id}-tab-write`}
416
+ type="button"
417
+ role="tab"
418
+ aria-selected={mode === "write"}
419
+ aria-controls={`${id}-panel-write`}
420
+ tabindex={mode === "write" ? 0 : -1}
421
+ data-active={mode === "write" ? "true" : undefined}
422
+ {disabled}
423
+ onclick={() => setMode("write")}
424
+ onkeydown={handleTabKeydown}
425
+ >
426
+ {writeLabel}
427
+ </button>
428
+ <button
429
+ id={`${id}-tab-preview`}
430
+ type="button"
431
+ role="tab"
432
+ aria-selected={mode === "preview"}
433
+ aria-controls={`${id}-panel-preview`}
434
+ tabindex={mode === "preview" ? 0 : -1}
435
+ data-active={mode === "preview" ? "true" : undefined}
436
+ {disabled}
437
+ onclick={() => setMode("preview")}
438
+ onkeydown={handleTabKeydown}
439
+ >
440
+ {previewLabel}
441
+ </button>
442
+ </div>
443
+ {/if}
444
+ {#if showMarkdownHint && mode === "write"}
445
+ <span class="stuic-comment-input-hint">{markdownHintLabel}</span>
446
+ {/if}
447
+ </div>
448
+ {/if}
449
+
450
+ <div class="stuic-comment-input-surface">
451
+ <div
452
+ class="stuic-comment-input-panel"
453
+ id={`${id}-panel-write`}
454
+ role={showTabs ? "tabpanel" : undefined}
455
+ aria-labelledby={showTabs ? `${id}-tab-write` : undefined}
456
+ hidden={showTabs && mode === "preview"}
457
+ >
458
+ <textarea
459
+ bind:value
460
+ bind:this={input}
461
+ {...rest}
462
+ {id}
463
+ {name}
464
+ {placeholder}
465
+ {tabindex}
466
+ {required}
467
+ disabled={_busy}
468
+ class={twMerge("stuic-comment-input-textarea", classInput)}
469
+ oninput={(e) => onChange?.(e.currentTarget.value)}
470
+ onkeydown={handleKeydown}
471
+ use:trim={() => ({
472
+ enabled: useTrim,
473
+ setValue: (v: string) => (value = v),
474
+ })}
475
+ use:validateAction={() => ({
476
+ enabled: validateProp !== false,
477
+ ...(typeof validateProp === "boolean" ? {} : validateProp),
478
+ setValidationResult,
479
+ setDoValidate: (fn) => (_doValidate = fn),
480
+ })}
481
+ use:autogrow={() => ({
482
+ enabled: !!useAutogrow,
483
+ ...(typeof useAutogrow === "boolean" ? {} : useAutogrow),
484
+ value,
485
+ })}
486
+ ></textarea>
487
+ </div>
488
+
489
+ {#if showTabs && mode === "preview"}
490
+ <div
491
+ class={twMerge(
492
+ "stuic-comment-input-panel stuic-comment-input-preview",
493
+ classPreview
494
+ )}
495
+ id={`${id}-panel-preview`}
496
+ role="tabpanel"
497
+ aria-labelledby={`${id}-tab-preview`}
498
+ >
499
+ {#if previewError}
500
+ <p class="stuic-comment-input-preview-empty">Preview unavailable.</p>
501
+ {:else if !(value ?? "").trim()}
502
+ <p class="stuic-comment-input-preview-empty">{previewEmptyLabel}</p>
503
+ {:else if previewLoading && !previewHtml}
504
+ <p class="stuic-comment-input-preview-empty">{previewLoadingLabel}</p>
505
+ {:else}
506
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
507
+ {@html previewHtml}
508
+ {/if}
509
+ </div>
510
+ {/if}
511
+ </div>
512
+
513
+ {#if _showSubmit || _showCancel || footer}
514
+ <div class={twMerge("stuic-comment-input-footer", classFooter)}>
515
+ <div class="stuic-comment-input-footer-start">
516
+ {@render footer?.()}
517
+ </div>
518
+ <div class="stuic-comment-input-footer-end">
519
+ {#if _showCancel}
520
+ <Button
521
+ type="button"
522
+ variant="ghost"
523
+ size={renderSize}
524
+ disabled={submitting}
525
+ onclick={handleCancel}
526
+ >
527
+ {cancelLabel}
528
+ </Button>
529
+ {/if}
530
+ {#if _showSubmit}
531
+ <Button
532
+ type="button"
533
+ intent="primary"
534
+ size={renderSize}
535
+ disabled={!canSubmit}
536
+ spinner={submitting}
537
+ onclick={handleSubmit}
538
+ >
539
+ {submitLabel}
540
+ </Button>
541
+ {/if}
542
+ </div>
543
+ </div>
544
+ {/if}
545
+ </div>
546
+ </div>
547
+ </InputWrap>
@@ -0,0 +1,113 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLTextareaAttributes } from "svelte/elements";
3
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+ import type { InputWrapClassProps } from "../Input/types.js";
6
+ import type { MarkdownRenderer } from "./_internal/render.js";
7
+ type SnippetWithId = Snippet<[{
8
+ id: string;
9
+ }]>;
10
+ /** Active tab of the comment box. */
11
+ export type CommentInputMode = "write" | "preview";
12
+ export interface Props extends Omit<HTMLTextareaAttributes, "value" | "class" | "style">, InputWrapClassProps {
13
+ /** Comment text (raw markdown). Bindable. */
14
+ value?: string;
15
+ /** Active tab. Bindable. Default `"write"`. */
16
+ mode?: CommentInputMode;
17
+ /** The underlying `<textarea>` element. Bindable. */
18
+ input?: HTMLTextAreaElement;
19
+ /** Name attribute of the textarea, for native form submission. */
20
+ name?: string;
21
+ label?: SnippetWithId | THC;
22
+ description?: SnippetWithId | THC;
23
+ class?: string;
24
+ id?: string;
25
+ tabindex?: number;
26
+ renderSize?: "sm" | "md" | "lg" | string;
27
+ required?: boolean;
28
+ disabled?: boolean;
29
+ placeholder?: string;
30
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
31
+ /** Trim surrounding whitespace on blur. Default `true`. */
32
+ useTrim?: boolean;
33
+ /** Grow the textarea with its content (up to `max`). Default `true`. */
34
+ useAutogrow?: boolean | {
35
+ enabled?: boolean;
36
+ max?: number;
37
+ };
38
+ /** Show the Write/Preview tabs. Default `true`. Set `false` for a plain markdown textarea. */
39
+ showTabs?: boolean;
40
+ /** Label for the "write" tab. Default `"Write"`. */
41
+ writeLabel?: string;
42
+ /** Label for the "preview" tab. Default `"Preview"`. */
43
+ previewLabel?: string;
44
+ /** Show the subtle "Markdown supported" hint in the tab bar. Default `true`. */
45
+ showMarkdownHint?: boolean;
46
+ /** Text of the markdown hint. Default `"Markdown supported"`. */
47
+ markdownHintLabel?: string;
48
+ /** Placeholder shown in the Preview tab when there is nothing to render. */
49
+ previewEmptyLabel?: string;
50
+ /** Text shown in the Preview tab while the renderer is (lazy-)loading. */
51
+ previewLoadingLabel?: string;
52
+ /**
53
+ * Override the Preview renderer. Receives the raw markdown, returns (or
54
+ * resolves to) an HTML string that is rendered as-is — **you are responsible
55
+ * for sanitizing it**. When omitted, the bundled marked + DOMPurify pipeline
56
+ * is used (lazy-loaded; requires the optional `marked` / `dompurify` peers).
57
+ */
58
+ renderMarkdown?: MarkdownRenderer;
59
+ /**
60
+ * Submit handler. Receives the current value. May be async — the submit
61
+ * button shows a spinner and the box is disabled while it resolves.
62
+ */
63
+ onSubmit?: (value: string) => void | Promise<void>;
64
+ /** Cancel handler. When set, a Cancel button is shown by default. */
65
+ onCancel?: () => void;
66
+ /** Fired on every edit with the new value. */
67
+ onChange?: (value: string) => void;
68
+ /** Submit button label. Default `"Comment"`. */
69
+ submitLabel?: string;
70
+ /** Cancel button label. Default `"Cancel"`. */
71
+ cancelLabel?: string;
72
+ /** Show the submit button. Defaults to `true` when `onSubmit` is set. */
73
+ showSubmit?: boolean;
74
+ /** Show the cancel button. Defaults to `true` when `onCancel` is set. */
75
+ showCancel?: boolean;
76
+ /** Submit on ⌘/Ctrl+Enter while the textarea is focused. Default `true`. */
77
+ submitOnModEnter?: boolean;
78
+ /** Clear the value after a successful submit. Default `true`. */
79
+ clearOnSubmit?: boolean;
80
+ /** Disable the submit button when the value is empty/whitespace. Default `true`. */
81
+ submitDisabledWhenEmpty?: boolean;
82
+ /** Externally-controlled busy state (disables the box + submit). */
83
+ busy?: boolean;
84
+ labelAfter?: SnippetWithId | THC;
85
+ below?: SnippetWithId | THC;
86
+ labelLeft?: boolean;
87
+ labelLeftWidth?: "normal" | "wide";
88
+ labelLeftBreakpoint?: number;
89
+ /** Optional avatar/gutter rendered to the left of the box. */
90
+ avatar?: SnippetWithId | THC;
91
+ /** Extra content rendered in the footer, left of the buttons (e.g. attach). */
92
+ footer?: Snippet;
93
+ /** Classes for the `<textarea>` element. */
94
+ classInput?: string;
95
+ /** Classes for the tab bar. */
96
+ classBar?: string;
97
+ /** Classes for the preview panel. */
98
+ classPreview?: string;
99
+ /** Classes for the footer (button row). */
100
+ classFooter?: string;
101
+ style?: string;
102
+ }
103
+ import { type ValidationResult } from "../../actions/validate.svelte.js";
104
+ declare const CommentInput: import("svelte").Component<Props, {
105
+ validate: () => ValidationResult | undefined;
106
+ clearValidation: () => void;
107
+ getValidation: () => ValidationResult | undefined;
108
+ focus: () => void;
109
+ scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
110
+ submit: () => void;
111
+ }, "value" | "input" | "mode">;
112
+ type CommentInput = ReturnType<typeof CommentInput>;
113
+ export default CommentInput;
@@ -0,0 +1,117 @@
1
+ # CommentInput
2
+
3
+ A GitHub-style comment box: a **raw markdown** `<textarea>` with **Write / Preview**
4
+ tabs and a submit footer. It composes the same `InputWrap` scaffold as the
5
+ `Field*` components, so its label / description / validation / sizing / `class*`
6
+ props and look-and-feel match the rest of the form family.
7
+
8
+ Unlike `MarkdownEditor` (a WYSIWYG Milkdown/CodeMirror surface), `CommentInput` keeps
9
+ the lightweight "type raw markdown, flip to a rendered preview" model — ideal for
10
+ comments, replies and short discussion input.
11
+
12
+ ```svelte
13
+ <script>
14
+ import { CommentInput } from "@marianmeres/stuic";
15
+
16
+ let value = $state("");
17
+
18
+ async function post(text) {
19
+ await api.addComment(text); // your handler
20
+ }
21
+ </script>
22
+
23
+ <CommentInput
24
+ bind:value
25
+ label="Add a comment"
26
+ placeholder="Leave a comment…"
27
+ onSubmit={post}
28
+ />
29
+ ```
30
+
31
+ ## Preview rendering & dependencies
32
+
33
+ The Preview tab renders markdown to **sanitized** HTML. The bundled default
34
+ renderer uses `marked` + `dompurify`, declared as **optional peer dependencies**
35
+ and lazy-loaded the first time a Preview is opened:
36
+
37
+ ```sh
38
+ npm i marked dompurify
39
+ ```
40
+
41
+ If you don't install them, install your own renderer via `renderMarkdown` instead
42
+ (the bundled one is then never imported):
43
+
44
+ ```svelte
45
+ <CommentInput bind:value renderMarkdown={(md) => mySanitizedHtml(md)} />
46
+ ```
47
+
48
+ > Comment text is untrusted user content and `marked` emits embedded raw HTML
49
+ > verbatim. If you pass your own `renderMarkdown`, **you** are responsible for
50
+ > sanitizing its output.
51
+
52
+ ## Props
53
+
54
+ | Prop | Type | Default | Description |
55
+ | ------------------------- | ------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
56
+ | `value` | `string` (bindable) | `""` | Comment text (raw markdown). |
57
+ | `mode` | `"write" \| "preview"` (bindable) | `"write"` | Active tab. |
58
+ | `input` | `HTMLTextAreaElement` (bindable) | — | The underlying textarea element. |
59
+ | `name` | `string` | — | Textarea `name`, for native form submission. |
60
+ | `label` | `THC \| Snippet` | `""` | Field label (via InputWrap). |
61
+ | `description` | `THC \| Snippet` | — | Collapsible hint below the box. |
62
+ | `placeholder` | `string` | — | Textarea placeholder. |
63
+ | `renderSize` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Size variant. |
64
+ | `required` | `boolean` | `false` | Mark required + enforce on validation. |
65
+ | `disabled` | `boolean` | `false` | Disable the whole box. |
66
+ | `validate` | `boolean \| ValidateOptions` | — | Enable validation (same contract as `FieldTextarea`). |
67
+ | `useTrim` | `boolean` | `true` | Trim surrounding whitespace on blur. |
68
+ | `useAutogrow` | `boolean \| { enabled?, max? }` | `true` | Grow the textarea with content (default max 250px). |
69
+ | `showTabs` | `boolean` | `true` | Show Write/Preview tabs. `false` → plain markdown textarea. |
70
+ | `writeLabel` | `string` | `"Write"` | Write tab label. |
71
+ | `previewLabel` | `string` | `"Preview"` | Preview tab label. |
72
+ | `showMarkdownHint` | `boolean` | `true` | Show the subtle "Markdown supported" hint. |
73
+ | `markdownHintLabel` | `string` | `"Markdown supported"` | Hint text. |
74
+ | `previewEmptyLabel` | `string` | `"Nothing to preview"` | Shown in Preview when empty. |
75
+ | `previewLoadingLabel` | `string` | `"Loading preview…"` | Shown while the Preview renderer is loading. |
76
+ | `renderMarkdown` | `(md: string) => string \| Promise<string>` | bundled marked+DOMPurify | Override the Preview renderer. Output is rendered as-is. |
77
+ | `onSubmit` | `(value: string) => void \| Promise<void>` | — | Submit handler. Async → spinner + disabled while pending. |
78
+ | `onCancel` | `() => void` | — | Cancel handler. |
79
+ | `onChange` | `(value: string) => void` | — | Fired on every edit. |
80
+ | `submitLabel` | `string` | `"Comment"` | Submit button label. |
81
+ | `cancelLabel` | `string` | `"Cancel"` | Cancel button label. |
82
+ | `showSubmit` | `boolean` | `!!onSubmit` | Show the submit button. |
83
+ | `showCancel` | `boolean` | `!!onCancel` | Show the cancel button. |
84
+ | `submitOnModEnter` | `boolean` | `true` | Submit on ⌘/Ctrl+Enter. |
85
+ | `clearOnSubmit` | `boolean` | `true` | Clear value after a successful submit. |
86
+ | `submitDisabledWhenEmpty` | `boolean` | `true` | Disable submit when empty/whitespace. |
87
+ | `busy` | `boolean` | `false` | External busy state (disables box + submit). |
88
+ | `avatar` | `THC \| Snippet` | — | Optional gutter to the left of the box. |
89
+ | `footer` | `Snippet` | — | Extra footer content, left of the buttons. |
90
+
91
+ Plus the standard `InputWrap` layout props (`labelAfter`, `below`, `labelLeft`,
92
+ `labelLeftWidth`, `labelLeftBreakpoint`) and `class*` props (`classInput`,
93
+ `classBar`, `classPreview`, `classFooter`, and the shared `InputWrapClassProps`).
94
+
95
+ ## Imperative API
96
+
97
+ Bind the component (`bind:this`) to call: `validate()`, `clearValidation()`,
98
+ `getValidation()`, `focus()`, `scrollIntoView()`, `submit()`.
99
+
100
+ ## CSS variables
101
+
102
+ All optional; each falls back to the shared `--stuic-input-*` token, then a global
103
+ default.
104
+
105
+ | Variable | Falls back to |
106
+ | ------------------------------------- | ------------------------------ |
107
+ | `--stuic-comment-input-radius` | `--stuic-input-radius` |
108
+ | `--stuic-comment-input-border` | `--stuic-input-border` |
109
+ | `--stuic-comment-input-border-focus` | `--stuic-input-border-focus` |
110
+ | `--stuic-comment-input-bg` | `--stuic-input-bg` |
111
+ | `--stuic-comment-input-padding-x` | `--stuic-input-padding-x-*` |
112
+ | `--stuic-comment-input-padding-y` | `--stuic-input-padding-y-*` |
113
+ | `--stuic-comment-input-font-size` | `--stuic-input-font-size-*` |
114
+ | `--stuic-comment-input-min-height` | `5rem` (`4rem` sm / `7rem` lg) |
115
+ | `--stuic-comment-input-gap` | `0.625rem` |
116
+ | `--stuic-comment-input-tab-active-bg` | subtle overlay |
117
+ | `--stuic-comment-input-code-bg` | subtle overlay |
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Default Preview renderer for {@link CommentInput}: turns a markdown string into
3
+ * **sanitized** HTML.
4
+ *
5
+ * `marked` and `dompurify` are declared as *optional* peer dependencies and are
6
+ * imported dynamically the first time a Preview tab is opened — consumers who
7
+ * never preview (or who pass their own `renderMarkdown`) don't pay for them, and
8
+ * a consumer who hasn't installed them gets a caught error (handled by the
9
+ * component) rather than a hard build failure. Mirrors the lazy-load convention
10
+ * used by `MarkdownEditor` for its Milkdown/CodeMirror backends.
11
+ *
12
+ * Sanitization is non-negotiable here: comment text is untrusted user content and
13
+ * `marked` emits raw HTML embedded in markdown verbatim (an XSS vector). Every
14
+ * code path that returns HTML runs it through DOMPurify first.
15
+ */
16
+ /** A markdown-to-HTML renderer. Return value may be sync or async. */
17
+ export type MarkdownRenderer = (markdown: string) => string | Promise<string>;
18
+ /**
19
+ * Render markdown to sanitized HTML using the bundled (lazy) marked + DOMPurify
20
+ * pipeline. Throws if the optional peers aren't installed (the caller catches
21
+ * this and shows a fallback). SSR-safe: returns an empty string off the DOM,
22
+ * since DOMPurify needs a `window` to sanitize against.
23
+ */
24
+ export declare function renderMarkdownSafe(markdown: string): Promise<string>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Default Preview renderer for {@link CommentInput}: turns a markdown string into
3
+ * **sanitized** HTML.
4
+ *
5
+ * `marked` and `dompurify` are declared as *optional* peer dependencies and are
6
+ * imported dynamically the first time a Preview tab is opened — consumers who
7
+ * never preview (or who pass their own `renderMarkdown`) don't pay for them, and
8
+ * a consumer who hasn't installed them gets a caught error (handled by the
9
+ * component) rather than a hard build failure. Mirrors the lazy-load convention
10
+ * used by `MarkdownEditor` for its Milkdown/CodeMirror backends.
11
+ *
12
+ * Sanitization is non-negotiable here: comment text is untrusted user content and
13
+ * `marked` emits raw HTML embedded in markdown verbatim (an XSS vector). Every
14
+ * code path that returns HTML runs it through DOMPurify first.
15
+ */
16
+ // Cache the resolved (marked + DOMPurify) pipeline so the dynamic imports and
17
+ // DOMPurify instance creation happen at most once per page.
18
+ let cached = null;
19
+ async function load() {
20
+ if (cached)
21
+ return cached;
22
+ const [{ marked }, dompurify] = await Promise.all([
23
+ import("marked"),
24
+ import("dompurify"),
25
+ ]);
26
+ // DOMPurify's default export is the instance in a browser/DOM context.
27
+ const DOMPurify = dompurify.default ?? dompurify;
28
+ cached = async (markdown) => {
29
+ // GitHub-flavored markdown + soft line breaks → <br>, matching how comment
30
+ // boxes (incl. GitHub's) treat single newlines.
31
+ const html = await marked.parse(markdown ?? "", { gfm: true, breaks: true });
32
+ return DOMPurify.sanitize(html);
33
+ };
34
+ return cached;
35
+ }
36
+ /**
37
+ * Render markdown to sanitized HTML using the bundled (lazy) marked + DOMPurify
38
+ * pipeline. Throws if the optional peers aren't installed (the caller catches
39
+ * this and shows a fallback). SSR-safe: returns an empty string off the DOM,
40
+ * since DOMPurify needs a `window` to sanitize against.
41
+ */
42
+ export async function renderMarkdownSafe(markdown) {
43
+ if (typeof window === "undefined")
44
+ return "";
45
+ const render = await load();
46
+ return render(markdown);
47
+ }
@@ -0,0 +1,310 @@
1
+ /******************************************************************************
2
+ CommentInput
3
+
4
+ A GitHub-style comment box: a raw-markdown textarea with Write/Preview tabs and
5
+ a submit footer. Field chrome (label/description/validation/sizing) comes from
6
+ the shared InputWrap, so it matches the `Field*` family; everything specific to
7
+ the box lives here.
8
+
9
+ Tokens follow the library convention: a component token that falls back to the
10
+ shared `--stuic-input-*` token, then to a global default — so the box inherits
11
+ input theming for free but can be retargeted on its own.
12
+ ******************************************************************************/
13
+
14
+ .stuic-comment-input {
15
+ --_radius: var(
16
+ --stuic-comment-input-radius,
17
+ var(--stuic-input-radius, var(--stuic-radius))
18
+ );
19
+ --_border-color: var(
20
+ --stuic-comment-input-border,
21
+ var(--stuic-input-border, currentColor)
22
+ );
23
+ --_bg: var(--stuic-comment-input-bg, var(--stuic-input-bg, transparent));
24
+ --_pad-x: var(
25
+ --stuic-comment-input-padding-x,
26
+ var(--stuic-input-padding-x-md, 0.75rem)
27
+ );
28
+ --_pad-y: var(--stuic-comment-input-padding-y, var(--stuic-input-padding-y-md, 0.5rem));
29
+ --_min-height: var(--stuic-comment-input-min-height, 5rem);
30
+ --_font-size: var(
31
+ --stuic-comment-input-font-size,
32
+ var(--stuic-input-font-size-md, 0.9375rem)
33
+ );
34
+
35
+ display: flex;
36
+ align-items: flex-start;
37
+ gap: var(--stuic-comment-input-gap, 0.625rem);
38
+ width: 100%;
39
+ min-width: 0;
40
+ }
41
+
42
+ .stuic-comment-input-avatar {
43
+ flex: 0 0 auto;
44
+ padding-top: 0.125rem;
45
+ }
46
+
47
+ /* The bordered card — focus ring lives here, not on the avatar row. */
48
+ .stuic-comment-input-editor {
49
+ display: flex;
50
+ flex-direction: column;
51
+ flex: 1 1 auto;
52
+ min-width: 0;
53
+ max-width: 100%;
54
+ background: var(--_bg);
55
+ border: var(--stuic-border-width, 1px) solid var(--_border-color);
56
+ border-radius: var(--_radius);
57
+ color: var(--stuic-input-text, inherit);
58
+ overflow: hidden;
59
+ transition: border-color var(--stuic-transition, 150ms);
60
+ }
61
+
62
+ .stuic-comment-input-editor:focus-within {
63
+ border-color: var(
64
+ --stuic-comment-input-border-focus,
65
+ var(--stuic-input-border-focus, currentColor)
66
+ );
67
+ }
68
+
69
+ .stuic-comment-input.disabled {
70
+ opacity: 0.6;
71
+ pointer-events: none;
72
+ }
73
+
74
+ /* --- Tab bar -------------------------------------------------------------- */
75
+ .stuic-comment-input-bar {
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ gap: 0.5rem;
80
+ padding: 0.25rem 0.375rem;
81
+ background: var(--stuic-comment-input-bar-bg, transparent);
82
+ }
83
+
84
+ .stuic-comment-input-tabs {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 0.125rem;
88
+ }
89
+
90
+ .stuic-comment-input-tabs button {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 2rem;
95
+ padding: 0 0.625rem;
96
+ font-size: 0.8125rem;
97
+ line-height: 1;
98
+ color: inherit;
99
+ opacity: 0.7;
100
+ background: transparent;
101
+ border: none;
102
+ border-radius: var(--stuic-radius, 0.375rem);
103
+ transition:
104
+ background-color var(--stuic-transition, 150ms),
105
+ opacity var(--stuic-transition, 150ms);
106
+ }
107
+
108
+ .stuic-comment-input-tabs button:hover:not(:disabled) {
109
+ background: var(--stuic-comment-input-tab-hover-bg, rgb(0 0 0 / 0.06));
110
+ }
111
+
112
+ .stuic-comment-input-tabs button[data-active="true"] {
113
+ opacity: 1;
114
+ font-weight: 600;
115
+ background: var(--stuic-comment-input-tab-active-bg, rgb(0 0 0 / 0.06));
116
+ }
117
+
118
+ .stuic-comment-input-tabs button:disabled {
119
+ opacity: 0.4;
120
+ }
121
+
122
+ .stuic-comment-input-hint {
123
+ font-size: 0.75rem;
124
+ opacity: 0.5;
125
+ padding-right: 0.25rem;
126
+ }
127
+
128
+ :root.dark .stuic-comment-input-tabs button:hover:not(:disabled),
129
+ :root.dark .stuic-comment-input-tabs button[data-active="true"] {
130
+ background: var(--stuic-comment-input-tab-active-bg, rgb(255 255 255 / 0.08));
131
+ }
132
+
133
+ @media (pointer: coarse) {
134
+ .stuic-comment-input-tabs button {
135
+ height: 2.5rem;
136
+ }
137
+ }
138
+
139
+ /* --- Surface (textarea + preview share the same box) ---------------------- */
140
+ .stuic-comment-input-surface {
141
+ min-width: 0;
142
+ }
143
+
144
+ /* The inactive tab panel is removed via the `hidden` attribute; the explicit
145
+ rule guarantees it collapses regardless of any utility-layer display. The
146
+ write panel stays in the DOM while hidden so its textarea still submits and
147
+ validates. */
148
+ .stuic-comment-input-panel[hidden] {
149
+ display: none;
150
+ }
151
+
152
+ .stuic-comment-input-textarea {
153
+ display: block;
154
+ width: 100%;
155
+ min-height: var(--_min-height);
156
+ max-width: 100%;
157
+ margin: 0;
158
+ padding: var(--_pad-y) var(--_pad-x);
159
+ font-size: var(--_font-size);
160
+ line-height: 1.6;
161
+ color: inherit;
162
+ background: transparent;
163
+ border: none;
164
+ outline: none;
165
+ box-shadow: none;
166
+ resize: none;
167
+ }
168
+
169
+ /* --- Preview -------------------------------------------------------------- */
170
+ .stuic-comment-input-preview {
171
+ min-height: var(--_min-height);
172
+ padding: var(--_pad-y) var(--_pad-x);
173
+ font-size: var(--_font-size);
174
+ line-height: 1.6;
175
+ overflow-wrap: break-word;
176
+ }
177
+
178
+ .stuic-comment-input-preview-empty {
179
+ opacity: 0.5;
180
+ font-style: italic;
181
+ }
182
+
183
+ .stuic-comment-input-preview > * + * {
184
+ margin-top: 0.75em;
185
+ }
186
+ .stuic-comment-input-preview h1 {
187
+ font-size: 1.5em;
188
+ font-weight: 700;
189
+ line-height: 1.2;
190
+ }
191
+ .stuic-comment-input-preview h2 {
192
+ font-size: 1.3em;
193
+ font-weight: 700;
194
+ line-height: 1.25;
195
+ }
196
+ .stuic-comment-input-preview h3 {
197
+ font-size: 1.15em;
198
+ font-weight: 600;
199
+ }
200
+ .stuic-comment-input-preview ul,
201
+ .stuic-comment-input-preview ol {
202
+ padding-left: 1.5em;
203
+ }
204
+ .stuic-comment-input-preview ul {
205
+ list-style: disc;
206
+ }
207
+ .stuic-comment-input-preview ol {
208
+ list-style: decimal;
209
+ }
210
+ .stuic-comment-input-preview a {
211
+ text-decoration: underline;
212
+ }
213
+ .stuic-comment-input-preview blockquote {
214
+ padding-left: 1em;
215
+ border-left: 3px solid var(--_border-color);
216
+ opacity: 0.85;
217
+ }
218
+ .stuic-comment-input-preview code {
219
+ font-family: var(
220
+ --stuic-comment-input-font-mono,
221
+ ui-monospace,
222
+ SFMono-Regular,
223
+ Menlo,
224
+ monospace
225
+ );
226
+ font-size: 0.9em;
227
+ padding: 0.1em 0.3em;
228
+ border-radius: 0.25rem;
229
+ background: var(--stuic-comment-input-code-bg, rgb(0 0 0 / 0.06));
230
+ }
231
+ .stuic-comment-input-preview pre {
232
+ font-family: var(
233
+ --stuic-comment-input-font-mono,
234
+ ui-monospace,
235
+ SFMono-Regular,
236
+ Menlo,
237
+ monospace
238
+ );
239
+ padding: 0.75em 1em;
240
+ border-radius: var(--stuic-radius, 0.375rem);
241
+ background: var(--stuic-comment-input-code-bg, rgb(0 0 0 / 0.06));
242
+ overflow-x: auto;
243
+ }
244
+ .stuic-comment-input-preview pre code {
245
+ padding: 0;
246
+ background: transparent;
247
+ }
248
+ .stuic-comment-input-preview hr {
249
+ border: none;
250
+ border-top: var(--stuic-border-width, 1px) solid var(--_border-color);
251
+ }
252
+ .stuic-comment-input-preview table {
253
+ border-collapse: collapse;
254
+ }
255
+ .stuic-comment-input-preview th,
256
+ .stuic-comment-input-preview td {
257
+ border: var(--stuic-border-width, 1px) solid var(--_border-color);
258
+ padding: 0.25em 0.5em;
259
+ }
260
+
261
+ :root.dark .stuic-comment-input-preview code,
262
+ :root.dark .stuic-comment-input-preview pre {
263
+ background: var(--stuic-comment-input-code-bg, rgb(255 255 255 / 0.08));
264
+ }
265
+
266
+ /* --- Footer (button row) -------------------------------------------------- */
267
+ .stuic-comment-input-footer {
268
+ display: flex;
269
+ align-items: center;
270
+ justify-content: space-between;
271
+ gap: 0.5rem;
272
+ padding: 0.5rem;
273
+ }
274
+
275
+ .stuic-comment-input-footer-start,
276
+ .stuic-comment-input-footer-end {
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 0.5rem;
280
+ }
281
+
282
+ .stuic-comment-input-footer-end {
283
+ margin-left: auto;
284
+ }
285
+
286
+ /* --- Size variants -------------------------------------------------------- */
287
+ .stuic-comment-input[data-size="sm"] {
288
+ --_pad-x: var(--stuic-comment-input-padding-x, var(--stuic-input-padding-x-sm, 0.5rem));
289
+ --_pad-y: var(
290
+ --stuic-comment-input-padding-y,
291
+ var(--stuic-input-padding-y-sm, 0.375rem)
292
+ );
293
+ --_font-size: var(
294
+ --stuic-comment-input-font-size,
295
+ var(--stuic-input-font-size-sm, 0.8125rem)
296
+ );
297
+ --_min-height: var(--stuic-comment-input-min-height, 4rem);
298
+ }
299
+ .stuic-comment-input[data-size="lg"] {
300
+ --_pad-x: var(--stuic-comment-input-padding-x, var(--stuic-input-padding-x-lg, 1rem));
301
+ --_pad-y: var(
302
+ --stuic-comment-input-padding-y,
303
+ var(--stuic-input-padding-y-lg, 0.75rem)
304
+ );
305
+ --_font-size: var(
306
+ --stuic-comment-input-font-size,
307
+ var(--stuic-input-font-size-lg, 1.0625rem)
308
+ );
309
+ --_min-height: var(--stuic-comment-input-min-height, 7rem);
310
+ }
@@ -0,0 +1,2 @@
1
+ export { default as CommentInput, type Props as CommentInputProps, type CommentInputMode, } from "./CommentInput.svelte";
2
+ export type { MarkdownRenderer } from "./_internal/render.js";
@@ -0,0 +1 @@
1
+ export { default as CommentInput, } from "./CommentInput.svelte";
package/dist/index.css CHANGED
@@ -71,6 +71,7 @@ In practice:
71
71
  @import "./components/LoginOrRegisterForm/index.css";
72
72
  @import "./components/Checkout/index.css";
73
73
  @import "./components/CommandMenu/index.css";
74
+ @import "./components/CommentInput/index.css";
74
75
  @import "./components/ContactUsForm/index.css";
75
76
  @import "./components/CronInput/index.css";
76
77
  @import "./components/DataTable/index.css";
package/dist/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export * from "./components/Checkout/index.js";
38
38
  export * from "./components/Collapsible/index.js";
39
39
  export * from "./components/ColorScheme/index.js";
40
40
  export * from "./components/CommandMenu/index.js";
41
+ export * from "./components/CommentInput/index.js";
41
42
  export * from "./components/ContactUsForm/index.js";
42
43
  export * from "./components/CronInput/index.js";
43
44
  export * from "./components/DataTable/index.js";
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ export * from "./components/Checkout/index.js";
39
39
  export * from "./components/Collapsible/index.js";
40
40
  export * from "./components/ColorScheme/index.js";
41
41
  export * from "./components/CommandMenu/index.js";
42
+ export * from "./components/CommentInput/index.js";
42
43
  export * from "./components/ContactUsForm/index.js";
43
44
  export * from "./components/CronInput/index.js";
44
45
  export * from "./components/DataTable/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.132.0",
3
+ "version": "3.133.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -72,6 +72,8 @@
72
72
  "@milkdown/prose": "^7.0.0",
73
73
  "@milkdown/transformer": "^7.0.0",
74
74
  "@milkdown/utils": "^7.0.0",
75
+ "dompurify": "^3.0.0",
76
+ "marked": "^18.0.0",
75
77
  "svelte": "^5.0.0"
76
78
  },
77
79
  "peerDependenciesMeta": {
@@ -119,15 +121,21 @@
119
121
  },
120
122
  "@milkdown/utils": {
121
123
  "optional": true
124
+ },
125
+ "dompurify": {
126
+ "optional": true
127
+ },
128
+ "marked": {
129
+ "optional": true
122
130
  }
123
131
  },
124
132
  "devDependencies": {
125
133
  "@codemirror/commands": "^6.10.4",
126
134
  "@codemirror/lang-markdown": "^6.5.0",
127
- "@codemirror/language": "^6.12.3",
135
+ "@codemirror/language": "^6.12.4",
128
136
  "@codemirror/language-data": "^6.5.2",
129
137
  "@codemirror/state": "^6.7.0",
130
- "@codemirror/view": "^6.43.2",
138
+ "@codemirror/view": "^6.43.4",
131
139
  "@eslint/js": "^9.39.4",
132
140
  "@marianmeres/random-human-readable": "^1.10.2",
133
141
  "@milkdown/core": "^7.21.2",
@@ -149,11 +157,13 @@
149
157
  "@tailwindcss/vite": "^4.3.1",
150
158
  "@types/node": "^25.9.4",
151
159
  "@vitest/browser-playwright": "^4.1.9",
160
+ "dompurify": "^3.4.11",
152
161
  "dotenv": "^16.6.1",
153
162
  "eslint": "^9.39.4",
154
163
  "globals": "^16.5.0",
164
+ "marked": "^18.0.5",
155
165
  "playwright": "^1.61.1",
156
- "prettier": "^3.8.4",
166
+ "prettier": "^3.9.0",
157
167
  "prettier-plugin-svelte": "^3.5.2",
158
168
  "publint": "^0.3.21",
159
169
  "svelte": "^5.56.4",
@@ -170,7 +180,7 @@
170
180
  "@marianmeres/clog": "^3.21.0",
171
181
  "@marianmeres/countries": "^1.0.1",
172
182
  "@marianmeres/cron": "^2.0.1",
173
- "@marianmeres/design-tokens": "^1.9.0",
183
+ "@marianmeres/design-tokens": "^1.12.0",
174
184
  "@marianmeres/icons-fns": "^5.0.0",
175
185
  "@marianmeres/item-collection": "^1.4.2",
176
186
  "@marianmeres/paging-store": "^2.1.1",