@marianmeres/stuic 2.7.1 → 2.8.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.
@@ -20,7 +20,7 @@ export declare function isTooltipSupported(): boolean;
20
20
  /**
21
21
  * Valid positions for tooltip placement relative to the anchor element.
22
22
  */
23
- export type TooltipPosition = "top" | "top-left" | "top-right" | "bottom" | "bottom-left" | "bottom-right" | "left" | "right";
23
+ export type TooltipPosition = "top" | "top-left" | "top-right" | "top-span-left" | "top-span-right" | "bottom" | "bottom-left" | "bottom-right" | "bottom-span-left" | "bottom-span-right" | "left" | "right";
24
24
  /**
25
25
  * A Svelte action that displays a tooltip anchored to an element using CSS Anchor Positioning.
26
26
  *
@@ -37,9 +37,13 @@ const POSITION_MAP = {
37
37
  top: "top",
38
38
  "top-left": "top left",
39
39
  "top-right": "top right",
40
+ "top-span-left": "top span-left",
41
+ "top-span-right": "top span-right",
40
42
  bottom: "bottom",
41
43
  "bottom-left": "bottom left",
42
44
  "bottom-right": "bottom right",
45
+ "bottom-span-left": "bottom span-left",
46
+ "bottom-span-right": "bottom span-right",
43
47
  left: "left",
44
48
  right: "right",
45
49
  };
@@ -145,6 +145,11 @@ export interface ValidateOptions {
145
145
  * ```ts
146
146
  * validate.t = (reason, value, fallback) => translations[reason] ?? fallback;
147
147
  * ```
148
+ *
149
+ * **Hidden Input Limitation**: Browsers don't populate `el.validationMessage`
150
+ * for hidden inputs (`type="hidden"`) even when `setCustomValidity()` is called.
151
+ * This action works around this by preserving the `customValidator` return value
152
+ * and using it directly as the error message fallback.
148
153
  */
149
154
  export declare function validate(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, fn?: () => boolean | ValidateOptions): void;
150
155
  export declare namespace validate {
@@ -127,6 +127,11 @@ const KNOWN_REASONS = [
127
127
  * ```ts
128
128
  * validate.t = (reason, value, fallback) => translations[reason] ?? fallback;
129
129
  * ```
130
+ *
131
+ * **Hidden Input Limitation**: Browsers don't populate `el.validationMessage`
132
+ * for hidden inputs (`type="hidden"`) even when `setCustomValidity()` is called.
133
+ * This action works around this by preserving the `customValidator` return value
134
+ * and using it directly as the error message fallback.
130
135
  */
131
136
  export function validate(el, fn) {
132
137
  $effect(() => {
@@ -153,8 +158,14 @@ export function validate(el, fn) {
153
158
  if (!enabled)
154
159
  return;
155
160
  el.checkValidity();
161
+ // Store customValidator message directly - hidden inputs (type="hidden")
162
+ // don't populate el.validationMessage even when setCustomValidity() is called.
163
+ // This is a browser limitation. We preserve the message here and use it
164
+ // directly as the fallback in ValidationResult.message below.
165
+ let customValidatorMessage = "";
156
166
  if (typeof customValidator === "function") {
157
- el.setCustomValidity(customValidator(el.value, context, el) || "");
167
+ customValidatorMessage = customValidator(el.value, context, el) || "";
168
+ el.setCustomValidity(customValidatorMessage);
158
169
  }
159
170
  // this triggers the bubble, which is not what we want
160
171
  // el.reportValidity();
@@ -172,12 +183,11 @@ export function validate(el, fn) {
172
183
  validity: validityState,
173
184
  reasons,
174
185
  valid: validityState?.valid,
175
- // use translate fn for first reason (if fn provided and allowed),
176
- // otherwise fallback to native msg
177
- message: _t(reasons?.[0], el.value, el.validationMessage ||
178
- // PROBLEM: hidden inputs do not report validationMessage-s even
179
- // if correctly reported as invalid. So all we can do, is
180
- // put only something generic here...
186
+ // Use translate fn for first reason (if fn provided and allowed),
187
+ // otherwise fallback to native msg. We use customValidatorMessage first
188
+ // because hidden inputs don't populate el.validationMessage (see above).
189
+ message: _t(reasons?.[0], el.value, customValidatorMessage ||
190
+ el.validationMessage ||
181
191
  "This field is invalid. Please review and try again."),
182
192
  });
183
193
  // });
@@ -173,6 +173,7 @@
173
173
  onProgress?: (blobUrl: string, progress: number) => any
174
174
  ) => Promise<FieldAssetWithBlobUrl[]>;
175
175
  withOnProgress?: boolean;
176
+ classControls?: string;
176
177
  }
177
178
  </script>
178
179
 
@@ -229,6 +230,7 @@
229
230
  // onChange,
230
231
  processAssets,
231
232
  withOnProgress,
233
+ classControls = "",
232
234
  parseValue = (strigifiedModels: string) => {
233
235
  const val = strigifiedModels ?? "[]";
234
236
  try {
@@ -273,13 +275,12 @@
273
275
  let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
274
276
  enabled: true,
275
277
  customValidator(value: any, context: Record<string, any> | undefined, el: any) {
276
- // NOTE: the below error message code will be ignored, so it's just cosmetics.
277
- // NOTE2: we are NOT validating
278
- // - neither the actual form value.
279
- // - nor the hidden input's value
278
+ // Return actual translated messages (not reason names) because hidden inputs
279
+ // don't support el.validationMessage - the validate action preserves our
280
+ // return value and uses it directly as the error message.
280
281
 
281
- if (required && !assets.length) return "valueMissing";
282
- if (assets.length > cardinality) return "rangeOverflow";
282
+ if (required && !assets.length) return t("field_req_att");
283
+ if (assets.length > cardinality) return t("cardinality_reached", { max: cardinality });
283
284
 
284
285
  // normally, with other fieldtypes, we would continue with provided validator:
285
286
  // return (validate as any)?.customValidator?.(value, context, el) || "";
@@ -289,10 +290,6 @@
289
290
  }
290
291
  return "";
291
292
  },
292
- t(reason: keyof ValidityStateFlags, value: any, fallback: string) {
293
- // Unfortunately, for hidden, everything is a `customError` reason. So, we must generalize...
294
- return t("field_req_att");
295
- },
296
293
  setValidationResult,
297
294
  });
298
295
 
@@ -378,7 +375,7 @@
378
375
  {@const _is_img = isImage(asset.type ?? thumb)}
379
376
  <div class="relative group">
380
377
  <button
381
- class={[objectSize, "bg-black/10 grid place-content-center"]}
378
+ class={[objectSize, "bg-black/10 grid place-content-center", classControls]}
382
379
  onclick={(e) => {
383
380
  e.stopPropagation();
384
381
  e.preventDefault();
@@ -433,7 +430,7 @@
433
430
  e.stopPropagation();
434
431
  inputEl.click();
435
432
  }}
436
- class={[objectSize, " grid place-content-center group"]}
433
+ class={[objectSize, " grid place-content-center group", classControls]}
437
434
  >
438
435
  {@html iconAdd({ size: 32, class: "opacity-75 group-hover:opacity-100" })}
439
436
  </button>
@@ -441,7 +438,7 @@
441
438
  {/snippet}
442
439
 
443
440
  <div
444
- class="w-full"
441
+ class="w-full stuic-field-assets"
445
442
  use:highlightDragover={() => ({
446
443
  enabled: typeof processAssets === "function",
447
444
  classes: ["outline-dashed outline-2 outline-neutral-300"],
@@ -559,7 +556,7 @@
559
556
  classBackdrop="p-4 md:p-4"
560
557
  classInner="max-w-full h-full"
561
558
  class="max-h-full md:max-h-full"
562
- classMain="flex items-center justify-center relative "
559
+ classMain="flex items-center justify-center relative stuic-field-assets stuic-field-assets-open"
563
560
  >
564
561
  {@const previewAsset = assets?.[previewIdx]}
565
562
  {#if previewAsset}
@@ -585,13 +582,21 @@
585
582
 
586
583
  {#if assets?.length > 1}
587
584
  <div class={["absolute inset-0 flex items-center justify-between"]}>
588
- <button class="p-4 focus:outline-0" onclick={preview_previous} type="button">
585
+ <button
586
+ class={twMerge("p-4", classControls)}
587
+ onclick={preview_previous}
588
+ type="button"
589
+ >
589
590
  <span class="bg-white rounded-full p-3 block">
590
591
  {@html iconPrevious()}
591
592
  </span>
592
593
  </button>
593
594
 
594
- <button class="p-4 focus:outline-0" onclick={preview_next} type="button">
595
+ <button
596
+ class={twMerge("p-4", classControls)}
597
+ onclick={preview_next}
598
+ type="button"
599
+ >
595
600
  <span class="bg-white rounded-full p-3 block">
596
601
  {@html iconNext()}
597
602
  </span>
@@ -602,7 +607,7 @@
602
607
  <!-- bg-white rounded-md p-2 -->
603
608
  <div class="absolute top-4 right-4 flex items-center space-x-3">
604
609
  <button
605
- class={TOP_BUTTON_CLS}
610
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
606
611
  onclick={(e) => {
607
612
  e.preventDefault();
608
613
  remove_by_idx(previewIdx);
@@ -615,7 +620,7 @@
615
620
  {@html iconDelete({ class: "size-6" })}
616
621
  </button>
617
622
  <button
618
- class={TOP_BUTTON_CLS}
623
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
619
624
  type="button"
620
625
  onclick={(e) => {
621
626
  e.preventDefault();
@@ -627,7 +632,7 @@
627
632
  {@html iconDownload({ class: "size-6" })}
628
633
  </button>
629
634
  <button
630
- class={TOP_BUTTON_CLS}
635
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
631
636
  onclick={modal.close}
632
637
  aria-label={t("close")}
633
638
  type="button"
@@ -63,6 +63,7 @@ export interface Props extends Record<string, any> {
63
63
  accept?: string;
64
64
  processAssets?: (assets: FieldAsset[], onProgress?: (blobUrl: string, progress: number) => any) => Promise<FieldAssetWithBlobUrl[]>;
65
65
  withOnProgress?: boolean;
66
+ classControls?: string;
66
67
  }
67
68
  declare const FieldAssets: import("svelte").Component<Props, {}, "value">;
68
69
  type FieldAssets = ReturnType<typeof FieldAssets>;
@@ -10,9 +10,9 @@
10
10
  type SnippetWithId = Snippet<[{ id: string }]>;
11
11
 
12
12
  export interface KeyValueEntry {
13
- id: string;
14
13
  key: string;
15
- value: string;
14
+ value: string; // Raw input from user
15
+ parsedValue: unknown; // Parsed JSON value
16
16
  }
17
17
 
18
18
  export interface Props extends Record<string, any> {
@@ -52,8 +52,6 @@
52
52
  </script>
53
53
 
54
54
  <script lang="ts">
55
- import { iconFeatherChevronDown } from "@marianmeres/icons-fns/feather/iconFeatherChevronDown.js";
56
- import { iconFeatherChevronUp } from "@marianmeres/icons-fns/feather/iconFeatherChevronUp.js";
57
55
  import { iconFeatherPlus } from "@marianmeres/icons-fns/feather/iconFeatherPlus.js";
58
56
  import { iconFeatherTrash2 } from "@marianmeres/icons-fns/feather/iconFeatherTrash2.js";
59
57
  import { tick } from "svelte";
@@ -74,27 +72,29 @@
74
72
  ) {
75
73
  const m: Record<string, string> = {
76
74
  field_req_att: "This field requires attention. Please review and try again.",
75
+ entry_required: "At least one entry is required",
77
76
  key_placeholder: "Key",
78
77
  value_placeholder: "Value",
79
78
  add_label: "Add",
80
79
  empty_message: "No entries",
81
80
  remove_entry: "Remove entry",
82
- move_up: "Move up",
83
- move_down: "Move down",
81
+ duplicate_keys: "Duplicate keys are not allowed",
84
82
  };
85
83
  let out = m[k] ?? fallback ?? k;
86
84
  return isPlainObject(values) ? replaceMap(out, values as any) : out;
87
85
  }
88
86
 
87
+ const SERIALIZED_DEFAULT = "{}";
88
+
89
89
  let {
90
- value = $bindable("[]"),
90
+ value = $bindable(),
91
91
  name,
92
92
  id = getId(),
93
93
  label,
94
94
  description,
95
95
  class: classProp,
96
96
  tabindex = 0,
97
- renderSize = "md",
97
+ renderSize = "sm",
98
98
  required = false,
99
99
  disabled = false,
100
100
  validate,
@@ -124,18 +124,32 @@
124
124
  let hiddenInputEl: HTMLInputElement | undefined = $state();
125
125
  let keyInputRefs: HTMLInputElement[] = $state([]);
126
126
 
127
- // Internal state
128
- let entries: KeyValueEntry[] = $state(parseValue(value));
127
+ // Internal state - handle undefined value from async form initialization
128
+ let entries: KeyValueEntry[] = $state(parseValue(value ?? SERIALIZED_DEFAULT));
129
+
130
+ // Parse JSON value with auto-detect: try JSON first, fallback to plain string
131
+ function parseJsonValue(input: string): { value: unknown } {
132
+ const trimmed = input.trim();
133
+ if (trimmed === "") return { value: "" };
134
+
135
+ try {
136
+ const parsed = JSON.parse(trimmed);
137
+ return { value: parsed };
138
+ } catch {
139
+ // If parse fails, treat as plain string
140
+ return { value: trimmed };
141
+ }
142
+ }
129
143
 
130
144
  // Parse external JSON string to internal entries
131
145
  function parseValue(jsonString: string): KeyValueEntry[] {
132
146
  try {
133
- const parsed = JSON.parse(jsonString || "[]");
134
- if (!Array.isArray(parsed)) return [];
135
- return parsed.map(([key, val]: [string, string]) => ({
136
- id: getId("entry-"),
137
- key: key ?? "",
138
- value: val ?? "",
147
+ const parsed = JSON.parse(jsonString || SERIALIZED_DEFAULT);
148
+ if (!isPlainObject(parsed)) return [];
149
+ return Object.entries(parsed).map(([key, val]) => ({
150
+ key,
151
+ value: typeof val === "string" ? val : JSON.stringify(val),
152
+ parsedValue: val,
139
153
  }));
140
154
  } catch (e) {
141
155
  return [];
@@ -144,7 +158,13 @@
144
158
 
145
159
  // Serialize internal entries to external JSON string
146
160
  function serializeValue(entries: KeyValueEntry[]): string {
147
- return JSON.stringify(entries.map((e) => [e.key, e.value]));
161
+ const obj: Record<string, unknown> = {};
162
+ for (const e of entries) {
163
+ if (e.key) {
164
+ obj[e.key] = e.parsedValue;
165
+ }
166
+ }
167
+ return JSON.stringify(obj);
148
168
  }
149
169
 
150
170
  // Sync internal state to external value
@@ -158,7 +178,7 @@
158
178
 
159
179
  // Sync external value to internal state when it changes externally
160
180
  $effect(() => {
161
- const newEntries = parseValue(value);
181
+ const newEntries = parseValue(value ?? SERIALIZED_DEFAULT);
162
182
  const currentSerialized = serializeValue(entries);
163
183
  const newSerialized = serializeValue(newEntries);
164
184
  if (currentSerialized !== newSerialized) {
@@ -168,7 +188,7 @@
168
188
 
169
189
  // Add new entry
170
190
  function addEntry() {
171
- const newEntry = { id: getId("entry-"), key: "", value: "" };
191
+ const newEntry: KeyValueEntry = { key: "", value: "", parsedValue: "" };
172
192
  entries = [...entries, newEntry];
173
193
  syncToValue();
174
194
  // Focus the new key input after render
@@ -184,20 +204,15 @@
184
204
  syncToValue();
185
205
  }
186
206
 
187
- // Move entry up or down
188
- function moveEntry(idx: number, direction: "up" | "down") {
189
- const newIdx = direction === "up" ? idx - 1 : idx + 1;
190
- if (newIdx < 0 || newIdx >= entries.length) return;
191
-
192
- const newEntries = [...entries];
193
- [newEntries[idx], newEntries[newIdx]] = [newEntries[newIdx], newEntries[idx]];
194
- entries = newEntries;
195
- syncToValue();
196
- }
197
-
198
207
  // Update entry field
199
208
  function updateEntry(idx: number, field: "key" | "value", newValue: string) {
200
- entries[idx][field] = newValue;
209
+ if (field === "value") {
210
+ const { value: parsed } = parseJsonValue(newValue);
211
+ entries[idx].value = newValue;
212
+ entries[idx].parsedValue = parsed;
213
+ } else {
214
+ entries[idx].key = newValue;
215
+ }
201
216
  syncToValue();
202
217
  }
203
218
 
@@ -210,24 +225,27 @@
210
225
  customValidator(val: any, context: Record<string, any> | undefined, el: any) {
211
226
  // Validate JSON structure
212
227
  try {
213
- const parsed = JSON.parse(val || "[]");
214
- if (!Array.isArray(parsed)) return "typeMismatch";
215
- for (const entry of parsed) {
216
- if (!Array.isArray(entry) || entry.length !== 2) return "typeMismatch";
217
- }
228
+ const parsed = JSON.parse(val || SERIALIZED_DEFAULT);
229
+ if (!isPlainObject(parsed)) return t("field_req_att");
218
230
  } catch (e) {
219
- return "typeMismatch";
231
+ return t("field_req_att");
220
232
  }
221
233
 
222
- // Required check
223
- if (required && !entries.length) return "valueMissing";
234
+ // Get non-empty keys first (used for both required and duplicate checks)
235
+ const keys = entries.map((e) => e.key).filter((k) => k.trim() !== "");
236
+
237
+ // Required check FIRST - must have at least one entry with non-empty key
238
+ if (required && keys.length === 0) return t("entry_required");
239
+
240
+ // Then check for duplicate keys
241
+ const uniqueKeys = new Set(keys);
242
+ if (keys.length !== uniqueKeys.size) {
243
+ return t("duplicate_keys");
244
+ }
224
245
 
225
246
  // Continue with provided validator
226
247
  return (validate as any)?.customValidator?.(val, context, el) || "";
227
248
  },
228
- t(reason: keyof ValidityStateFlags, val: any, fallback: string) {
229
- return t("field_req_att");
230
- },
231
249
  setValidationResult,
232
250
  });
233
251
 
@@ -278,7 +296,7 @@
278
296
  </div>
279
297
  {:else}
280
298
  <div class="p-2">
281
- {#each entries as entry, idx (entry.id)}
299
+ {#each entries as entry, idx (idx)}
282
300
  <div
283
301
  class={twMerge(
284
302
  "flex gap-2 items-start py-2",
@@ -323,26 +341,8 @@
323
341
  <!-- use:autogrow={() => ({ enabled: true, value: entry.value })} -->
324
342
  </div>
325
343
 
326
- <!-- Controls: Up/Down + Delete -->
327
- <div class="flex flex-col gap-0.5 pt-0.5">
328
- <button
329
- type="button"
330
- onclick={() => moveEntry(idx, "up")}
331
- disabled={disabled || idx === 0}
332
- class={BTN_CLS}
333
- aria-label={t("move_up")}
334
- >
335
- {@html iconFeatherChevronUp({ size: 14 })}
336
- </button>
337
- <button
338
- type="button"
339
- onclick={() => moveEntry(idx, "down")}
340
- disabled={disabled || idx === entries.length - 1}
341
- class={BTN_CLS}
342
- aria-label={t("move_down")}
343
- >
344
- {@html iconFeatherChevronDown({ size: 14 })}
345
- </button>
344
+ <!-- Delete button -->
345
+ <div class="pt-0.5">
346
346
  <button
347
347
  type="button"
348
348
  onclick={() => removeEntry(idx)}
@@ -386,7 +386,7 @@
386
386
  <input
387
387
  type="hidden"
388
388
  {name}
389
- {value}
389
+ value={value ?? SERIALIZED_DEFAULT}
390
390
  bind:this={hiddenInputEl}
391
391
  use:validateAction={() => wrappedValidate}
392
392
  />
@@ -6,9 +6,9 @@ type SnippetWithId = Snippet<[{
6
6
  id: string;
7
7
  }]>;
8
8
  export interface KeyValueEntry {
9
- id: string;
10
9
  key: string;
11
10
  value: string;
11
+ parsedValue: unknown;
12
12
  }
13
13
  export interface Props extends Record<string, any> {
14
14
  value: string;
@@ -185,20 +185,19 @@
185
185
  enabled: !!validate,
186
186
  ...(typeof validate === "boolean"
187
187
  ? {
188
- // PROBLEM with hidden inputs is that they:
189
- // 1. do not report required (AFAICT)
190
- // 2. do not report el.validationMessage even if invalid via custom validation
188
+ // Return actual messages (not reason names) because hidden inputs
189
+ // don't support el.validationMessage - the validate action preserves
190
+ // our return value and uses it directly as the error message.
191
191
  customValidator(val, ctx, el) {
192
- // so, here, we're fixing (1.) and will handle the (2.) elsewhere
193
- // (the message will be ignored anyway, we just need to send non-empty string)
194
- if (required && !val) return "valueMissing";
192
+ if (required && !val)
193
+ return "This field requires attention. Please review and try again.";
195
194
 
196
195
  // also, by default, JSON validation is built in
197
196
  try {
198
197
  JSON.parse(val as string);
199
198
  return "";
200
199
  } catch (e) {
201
- return "typeMismatch";
200
+ return "This field is invalid. Please review and try again.";
202
201
  }
203
202
  },
204
203
  }
@@ -179,8 +179,8 @@ A comprehensive form input system with multiple field components, validation sup
179
179
  <script lang="ts">
180
180
  import { FieldKeyValues } from 'stuic';
181
181
 
182
- // Value is a JSON string of [[key, value], [key, value], ...]
183
- let headers = $state('[]');
182
+ // Value is a JSON string of {key: value, key2: value2, ...}
183
+ let headers = $state('{}');
184
184
  </script>
185
185
 
186
186
  <FieldKeyValues
@@ -196,7 +196,7 @@ A comprehensive form input system with multiple field components, validation sup
196
196
 
197
197
  | Prop | Type | Default | Description |
198
198
  |------|------|---------|-------------|
199
- | `value` | `string` | `"[]"` | JSON string of `[[key, value], ...]` (bindable) |
199
+ | `value` | `string` | `"{}"` | JSON string of `{key: value, ...}` (bindable) |
200
200
  | `name` | `string` | - | Form field name |
201
201
  | `keyPlaceholder` | `string` | `"Key"` | Placeholder for key input |
202
202
  | `valuePlaceholder` | `string` | `"Value"` | Placeholder for value textarea |
@@ -209,9 +209,9 @@ A comprehensive form input system with multiple field components, validation sup
209
209
 
210
210
  Features:
211
211
  - Add/remove key-value pairs with + and trash buttons
212
- - Reorder entries with up/down arrow buttons
213
- - Duplicate keys are allowed
214
- - Value is serialized as ordered map: `[[key, value], [key2, value2]]`
212
+ - Values support any JSON type (auto-detected): plain text → string, `42` → number, `true` → boolean, `{"a":1}` → object
213
+ - Duplicate keys are validated and rejected on form submission
214
+ - Value is serialized as plain object: `{key: value, key2: value2}`
215
215
  - Validation at top level only (not individual pairs)
216
216
 
217
217
  ## Validation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",