@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.
@@ -0,0 +1,117 @@
1
+ # CommentInput
2
+
3
+ A GitHub-style comment box: a **raw markdown** `<textarea>` with **Write / Preview**
4
+ tabs and a submit footer. It composes the same `InputWrap` scaffold as the
5
+ `Field*` components, so its label / description / validation / sizing / `class*`
6
+ props and look-and-feel match the rest of the form family.
7
+
8
+ Unlike `MarkdownEditor` (a WYSIWYG Milkdown/CodeMirror surface), `CommentInput` keeps
9
+ the lightweight "type raw markdown, flip to a rendered preview" model — ideal for
10
+ comments, replies and short discussion input.
11
+
12
+ ```svelte
13
+ <script>
14
+ import { CommentInput } from "@marianmeres/stuic";
15
+
16
+ let value = $state("");
17
+
18
+ async function post(text) {
19
+ await api.addComment(text); // your handler
20
+ }
21
+ </script>
22
+
23
+ <CommentInput
24
+ bind:value
25
+ label="Add a comment"
26
+ placeholder="Leave a comment…"
27
+ onSubmit={post}
28
+ />
29
+ ```
30
+
31
+ ## Preview rendering & dependencies
32
+
33
+ The Preview tab renders markdown to **sanitized** HTML. The bundled default
34
+ renderer uses `marked` + `dompurify`, declared as **optional peer dependencies**
35
+ and lazy-loaded the first time a Preview is opened:
36
+
37
+ ```sh
38
+ npm i marked dompurify
39
+ ```
40
+
41
+ If you don't install them, install your own renderer via `renderMarkdown` instead
42
+ (the bundled one is then never imported):
43
+
44
+ ```svelte
45
+ <CommentInput bind:value renderMarkdown={(md) => mySanitizedHtml(md)} />
46
+ ```
47
+
48
+ > Comment text is untrusted user content and `marked` emits embedded raw HTML
49
+ > verbatim. If you pass your own `renderMarkdown`, **you** are responsible for
50
+ > sanitizing its output.
51
+
52
+ ## Props
53
+
54
+ | Prop | Type | Default | Description |
55
+ | ------------------------- | ------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
56
+ | `value` | `string` (bindable) | `""` | Comment text (raw markdown). |
57
+ | `mode` | `"write" \| "preview"` (bindable) | `"write"` | Active tab. |
58
+ | `input` | `HTMLTextAreaElement` (bindable) | — | The underlying textarea element. |
59
+ | `name` | `string` | — | Textarea `name`, for native form submission. |
60
+ | `label` | `THC \| Snippet` | `""` | Field label (via InputWrap). |
61
+ | `description` | `THC \| Snippet` | — | Collapsible hint below the box. |
62
+ | `placeholder` | `string` | — | Textarea placeholder. |
63
+ | `renderSize` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Size variant. |
64
+ | `required` | `boolean` | `false` | Mark required + enforce on validation. |
65
+ | `disabled` | `boolean` | `false` | Disable the whole box. |
66
+ | `validate` | `boolean \| ValidateOptions` | — | Enable validation (same contract as `FieldTextarea`). |
67
+ | `useTrim` | `boolean` | `true` | Trim surrounding whitespace on blur. |
68
+ | `useAutogrow` | `boolean \| { enabled?, max? }` | `true` | Grow the textarea with content (default max 250px). |
69
+ | `showTabs` | `boolean` | `true` | Show Write/Preview tabs. `false` → plain markdown textarea. |
70
+ | `writeLabel` | `string` | `"Write"` | Write tab label. |
71
+ | `previewLabel` | `string` | `"Preview"` | Preview tab label. |
72
+ | `showMarkdownHint` | `boolean` | `true` | Show the subtle "Markdown supported" hint. |
73
+ | `markdownHintLabel` | `string` | `"Markdown supported"` | Hint text. |
74
+ | `previewEmptyLabel` | `string` | `"Nothing to preview"` | Shown in Preview when empty. |
75
+ | `previewLoadingLabel` | `string` | `"Loading preview…"` | Shown while the Preview renderer is loading. |
76
+ | `renderMarkdown` | `(md: string) => string \| Promise<string>` | bundled marked+DOMPurify | Override the Preview renderer. Output is rendered as-is. |
77
+ | `onSubmit` | `(value: string) => void \| Promise<void>` | — | Submit handler. Async → spinner + disabled while pending. |
78
+ | `onCancel` | `() => void` | — | Cancel handler. |
79
+ | `onChange` | `(value: string) => void` | — | Fired on every edit. |
80
+ | `submitLabel` | `string` | `"Comment"` | Submit button label. |
81
+ | `cancelLabel` | `string` | `"Cancel"` | Cancel button label. |
82
+ | `showSubmit` | `boolean` | `!!onSubmit` | Show the submit button. |
83
+ | `showCancel` | `boolean` | `!!onCancel` | Show the cancel button. |
84
+ | `submitOnModEnter` | `boolean` | `true` | Submit on ⌘/Ctrl+Enter. |
85
+ | `clearOnSubmit` | `boolean` | `true` | Clear value after a successful submit. |
86
+ | `submitDisabledWhenEmpty` | `boolean` | `true` | Disable submit when empty/whitespace. |
87
+ | `busy` | `boolean` | `false` | External busy state (disables box + submit). |
88
+ | `avatar` | `THC \| Snippet` | — | Optional gutter to the left of the box. |
89
+ | `footer` | `Snippet` | — | Extra footer content, left of the buttons. |
90
+
91
+ Plus the standard `InputWrap` layout props (`labelAfter`, `below`, `labelLeft`,
92
+ `labelLeftWidth`, `labelLeftBreakpoint`) and `class*` props (`classInput`,
93
+ `classBar`, `classPreview`, `classFooter`, and the shared `InputWrapClassProps`).
94
+
95
+ ## Imperative API
96
+
97
+ Bind the component (`bind:this`) to call: `validate()`, `clearValidation()`,
98
+ `getValidation()`, `focus()`, `scrollIntoView()`, `submit()`.
99
+
100
+ ## CSS variables
101
+
102
+ All optional; each falls back to the shared `--stuic-input-*` token, then a global
103
+ default.
104
+
105
+ | Variable | Falls back to |
106
+ | ------------------------------------- | ------------------------------ |
107
+ | `--stuic-comment-input-radius` | `--stuic-input-radius` |
108
+ | `--stuic-comment-input-border` | `--stuic-input-border` |
109
+ | `--stuic-comment-input-border-focus` | `--stuic-input-border-focus` |
110
+ | `--stuic-comment-input-bg` | `--stuic-input-bg` |
111
+ | `--stuic-comment-input-padding-x` | `--stuic-input-padding-x-*` |
112
+ | `--stuic-comment-input-padding-y` | `--stuic-input-padding-y-*` |
113
+ | `--stuic-comment-input-font-size` | `--stuic-input-font-size-*` |
114
+ | `--stuic-comment-input-min-height` | `5rem` (`4rem` sm / `7rem` lg) |
115
+ | `--stuic-comment-input-gap` | `0.625rem` |
116
+ | `--stuic-comment-input-tab-active-bg` | subtle overlay |
117
+ | `--stuic-comment-input-code-bg` | subtle overlay |
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Default Preview renderer for {@link CommentInput}: turns a markdown string into
3
+ * **sanitized** HTML.
4
+ *
5
+ * `marked` and `dompurify` are declared as *optional* peer dependencies and are
6
+ * imported dynamically the first time a Preview tab is opened — consumers who
7
+ * never preview (or who pass their own `renderMarkdown`) don't pay for them, and
8
+ * a consumer who hasn't installed them gets a caught error (handled by the
9
+ * component) rather than a hard build failure. Mirrors the lazy-load convention
10
+ * used by `MarkdownEditor` for its Milkdown/CodeMirror backends.
11
+ *
12
+ * Sanitization is non-negotiable here: comment text is untrusted user content and
13
+ * `marked` emits raw HTML embedded in markdown verbatim (an XSS vector). Every
14
+ * code path that returns HTML runs it through DOMPurify first.
15
+ */
16
+ /** A markdown-to-HTML renderer. Return value may be sync or async. */
17
+ export type MarkdownRenderer = (markdown: string) => string | Promise<string>;
18
+ /**
19
+ * Render markdown to sanitized HTML using the bundled (lazy) marked + DOMPurify
20
+ * pipeline. Throws if the optional peers aren't installed (the caller catches
21
+ * this and shows a fallback). SSR-safe: returns an empty string off the DOM,
22
+ * since DOMPurify needs a `window` to sanitize against.
23
+ */
24
+ export declare function renderMarkdownSafe(markdown: string): Promise<string>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Default Preview renderer for {@link CommentInput}: turns a markdown string into
3
+ * **sanitized** HTML.
4
+ *
5
+ * `marked` and `dompurify` are declared as *optional* peer dependencies and are
6
+ * imported dynamically the first time a Preview tab is opened — consumers who
7
+ * never preview (or who pass their own `renderMarkdown`) don't pay for them, and
8
+ * a consumer who hasn't installed them gets a caught error (handled by the
9
+ * component) rather than a hard build failure. Mirrors the lazy-load convention
10
+ * used by `MarkdownEditor` for its Milkdown/CodeMirror backends.
11
+ *
12
+ * Sanitization is non-negotiable here: comment text is untrusted user content and
13
+ * `marked` emits raw HTML embedded in markdown verbatim (an XSS vector). Every
14
+ * code path that returns HTML runs it through DOMPurify first.
15
+ */
16
+ // Cache the resolved (marked + DOMPurify) pipeline so the dynamic imports and
17
+ // DOMPurify instance creation happen at most once per page.
18
+ let cached = null;
19
+ async function load() {
20
+ if (cached)
21
+ return cached;
22
+ const [{ marked }, dompurify] = await Promise.all([
23
+ import("marked"),
24
+ import("dompurify"),
25
+ ]);
26
+ // DOMPurify's default export is the instance in a browser/DOM context.
27
+ const DOMPurify = dompurify.default ?? dompurify;
28
+ cached = async (markdown) => {
29
+ // GitHub-flavored markdown + soft line breaks → <br>, matching how comment
30
+ // boxes (incl. GitHub's) treat single newlines.
31
+ const html = await marked.parse(markdown ?? "", { gfm: true, breaks: true });
32
+ return DOMPurify.sanitize(html);
33
+ };
34
+ return cached;
35
+ }
36
+ /**
37
+ * Render markdown to sanitized HTML using the bundled (lazy) marked + DOMPurify
38
+ * pipeline. Throws if the optional peers aren't installed (the caller catches
39
+ * this and shows a fallback). SSR-safe: returns an empty string off the DOM,
40
+ * since DOMPurify needs a `window` to sanitize against.
41
+ */
42
+ export async function renderMarkdownSafe(markdown) {
43
+ if (typeof window === "undefined")
44
+ return "";
45
+ const render = await load();
46
+ return render(markdown);
47
+ }
@@ -0,0 +1,310 @@
1
+ /******************************************************************************
2
+ CommentInput
3
+
4
+ A GitHub-style comment box: a raw-markdown textarea with Write/Preview tabs and
5
+ a submit footer. Field chrome (label/description/validation/sizing) comes from
6
+ the shared InputWrap, so it matches the `Field*` family; everything specific to
7
+ the box lives here.
8
+
9
+ Tokens follow the library convention: a component token that falls back to the
10
+ shared `--stuic-input-*` token, then to a global default — so the box inherits
11
+ input theming for free but can be retargeted on its own.
12
+ ******************************************************************************/
13
+
14
+ .stuic-comment-input {
15
+ --_radius: var(
16
+ --stuic-comment-input-radius,
17
+ var(--stuic-input-radius, var(--stuic-radius))
18
+ );
19
+ --_border-color: var(
20
+ --stuic-comment-input-border,
21
+ var(--stuic-input-border, currentColor)
22
+ );
23
+ --_bg: var(--stuic-comment-input-bg, var(--stuic-input-bg, transparent));
24
+ --_pad-x: var(
25
+ --stuic-comment-input-padding-x,
26
+ var(--stuic-input-padding-x-md, 0.75rem)
27
+ );
28
+ --_pad-y: var(--stuic-comment-input-padding-y, var(--stuic-input-padding-y-md, 0.5rem));
29
+ --_min-height: var(--stuic-comment-input-min-height, 5rem);
30
+ --_font-size: var(
31
+ --stuic-comment-input-font-size,
32
+ var(--stuic-input-font-size-md, 0.9375rem)
33
+ );
34
+
35
+ display: flex;
36
+ align-items: flex-start;
37
+ gap: var(--stuic-comment-input-gap, 0.625rem);
38
+ width: 100%;
39
+ min-width: 0;
40
+ }
41
+
42
+ .stuic-comment-input-avatar {
43
+ flex: 0 0 auto;
44
+ padding-top: 0.125rem;
45
+ }
46
+
47
+ /* The bordered card — focus ring lives here, not on the avatar row. */
48
+ .stuic-comment-input-editor {
49
+ display: flex;
50
+ flex-direction: column;
51
+ flex: 1 1 auto;
52
+ min-width: 0;
53
+ max-width: 100%;
54
+ background: var(--_bg);
55
+ border: var(--stuic-border-width, 1px) solid var(--_border-color);
56
+ border-radius: var(--_radius);
57
+ color: var(--stuic-input-text, inherit);
58
+ overflow: hidden;
59
+ transition: border-color var(--stuic-transition, 150ms);
60
+ }
61
+
62
+ .stuic-comment-input-editor:focus-within {
63
+ border-color: var(
64
+ --stuic-comment-input-border-focus,
65
+ var(--stuic-input-border-focus, currentColor)
66
+ );
67
+ }
68
+
69
+ .stuic-comment-input.disabled {
70
+ opacity: 0.6;
71
+ pointer-events: none;
72
+ }
73
+
74
+ /* --- Tab bar -------------------------------------------------------------- */
75
+ .stuic-comment-input-bar {
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ gap: 0.5rem;
80
+ padding: 0.25rem 0.375rem;
81
+ background: var(--stuic-comment-input-bar-bg, transparent);
82
+ }
83
+
84
+ .stuic-comment-input-tabs {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 0.125rem;
88
+ }
89
+
90
+ .stuic-comment-input-tabs button {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 2rem;
95
+ padding: 0 0.625rem;
96
+ font-size: 0.8125rem;
97
+ line-height: 1;
98
+ color: inherit;
99
+ opacity: 0.7;
100
+ background: transparent;
101
+ border: none;
102
+ border-radius: var(--stuic-radius, 0.375rem);
103
+ transition:
104
+ background-color var(--stuic-transition, 150ms),
105
+ opacity var(--stuic-transition, 150ms);
106
+ }
107
+
108
+ .stuic-comment-input-tabs button:hover:not(:disabled) {
109
+ background: var(--stuic-comment-input-tab-hover-bg, rgb(0 0 0 / 0.06));
110
+ }
111
+
112
+ .stuic-comment-input-tabs button[data-active="true"] {
113
+ opacity: 1;
114
+ font-weight: 600;
115
+ background: var(--stuic-comment-input-tab-active-bg, rgb(0 0 0 / 0.06));
116
+ }
117
+
118
+ .stuic-comment-input-tabs button:disabled {
119
+ opacity: 0.4;
120
+ }
121
+
122
+ .stuic-comment-input-hint {
123
+ font-size: 0.75rem;
124
+ opacity: 0.5;
125
+ padding-right: 0.25rem;
126
+ }
127
+
128
+ :root.dark .stuic-comment-input-tabs button:hover:not(:disabled),
129
+ :root.dark .stuic-comment-input-tabs button[data-active="true"] {
130
+ background: var(--stuic-comment-input-tab-active-bg, rgb(255 255 255 / 0.08));
131
+ }
132
+
133
+ @media (pointer: coarse) {
134
+ .stuic-comment-input-tabs button {
135
+ height: 2.5rem;
136
+ }
137
+ }
138
+
139
+ /* --- Surface (textarea + preview share the same box) ---------------------- */
140
+ .stuic-comment-input-surface {
141
+ min-width: 0;
142
+ }
143
+
144
+ /* The inactive tab panel is removed via the `hidden` attribute; the explicit
145
+ rule guarantees it collapses regardless of any utility-layer display. The
146
+ write panel stays in the DOM while hidden so its textarea still submits and
147
+ validates. */
148
+ .stuic-comment-input-panel[hidden] {
149
+ display: none;
150
+ }
151
+
152
+ .stuic-comment-input-textarea {
153
+ display: block;
154
+ width: 100%;
155
+ min-height: var(--_min-height);
156
+ max-width: 100%;
157
+ margin: 0;
158
+ padding: var(--_pad-y) var(--_pad-x);
159
+ font-size: var(--_font-size);
160
+ line-height: 1.6;
161
+ color: inherit;
162
+ background: transparent;
163
+ border: none;
164
+ outline: none;
165
+ box-shadow: none;
166
+ resize: none;
167
+ }
168
+
169
+ /* --- Preview -------------------------------------------------------------- */
170
+ .stuic-comment-input-preview {
171
+ min-height: var(--_min-height);
172
+ padding: var(--_pad-y) var(--_pad-x);
173
+ font-size: var(--_font-size);
174
+ line-height: 1.6;
175
+ overflow-wrap: break-word;
176
+ }
177
+
178
+ .stuic-comment-input-preview-empty {
179
+ opacity: 0.5;
180
+ font-style: italic;
181
+ }
182
+
183
+ .stuic-comment-input-preview > * + * {
184
+ margin-top: 0.75em;
185
+ }
186
+ .stuic-comment-input-preview h1 {
187
+ font-size: 1.5em;
188
+ font-weight: 700;
189
+ line-height: 1.2;
190
+ }
191
+ .stuic-comment-input-preview h2 {
192
+ font-size: 1.3em;
193
+ font-weight: 700;
194
+ line-height: 1.25;
195
+ }
196
+ .stuic-comment-input-preview h3 {
197
+ font-size: 1.15em;
198
+ font-weight: 600;
199
+ }
200
+ .stuic-comment-input-preview ul,
201
+ .stuic-comment-input-preview ol {
202
+ padding-left: 1.5em;
203
+ }
204
+ .stuic-comment-input-preview ul {
205
+ list-style: disc;
206
+ }
207
+ .stuic-comment-input-preview ol {
208
+ list-style: decimal;
209
+ }
210
+ .stuic-comment-input-preview a {
211
+ text-decoration: underline;
212
+ }
213
+ .stuic-comment-input-preview blockquote {
214
+ padding-left: 1em;
215
+ border-left: 3px solid var(--_border-color);
216
+ opacity: 0.85;
217
+ }
218
+ .stuic-comment-input-preview code {
219
+ font-family: var(
220
+ --stuic-comment-input-font-mono,
221
+ ui-monospace,
222
+ SFMono-Regular,
223
+ Menlo,
224
+ monospace
225
+ );
226
+ font-size: 0.9em;
227
+ padding: 0.1em 0.3em;
228
+ border-radius: 0.25rem;
229
+ background: var(--stuic-comment-input-code-bg, rgb(0 0 0 / 0.06));
230
+ }
231
+ .stuic-comment-input-preview pre {
232
+ font-family: var(
233
+ --stuic-comment-input-font-mono,
234
+ ui-monospace,
235
+ SFMono-Regular,
236
+ Menlo,
237
+ monospace
238
+ );
239
+ padding: 0.75em 1em;
240
+ border-radius: var(--stuic-radius, 0.375rem);
241
+ background: var(--stuic-comment-input-code-bg, rgb(0 0 0 / 0.06));
242
+ overflow-x: auto;
243
+ }
244
+ .stuic-comment-input-preview pre code {
245
+ padding: 0;
246
+ background: transparent;
247
+ }
248
+ .stuic-comment-input-preview hr {
249
+ border: none;
250
+ border-top: var(--stuic-border-width, 1px) solid var(--_border-color);
251
+ }
252
+ .stuic-comment-input-preview table {
253
+ border-collapse: collapse;
254
+ }
255
+ .stuic-comment-input-preview th,
256
+ .stuic-comment-input-preview td {
257
+ border: var(--stuic-border-width, 1px) solid var(--_border-color);
258
+ padding: 0.25em 0.5em;
259
+ }
260
+
261
+ :root.dark .stuic-comment-input-preview code,
262
+ :root.dark .stuic-comment-input-preview pre {
263
+ background: var(--stuic-comment-input-code-bg, rgb(255 255 255 / 0.08));
264
+ }
265
+
266
+ /* --- Footer (button row) -------------------------------------------------- */
267
+ .stuic-comment-input-footer {
268
+ display: flex;
269
+ align-items: center;
270
+ justify-content: space-between;
271
+ gap: 0.5rem;
272
+ padding: 0.5rem;
273
+ }
274
+
275
+ .stuic-comment-input-footer-start,
276
+ .stuic-comment-input-footer-end {
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 0.5rem;
280
+ }
281
+
282
+ .stuic-comment-input-footer-end {
283
+ margin-left: auto;
284
+ }
285
+
286
+ /* --- Size variants -------------------------------------------------------- */
287
+ .stuic-comment-input[data-size="sm"] {
288
+ --_pad-x: var(--stuic-comment-input-padding-x, var(--stuic-input-padding-x-sm, 0.5rem));
289
+ --_pad-y: var(
290
+ --stuic-comment-input-padding-y,
291
+ var(--stuic-input-padding-y-sm, 0.375rem)
292
+ );
293
+ --_font-size: var(
294
+ --stuic-comment-input-font-size,
295
+ var(--stuic-input-font-size-sm, 0.8125rem)
296
+ );
297
+ --_min-height: var(--stuic-comment-input-min-height, 4rem);
298
+ }
299
+ .stuic-comment-input[data-size="lg"] {
300
+ --_pad-x: var(--stuic-comment-input-padding-x, var(--stuic-input-padding-x-lg, 1rem));
301
+ --_pad-y: var(
302
+ --stuic-comment-input-padding-y,
303
+ var(--stuic-input-padding-y-lg, 0.75rem)
304
+ );
305
+ --_font-size: var(
306
+ --stuic-comment-input-font-size,
307
+ var(--stuic-input-font-size-lg, 1.0625rem)
308
+ );
309
+ --_min-height: var(--stuic-comment-input-min-height, 7rem);
310
+ }
@@ -0,0 +1,2 @@
1
+ export { default as CommentInput, type Props as CommentInputProps, type CommentInputMode, } from "./CommentInput.svelte";
2
+ export type { MarkdownRenderer } from "./_internal/render.js";
@@ -0,0 +1 @@
1
+ export { default as CommentInput, } from "./CommentInput.svelte";
@@ -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