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 pagination = data?.pagination
|
||||||
const categories = filterOptions?.categories ?? []
|
const categories = filterOptions?.categories ?? []
|
||||||
const routes = filterOptions?.routes ?? []
|
const routes = filterOptions?.routes ?? []
|
||||||
|
const availableTags = filterOptions?.tags ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface-900">
|
<div className="min-h-screen bg-surface-900">
|
||||||
|
|
@ -217,6 +218,7 @@ export default function App() {
|
||||||
<StoreModal
|
<StoreModal
|
||||||
store={editStore}
|
store={editStore}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
availableTags={availableTags}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onClose={() => setEditStore(undefined)}
|
onClose={() => setEditStore(undefined)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,25 @@ import { useEffect, useState } from 'react'
|
||||||
import { X, Store as StoreIcon } from 'lucide-react'
|
import { X, Store as StoreIcon } from 'lucide-react'
|
||||||
import type { Store, Route } from '../types'
|
import type { Store, Route } from '../types'
|
||||||
import { parseTags } from '../utils'
|
import { parseTags } from '../utils'
|
||||||
|
import { TagInput } from './TagInput'
|
||||||
|
|
||||||
const ROUTES: Route[] = ['auto', 'ask', 'verify', 'ignore']
|
const ROUTES: Route[] = ['auto', 'ask', 'verify', 'ignore']
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
store?: Store | null
|
store?: Store | null
|
||||||
categories: string[]
|
categories: string[]
|
||||||
|
availableTags: string[]
|
||||||
onSave: (data: Omit<Store, 'id' | 'created_at'>) => void
|
onSave: (data: Omit<Store, 'id' | 'created_at'>) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoreModal({ store, categories, onSave, onClose }: Props) {
|
export function StoreModal({ store, categories, availableTags, onSave, onClose }: Props) {
|
||||||
const isEdit = !!store
|
const isEdit = !!store
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
store: store?.store ?? '',
|
store: store?.store ?? '',
|
||||||
category: store?.category ?? (categories[0] ?? ''),
|
category: store?.category ?? (categories[0] ?? ''),
|
||||||
tags: store?.tags ?? '',
|
tagList: parseTags(store?.tags ?? ''),
|
||||||
route: (store?.route ?? 'auto') as Route,
|
route: (store?.route ?? 'auto') as Route,
|
||||||
display_order: store?.display_order ?? 0,
|
display_order: store?.display_order ?? 0,
|
||||||
})
|
})
|
||||||
|
|
@ -30,15 +32,13 @@ export function StoreModal({ store, categories, onSave, onClose }: Props) {
|
||||||
}
|
}
|
||||||
}, [categories, isEdit, form.category])
|
}, [categories, isEdit, form.category])
|
||||||
|
|
||||||
const tagList = parseTags(form.tags)
|
|
||||||
|
|
||||||
function set<K extends keyof typeof form>(key: K, value: typeof form[K]) {
|
function set<K extends keyof typeof form>(key: K, value: typeof form[K]) {
|
||||||
setForm(f => ({ ...f, [key]: value }))
|
setForm(f => ({ ...f, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSave(form)
|
onSave({ ...form, tags: form.tagList.join(', ') })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,23 +128,13 @@ export function StoreModal({ store, categories, onSave, onClose }: Props) {
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<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>
|
</label>
|
||||||
<input
|
<TagInput
|
||||||
value={form.tags}
|
value={form.tagList}
|
||||||
onChange={e => set('tags', e.target.value)}
|
availableTags={availableTags}
|
||||||
placeholder="Auto, Groceries, Delivery"
|
onChange={tags => set('tagList', tags)}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 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