Add live tag input with chips, autocomplete, and suggestions
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:
2026-04-13 15:55:30 +07:00
parent 9a73c60d10
commit f937394b5e
3 changed files with 105 additions and 42 deletions
+99
View File
@@ -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>