@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.
- package/dist/components/CommentInput/CommentInput.svelte +547 -0
- package/dist/components/CommentInput/CommentInput.svelte.d.ts +113 -0
- package/dist/components/CommentInput/README.md +117 -0
- package/dist/components/CommentInput/_internal/render.d.ts +24 -0
- package/dist/components/CommentInput/_internal/render.js +47 -0
- package/dist/components/CommentInput/index.css +310 -0
- package/dist/components/CommentInput/index.d.ts +2 -0
- package/dist/components/CommentInput/index.js +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +15 -5
|
@@ -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 @@
|
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|