Add live tag input with chips, autocomplete, and suggestions
Deploy to LXC / deploy (push) Successful in 19s
Deploy to LXC / deploy (push) Successful in 19s
Reusable TagInput component: - Tags appear as blue chips instantly on comma/Enter press - Remove tags by clicking X on the chip or Backspace when empty - Autocomplete dropdown filters existing tags as you type - All existing tags shown as clickable suggestions below - Hidden input submits comma-separated value - Applied to wiki new and edit pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
existingTags?: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name = 'tags', value = '', existingTags = [] }: Props = $props();
|
||||||
|
|
||||||
|
let tags = $state<string[]>(value ? value.split(',').map(t => t.trim()).filter(Boolean) : []);
|
||||||
|
let input = $state('');
|
||||||
|
|
||||||
|
function addTag(tag: string) {
|
||||||
|
const t = tag.trim().toLowerCase();
|
||||||
|
if (t && !tags.includes(t)) {
|
||||||
|
tags = [...tags, t];
|
||||||
|
}
|
||||||
|
input = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
tags = tags.filter(t => t !== tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === ',' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (input.trim()) addTag(input);
|
||||||
|
} else if (e.key === 'Backspace' && !input && tags.length > 0) {
|
||||||
|
tags = tags.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const val = (e.target as HTMLInputElement).value;
|
||||||
|
if (val.includes(',')) {
|
||||||
|
const parts = val.split(',');
|
||||||
|
for (const p of parts.slice(0, -1)) {
|
||||||
|
if (p.trim()) addTag(p);
|
||||||
|
}
|
||||||
|
input = parts[parts.length - 1];
|
||||||
|
} else {
|
||||||
|
input = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = $derived(
|
||||||
|
input.length > 0
|
||||||
|
? existingTags.filter(t => t.name.includes(input.toLowerCase()) && !tags.includes(t.name))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiddenValue = $derived(tags.join(', '));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
{#each tags as tag}
|
||||||
|
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
{tag}
|
||||||
|
<button type="button" onclick={() => removeTag(tag)} aria-label="Remove {tag}" class="hover:text-blue-900 dark:hover:text-blue-100">
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={input}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder={tags.length === 0 ? 'Add tags...' : ''}
|
||||||
|
class="min-w-[80px] flex-1 border-0 bg-transparent px-1 py-0.5 text-sm outline-none dark:text-white dark:placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if suggestions.length > 0}
|
||||||
|
<div class="mt-1 rounded-md border border-gray-200 bg-white shadow-sm dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
{#each suggestions.slice(0, 8) as tag}
|
||||||
|
<button type="button" onclick={() => addTag(tag.name)}
|
||||||
|
class="block w-full px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600">
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if existingTags.length > 0 && input.length === 0}
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
|
{#each existingTags.filter(t => !tags.includes(t.name)) as tag}
|
||||||
|
<button type="button" onclick={() => addTag(tag.name)}
|
||||||
|
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input type="hidden" {name} value={hiddenValue} />
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import TagInput from '$lib/components/ui/TagInput.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -48,27 +49,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="tags" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
|
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>
|
||||||
<input type="text" id="tags" name="tags" value={data.currentTags}
|
<TagInput value={data.currentTags} existingTags={data.existingTags} />
|
||||||
placeholder="color-classic, bluescsi (comma-separated)"
|
|
||||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
||||||
{#if data.existingTags.length > 0}
|
|
||||||
<div class="mt-1 flex flex-wrap gap-1">
|
|
||||||
{#each data.existingTags as tag}
|
|
||||||
<button type="button"
|
|
||||||
onclick={() => {
|
|
||||||
const input = document.getElementById('tags') as HTMLInputElement;
|
|
||||||
const current = input.value.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
if (!current.includes(tag.name)) {
|
|
||||||
input.value = [...current, tag.name].join(', ');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import TagInput from '$lib/components/ui/TagInput.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -47,27 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="tags" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
|
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>
|
||||||
<input type="text" id="tags" name="tags"
|
<TagInput existingTags={data.existingTags} />
|
||||||
placeholder="color-classic, bluescsi, scsi (comma-separated)"
|
|
||||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
|
||||||
{#if data.existingTags.length > 0}
|
|
||||||
<div class="mt-1 flex flex-wrap gap-1">
|
|
||||||
{#each data.existingTags as tag}
|
|
||||||
<button type="button"
|
|
||||||
onclick={(e) => {
|
|
||||||
const input = document.getElementById('tags') as HTMLInputElement;
|
|
||||||
const current = input.value.split(',').map(t => t.trim()).filter(Boolean);
|
|
||||||
if (!current.includes(tag.name)) {
|
|
||||||
input.value = [...current, tag.name].join(', ');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Markdown editor -->
|
<!-- Markdown editor -->
|
||||||
|
|||||||
Reference in New Issue
Block a user