Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 143 lines 4.9 kB view raw
1import { useState, useRef, useEffect } from 'react'; 2import { X, Tag } from 'lucide-react'; 3 4interface TagInputProps { 5 tags: string[]; 6 onChange: (tags: string[]) => void; 7 suggestions?: string[]; 8 placeholder?: string; 9} 10 11export default function TagInput({ 12 tags, 13 onChange, 14 suggestions = [], 15 placeholder = 'Add tag...', 16}: TagInputProps) { 17 const [input, setInput] = useState(''); 18 const [showSuggestions, setShowSuggestions] = useState(false); 19 const [selectedIndex, setSelectedIndex] = useState(0); 20 const inputRef = useRef<HTMLInputElement>(null); 21 const containerRef = useRef<HTMLDivElement>(null); 22 23 const filtered = input.trim() 24 ? suggestions.filter((s) => s.toLowerCase().includes(input.toLowerCase()) && !tags.includes(s)) 25 : []; 26 27 useEffect(() => { 28 setSelectedIndex(0); 29 }, [input]); 30 31 useEffect(() => { 32 function handleClickOutside(e: MouseEvent) { 33 if (containerRef.current && !containerRef.current.contains(e.target as Node)) { 34 setShowSuggestions(false); 35 } 36 } 37 document.addEventListener('mousedown', handleClickOutside); 38 return () => document.removeEventListener('mousedown', handleClickOutside); 39 }, []); 40 41 function addTag(tag: string) { 42 const normalized = tag 43 .trim() 44 .toLowerCase() 45 .replace(/[^a-z0-9_-]/g, ''); 46 if (normalized && !tags.includes(normalized) && tags.length < 10) { 47 onChange([...tags, normalized]); 48 } 49 setInput(''); 50 setShowSuggestions(false); 51 inputRef.current?.focus(); 52 } 53 54 function removeTag(tag: string) { 55 onChange(tags.filter((t) => t !== tag)); 56 inputRef.current?.focus(); 57 } 58 59 function handleKeyDown(e: React.KeyboardEvent) { 60 if (e.key === 'Enter' || e.key === ',') { 61 e.preventDefault(); 62 if (filtered.length > 0 && showSuggestions) { 63 addTag(filtered[selectedIndex] || filtered[0]); 64 } else if (input.trim()) { 65 addTag(input); 66 } 67 } else if (e.key === 'Backspace' && !input && tags.length > 0) { 68 removeTag(tags[tags.length - 1]); 69 } else if (e.key === 'ArrowDown' && showSuggestions) { 70 e.preventDefault(); 71 setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); 72 } else if (e.key === 'ArrowUp' && showSuggestions) { 73 e.preventDefault(); 74 setSelectedIndex((i) => Math.max(i - 1, 0)); 75 } else if (e.key === 'Escape') { 76 setShowSuggestions(false); 77 } 78 } 79 80 return ( 81 <div ref={containerRef} className="relative"> 82 <div 83 className="flex flex-wrap items-center gap-1.5 p-2 bg-[var(--bg-card)] border border-[var(--border)] rounded-lg text-xs cursor-text min-h-[34px] focus-within:border-[var(--accent)] focus-within:ring-1 focus-within:ring-[var(--accent-subtle)] transition-all" 84 onClick={() => inputRef.current?.focus()} 85 > 86 <Tag size={12} className="text-[var(--text-tertiary)] flex-shrink-0" /> 87 {tags.map((tag) => ( 88 <span 89 key={tag} 90 className="inline-flex items-center gap-1 px-2 py-0.5 bg-[var(--accent-subtle)] text-[var(--accent)] rounded-md font-medium text-[11px]" 91 > 92 {tag} 93 <button 94 type="button" 95 onClick={(e) => { 96 e.stopPropagation(); 97 removeTag(tag); 98 }} 99 className="hover:text-[var(--text-primary)] transition-colors" 100 > 101 <X size={10} /> 102 </button> 103 </span> 104 ))} 105 <input 106 ref={inputRef} 107 type="text" 108 value={input} 109 onChange={(e) => { 110 setInput(e.target.value); 111 setShowSuggestions(true); 112 }} 113 onFocus={() => setShowSuggestions(true)} 114 onKeyDown={handleKeyDown} 115 placeholder={tags.length === 0 ? placeholder : ''} 116 className="flex-1 min-w-[60px] bg-transparent border-none outline-none text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)]" 117 /> 118 </div> 119 120 {showSuggestions && filtered.length > 0 && ( 121 <div className="absolute z-50 mt-1 w-full bg-[var(--bg-card)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden max-h-[140px] overflow-y-auto"> 122 {filtered.slice(0, 8).map((suggestion, i) => ( 123 <button 124 key={suggestion} 125 type="button" 126 onMouseDown={(e) => { 127 e.preventDefault(); 128 addTag(suggestion); 129 }} 130 className={`w-full text-left px-3 py-1.5 text-[11px] transition-colors ${ 131 i === selectedIndex 132 ? 'bg-[var(--accent-subtle)] text-[var(--accent)]' 133 : 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]' 134 }`} 135 > 136 {suggestion} 137 </button> 138 ))} 139 </div> 140 )} 141 </div> 142 ); 143}