@marianmeres/stuic 3.131.0 → 3.132.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/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/package.json +1 -1
|
@@ -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
|
|
@@ -61,6 +61,13 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
61
61
|
withOnProgress?: boolean;
|
|
62
62
|
classControls?: string;
|
|
63
63
|
isLoading?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Opt-in: when true, each asset tile in the grid gets "Move earlier" / "Move later"
|
|
66
|
+
* controls (shown on hover/focus) so the user can manually reorder the assets. The
|
|
67
|
+
* chosen order is serialized to `value`. Buttons only, no drag (the field's drag is
|
|
68
|
+
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
69
|
+
*/
|
|
70
|
+
ordered?: boolean;
|
|
64
71
|
classWrap?: string;
|
|
65
72
|
}
|
|
66
73
|
declare const FieldAssets: import("svelte").Component<Props, {
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
/**
|
|
87
87
|
* Opt-in: when true (and multi-select), exposes a dedicated "Arrange" screen in the
|
|
88
88
|
* modal that lets the user manually reorder the current selection (buttons only, no
|
|
89
|
-
* drag), plus "Sort A–Z" / "Reverse" shortcuts. The chosen order is what gets
|
|
89
|
+
* drag), plus "Sort A–Z" / "Reverse" / "Shuffle" shortcuts. The chosen order is what gets
|
|
90
90
|
* serialized to `value` on submit. No-op for single-select. Default `false`.
|
|
91
91
|
*/
|
|
92
92
|
ordered?: boolean;
|
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
arrange_help: "Reorder the selected items. Use the buttons to move them.",
|
|
131
131
|
sort_az: "Sort A–Z",
|
|
132
132
|
reverse: "Reverse",
|
|
133
|
+
shuffle: "Shuffle",
|
|
133
134
|
move_up: "Move up",
|
|
134
135
|
move_down: "Move down",
|
|
135
136
|
move_to_top: "Move to top",
|
|
@@ -140,6 +141,7 @@
|
|
|
140
141
|
removed_item: "Removed {{value}}",
|
|
141
142
|
sorted_az: "Sorted A to Z",
|
|
142
143
|
reversed: "Order reversed",
|
|
144
|
+
shuffled: "Order shuffled",
|
|
143
145
|
};
|
|
144
146
|
let out = m[k] ?? fallback ?? k;
|
|
145
147
|
|
|
@@ -528,6 +530,17 @@
|
|
|
528
530
|
liveAnnouncement = t("reversed") as string;
|
|
529
531
|
}
|
|
530
532
|
|
|
533
|
+
// random reorder (Fisher–Yates); same snapshot/clear/re-add dance as reverse()
|
|
534
|
+
function shuffle() {
|
|
535
|
+
const arr = [...selected.items];
|
|
536
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
537
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
538
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
539
|
+
}
|
|
540
|
+
_selectedColl.clear(false).addMany(arr);
|
|
541
|
+
liveAnnouncement = t("shuffled") as string;
|
|
542
|
+
}
|
|
543
|
+
|
|
531
544
|
function enterArrange() {
|
|
532
545
|
if (!selected.items.length) return;
|
|
533
546
|
arrangeMode = true;
|
|
@@ -908,6 +921,15 @@
|
|
|
908
921
|
>
|
|
909
922
|
{@html t("reverse")}
|
|
910
923
|
</button>
|
|
924
|
+
<button
|
|
925
|
+
type="button"
|
|
926
|
+
onclick={shuffle}
|
|
927
|
+
disabled={selected.items.length < 2}
|
|
928
|
+
class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
|
|
929
|
+
tabindex={6}
|
|
930
|
+
>
|
|
931
|
+
{@html t("shuffle")}
|
|
932
|
+
</button>
|
|
911
933
|
<span
|
|
912
934
|
class="flex-1 block justify-end text-right text-xs p-1 pr-2 stuic-field-options-muted"
|
|
913
935
|
>
|
|
@@ -58,7 +58,7 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
58
58
|
/**
|
|
59
59
|
* Opt-in: when true (and multi-select), exposes a dedicated "Arrange" screen in the
|
|
60
60
|
* modal that lets the user manually reorder the current selection (buttons only, no
|
|
61
|
-
* drag), plus "Sort A–Z" / "Reverse" shortcuts. The chosen order is what gets
|
|
61
|
+
* drag), plus "Sort A–Z" / "Reverse" / "Shuffle" shortcuts. The chosen order is what gets
|
|
62
62
|
* serialized to `value` on submit. No-op for single-select. Default `false`.
|
|
63
63
|
*/
|
|
64
64
|
ordered?: boolean;
|
|
@@ -472,6 +472,33 @@ on submit (and round-trips on reopen). Single-select fields ignore the prop.
|
|
|
472
472
|
|
|
473
473
|
---
|
|
474
474
|
|
|
475
|
+
## FieldAssets
|
|
476
|
+
|
|
477
|
+
Asset/image upload field with an inline thumbnail grid and a built-in lightbox preview
|
|
478
|
+
(`AssetsPreview`). Files are added via the picker button or by dragging them onto the
|
|
479
|
+
field; each shows as a thumbnail with its filename (and an upload progress indicator while
|
|
480
|
+
processing).
|
|
481
|
+
|
|
482
|
+
### Ordering the assets (`ordered`)
|
|
483
|
+
|
|
484
|
+
By default assets keep their upload order. Opt in with `ordered` to let users reorder them
|
|
485
|
+
manually: each thumbnail gains **Move earlier** / **Move later** controls (revealed on
|
|
486
|
+
hover/focus) that shift the asset one position in the sequence. Buttons only, no drag — the
|
|
487
|
+
field's drag gesture is reserved for file drops — with full keyboard support and aria-live
|
|
488
|
+
announcements. The chosen order is serialized to `value`.
|
|
489
|
+
|
|
490
|
+
```svelte
|
|
491
|
+
<FieldAssets
|
|
492
|
+
label="Gallery (ordered)"
|
|
493
|
+
name="gallery"
|
|
494
|
+
bind:value
|
|
495
|
+
{processAssets}
|
|
496
|
+
ordered
|
|
497
|
+
/>
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
475
502
|
## Honeypot & TimeTrap (anti-bot primitives)
|
|
476
503
|
|
|
477
504
|
Two small, **client-side, server-less** primitives for cheap spam mitigation. They produce **signals** — they do not block anything. Read the signal, then enforce on your server (the only place enforcement is trustworthy). Both are reusable on any form; [`ContactUsForm`](../ContactUsForm/README.md) composes them by default.
|