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:
parent
69e0b864e4
commit
7152d6b492
3 changed files with 146 additions and 20 deletions
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
134
src/components/TagInput.tsx
Normal 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 “{query.trim()}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue