@marianmeres/stuic 2.6.0 → 2.7.1

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/dist/README.md CHANGED
@@ -44,6 +44,7 @@ npm install @marianmeres/stuic
44
44
  - **FieldFile** - File upload input
45
45
  - **FieldAssets** - Multi-file upload with preview
46
46
  - **FieldOptions** - Modal-based multi-select picker
47
+ - **FieldKeyValues** - Key-value pairs editor with JSON serialization
47
48
  - **FieldSwitch** - Toggle switch within a form
48
49
  - **Fieldset** - Group of form fields with legend
49
50
 
@@ -7,6 +7,7 @@
7
7
  * @param el - The textarea element to apply autogrow to
8
8
  * @param fn - Optional function returning configuration options
9
9
  * @param fn.enabled - Whether autogrow is active (default: true)
10
+ * @param fn.min - Minimum height in pixels (default: 0)
10
11
  * @param fn.max - Maximum height in pixels (default: 250)
11
12
  * @param fn.immediate - Set height immediately on mount (default: true)
12
13
  * @param fn.value - If provided, triggers resize when value changes programmatically
@@ -7,6 +7,7 @@
7
7
  * @param el - The textarea element to apply autogrow to
8
8
  * @param fn - Optional function returning configuration options
9
9
  * @param fn.enabled - Whether autogrow is active (default: true)
10
+ * @param fn.min - Minimum height in pixels (default: 0)
10
11
  * @param fn.max - Maximum height in pixels (default: 250)
11
12
  * @param fn.immediate - Set height immediately on mount (default: true)
12
13
  * @param fn.value - If provided, triggers resize when value changes programmatically
@@ -23,16 +24,16 @@
23
24
  * ```
24
25
  */
25
26
  export function autogrow(el, fn) {
26
- let lastValue = el.value;
27
+ let lastValue = undefined;
27
28
  $effect(() => {
28
- const { enabled = true, max = 250, immediate = true, value } = fn?.() || {};
29
+ const { enabled = true, min = 0, max = 250, immediate = true, value } = fn?.() || {};
29
30
  if (!enabled)
30
31
  return;
31
32
  function set_height() {
32
33
  // console.log(123, el.value);
33
34
  if (enabled) {
34
35
  el.style.height = "auto"; // Reset height to auto to correctly calculate scrollHeight
35
- el.style.height = Math.min(el.scrollHeight, max) + "px";
36
+ el.style.height = Math.max(min, Math.min(el.scrollHeight, max)) + "px";
36
37
  }
37
38
  }
38
39
  // eventlistener strategy (we're not passing value)
@@ -44,8 +45,11 @@ export function autogrow(el, fn) {
44
45
  }
45
46
  // strategy with provided value
46
47
  else {
47
- if (value !== lastValue) {
48
- set_height();
48
+ // On first run or when value changes, resize
49
+ if (lastValue === undefined || value !== lastValue) {
50
+ if (immediate || value !== lastValue) {
51
+ set_height();
52
+ }
49
53
  lastValue = value;
50
54
  }
51
55
  }
@@ -0,0 +1,392 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import {
4
+ type ValidateOptions,
5
+ type ValidationResult,
6
+ } from "../../actions/validate.svelte.js";
7
+ import type { TranslateFn } from "../../types.js";
8
+ import type { THC } from "../Thc/Thc.svelte";
9
+
10
+ type SnippetWithId = Snippet<[{ id: string }]>;
11
+
12
+ export interface KeyValueEntry {
13
+ id: string;
14
+ key: string;
15
+ value: string;
16
+ }
17
+
18
+ export interface Props extends Record<string, any> {
19
+ value: string;
20
+ name: string;
21
+ label?: SnippetWithId | THC;
22
+ description?: SnippetWithId | THC;
23
+ class?: string;
24
+ id?: string;
25
+ tabindex?: number;
26
+ renderSize?: "sm" | "md" | "lg" | string;
27
+ required?: boolean;
28
+ disabled?: boolean;
29
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
30
+ labelAfter?: SnippetWithId | THC;
31
+ below?: SnippetWithId | THC;
32
+ labelLeft?: boolean;
33
+ labelLeftWidth?: "normal" | "wide";
34
+ labelLeftBreakpoint?: number;
35
+ classLabel?: string;
36
+ classLabelBox?: string;
37
+ classInputBox?: string;
38
+ classInputBoxWrap?: string;
39
+ classDescBox?: string;
40
+ classBelowBox?: string;
41
+ classEntry?: string;
42
+ classKeyInput?: string;
43
+ classValueInput?: string;
44
+ style?: string;
45
+ keyPlaceholder?: string;
46
+ valuePlaceholder?: string;
47
+ addLabel?: string;
48
+ emptyMessage?: string;
49
+ onChange?: (value: string) => void;
50
+ t?: TranslateFn;
51
+ }
52
+ </script>
53
+
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
+ import { iconFeatherPlus } from "@marianmeres/icons-fns/feather/iconFeatherPlus.js";
58
+ import { iconFeatherTrash2 } from "@marianmeres/icons-fns/feather/iconFeatherTrash2.js";
59
+ import { tick } from "svelte";
60
+ import { autogrow } from "../../actions/autogrow.svelte.js";
61
+ import { validate as validateAction } from "../../actions/validate.svelte.js";
62
+ import { getId } from "../../utils/get-id.js";
63
+ import { isPlainObject } from "../../utils/is-plain-object.js";
64
+ import { replaceMap } from "../../utils/replace-map.js";
65
+ import { twMerge } from "../../utils/tw-merge.js";
66
+ import InputWrap from "./_internal/InputWrap.svelte";
67
+
68
+ // i18n ready
69
+ function t_default(
70
+ k: string,
71
+ values: false | null | undefined | Record<string, string | number> = null,
72
+ fallback: string | boolean = "",
73
+ i18nSpanWrap: boolean = true
74
+ ) {
75
+ const m: Record<string, string> = {
76
+ field_req_att: "This field requires attention. Please review and try again.",
77
+ key_placeholder: "Key",
78
+ value_placeholder: "Value",
79
+ add_label: "Add",
80
+ empty_message: "No entries",
81
+ remove_entry: "Remove entry",
82
+ move_up: "Move up",
83
+ move_down: "Move down",
84
+ };
85
+ let out = m[k] ?? fallback ?? k;
86
+ return isPlainObject(values) ? replaceMap(out, values as any) : out;
87
+ }
88
+
89
+ let {
90
+ value = $bindable("[]"),
91
+ name,
92
+ id = getId(),
93
+ label,
94
+ description,
95
+ class: classProp,
96
+ tabindex = 0,
97
+ renderSize = "md",
98
+ required = false,
99
+ disabled = false,
100
+ validate,
101
+ labelAfter,
102
+ below,
103
+ labelLeft,
104
+ labelLeftWidth,
105
+ labelLeftBreakpoint,
106
+ classLabel,
107
+ classLabelBox,
108
+ classInputBox,
109
+ classInputBoxWrap,
110
+ classDescBox,
111
+ classBelowBox,
112
+ classEntry,
113
+ classKeyInput,
114
+ classValueInput,
115
+ style,
116
+ keyPlaceholder,
117
+ valuePlaceholder,
118
+ addLabel,
119
+ emptyMessage,
120
+ onChange,
121
+ t = t_default,
122
+ }: Props = $props();
123
+
124
+ let hiddenInputEl: HTMLInputElement | undefined = $state();
125
+ let keyInputRefs: HTMLInputElement[] = $state([]);
126
+
127
+ // Internal state
128
+ let entries: KeyValueEntry[] = $state(parseValue(value));
129
+
130
+ // Parse external JSON string to internal entries
131
+ function parseValue(jsonString: string): KeyValueEntry[] {
132
+ 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 ?? "",
139
+ }));
140
+ } catch (e) {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ // Serialize internal entries to external JSON string
146
+ function serializeValue(entries: KeyValueEntry[]): string {
147
+ return JSON.stringify(entries.map((e) => [e.key, e.value]));
148
+ }
149
+
150
+ // Sync internal state to external value
151
+ function syncToValue() {
152
+ value = serializeValue(entries);
153
+ tick().then(() => {
154
+ hiddenInputEl?.dispatchEvent(new Event("change", { bubbles: true }));
155
+ });
156
+ onChange?.(value);
157
+ }
158
+
159
+ // Sync external value to internal state when it changes externally
160
+ $effect(() => {
161
+ const newEntries = parseValue(value);
162
+ const currentSerialized = serializeValue(entries);
163
+ const newSerialized = serializeValue(newEntries);
164
+ if (currentSerialized !== newSerialized) {
165
+ entries = newEntries;
166
+ }
167
+ });
168
+
169
+ // Add new entry
170
+ function addEntry() {
171
+ const newEntry = { id: getId("entry-"), key: "", value: "" };
172
+ entries = [...entries, newEntry];
173
+ syncToValue();
174
+ // Focus the new key input after render
175
+ tick().then(() => {
176
+ const lastInput = keyInputRefs[keyInputRefs.length - 1];
177
+ lastInput?.focus();
178
+ });
179
+ }
180
+
181
+ // Remove entry by index
182
+ function removeEntry(idx: number) {
183
+ entries = entries.filter((_, i) => i !== idx);
184
+ syncToValue();
185
+ }
186
+
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
+ // Update entry field
199
+ function updateEntry(idx: number, field: "key" | "value", newValue: string) {
200
+ entries[idx][field] = newValue;
201
+ syncToValue();
202
+ }
203
+
204
+ // Validation
205
+ let validation: ValidationResult | undefined = $state();
206
+ const setValidationResult = (res: ValidationResult) => (validation = res);
207
+
208
+ let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
209
+ enabled: true,
210
+ customValidator(val: any, context: Record<string, any> | undefined, el: any) {
211
+ // Validate JSON structure
212
+ 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
+ }
218
+ } catch (e) {
219
+ return "typeMismatch";
220
+ }
221
+
222
+ // Required check
223
+ if (required && !entries.length) return "valueMissing";
224
+
225
+ // Continue with provided validator
226
+ return (validate as any)?.customValidator?.(val, context, el) || "";
227
+ },
228
+ t(reason: keyof ValidityStateFlags, val: any, fallback: string) {
229
+ return t("field_req_att");
230
+ },
231
+ setValidationResult,
232
+ });
233
+
234
+ const BTN_CLS = [
235
+ "p-1 rounded",
236
+ "opacity-50 hover:opacity-100",
237
+ "hover:bg-neutral-200 dark:hover:bg-neutral-600",
238
+ "focus-visible:outline-neutral-400",
239
+ "disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent",
240
+ ].join(" ");
241
+
242
+ const INPUT_CLS = [
243
+ "rounded bg-neutral-50 dark:bg-neutral-800",
244
+ "focus:outline-none focus:ring-0",
245
+ "border border-neutral-300 dark:border-neutral-600",
246
+ "focus:border-neutral-400 focus:dark:border-neutral-500",
247
+ "focus-visible:outline-none focus-visible:ring-0",
248
+ // "py-1.5 px-2.5",
249
+ ].join(" ");
250
+ </script>
251
+
252
+ <InputWrap
253
+ {id}
254
+ {label}
255
+ {description}
256
+ {labelAfter}
257
+ {below}
258
+ {required}
259
+ {disabled}
260
+ size={renderSize}
261
+ class={classProp}
262
+ {labelLeft}
263
+ {labelLeftWidth}
264
+ {labelLeftBreakpoint}
265
+ {classLabel}
266
+ {classLabelBox}
267
+ {classInputBox}
268
+ {classInputBoxWrap}
269
+ {classDescBox}
270
+ {classBelowBox}
271
+ {validation}
272
+ {style}
273
+ >
274
+ <div class="w-full">
275
+ {#if entries.length === 0}
276
+ <div class="p-3 text-sm opacity-50 text-center">
277
+ {emptyMessage ?? t("empty_message")}
278
+ </div>
279
+ {:else}
280
+ <div class="p-2">
281
+ {#each entries as entry, idx (entry.id)}
282
+ <div
283
+ class={twMerge(
284
+ "flex gap-2 items-start py-2",
285
+ idx > 0 && "border-t border-neutral-200 dark:border-neutral-600",
286
+ classEntry
287
+ )}
288
+ >
289
+ <!-- Key/Value inputs -->
290
+ <div class="flex-1 flex flex-col gap-1">
291
+ <!-- Key input (single-line) -->
292
+ <input
293
+ type="text"
294
+ value={entry.key}
295
+ oninput={(e) => updateEntry(idx, "key", e.currentTarget.value)}
296
+ placeholder={keyPlaceholder ?? t("key_placeholder")}
297
+ class={twMerge(
298
+ INPUT_CLS,
299
+ "flex-1",
300
+ renderSize === "sm" && "text-sm",
301
+ classKeyInput
302
+ )}
303
+ {disabled}
304
+ {tabindex}
305
+ bind:this={keyInputRefs[idx]}
306
+ />
307
+
308
+ <!-- Value textarea -->
309
+ <textarea
310
+ value={entry.value}
311
+ oninput={(e) => updateEntry(idx, "value", e.currentTarget.value)}
312
+ placeholder={valuePlaceholder ?? t("value_placeholder")}
313
+ class={twMerge(
314
+ INPUT_CLS,
315
+ "min-h-10 overflow-auto",
316
+ renderSize === "sm" && "text-sm",
317
+ classValueInput
318
+ )}
319
+ style="resize: vertical;"
320
+ {disabled}
321
+ {tabindex}
322
+ ></textarea>
323
+ <!-- use:autogrow={() => ({ enabled: true, value: entry.value })} -->
324
+ </div>
325
+
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>
346
+ <button
347
+ type="button"
348
+ onclick={() => removeEntry(idx)}
349
+ class={BTN_CLS}
350
+ {disabled}
351
+ aria-label={t("remove_entry")}
352
+ >
353
+ {@html iconFeatherTrash2({ size: 14 })}
354
+ </button>
355
+ </div>
356
+ </div>
357
+ {/each}
358
+ </div>
359
+ {/if}
360
+
361
+ <!-- Add button -->
362
+ <div
363
+ class={twMerge(
364
+ "p-2",
365
+ entries.length > 0 && "border-t border-neutral-200 dark:border-neutral-600"
366
+ )}
367
+ >
368
+ <button
369
+ type="button"
370
+ onclick={addEntry}
371
+ class={twMerge(
372
+ "flex items-center gap-1 text-sm opacity-75 hover:opacity-100",
373
+ "bg-neutral-200 dark:bg-neutral-600",
374
+ "p-1 pr-2 rounded hover:bg-neutral-300 dark:hover:bg-neutral-500"
375
+ )}
376
+ {disabled}
377
+ >
378
+ {@html iconFeatherPlus({ size: 16 })}
379
+ <span>{addLabel ?? t("add_label")}</span>
380
+ </button>
381
+ </div>
382
+ </div>
383
+ </InputWrap>
384
+
385
+ <!-- Hidden input for form submission and validation -->
386
+ <input
387
+ type="hidden"
388
+ {name}
389
+ {value}
390
+ bind:this={hiddenInputEl}
391
+ use:validateAction={() => wrappedValidate}
392
+ />
@@ -0,0 +1,49 @@
1
+ import type { Snippet } from "svelte";
2
+ import { type ValidateOptions } from "../../actions/validate.svelte.js";
3
+ import type { TranslateFn } from "../../types.js";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+ type SnippetWithId = Snippet<[{
6
+ id: string;
7
+ }]>;
8
+ export interface KeyValueEntry {
9
+ id: string;
10
+ key: string;
11
+ value: string;
12
+ }
13
+ export interface Props extends Record<string, any> {
14
+ value: string;
15
+ name: string;
16
+ label?: SnippetWithId | THC;
17
+ description?: SnippetWithId | THC;
18
+ class?: string;
19
+ id?: string;
20
+ tabindex?: number;
21
+ renderSize?: "sm" | "md" | "lg" | string;
22
+ required?: boolean;
23
+ disabled?: boolean;
24
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
25
+ labelAfter?: SnippetWithId | THC;
26
+ below?: SnippetWithId | THC;
27
+ labelLeft?: boolean;
28
+ labelLeftWidth?: "normal" | "wide";
29
+ labelLeftBreakpoint?: number;
30
+ classLabel?: string;
31
+ classLabelBox?: string;
32
+ classInputBox?: string;
33
+ classInputBoxWrap?: string;
34
+ classDescBox?: string;
35
+ classBelowBox?: string;
36
+ classEntry?: string;
37
+ classKeyInput?: string;
38
+ classValueInput?: string;
39
+ style?: string;
40
+ keyPlaceholder?: string;
41
+ valuePlaceholder?: string;
42
+ addLabel?: string;
43
+ emptyMessage?: string;
44
+ onChange?: (value: string) => void;
45
+ t?: TranslateFn;
46
+ }
47
+ declare const FieldKeyValues: import("svelte").Component<Props, {}, "value">;
48
+ type FieldKeyValues = ReturnType<typeof FieldKeyValues>;
49
+ export default FieldKeyValues;
@@ -14,6 +14,7 @@ A comprehensive form input system with multiple field components, validation sup
14
14
  | `FieldSwitch` | Toggle switch field |
15
15
  | `FieldFile` | File upload input |
16
16
  | `FieldAssets` | Asset/image upload with preview |
17
+ | `FieldKeyValues` | Key-value pairs editor with JSON serialization |
17
18
  | `FieldLikeButton` | Like/favorite toggle button |
18
19
  | `Fieldset` | Fieldset with legend |
19
20
 
@@ -172,6 +173,47 @@ A comprehensive form input system with multiple field components, validation sup
172
173
  />
173
174
  ```
174
175
 
176
+ ### Key-Value Pairs
177
+
178
+ ```svelte
179
+ <script lang="ts">
180
+ import { FieldKeyValues } from 'stuic';
181
+
182
+ // Value is a JSON string of [[key, value], [key, value], ...]
183
+ let headers = $state('[]');
184
+ </script>
185
+
186
+ <FieldKeyValues
187
+ label="HTTP Headers"
188
+ description="Add custom headers as key-value pairs"
189
+ bind:value={headers}
190
+ name="headers"
191
+ required
192
+ />
193
+ ```
194
+
195
+ #### FieldKeyValues Props
196
+
197
+ | Prop | Type | Default | Description |
198
+ |------|------|---------|-------------|
199
+ | `value` | `string` | `"[]"` | JSON string of `[[key, value], ...]` (bindable) |
200
+ | `name` | `string` | - | Form field name |
201
+ | `keyPlaceholder` | `string` | `"Key"` | Placeholder for key input |
202
+ | `valuePlaceholder` | `string` | `"Value"` | Placeholder for value textarea |
203
+ | `addLabel` | `string` | `"Add"` | Label for add button |
204
+ | `emptyMessage` | `string` | `"No entries"` | Message when no entries |
205
+ | `classEntry` | `string` | - | CSS for each entry row |
206
+ | `classKeyInput` | `string` | - | CSS for key inputs |
207
+ | `classValueInput` | `string` | - | CSS for value textareas |
208
+ | `onChange` | `(value: string) => void` | - | Callback on value change |
209
+
210
+ Features:
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]]`
215
+ - Validation at top level only (not individual pairs)
216
+
175
217
  ## Validation
176
218
 
177
219
  Validation is handled by the `validate` action. Pass `validate={true}` for default HTML5 validation, or pass options:
@@ -10,3 +10,4 @@ export { default as FieldSelect, type Props as FieldSelectProps, } from "./Field
10
10
  export { default as Fieldset, type Props as FieldsetProps } from "./Fieldset.svelte";
11
11
  export { default as FieldSwitch, type Props as FieldSwitchProps, } from "./FieldSwitch.svelte";
12
12
  export { default as FieldTextarea, type Props as FieldTextareaProps, } from "./FieldTextarea.svelte";
13
+ export { default as FieldKeyValues, type Props as FieldKeyValuesProps, type KeyValueEntry, } from "./FieldKeyValues.svelte";
@@ -10,3 +10,4 @@ export { default as FieldSelect, } from "./FieldSelect.svelte";
10
10
  export { default as Fieldset } from "./Fieldset.svelte";
11
11
  export { default as FieldSwitch, } from "./FieldSwitch.svelte";
12
12
  export { default as FieldTextarea, } from "./FieldTextarea.svelte";
13
+ export { default as FieldKeyValues, } from "./FieldKeyValues.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.6.0",
3
+ "version": "2.7.1",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",