@marianmeres/stuic 3.181.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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/dist/components/FieldsBuilder/FieldsBuilder.svelte +101 -150
  3. package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +10 -1
  4. package/dist/components/FieldsBuilder/README.md +82 -3
  5. package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte +624 -0
  6. package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte.d.ts +41 -0
  7. package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte +174 -0
  8. package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte.d.ts +20 -0
  9. package/dist/components/FieldsBuilder/_internal/column-meta.d.ts +27 -0
  10. package/dist/components/FieldsBuilder/_internal/column-meta.js +15 -0
  11. package/dist/components/FieldsBuilder/i18n-sk.js +17 -0
  12. package/dist/components/FieldsBuilder/i18n.d.ts +17 -0
  13. package/dist/components/FieldsBuilder/i18n.js +17 -0
  14. package/dist/components/FieldsBuilder/index.css +20 -0
  15. package/dist/components/FieldsBuilder/index.d.ts +2 -2
  16. package/dist/components/FieldsBuilder/types.d.ts +39 -0
  17. package/dist/components/FieldsBuilder/utils.d.ts +36 -1
  18. package/dist/components/FieldsBuilder/utils.js +180 -55
  19. package/dist/components/Input/FieldAssets.svelte +9 -132
  20. package/dist/components/Input/FieldSingleAsset.svelte +876 -0
  21. package/dist/components/Input/FieldSingleAsset.svelte.d.ts +135 -0
  22. package/dist/components/Input/FieldTable.svelte +907 -0
  23. package/dist/components/Input/FieldTable.svelte.d.ts +98 -0
  24. package/dist/components/Input/README.md +467 -20
  25. package/dist/components/Input/_internal/asset-helpers.d.ts +16 -0
  26. package/dist/components/Input/_internal/asset-helpers.js +46 -0
  27. package/dist/components/Input/_internal/paste-target.d.ts +15 -0
  28. package/dist/components/Input/_internal/paste-target.js +114 -0
  29. package/dist/components/Input/field-single-asset-i18n-sk.d.ts +21 -0
  30. package/dist/components/Input/field-single-asset-i18n-sk.js +49 -0
  31. package/dist/components/Input/field-single-asset-i18n.d.ts +68 -0
  32. package/dist/components/Input/field-single-asset-i18n.js +75 -0
  33. package/dist/components/Input/field-table-i18n-sk.d.ts +21 -0
  34. package/dist/components/Input/field-table-i18n-sk.js +41 -0
  35. package/dist/components/Input/field-table-i18n.d.ts +60 -0
  36. package/dist/components/Input/field-table-i18n.js +66 -0
  37. package/dist/components/Input/field-table-number.d.ts +48 -0
  38. package/dist/components/Input/field-table-number.js +86 -0
  39. package/dist/components/Input/index.css +801 -0
  40. package/dist/components/Input/index.d.ts +7 -0
  41. package/dist/components/Input/index.js +7 -0
  42. package/docs/domains/actions.md +3 -3
  43. package/docs/domains/components.md +45 -38
  44. package/package.json +1 -1
@@ -57,10 +57,102 @@ export function isKeyReserved(key, reservedKeys) {
57
57
  ? !!reservedKeys(key)
58
58
  : reservedKeys.includes(key);
59
59
  }
60
+ /**
61
+ * The palette a column of `entry` may take: its own `columnTypes`, else the
62
+ * component's `types` without every entry that `supportsColumns` itself
63
+ * (columns never nest).
64
+ */
65
+ export function resolveColumnTypes(entry, types) {
66
+ return entry.columnTypes ?? (types ?? []).filter((td) => !td.supportsColumns);
67
+ }
68
+ /**
69
+ * Materialize the `default` of every declared extra that the bag does not
70
+ * hold yet. Returns the same bag when nothing was seeded (so `undefined`
71
+ * stays `undefined` for a type without defaults).
72
+ */
73
+ export function seedExtraDefaults(extras, entry) {
74
+ let next = extras;
75
+ for (const ex of entry?.extras ?? []) {
76
+ if (ex.default !== undefined && next?.[ex.key] === undefined) {
77
+ next = { ...(next ?? {}), [ex.key]: ex.default };
78
+ }
79
+ }
80
+ return next;
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // the single-value rules, shared by the field loop and the column loop so the
84
+ // two cannot drift; each returns the FIRST offending message or undefined
85
+ // ---------------------------------------------------------------------------
86
+ function labelError(label, defaultLanguage, t) {
87
+ return getLocalizedText(label, defaultLanguage).trim()
88
+ ? undefined
89
+ : t("err_label_required");
90
+ }
91
+ function keyError(key, rules, t) {
92
+ // presence is checked trimmed, but pattern/length run on the RAW key —
93
+ // a whitespace-padded key must fail here, not at the consumer's gate
94
+ const k = key ?? "";
95
+ if (!k.trim())
96
+ return t("err_key_required");
97
+ if (k.length > rules.maxLength)
98
+ return t("err_key_maxlength", { max: rules.maxLength });
99
+ if (!rules.pattern.test(k))
100
+ return t("err_key_pattern");
101
+ if (rules.isDuplicate(k.trim()))
102
+ return t(rules.duplicateMessageKey);
103
+ if (rules.isReserved?.(k))
104
+ return t("err_key_reserved");
105
+ return undefined;
106
+ }
107
+ function optionsError(options, t) {
108
+ const list = options ?? [];
109
+ const values = list.map((o) => (o.value ?? "").trim());
110
+ if (!list.length)
111
+ return t("err_options_required");
112
+ if (values.some((v) => !v))
113
+ return t("err_option_value_required");
114
+ if (new Set(values).size !== values.length)
115
+ return t("err_option_value_duplicate");
116
+ return undefined;
117
+ }
118
+ // `maxlength` is re-checked here on purpose: the input's attribute stops
119
+ // typing, it does not bound a seeded or pasted-then-mutated value
120
+ function extrasError(extras, entry, displayLanguages, t) {
121
+ for (const ex of entry?.extras ?? []) {
122
+ if (ex.type !== "string" || !ex.maxlength)
123
+ continue;
124
+ const v = extras?.[ex.key];
125
+ if (typeof v === "string" && v.length > ex.maxlength) {
126
+ return t("err_extra_maxlength", {
127
+ label: getLocalizedText(ex.label, displayLanguages),
128
+ max: ex.maxlength,
129
+ });
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+ function countKeys(items) {
135
+ const counts = new Map();
136
+ for (const it of items) {
137
+ const k = (it.key ?? "").trim();
138
+ if (k)
139
+ counts.set(k, (counts.get(k) ?? 0) + 1);
140
+ }
141
+ return counts;
142
+ }
143
+ /** First string member of an errors object (skips the `columnErrors` array). */
144
+ function firstMessage(errs) {
145
+ if (!errs)
146
+ return "";
147
+ return Object.values(errs).find((v) => typeof v === "string") ?? "";
148
+ }
60
149
  /**
61
150
  * Validate a list of field defs: label non-empty, key present/pattern/length/
62
151
  * unique/not-reserved, choice types have at least one option with non-empty
63
152
  * unique values, string extras within `maxlength`, `maxFields` not exceeded.
153
+ * A `supportsColumns` type additionally needs at least one column, at most
154
+ * `maxColumns`, and every column (of a known column type) passes the label /
155
+ * key (unique within the field) / options / extras rules above.
64
156
  *
65
157
  * This is client-side convenience only — a consumer persisting the list MUST
66
158
  * re-validate server-side; this function is not a security boundary.
@@ -73,76 +165,109 @@ export function validateFieldDefs(defs, opts = {}) {
73
165
  const displayLanguages = [opts.displayLanguage, opts.defaultLanguage].filter((l) => !!l);
74
166
  const rowErrors = defs.map(() => null);
75
167
  const put = (i, field, msg) => {
168
+ if (!msg)
169
+ return;
76
170
  rowErrors[i] ??= {};
77
171
  rowErrors[i][field] ??= msg;
78
172
  };
79
173
  // keys of ALL defs (incl. unknown types) occupy the key space
80
- const keyCounts = new Map();
81
- for (const d of defs) {
82
- const k = (d.key ?? "").trim();
83
- if (k)
84
- keyCounts.set(k, (keyCounts.get(k) ?? 0) + 1);
85
- }
174
+ const keyCounts = countKeys(defs);
86
175
  defs.forEach((d, i) => {
87
176
  // unknown type: keep as-is, do not block (see the doc comment above)
88
177
  if (typeMap && !typeMap.has(d.type))
89
178
  return;
90
- if (!getLocalizedText(d.label, opts.defaultLanguage).trim()) {
91
- put(i, "label", t("err_label_required"));
92
- }
93
- // presence is checked trimmed, but pattern/length run on the RAW key —
94
- // a whitespace-padded key must fail here, not at the consumer's gate
95
- const k = d.key ?? "";
96
- if (!k.trim()) {
97
- put(i, "key", t("err_key_required"));
98
- }
99
- else {
100
- if (k.length > keyMaxLength) {
101
- put(i, "key", t("err_key_maxlength", { max: keyMaxLength }));
102
- }
103
- else if (!keyPattern.test(k)) {
104
- put(i, "key", t("err_key_pattern"));
105
- }
106
- if ((keyCounts.get(k.trim()) ?? 0) > 1)
107
- put(i, "key", t("err_key_duplicate"));
108
- if (isKeyReserved(k, opts.reservedKeys))
109
- put(i, "key", t("err_key_reserved"));
110
- }
111
- if (typeMap?.get(d.type)?.supportsOptions) {
112
- const options = d.options ?? [];
113
- const values = options.map((o) => (o.value ?? "").trim());
114
- if (!options.length) {
115
- put(i, "options", t("err_options_required"));
116
- }
117
- else if (values.some((v) => !v)) {
118
- put(i, "options", t("err_option_value_required"));
119
- }
120
- else if (new Set(values).size !== values.length) {
121
- put(i, "options", t("err_option_value_duplicate"));
122
- }
123
- }
124
- // `maxlength` is re-checked here on purpose: the input's attribute stops
125
- // typing, it does not bound a seeded or pasted-then-mutated value
126
- for (const ex of typeMap?.get(d.type)?.extras ?? []) {
127
- if (ex.type !== "string" || !ex.maxlength)
128
- continue;
129
- const v = d.extras?.[ex.key];
130
- if (typeof v === "string" && v.length > ex.maxlength) {
131
- put(i, "extras", t("err_extra_maxlength", {
132
- label: getLocalizedText(ex.label, displayLanguages),
133
- max: ex.maxlength,
134
- }));
135
- }
179
+ const entry = typeMap?.get(d.type);
180
+ put(i, "label", labelError(d.label, opts.defaultLanguage, t));
181
+ put(i, "key", keyError(d.key, {
182
+ pattern: keyPattern,
183
+ maxLength: keyMaxLength,
184
+ isDuplicate: (k) => (keyCounts.get(k) ?? 0) > 1,
185
+ duplicateMessageKey: "err_key_duplicate",
186
+ isReserved: (k) => isKeyReserved(k, opts.reservedKeys),
187
+ }, t));
188
+ if (entry?.supportsOptions)
189
+ put(i, "options", optionsError(d.options, t));
190
+ if (entry?.supportsColumns) {
191
+ const { summary, columnErrors } = validateColumns(d.columns, entry, {
192
+ columnTypes: resolveColumnTypes(entry, opts.types),
193
+ keyPattern,
194
+ keyMaxLength,
195
+ defaultLanguage: opts.defaultLanguage,
196
+ displayLanguages,
197
+ t,
198
+ });
199
+ put(i, "columns", summary);
200
+ if (columnErrors)
201
+ rowErrors[i].columnErrors = columnErrors;
136
202
  }
203
+ put(i, "extras", extrasError(d.extras, entry, displayLanguages, t));
137
204
  });
138
- let message = rowErrors.find(Boolean)
139
- ? Object.values(rowErrors.find(Boolean))[0]
140
- : "";
205
+ // the summary is the first STRING member of the first erroneous row —
206
+ // `columnErrors` (an array) must never be picked here
207
+ let message = firstMessage(rowErrors.find(Boolean));
141
208
  if (!message && opts.maxFields && defs.length > opts.maxFields) {
142
209
  message = t("err_max_fields", { max: opts.maxFields });
143
210
  }
144
211
  return { valid: !message, message, rowErrors };
145
212
  }
213
+ /**
214
+ * The column rules of one `supportsColumns` def. A column whose type is not
215
+ * in the column palette is NOT validated (it round-trips untouched, like an
216
+ * unknown field type) but its key still counts toward uniqueness. `reservedKeys`
217
+ * does not apply: it guards the field-key namespace, and a column key lives
218
+ * inside one field's rows.
219
+ */
220
+ function validateColumns(columns, entry, opts) {
221
+ const { t } = opts;
222
+ const list = columns ?? [];
223
+ // a `supportsColumns` entry inside the column palette is a plain column
224
+ // type here — nothing nests — so only `type` / `supportsOptions` / `extras` matter
225
+ const colTypeMap = new Map(opts.columnTypes.map((td) => [td.type, td]));
226
+ const keyCounts = countKeys(list);
227
+ let summary;
228
+ if (!list.length)
229
+ summary = t("err_columns_required");
230
+ else if (entry.maxColumns && list.length > entry.maxColumns) {
231
+ summary = t("err_max_columns", { max: entry.maxColumns });
232
+ }
233
+ const columnErrors = list.map((c) => {
234
+ const ct = colTypeMap.get(c.type);
235
+ if (!ct)
236
+ return null;
237
+ const e = {};
238
+ const label = labelError(c.label, opts.defaultLanguage, t);
239
+ if (label)
240
+ e.label = label;
241
+ const key = keyError(c.key, {
242
+ pattern: opts.keyPattern,
243
+ maxLength: opts.keyMaxLength,
244
+ isDuplicate: (k) => (keyCounts.get(k) ?? 0) > 1,
245
+ duplicateMessageKey: "err_column_key_duplicate",
246
+ }, t);
247
+ if (key)
248
+ e.key = key;
249
+ if (ct.supportsOptions) {
250
+ const options = optionsError(c.options, t);
251
+ if (options)
252
+ e.options = options;
253
+ }
254
+ const extras = extrasError(c.extras, ct, opts.displayLanguages, t);
255
+ if (extras)
256
+ e.extras = extras;
257
+ return Object.keys(e).length ? e : null;
258
+ });
259
+ const firstIdx = columnErrors.findIndex(Boolean);
260
+ if (!summary && firstIdx >= 0) {
261
+ summary = t("err_column", {
262
+ position: firstIdx + 1,
263
+ message: firstMessage(columnErrors[firstIdx]),
264
+ });
265
+ }
266
+ return {
267
+ summary,
268
+ columnErrors: firstIdx >= 0 ? columnErrors : undefined,
269
+ };
270
+ }
146
271
  /**
147
272
  * A small general-purpose palette — handy for demos and for consumers with no
148
273
  * opinion. `types` is a required prop, so nobody gets this by accident.
@@ -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