@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,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";
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
iconFileWord,
|
|
15
15
|
iconFileZip,
|
|
16
16
|
iconPlus as iconAdd,
|
|
17
|
+
iconChevronLeft,
|
|
18
|
+
iconChevronRight,
|
|
17
19
|
} from "../../icons/index.js";
|
|
18
|
-
import { onDestroy, type Snippet } from "svelte";
|
|
20
|
+
import { onDestroy, tick, type Snippet } from "svelte";
|
|
19
21
|
import { fileDropzone } from "../../actions/file-dropzone.svelte.js";
|
|
20
22
|
import { highlightDragover } from "../../actions/highlight-dragover.svelte.js";
|
|
21
23
|
import { tooltip } from "../../actions/index.js";
|
|
@@ -58,6 +60,10 @@
|
|
|
58
60
|
close: "Close preview window",
|
|
59
61
|
download: "Download original",
|
|
60
62
|
invalid_type: 'Some of the files are not supported. Allowed are only "{{accept}}".',
|
|
63
|
+
move_prev: "Move earlier",
|
|
64
|
+
move_next: "Move later",
|
|
65
|
+
moved_prev: "Moved {{name}} earlier",
|
|
66
|
+
moved_next: "Moved {{name}} later",
|
|
61
67
|
};
|
|
62
68
|
let out = m[k] ?? fallback ?? k;
|
|
63
69
|
|
|
@@ -167,6 +173,13 @@
|
|
|
167
173
|
withOnProgress?: boolean;
|
|
168
174
|
classControls?: string;
|
|
169
175
|
isLoading?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Opt-in: when true, each asset tile in the grid gets "Move earlier" / "Move later"
|
|
178
|
+
* controls (shown on hover/focus) so the user can manually reorder the assets. The
|
|
179
|
+
* chosen order is serialized to `value`. Buttons only, no drag (the field's drag is
|
|
180
|
+
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
181
|
+
*/
|
|
182
|
+
ordered?: boolean;
|
|
170
183
|
//
|
|
171
184
|
classWrap?: string;
|
|
172
185
|
}
|
|
@@ -231,6 +244,7 @@
|
|
|
231
244
|
withOnProgress,
|
|
232
245
|
classControls = "",
|
|
233
246
|
isLoading = false,
|
|
247
|
+
ordered = false,
|
|
234
248
|
parseValue = (strigifiedModels: string) => {
|
|
235
249
|
const val = strigifiedModels ?? "[]";
|
|
236
250
|
try {
|
|
@@ -249,6 +263,10 @@
|
|
|
249
263
|
let isUploading = $state(false);
|
|
250
264
|
let cardinality = $derived(_cardinality === -1 ? Infinity : _cardinality);
|
|
251
265
|
let isMultiple = $derived(cardinality > 1);
|
|
266
|
+
// reordering ("ordered") is opt-in and only meaningful with more than one asset
|
|
267
|
+
let canArrange = $derived(ordered && isMultiple);
|
|
268
|
+
// aria-live announcement text for reorder actions
|
|
269
|
+
let liveAnnouncement = $state("");
|
|
252
270
|
let parentHiddenInputEl: HTMLInputElement | undefined = $state();
|
|
253
271
|
let hasLabel = $derived(isTHCNotEmpty(label) || typeof label === "function");
|
|
254
272
|
let inputEl = $state<HTMLInputElement>()!;
|
|
@@ -348,6 +366,44 @@
|
|
|
348
366
|
notifications?.info(t("deleted", { name }));
|
|
349
367
|
}
|
|
350
368
|
|
|
369
|
+
// re-focus the same logical move button on the tile that moved, so repeated presses keep
|
|
370
|
+
// walking the item; if that button is now disabled (a boundary), fall back to the enabled one.
|
|
371
|
+
// We target by the NEW index (querySelectorAll DOM order == array order) to avoid escaping
|
|
372
|
+
// blob/url ids into a CSS attribute selector.
|
|
373
|
+
function focusMovedButton(to: number, which: "prev" | "next") {
|
|
374
|
+
tick().then(() => {
|
|
375
|
+
const tile = wrapEl?.querySelectorAll<HTMLElement>("[data-asset-tile]")[to];
|
|
376
|
+
if (!tile) return;
|
|
377
|
+
let btn = tile.querySelector<HTMLButtonElement>(`[data-arrange-btn="${which}"]`);
|
|
378
|
+
if (!btn || btn.disabled) {
|
|
379
|
+
btn =
|
|
380
|
+
tile.querySelector<HTMLButtonElement>(
|
|
381
|
+
`[data-arrange-btn="prev"]:not([disabled])`
|
|
382
|
+
) ||
|
|
383
|
+
tile.querySelector<HTMLButtonElement>(
|
|
384
|
+
`[data-arrange-btn="next"]:not([disabled])`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
btn?.focus();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Reorder by moving the asset at `from` to `to`. Splices a copy and re-serializes (same
|
|
392
|
+
// value-driven pattern as remove_by_idx). Safe mid-upload: processAssets reconciles the
|
|
393
|
+
// resolved asset by blob-url id (findIndex), never by array position.
|
|
394
|
+
function move(from: number, to: number) {
|
|
395
|
+
if (to < 0 || to >= assets.length || from === to) return;
|
|
396
|
+
const next = [...assets];
|
|
397
|
+
const [item] = next.splice(from, 1);
|
|
398
|
+
if (!item) return;
|
|
399
|
+
next.splice(to, 0, item);
|
|
400
|
+
value = serializeValue(next);
|
|
401
|
+
liveAnnouncement = t(to < from ? "moved_prev" : "moved_next", {
|
|
402
|
+
name: item.name,
|
|
403
|
+
}) as string;
|
|
404
|
+
focusMovedButton(to, to < from ? "prev" : "next");
|
|
405
|
+
}
|
|
406
|
+
|
|
351
407
|
onDestroy(() => {
|
|
352
408
|
try {
|
|
353
409
|
assets.forEach((a) => {
|
|
@@ -369,11 +425,15 @@
|
|
|
369
425
|
<SpinnerCircleOscillate />
|
|
370
426
|
</div>
|
|
371
427
|
{:else}
|
|
428
|
+
{#if canArrange}
|
|
429
|
+
<!-- screen-reader announcements for reorder actions -->
|
|
430
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
|
|
431
|
+
{/if}
|
|
372
432
|
<div class={["p-2 flex items-center gap-0.5 flex-wrap"]}>
|
|
373
|
-
{#each assets as asset, idx}
|
|
433
|
+
{#each assets as asset, idx (asset.id)}
|
|
374
434
|
{@const { thumb, full, original } = asset_urls(asset)}
|
|
375
435
|
{@const _is_img = isImage(asset.type ?? thumb)}
|
|
376
|
-
<div class="relative group">
|
|
436
|
+
<div class="relative group" data-asset-tile>
|
|
377
437
|
<button
|
|
378
438
|
class={[objectSize, "bg-black/10 grid place-content-center", classControls]}
|
|
379
439
|
onclick={(e) => {
|
|
@@ -420,6 +480,59 @@
|
|
|
420
480
|
</span>
|
|
421
481
|
{/if}
|
|
422
482
|
</button>
|
|
483
|
+
|
|
484
|
+
{#if canArrange && assets.length > 1}
|
|
485
|
+
<!-- Reorder controls (sibling of the thumbnail <button>, never nested — that
|
|
486
|
+
would be invalid HTML). Hidden until the tile is hovered/focused; pointer
|
|
487
|
+
events are gated to hover/focus so an invisible control can't intercept a
|
|
488
|
+
click meant for the thumbnail, while keyboard users can still Tab to them. -->
|
|
489
|
+
<div
|
|
490
|
+
class={[
|
|
491
|
+
"absolute inset-x-0 top-0 flex items-start justify-between p-0.5",
|
|
492
|
+
"opacity-0 transition-opacity pointer-events-none",
|
|
493
|
+
"group-hover:opacity-100 focus-within:opacity-100",
|
|
494
|
+
]}
|
|
495
|
+
>
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
data-arrange-btn="prev"
|
|
499
|
+
disabled={idx === 0}
|
|
500
|
+
aria-label={t("move_prev")}
|
|
501
|
+
use:tooltip={() => ({ content: t("move_prev") })}
|
|
502
|
+
class={[
|
|
503
|
+
"grid place-content-center rounded p-0.5 bg-black/60 text-white hover:bg-black/80",
|
|
504
|
+
"pointer-events-none group-hover:pointer-events-auto focus:pointer-events-auto",
|
|
505
|
+
"disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-black/60",
|
|
506
|
+
]}
|
|
507
|
+
onclick={(e) => {
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
move(idx, idx - 1);
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
{@html iconChevronLeft({ size: 16 })}
|
|
514
|
+
</button>
|
|
515
|
+
<button
|
|
516
|
+
type="button"
|
|
517
|
+
data-arrange-btn="next"
|
|
518
|
+
disabled={idx === assets.length - 1}
|
|
519
|
+
aria-label={t("move_next")}
|
|
520
|
+
use:tooltip={() => ({ content: t("move_next") })}
|
|
521
|
+
class={[
|
|
522
|
+
"grid place-content-center rounded p-0.5 bg-black/60 text-white hover:bg-black/80",
|
|
523
|
+
"pointer-events-none group-hover:pointer-events-auto focus:pointer-events-auto",
|
|
524
|
+
"disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-black/60",
|
|
525
|
+
]}
|
|
526
|
+
onclick={(e) => {
|
|
527
|
+
e.stopPropagation();
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
move(idx, idx + 1);
|
|
530
|
+
}}
|
|
531
|
+
>
|
|
532
|
+
{@html iconChevronRight({ size: 16 })}
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
{/if}
|
|
423
536
|
</div>
|
|
424
537
|
{/each}
|
|
425
538
|
<Button
|