@marianmeres/stuic 2.8.1 → 2.10.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.
@@ -24,13 +24,13 @@
24
24
 
25
25
  let container: HTMLDivElement = $state()!;
26
26
 
27
+ // Note: completeness and rotate are NOT included here - they're updated via setters
28
+ // to avoid recreating the SVG on every change
27
29
  let circle = $derived(
28
30
  svgCircle({
29
31
  strokeWidth,
30
- completeness,
31
32
  bgStrokeColor,
32
33
  roundedEdges,
33
- rotate,
34
34
  strokeWidthRatio,
35
35
  class: circleClass,
36
36
  circleStyle:
@@ -43,6 +43,9 @@
43
43
 
44
44
  $effect(() => {
45
45
  container.appendChild(circle.svg);
46
+ return () => {
47
+ circle.svg.remove();
48
+ };
46
49
  });
47
50
 
48
51
  $effect(() => {
@@ -174,6 +174,7 @@
174
174
  ) => Promise<FieldAssetWithBlobUrl[]>;
175
175
  withOnProgress?: boolean;
176
176
  classControls?: string;
177
+ isLoading?: boolean;
177
178
  }
178
179
  </script>
179
180
 
@@ -231,6 +232,7 @@
231
232
  processAssets,
232
233
  withOnProgress,
233
234
  classControls = "",
235
+ isLoading = false,
234
236
  parseValue = (strigifiedModels: string) => {
235
237
  const val = strigifiedModels ?? "[]";
236
238
  try {
@@ -280,7 +282,8 @@
280
282
  // return value and uses it directly as the error message.
281
283
 
282
284
  if (required && !assets.length) return t("field_req_att");
283
- if (assets.length > cardinality) return t("cardinality_reached", { max: cardinality });
285
+ if (assets.length > cardinality)
286
+ return t("cardinality_reached", { max: cardinality });
284
287
 
285
288
  // normally, with other fieldtypes, we would continue with provided validator:
286
289
  // return (validate as any)?.customValidator?.(value, context, el) || "";
@@ -369,72 +372,78 @@
369
372
  />
370
373
 
371
374
  {#snippet default_render()}
372
- <div class={["p-2 flex items-center gap-0.5 flex-wrap"]}>
373
- {#each assets as asset, idx}
374
- {@const { thumb, full, original } = asset_urls(asset)}
375
- {@const _is_img = isImage(asset.type ?? thumb)}
376
- <div class="relative group">
377
- <button
378
- class={[objectSize, "bg-black/10 grid place-content-center", classControls]}
379
- onclick={(e) => {
380
- e.stopPropagation();
381
- e.preventDefault();
382
- previewIdx = idx;
383
- modal.open();
384
- }}
385
- type="button"
386
- >
387
- {#if _is_img}
388
- <img
389
- src={thumb}
390
- alt={asset.name}
391
- class={[objectSize, objectFitStyle, "hover:saturate-150"]}
392
- />
393
- {:else}
394
- {@html getAssetIcon((asset.name ?? "").split(".").at(-1))({
395
- size: 32,
396
- class: "mx-auto",
397
- })}
398
- {/if}
399
- <span
400
- class="absolute bottom-1 left-1 right-1 grid bg-white/50 rounded"
401
- use:tooltip={() => ({ content: asset.name })}
375
+ {#if isLoading}
376
+ <div class="p-2 pl-8 flex items-center justify-center min-h-24">
377
+ <SpinnerCircle />
378
+ </div>
379
+ {:else}
380
+ <div class={["p-2 flex items-center gap-0.5 flex-wrap"]}>
381
+ {#each assets as asset, idx}
382
+ {@const { thumb, full, original } = asset_urls(asset)}
383
+ {@const _is_img = isImage(asset.type ?? thumb)}
384
+ <div class="relative group">
385
+ <button
386
+ class={[objectSize, "bg-black/10 grid place-content-center", classControls]}
387
+ onclick={(e) => {
388
+ e.stopPropagation();
389
+ e.preventDefault();
390
+ previewIdx = idx;
391
+ modal.open();
392
+ }}
393
+ type="button"
402
394
  >
403
- <span class="truncate px-2 text-xs">{asset.name}</span>
404
- </span>
405
-
406
- {#if asset.meta?.isUploading}
395
+ {#if _is_img}
396
+ <img
397
+ src={thumb}
398
+ alt={asset.name}
399
+ class={[objectSize, objectFitStyle, "hover:saturate-150"]}
400
+ />
401
+ {:else}
402
+ {@html getAssetIcon((asset.name ?? "").split(".").at(-1))({
403
+ size: 32,
404
+ class: "mx-auto",
405
+ })}
406
+ {/if}
407
407
  <span
408
- class="absolute inset-0 grid place-content-center pointer-events-none text-white"
408
+ class="absolute bottom-1 left-1 right-1 grid bg-white/50 rounded"
409
+ use:tooltip={() => ({ content: asset.name })}
409
410
  >
410
- {#if withOnProgress}
411
- <Circle
412
- class="text-white"
413
- animateCompletenessMs={300}
414
- bgStrokeColor="rgba(0 0 0 / 0.2)"
415
- completeness={progress[asset.id] / 100}
416
- rotate={-90}
417
- />
418
- {:else}
419
- <SpinnerCircle bgStrokeColor="gray" />
420
- {/if}
411
+ <span class="truncate px-2 text-xs">{asset.name}</span>
421
412
  </span>
422
- {/if}
423
- </button>
424
- </div>
425
- {/each}
426
- <button
427
- type="button"
428
- onclick={(e) => {
429
- e.preventDefault();
430
- e.stopPropagation();
431
- inputEl.click();
432
- }}
433
- class={[objectSize, " grid place-content-center group", classControls]}
434
- >
435
- {@html iconAdd({ size: 32, class: "opacity-75 group-hover:opacity-100" })}
436
- </button>
437
- </div>
413
+
414
+ {#if asset.meta?.isUploading}
415
+ <span
416
+ class="absolute inset-0 grid place-content-center pointer-events-none text-white"
417
+ >
418
+ {#if withOnProgress}
419
+ <Circle
420
+ class="text-white"
421
+ animateCompletenessMs={300}
422
+ bgStrokeColor="rgba(0 0 0 / 0.2)"
423
+ completeness={progress[asset.id] / 100}
424
+ rotate={-90}
425
+ />
426
+ {:else}
427
+ <SpinnerCircle bgStrokeColor="gray" />
428
+ {/if}
429
+ </span>
430
+ {/if}
431
+ </button>
432
+ </div>
433
+ {/each}
434
+ <button
435
+ type="button"
436
+ onclick={(e) => {
437
+ e.preventDefault();
438
+ e.stopPropagation();
439
+ inputEl.click();
440
+ }}
441
+ class={[objectSize, " grid place-content-center group", classControls]}
442
+ >
443
+ {@html iconAdd({ size: 32, class: "opacity-75 group-hover:opacity-100" })}
444
+ </button>
445
+ </div>
446
+ {/if}
438
447
  {/snippet}
439
448
 
440
449
  <div
@@ -64,6 +64,7 @@ export interface Props extends Record<string, any> {
64
64
  processAssets?: (assets: FieldAsset[], onProgress?: (blobUrl: string, progress: number) => any) => Promise<FieldAssetWithBlobUrl[]>;
65
65
  withOnProgress?: boolean;
66
66
  classControls?: string;
67
+ isLoading?: boolean;
67
68
  }
68
69
  declare const FieldAssets: import("svelte").Component<Props, {}, "value">;
69
70
  type FieldAssets = ReturnType<typeof FieldAssets>;
@@ -47,6 +47,7 @@
47
47
  addLabel?: string;
48
48
  emptyMessage?: string;
49
49
  onChange?: (value: string) => void;
50
+ strictJsonValidation?: boolean;
50
51
  t?: TranslateFn;
51
52
  }
52
53
  </script>
@@ -79,6 +80,7 @@
79
80
  empty_message: "No entries",
80
81
  remove_entry: "Remove entry",
81
82
  duplicate_keys: "Duplicate keys are not allowed",
83
+ invalid_json_syntax: "Invalid JSON syntax. Check for missing quotes or brackets.",
82
84
  };
83
85
  let out = m[k] ?? fallback ?? k;
84
86
  return isPlainObject(values) ? replaceMap(out, values as any) : out;
@@ -118,26 +120,37 @@
118
120
  addLabel,
119
121
  emptyMessage,
120
122
  onChange,
123
+ strictJsonValidation = true,
121
124
  t = t_default,
122
125
  }: Props = $props();
123
126
 
124
127
  let hiddenInputEl: HTMLInputElement | undefined = $state();
125
128
  let keyInputRefs: HTMLInputElement[] = $state([]);
129
+ let entryJsonErrors: boolean[] = $state([]);
126
130
 
127
131
  // Internal state - handle undefined value from async form initialization
128
132
  let entries: KeyValueEntry[] = $state(parseValue(value ?? SERIALIZED_DEFAULT));
129
133
 
130
134
  // Parse JSON value with auto-detect: try JSON first, fallback to plain string
131
- function parseJsonValue(input: string): { value: unknown } {
135
+ // Returns intendedJson=true if input looks like JSON (starts with { or [)
136
+ // Returns parseError=true if JSON parse failed
137
+ function parseJsonValue(input: string): {
138
+ value: unknown;
139
+ intendedJson: boolean;
140
+ parseError: boolean;
141
+ } {
132
142
  const trimmed = input.trim();
133
- if (trimmed === "") return { value: "" };
143
+ if (trimmed === "") return { value: "", intendedJson: false, parseError: false };
144
+
145
+ // Heuristics: input looks like it's trying to be JSON
146
+ const intendedJson = trimmed.startsWith("{") || trimmed.startsWith("[");
134
147
 
135
148
  try {
136
149
  const parsed = JSON.parse(trimmed);
137
- return { value: parsed };
150
+ return { value: parsed, intendedJson, parseError: false };
138
151
  } catch {
139
- // If parse fails, treat as plain string
140
- return { value: trimmed };
152
+ // If parse fails, treat as plain string but flag the error
153
+ return { value: trimmed, intendedJson, parseError: intendedJson };
141
154
  }
142
155
  }
143
156
 
@@ -183,6 +196,8 @@
183
196
  const newSerialized = serializeValue(newEntries);
184
197
  if (currentSerialized !== newSerialized) {
185
198
  entries = newEntries;
199
+ // Reset JSON errors when external value changes (assume valid JSON from external source)
200
+ entryJsonErrors = new Array(newEntries.length).fill(false);
186
201
  }
187
202
  });
188
203
 
@@ -190,6 +205,7 @@
190
205
  function addEntry() {
191
206
  const newEntry: KeyValueEntry = { key: "", value: "", parsedValue: "" };
192
207
  entries = [...entries, newEntry];
208
+ entryJsonErrors = [...entryJsonErrors, false];
193
209
  syncToValue();
194
210
  // Focus the new key input after render
195
211
  tick().then(() => {
@@ -201,15 +217,17 @@
201
217
  // Remove entry by index
202
218
  function removeEntry(idx: number) {
203
219
  entries = entries.filter((_, i) => i !== idx);
220
+ entryJsonErrors = entryJsonErrors.filter((_, i) => i !== idx);
204
221
  syncToValue();
205
222
  }
206
223
 
207
224
  // Update entry field
208
225
  function updateEntry(idx: number, field: "key" | "value", newValue: string) {
209
226
  if (field === "value") {
210
- const { value: parsed } = parseJsonValue(newValue);
227
+ const { value: parsed, parseError } = parseJsonValue(newValue);
211
228
  entries[idx].value = newValue;
212
229
  entries[idx].parsedValue = parsed;
230
+ entryJsonErrors[idx] = parseError;
213
231
  } else {
214
232
  entries[idx].key = newValue;
215
233
  }
@@ -243,6 +261,11 @@
243
261
  return t("duplicate_keys");
244
262
  }
245
263
 
264
+ // Check for JSON syntax errors when strictJsonValidation is enabled
265
+ if (strictJsonValidation && entryJsonErrors.some(Boolean)) {
266
+ return t("invalid_json_syntax");
267
+ }
268
+
246
269
  // Continue with provided validator
247
270
  return (validate as any)?.customValidator?.(val, context, el) || "";
248
271
  },
@@ -42,6 +42,7 @@ export interface Props extends Record<string, any> {
42
42
  addLabel?: string;
43
43
  emptyMessage?: string;
44
44
  onChange?: (value: string) => void;
45
+ strictJsonValidation?: boolean;
45
46
  t?: TranslateFn;
46
47
  }
47
48
  declare const FieldKeyValues: import("svelte").Component<Props, {}, "value">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.8.1",
3
+ "version": "2.10.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",