@marianmeres/stuic 3.131.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/components/Input/FieldAssets.svelte +116 -3
- package/dist/components/Input/FieldAssets.svelte.d.ts +7 -0
- package/dist/components/Input/FieldOptions.svelte +23 -1
- package/dist/components/Input/FieldOptions.svelte.d.ts +1 -1
- package/dist/components/Input/README.md +27 -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;
|