Download tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte from Parakon/parakon-runtime: direct link, hf CLI and curl.
- Browser
- Download file 4.99 kB
-
https://huggingface.co/Parakon/parakon-runtime/resolve/main/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
- Command line
-
hf download hf://Parakon/parakon-runtime/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
-
curl -L -o KeyValuePairs.svelte https://huggingface.co/Parakon/parakon-runtime/resolve/main/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
4.99 kB
| <script lang="ts"> | |
| import { Plus, Trash2 } from '@lucide/svelte'; | |
| import { Input } from '$lib/components/ui/input'; | |
| import { KEY_VALUE_PAIR_KEY_MAX_LENGTH, KEY_VALUE_PAIR_VALUE_MAX_LENGTH } from '$lib/constants'; | |
| import type { KeyValuePair } from '$lib/types'; | |
| import { | |
| autoResizeTextarea, | |
| sanitizeKeyValuePairKey, | |
| sanitizeKeyValuePairValue | |
| } from '$lib/utils'; | |
| import { tick } from 'svelte'; | |
| interface Props { | |
| class?: string; | |
| pairs: KeyValuePair[]; | |
| onPairsChange: (pairs: KeyValuePair[]) => void; | |
| keyPlaceholder?: string; | |
| valuePlaceholder?: string; | |
| addButtonLabel?: string; | |
| emptyMessage?: string; | |
| sectionLabel?: string; | |
| sectionLabelOptional?: boolean; | |
| } | |
| let { | |
| addButtonLabel = 'Add', | |
| class: className = '', | |
| emptyMessage = 'No items configured.', | |
| keyPlaceholder = 'Key', | |
| onPairsChange, | |
| pairs, | |
| sectionLabel, | |
| sectionLabelOptional = true, | |
| valuePlaceholder = 'Value' | |
| }: Props = $props(); | |
| // Pre-allocate the ref array so `bind:ref={keyInputRefs[index]}` never reads `undefined` | |
| // for in-range indices; the $effect below keeps it in sync when `pairs` grows. | |
| // svelte-ignore state_referenced_locally | |
| let keyInputRefs: (HTMLInputElement | null)[] = $state(pairs.map(() => null)); | |
| async function addPair() { | |
| // Capture the target index before mutating so deletions earlier in the | |
| // list can't make keyInputRefs.length drift past the newly-appended row. | |
| const newIndex = pairs.length; | |
| onPairsChange([...pairs, { key: '', value: '' }]); | |
| await tick(); | |
| keyInputRefs[newIndex]?.focus(); | |
| } | |
| function removePair(index: number) { | |
| onPairsChange(pairs.filter((_, i) => i !== index)); | |
| } | |
| function updatePairKey(index: number, rawKey: string) { | |
| const key = sanitizeKeyValuePairKey(rawKey); | |
| const newPairs = [...pairs]; | |
| newPairs[index] = { ...newPairs[index], key }; | |
| onPairsChange(newPairs); | |
| } | |
| function trimPairKey(index: number, key: string) { | |
| const trimmed = key.trim(); | |
| if (trimmed === key) return; | |
| const newPairs = [...pairs]; | |
| newPairs[index] = { ...newPairs[index], key: trimmed }; | |
| onPairsChange(newPairs); | |
| } | |
| function updatePairValue(index: number, rawValue: string) { | |
| const value = sanitizeKeyValuePairValue(rawValue); | |
| const newPairs = [...pairs]; | |
| newPairs[index] = { ...newPairs[index], value }; | |
| onPairsChange(newPairs); | |
| } | |
| function trimPairValue(index: number, value: string) { | |
| const trimmed = value.trim(); | |
| if (trimmed === value) return; | |
| const newPairs = [...pairs]; | |
| newPairs[index] = { ...newPairs[index], value: trimmed }; | |
| onPairsChange(newPairs); | |
| } | |
| // Keep keyInputRefs aligned with pairs length so bind:ref never sees `undefined`. | |
| // $effect.pre runs during traversal in tree order, before the {#each} block re-renders, | |
| // so newly-appended items always have a defined slot when their binding is set up. | |
| $effect.pre(() => { | |
| while (keyInputRefs.length < pairs.length) { | |
| keyInputRefs.push(null); | |
| } | |
| }); | |
| </script> | |
| <div class={className}> | |
| <div class="mb-2 flex items-center justify-between"> | |
| {#if sectionLabel} | |
| <span class="text-xs font-medium select-none"> | |
| {sectionLabel} | |
| {#if sectionLabelOptional} | |
| <span class="text-muted-foreground">(optional)</span> | |
| {/if} | |
| </span> | |
| {/if} | |
| <button | |
| class="inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" | |
| onclick={addPair} | |
| type="button" | |
| > | |
| <Plus class="h-3 w-3" /> | |
| {addButtonLabel} | |
| </button> | |
| </div> | |
| {#if pairs.length > 0} | |
| <div class="space-y-3"> | |
| {#each pairs as pair, index (index)} | |
| <div class="flex items-start gap-2"> | |
| <Input | |
| bind:ref={keyInputRefs[index]} | |
| class="flex-1" | |
| maxlength={KEY_VALUE_PAIR_KEY_MAX_LENGTH} | |
| onblur={(e) => trimPairKey(index, e.currentTarget.value)} | |
| oninput={(e) => updatePairKey(index, e.currentTarget.value)} | |
| placeholder={keyPlaceholder} | |
| type="text" | |
| value={pair.key} | |
| /> | |
| <textarea | |
| use:autoResizeTextarea | |
| class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" | |
| maxlength={KEY_VALUE_PAIR_VALUE_MAX_LENGTH} | |
| onblur={(e) => trimPairValue(index, e.currentTarget.value)} | |
| oninput={(e) => { | |
| updatePairValue(index, e.currentTarget.value); | |
| autoResizeTextarea(e.currentTarget); | |
| }} | |
| placeholder={valuePlaceholder} | |
| rows="1" | |
| value={pair.value} | |
| ></textarea> | |
| <button | |
| aria-label="Remove item" | |
| class="mt-1.5 shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive" | |
| onclick={() => removePair(index)} | |
| type="button" | |
| > | |
| <Trash2 class="h-3.5 w-3.5" /> | |
| </button> | |
| </div> | |
| {/each} | |
| </div> | |
| {:else} | |
| <p class="select-none text-xs text-muted-foreground">{emptyMessage}</p> | |
| {/if} | |
| </div> | |