@marianmeres/stuic 3.182.0 → 3.183.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/README.md CHANGED
@@ -175,7 +175,7 @@ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header,
175
175
 
176
176
  ### Forms & Inputs
177
177
 
178
- FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldTable, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
178
+ FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldSingleAsset, FieldOptions, FieldKeyValues, FieldTable, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
179
179
 
180
180
  ### Buttons & Controls
181
181
 
@@ -39,6 +39,11 @@
39
39
  import SpinnerCircleOscillate from "../Spinner/SpinnerCircleOscillate.svelte";
40
40
  import { isTHCNotEmpty, type THC } from "../Thc/Thc.svelte";
41
41
  import InputWrap from "./_internal/InputWrap.svelte";
42
+ import { isAcceptedType } from "./_internal/asset-helpers.js";
43
+ import {
44
+ extractClipboardFiles,
45
+ registerPasteTarget,
46
+ } from "./_internal/paste-target.js";
42
47
  import type { InputWrapClassProps } from "./types.js";
43
48
  import Button from "../Button/Button.svelte";
44
49
 
@@ -92,27 +97,6 @@
92
97
  blobUrl: string;
93
98
  }
94
99
 
95
- function is_accepted_type(allowedAccept?: string, type?: string) {
96
- // on empty allow
97
- if (!allowedAccept || !type) return true;
98
-
99
- // normalize allowed (eg "images/*, video/mpg")
100
- const allowed = (allowedAccept ?? "")
101
- .split(",")
102
- .map((v) => `${v || ""}`.trim().toLowerCase())
103
- .map((v) => {
104
- if (v.includes("*")) {
105
- v = v.slice(0, v.indexOf("*"));
106
- }
107
- return v;
108
- })
109
- .filter(Boolean);
110
-
111
- if (!allowed.length) return true;
112
-
113
- return allowed.some((a) => type.toLowerCase().startsWith(a));
114
- }
115
-
116
100
  export function getAssetIcon(ext?: string) {
117
101
  const map: Record<string, CallableFunction> = {
118
102
  archive: iconFileZip,
@@ -131,96 +115,6 @@
131
115
  return map[getFileTypeLabel(ext ?? "unknown")] ?? iconFile;
132
116
  }
133
117
 
134
- // --- Clipboard paste plumbing (see the `pasteable` prop) -------------------
135
- // All mounted pasteable instances coordinate through ONE document-level
136
- // `paste` listener (installed while at least one is mounted). Document-level
137
- // (rather than a listener on the field wrapper) because browsers dispatch
138
- // `paste` at the focused/selection node — with focus on <body> the event
139
- // never bubbles through the field, which made the feature look dead unless
140
- // the field was clicked first.
141
- type PasteTarget = {
142
- el: HTMLElement;
143
- handle: (e: ClipboardEvent) => void;
144
- };
145
-
146
- const paste_targets = new Set<PasteTarget>();
147
-
148
- // True only when NOTHING is focused (browsers park focus on <body>/<html>).
149
- // The fallback below must never fire while the user has deliberately focused
150
- // some other element — a text input obviously, but also any other widget
151
- // (e.g. a different, non-pasteable FieldAssets): pasting "into" the thing
152
- // they focused must not teleport files to an unrelated field.
153
- function is_unclaimed_focus(el: Element | null): boolean {
154
- return !el || el === document.body || el === document.documentElement;
155
- }
156
-
157
- // Text-entry elements own their pastes even when they live INSIDE the field
158
- // (consumer content via the label/description/below snippets) — an editor's
159
- // image paste must insert into the editor, not upload into the field.
160
- function is_text_entry(el: Element | null): boolean {
161
- if (!el) return false;
162
- if ((el as HTMLElement).isContentEditable) return true;
163
- return ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
164
- }
165
-
166
- // A field hidden by CSS (kept-mounted inactive tab panel etc.) must not
167
- // claim the no-focus fallback — the user would see nothing happen.
168
- function is_visible(el: HTMLElement): boolean {
169
- return el.checkVisibility?.() ?? el.offsetParent !== null;
170
- }
171
-
172
- // A modal/dialog (drawer, modal) takes focus on open, so `active` is the
173
- // dialog panel — or a control inside it — and NEVER <body>. When the field
174
- // lives in such a dialog, treat a non-text focus within that SAME dialog as
175
- // unclaimed too, so a bare Ctrl/Cmd-V attaches with no prior click. Scoped to
176
- // a shared [aria-modal]/[role=dialog] ancestor on purpose: it must not make
177
- // the field greedy on ordinary (non-modal) pages, where a deliberately
178
- // focused control elsewhere still owns its paste.
179
- function shares_modal(fieldEl: HTMLElement, active: Element | null): boolean {
180
- if (!active) return false;
181
- const modal = fieldEl.closest?.("[aria-modal='true'],[role='dialog']");
182
- return !!modal && modal.contains(active);
183
- }
184
-
185
- function on_document_paste(e: ClipboardEvent) {
186
- // someone (an editor, another paste handler) already claimed it
187
- if (e.defaultPrevented) return;
188
- const active = document.activeElement;
189
- // 1) the field holding focus always wins — unless focus sits in a
190
- // text-entry element nested inside it: stand down entirely
191
- for (const t of paste_targets) {
192
- if (t.el.contains(active)) {
193
- if (is_text_entry(active)) return;
194
- return t.handle(e);
195
- }
196
- }
197
- // 2) fall back to the SINGLE mounted + visible pasteable field for a paste
198
- // NOT owned by a focused text-entry element, when focus is EITHER unclaimed
199
- // (fresh page, focus on <body>) OR parked on a non-text control inside the
200
- // SAME modal/dialog as the field (the drawer/modal case: the panel or the
201
- // row that opened it holds focus, so <body> is never active). A bare
202
- // Ctrl/Cmd-V then works with no prior click. With several fields mounted the
203
- // routing would be ambiguous, so focus (a click on the field) must decide.
204
- if (paste_targets.size === 1 && !is_text_entry(active)) {
205
- const [t] = paste_targets;
206
- if (
207
- is_visible(t.el) &&
208
- (is_unclaimed_focus(active) || shares_modal(t.el, active))
209
- ) {
210
- t.handle(e);
211
- }
212
- }
213
- }
214
-
215
- function register_paste_target(t: PasteTarget): () => void {
216
- if (!paste_targets.size) document.addEventListener("paste", on_document_paste);
217
- paste_targets.add(t);
218
- return () => {
219
- paste_targets.delete(t);
220
- if (!paste_targets.size) document.removeEventListener("paste", on_document_paste);
221
- };
222
- }
223
-
224
118
  type SnippetWithId = Snippet<[{ id: string }]>;
225
119
 
226
120
  export interface Props extends InputWrapClassProps, Record<string, any> {
@@ -554,7 +448,7 @@
554
448
  // processAssets (which would otherwise run with an empty batch).
555
449
  if (!incoming.length) return;
556
450
 
557
- if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
451
+ if (accept && incoming.some((f) => !isAcceptedType(accept, f.type, f.name))) {
558
452
  const msg = t("invalid_type", { accept });
559
453
  if (notifications) notifications.error(msg);
560
454
  else alert(msg);
@@ -627,29 +521,12 @@
627
521
  }
628
522
 
629
523
  // --- Clipboard paste (opt-in via `pasteable`) -------------------------------
630
- // Wired through the module-level document `paste` listener (see PasteTarget
631
- // above): the instance registers itself while mounted, and receives the paste
524
+ // Wired through the document-level `paste` listener shared with FieldSingleAsset
525
+ // (see `_internal/paste-target.ts`): the instance registers itself while mounted, and receives the paste
632
526
  // when it holds focus — or, as the only mounted pasteable field, when the
633
527
  // paste is unclaimed (focus not in a text-entry element). Pasted files go
634
528
  // through `handleIncomingFiles`, inheriting accept/cardinality validation and
635
529
  // the processAssets upload — no synthetic `change` event.
636
- function extractClipboardFiles(e: ClipboardEvent): File[] {
637
- const dt = e.clipboardData;
638
- if (!dt) return [];
639
- const out: File[] = [];
640
- // Prefer items (lets us keep only file-kind entries, e.g. a pasted
641
- // screenshot); fall back to `.files` for browsers that only populate that.
642
- for (let i = 0; i < (dt.items?.length ?? 0); i++) {
643
- const it = dt.items[i];
644
- if (it?.kind === "file") {
645
- const f = it.getAsFile();
646
- if (f) out.push(f);
647
- }
648
- }
649
- if (!out.length && dt.files?.length) out.push(...dt.files);
650
- return out;
651
- }
652
-
653
530
  function handlePaste(e: ClipboardEvent) {
654
531
  if (!pasteable || typeof processAssets !== "function") return;
655
532
  const files = extractClipboardFiles(e);
@@ -676,7 +553,7 @@
676
553
  if (!pasteable || typeof processAssets !== "function" || !wrapEl) return;
677
554
  if (disabled || isLoading) return;
678
555
  const el = wrapEl;
679
- const unregister = register_paste_target({ el, handle: handlePaste });
556
+ const unregister = registerPasteTarget({ el, handle: handlePaste });
680
557
  // clicking still focuses the field: gives the `:focus-within` ring (the
681
558
  // visible "paste lands here" affordance) and lets THIS field win the
682
559
  // routing when several pasteable fields are mounted