Replace tag text input with autocomplete tag picker

TagInput component shows selected tags as removable chips, autocompletes
from existing tags in the database, and allows adding new tags inline.
Enter/comma to confirm, Backspace to remove last tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maddox 2026-04-07 18:20:47 -04:00
parent 69e0b864e4
commit 7152d6b492
3 changed files with 146 additions and 20 deletions

View file

@ -114,6 +114,7 @@ export default function App() {
const pagination = data?.pagination
const categories = filterOptions?.categories ?? []
const routes = filterOptions?.routes ?? []
const availableTags = filterOptions?.tags ?? []
return (
<div className="min-h-screen bg-surface-900">
@ -217,6 +218,7 @@ export default function App() {
<StoreModal
store={editStore}
categories={categories}
availableTags={availableTags}
onSave={handleSave}
onClose={() => setEditStore(undefined)}
/>

View file

@ -2,23 +2,25 @@ import { useEffect, useState } from 'react'
import { X, Store as StoreIcon } from 'lucide-react'
import type { Store, Route } from '../types'
import { parseTags } from '../utils'
import { TagInput } from './TagInput'
const ROUTES: Route[] = ['auto', 'ask', 'verify', 'ignore']
interface Props {
store?: Store | null
categories: string[]
availableTags: string[]
onSave: (data: Omit<Store, 'id' | 'created_at'>) => void
onClose: () => void
}
export function StoreModal({ store, categories, onSave, onClose }: Props) {
export function StoreModal({ store, categories, availableTags, onSave, onClose }: Props) {
const isEdit = !!store
const [form, setForm] = useState({
store: store?.store ?? '',
category: store?.category ?? (categories[0] ?? ''),
tags: store?.tags ?? '',
tagList: parseTags(store?.tags ?? ''),
route: (store?.route ?? 'auto') as Route,
display_order: store?.display_order ?? 0,
})
@ -30,15 +32,13 @@ export function StoreModal({ store, categories, onSave, onClose }: Props) {
}
}, [categories, isEdit, form.category])
const tagList = parseTags(form.tags)
function set<K extends keyof typeof form>(key: K, value: typeof form[K]) {
setForm(f => ({ ...f, [key]: value }))
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSave(form)
onSave({ ...form, tags: form.tagList.join(', ') })
}
return (
@ -128,23 +128,13 @@ export function StoreModal({ store, categories, onSave, onClose }: Props) {
{/* Tags */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Tags <span className="text-slate-600 normal-case font-normal">(comma-separated)</span>
Tags
</label>
<input
value={form.tags}
onChange={e => set('tags', e.target.value)}
placeholder="Auto, Groceries, Delivery"
className="w-full bg-surface-800 border border-white/8 rounded-xl px-3.5 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
<TagInput
value={form.tagList}
availableTags={availableTags}
onChange={tags => set('tagList', tags)}
/>
{tagList.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{tagList.map(t => (
<span key={t} className="px-2 py-0.5 rounded-md bg-slate-700/60 text-xs text-slate-300 border border-slate-600/40">
{t}
</span>
))}
</div>
)}
</div>
{/* Actions */}

134
src/components/TagInput.tsx Normal file
View file

@ -0,0 +1,134 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Plus } from 'lucide-react'
interface Props {
value: string[]
availableTags: string[]
onChange: (tags: string[]) => void
}
export function TagInput({ value, availableTags, onChange }: Props) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const filtered = availableTags.filter(
t => t.toLowerCase().includes(query.toLowerCase()) && !value.includes(t)
)
const queryIsNew = query.trim() !== '' && !availableTags.some(
t => t.toLowerCase() === query.trim().toLowerCase()
) && !value.includes(query.trim())
function addTag(tag: string) {
const trimmed = tag.trim()
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed])
}
setQuery('')
inputRef.current?.focus()
}
function removeTag(tag: string) {
onChange(value.filter(t => t !== tag))
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if ((e.key === 'Enter' || e.key === ',') && query.trim()) {
e.preventDefault()
// If there's exactly one filtered match, use it; otherwise add as-typed
if (filtered.length === 1) {
addTag(filtered[0])
} else {
addTag(query.trim())
}
} else if (e.key === 'Backspace' && !query && value.length > 0) {
removeTag(value[value.length - 1])
} else if (e.key === 'Escape') {
setOpen(false)
setQuery('')
}
}
// Close dropdown on outside click
const handleClickOutside = useCallback((e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}, [])
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [handleClickOutside])
const showDropdown = open && (filtered.length > 0 || queryIsNew)
return (
<div ref={containerRef} className="relative">
{/* Tag chips + input */}
<div
className="min-h-[42px] w-full bg-[#161b27] border border-white/8 rounded-xl px-2.5 py-2 flex flex-wrap gap-1.5 cursor-text focus-within:border-blue-500/60 focus-within:ring-1 focus-within:ring-blue-500/20 transition-colors"
onClick={() => { inputRef.current?.focus(); setOpen(true) }}
>
{value.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-slate-700/70 px-2 py-0.5 text-xs text-slate-200 border border-slate-600/40"
>
{tag}
<button
type="button"
onClick={e => { e.stopPropagation(); removeTag(tag) }}
className="text-slate-500 hover:text-slate-200 transition-colors"
>
<X size={10} />
</button>
</span>
))}
<input
ref={inputRef}
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? 'Add tags…' : ''}
className="flex-1 min-w-[80px] bg-transparent text-sm text-slate-200 placeholder:text-slate-600 outline-none"
/>
</div>
{/* Dropdown */}
{showDropdown && (
<div className="absolute z-50 mt-1 w-full rounded-xl border border-white/8 bg-[#1e2535] shadow-xl overflow-hidden animate-fade-in">
<div className="max-h-48 overflow-y-auto py-1">
{filtered.map(tag => (
<button
key={tag}
type="button"
onMouseDown={e => { e.preventDefault(); addTag(tag) }}
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-white/6 transition-colors"
>
{tag}
</button>
))}
{queryIsNew && (
<button
type="button"
onMouseDown={e => { e.preventDefault(); addTag(query.trim()) }}
className="w-full text-left px-3 py-2 text-sm text-blue-400 hover:bg-white/6 transition-colors flex items-center gap-2"
>
<Plus size={13} />
Add &ldquo;{query.trim()}&rdquo;
</button>
)}
</div>
</div>
)}
<p className="mt-1.5 text-xs text-slate-600">
Type to search or create · Enter or comma to add · Backspace to remove
</p>
</div>
)
}