@marianmeres/stuic 3.134.0 → 3.135.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.
@@ -187,6 +187,15 @@
187
187
  * reserved for file drops); full keyboard + aria-live announcements. Default `false`.
188
188
  */
189
189
  ordered?: boolean;
190
+ /**
191
+ * Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
192
+ * the field. Focus-scoped — the paste is consumed only while the field (or a
193
+ * control inside it) holds focus; clicking anywhere in the field focuses it,
194
+ * so no Tab is needed. Routed through the same validation + upload path as
195
+ * drag and the file picker, so `accept`, `cardinality` and `processAssets`
196
+ * all apply. No-op unless `processAssets` is provided. Default `false`.
197
+ */
198
+ pasteable?: boolean;
190
199
  //
191
200
  classWrap?: string;
192
201
  }
@@ -252,6 +261,7 @@
252
261
  classControls = "",
253
262
  isLoading = false,
254
263
  ordered = false,
264
+ pasteable = false,
255
265
  parseValue = (strigifiedModels: string) => {
256
266
  const val = strigifiedModels ?? "[]";
257
267
  try {
@@ -280,6 +290,10 @@
280
290
  let inputEl = $state<HTMLInputElement>()!;
281
291
  // Outer wrapper for scrollIntoView and focus targeting.
282
292
  let wrapEl: HTMLDivElement | undefined = $state();
293
+ // The asset grid container, which lives INSIDE InputWrap's `.input-wrap` box.
294
+ // `focusForPaste` focuses this (not `wrapEl`, an ancestor) so the `:focus-within`
295
+ // ring on `.input-wrap` actually lights up on click.
296
+ let gridEl: HTMLDivElement | undefined = $state();
283
297
  let hiddenInputEl: HTMLInputElement | undefined = $state();
284
298
  let assetsPreview: AssetsPreview = $state()!;
285
299
 
@@ -412,6 +426,160 @@
412
426
  focusMovedButton(to, to < from ? "prev" : "next");
413
427
  }
414
428
 
429
+ // Funnels every file source (drop, picker, paste) through one path so they all
430
+ // share the same accept/cardinality checks, optimistic tiles and processAssets
431
+ // upload. Accepts a plain File[] too (paste builds one); the spread normalizes
432
+ // both it and a FileList.
433
+ function handleIncomingFiles(files: FileList | File[] | null, wasDrop = false) {
434
+ clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
435
+
436
+ // Copy the File objects into a stable array, then IMMEDIATELY release the
437
+ // hidden <input type="file">'s retained FileList. A native file input
438
+ // keeps its FileList after a selection until it is reset (form reset or
439
+ // `value = ""`), and its `change` event (wired by the `fileDropzone`
440
+ // action) calls back into this handler. Without this reset, any later
441
+ // stray `change` re-runs this with the SAME file still in `inputEl.files`,
442
+ // pushing a duplicate optimistic asset and firing a real re-upload.
443
+ // `onSubmitValidityCheck` used to dispatch exactly such a synthetic
444
+ // `change` on submit (now fixed there too — belt and braces). Clearing
445
+ // also restores the ability to re-select the same file twice in a row (an
446
+ // unchanged value emits no `change`). The blob URLs we create below are
447
+ // independent of the input, so clearing here is safe.
448
+ const incoming = [...(files ?? [])];
449
+ if (inputEl) inputEl.value = "";
450
+
451
+ // Nothing to consume — a cancelled picker, or a stray/synthetic `change`
452
+ // on an already-cleared input. Bail before touching state or calling
453
+ // processAssets (which would otherwise run with an empty batch).
454
+ if (!incoming.length) return;
455
+
456
+ if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
457
+ const msg = t("invalid_type", { accept });
458
+ if (notifications) notifications.error(msg);
459
+ else alert(msg);
460
+ return;
461
+ }
462
+
463
+ const cardErrMsg = t("cardinality_reached", { max: cardinality });
464
+ // `>=` (not `>`): refuse the moment the field already holds `cardinality`
465
+ // assets, instead of optimistically adding one past the limit and relying
466
+ // on the validator to reject it afterwards (the off-by-one that made the
467
+ // single-cardinality symptom loud).
468
+ if (assets.length >= cardinality) {
469
+ if (notifications) notifications.error(cardErrMsg);
470
+ else alert(cardErrMsg);
471
+ return;
472
+ }
473
+
474
+ const toBeProcessed: FieldAsset[] = [];
475
+
476
+ for (const file of incoming) {
477
+ if (assets.length >= cardinality) {
478
+ notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
479
+ break;
480
+ }
481
+
482
+ // this create a unique blob url, which we'll use as id as well
483
+ const url = URL.createObjectURL(file);
484
+ const asset: FieldAsset = {
485
+ id: url,
486
+ url: { thumb: url, full: url, original: url },
487
+ type: file.type,
488
+ name: file.name,
489
+ meta: { isUploading: true },
490
+ };
491
+
492
+ // ASAP optimistic UI update
493
+ assets.push(asset);
494
+
495
+ // prepare data for server upload
496
+ toBeProcessed.push(asset);
497
+ }
498
+ value = serializeValue(assets);
499
+
500
+ if (typeof processAssets === "function") {
501
+ isUploading = true;
502
+ processAssets(toBeProcessed, onProgress)
503
+ .then((uploaded: FieldAssetWithBlobUrl[]) => {
504
+ // clog("uploaded", uploaded);
505
+ for (const ass of uploaded ?? []) {
506
+ ass.meta ??= {};
507
+ ass.meta.isUploading = false;
508
+ if (ass.blobUrl) {
509
+ const idx = assets.findIndex((a) => a.id === ass.blobUrl);
510
+ if (idx > -1) {
511
+ ass.meta ??= {};
512
+ ass.meta.isUploading = false;
513
+ assets[idx] = ass;
514
+ } else {
515
+ clog.error(`Asset idx ${idx} not found?!?`, ass);
516
+ }
517
+ } else {
518
+ clog.warn(`Missing blobUrl in`, ass);
519
+ }
520
+ }
521
+ value = serializeValue(assets);
522
+ })
523
+ .catch((e) => notifications?.error(`${e}`))
524
+ .finally(() => (isUploading = false));
525
+ }
526
+ }
527
+
528
+ // --- Clipboard paste (opt-in via `pasteable`) -------------------------------
529
+ // Focus-scoped: the paste listener lives on the field wrapper, so it only fires
530
+ // while the field (or a control inside it) holds focus. `focusForPaste` focuses
531
+ // the wrapper on any click within it, so a click + Ctrl/Cmd-V works with no Tab.
532
+ // Pasted files go through `handleIncomingFiles`, inheriting accept/cardinality
533
+ // validation and the processAssets upload — no synthetic `change` event.
534
+ function extractClipboardFiles(e: ClipboardEvent): File[] {
535
+ const dt = e.clipboardData;
536
+ if (!dt) return [];
537
+ const out: File[] = [];
538
+ // Prefer items (lets us keep only file-kind entries, e.g. a pasted
539
+ // screenshot); fall back to `.files` for browsers that only populate that.
540
+ for (let i = 0; i < (dt.items?.length ?? 0); i++) {
541
+ const it = dt.items[i];
542
+ if (it?.kind === "file") {
543
+ const f = it.getAsFile();
544
+ if (f) out.push(f);
545
+ }
546
+ }
547
+ if (!out.length && dt.files?.length) out.push(...dt.files);
548
+ return out;
549
+ }
550
+
551
+ function handlePaste(e: ClipboardEvent) {
552
+ if (!pasteable || typeof processAssets !== "function") return;
553
+ const files = extractClipboardFiles(e);
554
+ if (!files.length) return; // let plain-text pastes etc. pass through
555
+ e.preventDefault();
556
+ handleIncomingFiles(files);
557
+ }
558
+
559
+ // Focus the wrapper on any click inside it so a following paste lands here.
560
+ // Capture phase: fires even though the inner thumbnail/control buttons
561
+ // stopPropagation, and even in browsers that don't focus <button> on click
562
+ // (Safari/Firefox on macOS). Skipped when focus is already inside the field —
563
+ // paste bubbles from any focused descendant anyway.
564
+ function focusForPaste() {
565
+ if (!pasteable || !wrapEl) return;
566
+ if (wrapEl.contains(document.activeElement)) return;
567
+ // Focus a node INSIDE `.input-wrap` (the grid) so its `:focus-within` ring
568
+ // shows; fall back to the wrapper when the grid isn't mounted (loading).
569
+ (gridEl ?? wrapEl).focus({ preventScroll: true });
570
+ }
571
+
572
+ $effect(() => {
573
+ if (!pasteable || !wrapEl) return;
574
+ const el = wrapEl;
575
+ el.addEventListener("paste", handlePaste);
576
+ el.addEventListener("click", focusForPaste, true);
577
+ return () => {
578
+ el.removeEventListener("paste", handlePaste);
579
+ el.removeEventListener("click", focusForPaste, true);
580
+ };
581
+ });
582
+
415
583
  onDestroy(() => {
416
584
  try {
417
585
  assets.forEach((a) => {
@@ -437,7 +605,11 @@
437
605
  <!-- screen-reader announcements for reorder actions -->
438
606
  <div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
439
607
  {/if}
440
- <div class={["p-2 flex items-center gap-0.5 flex-wrap"]}>
608
+ <div
609
+ class="p-2 flex items-center gap-0.5 flex-wrap w-full focus:outline-none"
610
+ bind:this={gridEl}
611
+ tabindex="-1"
612
+ >
441
613
  {#each assets as asset, idx (asset.id)}
442
614
  {@const { thumb, full, original } = asset_urls(asset)}
443
615
  {@const _is_img = isImage(asset.type ?? thumb)}
@@ -571,100 +743,7 @@
571
743
  use:fileDropzone={() => ({
572
744
  enabled: typeof processAssets === "function",
573
745
  inputEl,
574
- processFiles(files: FileList | null, wasDrop?: boolean) {
575
- clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
576
-
577
- // Copy the File objects into a stable array, then IMMEDIATELY release the
578
- // hidden <input type="file">'s retained FileList. A native file input
579
- // keeps its FileList after a selection until it is reset (form reset or
580
- // `value = ""`), and its `change` event (wired by the `fileDropzone`
581
- // action) calls back into this handler. Without this reset, any later
582
- // stray `change` re-runs processFiles with the SAME file still in
583
- // `inputEl.files`, pushing a duplicate optimistic asset and firing a real
584
- // re-upload. `onSubmitValidityCheck` used to dispatch exactly such a
585
- // synthetic `change` on submit (now fixed there too — belt and braces).
586
- // Clearing also restores the ability to re-select the same file twice in
587
- // a row (an unchanged value emits no `change`). The blob URLs we create
588
- // below are independent of the input, so clearing here is safe.
589
- const incoming = [...(files ?? [])];
590
- if (inputEl) inputEl.value = "";
591
-
592
- // Nothing to consume — a cancelled picker, or a stray/synthetic `change`
593
- // on an already-cleared input. Bail before touching state or calling
594
- // processAssets (which would otherwise run with an empty batch).
595
- if (!incoming.length) return;
596
-
597
- if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
598
- const msg = t("invalid_type", { accept });
599
- if (notifications) notifications.error(msg);
600
- else alert(msg);
601
- return;
602
- }
603
-
604
- const cardErrMsg = t("cardinality_reached", { max: cardinality });
605
- // `>=` (not `>`): refuse the moment the field already holds `cardinality`
606
- // assets, instead of optimistically adding one past the limit and relying
607
- // on the validator to reject it afterwards (the off-by-one that made the
608
- // single-cardinality symptom loud).
609
- if (assets.length >= cardinality) {
610
- if (notifications) notifications.error(cardErrMsg);
611
- else alert(cardErrMsg);
612
- return;
613
- }
614
-
615
- const toBeProcessed: FieldAsset[] = [];
616
-
617
- for (const file of incoming) {
618
- if (assets.length >= cardinality) {
619
- notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
620
- break;
621
- }
622
-
623
- // this create a unique blob url, which we'll use as id as well
624
- const url = URL.createObjectURL(file);
625
- const asset: FieldAsset = {
626
- id: url,
627
- url: { thumb: url, full: url, original: url },
628
- type: file.type,
629
- name: file.name,
630
- meta: { isUploading: true },
631
- };
632
-
633
- // ASAP optimistic UI update
634
- assets.push(asset);
635
-
636
- // prepare data for server upload
637
- toBeProcessed.push(asset);
638
- }
639
- value = serializeValue(assets);
640
-
641
- if (typeof processAssets === "function") {
642
- isUploading = true;
643
- processAssets(toBeProcessed, onProgress)
644
- .then((uploaded: FieldAssetWithBlobUrl[]) => {
645
- // clog("uploaded", uploaded);
646
- for (const ass of uploaded ?? []) {
647
- ass.meta ??= {};
648
- ass.meta.isUploading = false;
649
- if (ass.blobUrl) {
650
- const idx = assets.findIndex((a) => a.id === ass.blobUrl);
651
- if (idx > -1) {
652
- ass.meta ??= {};
653
- ass.meta.isUploading = false;
654
- assets[idx] = ass;
655
- } else {
656
- clog.error(`Asset idx ${idx} not found?!?`, ass);
657
- }
658
- } else {
659
- clog.warn(`Missing blobUrl in`, ass);
660
- }
661
- }
662
- value = serializeValue(assets);
663
- })
664
- .catch((e) => notifications?.error(`${e}`))
665
- .finally(() => (isUploading = false));
666
- }
667
- },
746
+ processFiles: handleIncomingFiles,
668
747
  allowClick: !assets?.length,
669
748
  })}
670
749
  >
@@ -684,7 +763,11 @@
684
763
  {classLabel}
685
764
  {classLabelBox}
686
765
  {classInputBox}
687
- {classInputBoxWrap}
766
+ classInputBoxWrap={twMerge(
767
+ pasteable &&
768
+ "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-(--stuic-color-ring)",
769
+ classInputBoxWrap
770
+ )}
688
771
  {classInputBoxWrapInvalid}
689
772
  {classDescBox}
690
773
  {classDescBoxToggle}
@@ -75,6 +75,15 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
75
75
  * reserved for file drops); full keyboard + aria-live announcements. Default `false`.
76
76
  */
77
77
  ordered?: boolean;
78
+ /**
79
+ * Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
80
+ * the field. Focus-scoped — the paste is consumed only while the field (or a
81
+ * control inside it) holds focus; clicking anywhere in the field focuses it,
82
+ * so no Tab is needed. Routed through the same validation + upload path as
83
+ * drag and the file picker, so `accept`, `cardinality` and `processAssets`
84
+ * all apply. No-op unless `processAssets` is provided. Default `false`.
85
+ */
86
+ pasteable?: boolean;
78
87
  classWrap?: string;
79
88
  }
80
89
  declare const FieldAssets: import("svelte").Component<Props, {
@@ -475,9 +475,29 @@ on submit (and round-trips on reopen). Single-select fields ignore the prop.
475
475
  ## FieldAssets
476
476
 
477
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).
478
+ (`AssetsPreview`). Files are added via the picker button, by dragging them onto the field,
479
+ or (opt-in) by pasting from the clipboard; each shows as a thumbnail with its filename (and
480
+ an upload progress indicator while processing).
481
+
482
+ ### Paste from the clipboard (`pasteable`)
483
+
484
+ Opt in with `pasteable` to let users paste image/file data from the clipboard (Ctrl/Cmd-V) —
485
+ a screenshot, a copied image, etc. It is **focus-scoped**: a paste is consumed only while the
486
+ field (or a control inside it) holds focus. Clicking anywhere in the field focuses it, so no
487
+ Tab is needed, and a `:focus-within` ring on the input box signals the paste-ready state.
488
+ Pasted files go through the same path as the picker and drag-and-drop, so `accept`,
489
+ `cardinality` and `processAssets` all apply. No-op unless `processAssets` is provided.
490
+
491
+ ```svelte
492
+ <FieldAssets
493
+ label="Screenshots"
494
+ name="screenshots"
495
+ bind:value
496
+ {processAssets}
497
+ accept="image/*"
498
+ pasteable
499
+ />
500
+ ```
481
501
 
482
502
  ### Ordering the assets (`ordered`)
483
503
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.134.0",
3
+ "version": "3.135.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",