From 7152d6b492f3b73a64dd2ada4546d87c6fd3b70b Mon Sep 17 00:00:00 2001 From: Maddox Date: Tue, 7 Apr 2026 18:20:47 -0400 Subject: [PATCH] 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 --- src/App.tsx | 2 + src/components/StoreModal.tsx | 30 +++----- src/components/TagInput.tsx | 134 ++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 src/components/TagInput.tsx diff --git a/src/App.tsx b/src/App.tsx index 4c4114e..90fbd0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
@@ -217,6 +218,7 @@ export default function App() { setEditStore(undefined)} /> diff --git a/src/components/StoreModal.tsx b/src/components/StoreModal.tsx index 1f68fea..2f0473d 100644 --- a/src/components/StoreModal.tsx +++ b/src/components/StoreModal.tsx @@ -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) => 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(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 */}
- 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" + set('tagList', tags)} /> - {tagList.length > 0 && ( -
- {tagList.map(t => ( - - {t} - - ))} -
- )}
{/* Actions */} diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx new file mode 100644 index 0000000..da18f9d --- /dev/null +++ b/src/components/TagInput.tsx @@ -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(null) + const containerRef = useRef(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) { + 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 ( +
+ {/* Tag chips + input */} +
{ inputRef.current?.focus(); setOpen(true) }} + > + {value.map(tag => ( + + {tag} + + + ))} + { 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" + /> +
+ + {/* Dropdown */} + {showDropdown && ( +
+
+ {filtered.map(tag => ( + + ))} + {queryIsNew && ( + + )} +
+
+ )} + +

+ Type to search or create · Enter or comma to add · Backspace to remove +

+
+ ) +}