@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 +1 -0
- package/dist/actions/autogrow.svelte.d.ts +1 -0
- package/dist/actions/autogrow.svelte.js +9 -5
- package/dist/components/Input/FieldKeyValues.svelte +392 -0
- package/dist/components/Input/FieldKeyValues.svelte.d.ts +49 -0
- package/dist/components/Input/README.md +42 -0
- package/dist/components/Input/index.d.ts +1 -0
- package/dist/components/Input/index.js +1 -0
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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";
|