@marianmeres/stuic 3.140.0 → 3.141.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,7 +187,7 @@
187
187
  </script>
188
188
 
189
189
  <script lang="ts">
190
- import { CronParser } from "@marianmeres/cron";
190
+ import { CronParser } from "@marianmeres/cron-parser";
191
191
  import {
192
192
  validate as validateAction,
193
193
  type ValidationResult,
@@ -1,6 +1,6 @@
1
1
  # CronInput
2
2
 
3
- A cron-expression editor with three progressive surfaces: **preset picker**, **5-field visual editor**, and **raw expression input** — plus a human-readable summary and the next-run preview. Backed by [`@marianmeres/cron`](https://www.npmjs.com/package/@marianmeres/cron).
3
+ A cron-expression editor with three progressive surfaces: **preset picker**, **5-field visual editor**, and **raw expression input** — plus a human-readable summary and the next-run preview. Backed by [`@marianmeres/cron-parser`](https://www.npmjs.com/package/@marianmeres/cron-parser).
4
4
 
5
5
  The cron expression string is the single source of truth; field / preset / raw surfaces are all views over it. All three stay in sync automatically when the bound `value` changes.
6
6
 
@@ -109,7 +109,7 @@ The component uses `new CronParser(value)` to validate. When invalid:
109
109
 
110
110
  ## Timezone
111
111
 
112
- All next-run calculations use the **host's local timezone**. There is no `timezone` prop today. DST transitions are handled correctly by `@marianmeres/cron`, but the displayed "next run" will naturally follow local-time semantics.
112
+ All next-run calculations use the **host's local timezone**. There is no `timezone` prop today. DST transitions are handled correctly by `@marianmeres/cron-parser`, but the displayed "next run" will naturally follow local-time semantics.
113
113
 
114
114
  ## Props
115
115
 
@@ -1,4 +1,4 @@
1
- import { CronParser } from "@marianmeres/cron";
1
+ import { CronParser } from "@marianmeres/cron-parser";
2
2
  /**
3
3
  * A reactive helper that parses a cron expression and computes the next run time,
4
4
  * updating automatically every minute.
@@ -131,6 +131,75 @@
131
131
  return map[getFileTypeLabel(ext ?? "unknown")] ?? iconFile;
132
132
  }
133
133
 
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
+ function on_document_paste(e: ClipboardEvent) {
173
+ // someone (an editor, another paste handler) already claimed it
174
+ if (e.defaultPrevented) return;
175
+ const active = document.activeElement;
176
+ // 1) the field holding focus always wins — unless focus sits in a
177
+ // text-entry element nested inside it: stand down entirely
178
+ for (const t of paste_targets) {
179
+ if (t.el.contains(active)) {
180
+ if (is_text_entry(active)) return;
181
+ return t.handle(e);
182
+ }
183
+ }
184
+ // 2) a paste with no focus anywhere falls back to the single mounted
185
+ // (and visible) pasteable field, so a bare Ctrl/Cmd-V works on a freshly
186
+ // loaded page with no prior click. With several fields mounted the
187
+ // routing would be ambiguous, so focus (a click on the field) must decide.
188
+ if (paste_targets.size === 1 && is_unclaimed_focus(active)) {
189
+ const [t] = paste_targets;
190
+ if (is_visible(t.el)) t.handle(e);
191
+ }
192
+ }
193
+
194
+ function register_paste_target(t: PasteTarget): () => void {
195
+ if (!paste_targets.size) document.addEventListener("paste", on_document_paste);
196
+ paste_targets.add(t);
197
+ return () => {
198
+ paste_targets.delete(t);
199
+ if (!paste_targets.size) document.removeEventListener("paste", on_document_paste);
200
+ };
201
+ }
202
+
134
203
  type SnippetWithId = Snippet<[{ id: string }]>;
135
204
 
136
205
  export interface Props extends InputWrapClassProps, Record<string, any> {
@@ -189,11 +258,19 @@
189
258
  ordered?: boolean;
190
259
  /**
191
260
  * 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`.
261
+ * the field. Routing: a paste is consumed while the field (or a control
262
+ * inside it) holds focus clicking anywhere in the field focuses it. On
263
+ * top of that, a paste with NO focus anywhere (fresh page, focus parked on
264
+ * <body>) is routed here as long as this is the only pasteable — and
265
+ * visible FieldAssets on the page, so a bare Ctrl/Cmd-V works with no
266
+ * prior click. Focus elsewhere is respected and never hijacked: not a text
267
+ * input/textarea/select/contenteditable (even one nested inside the field
268
+ * via the label/description/below snippets), not any other widget; with
269
+ * several pasteable fields mounted, click (focus) one to pick the
270
+ * destination. Disabled and `isLoading` fields take no pastes. Pasted
271
+ * files share the validation + upload path with drag and the file picker
272
+ * (`accept`, `cardinality` and `processAssets` all apply). No-op unless
273
+ * `processAssets` is provided. Default `false`.
197
274
  */
198
275
  pasteable?: boolean;
199
276
  //
@@ -526,11 +603,12 @@
526
603
  }
527
604
 
528
605
  // --- 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.
606
+ // Wired through the module-level document `paste` listener (see PasteTarget
607
+ // above): the instance registers itself while mounted, and receives the paste
608
+ // when it holds focus or, as the only mounted pasteable field, when the
609
+ // paste is unclaimed (focus not in a text-entry element). Pasted files go
610
+ // through `handleIncomingFiles`, inheriting accept/cardinality validation and
611
+ // the processAssets upload — no synthetic `change` event.
534
612
  function extractClipboardFiles(e: ClipboardEvent): File[] {
535
613
  const dt = e.clipboardData;
536
614
  if (!dt) return [];
@@ -556,11 +634,11 @@
556
634
  handleIncomingFiles(files);
557
635
  }
558
636
 
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.
637
+ // Focus the wrapper on any click inside it so a following paste routes here
638
+ // (the document-level listener routes by focus containment). Capture phase:
639
+ // fires even though the inner thumbnail/control buttons stopPropagation, and
640
+ // even in browsers that don't focus <button> on click (Safari/Firefox on
641
+ // macOS). Skipped when focus is already inside the field.
564
642
  function focusForPaste() {
565
643
  if (!pasteable || !wrapEl) return;
566
644
  if (wrapEl.contains(document.activeElement)) return;
@@ -570,12 +648,17 @@
570
648
  }
571
649
 
572
650
  $effect(() => {
573
- if (!pasteable || !wrapEl) return;
651
+ // disabled and still-loading fields do not take pastes at all
652
+ if (!pasteable || typeof processAssets !== "function" || !wrapEl) return;
653
+ if (disabled || isLoading) return;
574
654
  const el = wrapEl;
575
- el.addEventListener("paste", handlePaste);
655
+ const unregister = register_paste_target({ el, handle: handlePaste });
656
+ // clicking still focuses the field: gives the `:focus-within` ring (the
657
+ // visible "paste lands here" affordance) and lets THIS field win the
658
+ // routing when several pasteable fields are mounted
576
659
  el.addEventListener("click", focusForPaste, true);
577
660
  return () => {
578
- el.removeEventListener("paste", handlePaste);
661
+ unregister();
579
662
  el.removeEventListener("click", focusForPaste, true);
580
663
  };
581
664
  });
@@ -764,7 +847,11 @@
764
847
  {classLabelBox}
765
848
  {classInputBox}
766
849
  classInputBoxWrap={twMerge(
850
+ // the ring is the "paste lands here" affordance — only show it when
851
+ // paste actually works (same gating as the paste-target registration)
767
852
  pasteable &&
853
+ typeof processAssets === "function" &&
854
+ !disabled &&
768
855
  "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-(--stuic-color-ring)",
769
856
  classInputBoxWrap
770
857
  )}
@@ -77,11 +77,19 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
77
77
  ordered?: boolean;
78
78
  /**
79
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`.
80
+ * the field. Routing: a paste is consumed while the field (or a control
81
+ * inside it) holds focus clicking anywhere in the field focuses it. On
82
+ * top of that, a paste with NO focus anywhere (fresh page, focus parked on
83
+ * <body>) is routed here as long as this is the only pasteable — and
84
+ * visible FieldAssets on the page, so a bare Ctrl/Cmd-V works with no
85
+ * prior click. Focus elsewhere is respected and never hijacked: not a text
86
+ * input/textarea/select/contenteditable (even one nested inside the field
87
+ * via the label/description/below snippets), not any other widget; with
88
+ * several pasteable fields mounted, click (focus) one to pick the
89
+ * destination. Disabled and `isLoading` fields take no pastes. Pasted
90
+ * files share the validation + upload path with drag and the file picker
91
+ * (`accept`, `cardinality` and `processAssets` all apply). No-op unless
92
+ * `processAssets` is provided. Default `false`.
85
93
  */
86
94
  pasteable?: boolean;
87
95
  classWrap?: string;
@@ -482,9 +482,22 @@ an upload progress indicator while processing).
482
482
  ### Paste from the clipboard (`pasteable`)
483
483
 
484
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.
485
+ a screenshot, a copied image, etc. Routing (via a single document-level listener shared by
486
+ all mounted pasteable fields):
487
+
488
+ - a paste is consumed while the field (or a control inside it) holds focus — clicking
489
+ anywhere in the field focuses it, and a `:focus-within` ring on the input box signals the
490
+ paste-ready state;
491
+ - a paste with **no focus anywhere** (fresh page — focus parked on `<body>`) is routed to
492
+ the field as long as it is the _only_ pasteable (and visible) FieldAssets on the page, so
493
+ a bare Ctrl/Cmd-V works with no prior click;
494
+ - focus elsewhere is respected: pasting while a text input, textarea, select,
495
+ contenteditable (even one nested _inside_ the field via the `label`/`description`/`below`
496
+ snippets) or any other widget holds focus is never hijacked. With several pasteable fields
497
+ mounted, an unfocused paste is ambiguous and ignored — click (focus) a field to pick the
498
+ destination;
499
+ - `disabled` and `isLoading` fields take no pastes.
500
+
488
501
  Pasted files go through the same path as the picker and drag-and-drop, so `accept`,
489
502
  `cardinality` and `processAssets` all apply. No-op unless `processAssets` is provided.
490
503
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.140.0",
3
+ "version": "3.141.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -179,7 +179,7 @@
179
179
  "dependencies": {
180
180
  "@marianmeres/clog": "^3.21.0",
181
181
  "@marianmeres/countries": "^1.0.1",
182
- "@marianmeres/cron": "^2.0.1",
182
+ "@marianmeres/cron-parser": "^1.0.1",
183
183
  "@marianmeres/design-tokens": "^1.12.0",
184
184
  "@marianmeres/icons-fns": "^5.0.0",
185
185
  "@marianmeres/item-collection": "^1.4.2",