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>
|
||||
Reference in New Issue
Block a user