Something went wrong. Try again.
(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension web atproto comments
Something went wrong. Try again.
5.0 kB · 144 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145import { useState, useRef, useEffect } from 'react';import { X, Tag } from 'lucide-react';
interface TagInputProps { tags: string[]; onChange: (tags: string[]) => void; suggestions?: string[]; placeholder?: string;}
export default function TagInput({ tags, onChange, suggestions = [], placeholder = 'Add tag...',}: TagInputProps) { const [input, setInput] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef<HTMLInputElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const filtered = input.trim() ? suggestions.filter((s) => s.toLowerCase().includes(input.toLowerCase()) && !tags.includes(s)) : [];
useEffect(() => { setSelectedIndex(0); }, [input]);
useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setShowSuggestions(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []);
function addTag(tag: string) { const normalized = tag .trim() .toLowerCase() .replace(/[^a-z0-9_-]/g, ''); if (normalized && !tags.includes(normalized) && tags.length < 10) { onChange([...tags, normalized]); } setInput(''); setShowSuggestions(false); inputRef.current?.focus(); }
function removeTag(tag: string) { onChange(tags.filter((t) => t !== tag)); inputRef.current?.focus(); }
function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (filtered.length > 0 && showSuggestions) { addTag(filtered[selectedIndex] || filtered[0]); } else if (input.trim()) { addTag(input); } } else if (e.key === 'Backspace' && !input && tags.length > 0) { removeTag(tags[tags.length - 1]); } else if (e.key === 'ArrowDown' && showSuggestions) { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); } else if (e.key === 'ArrowUp' && showSuggestions) { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Escape') { setShowSuggestions(false); } }
return ( <div ref={containerRef} className="relative"> <div 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)] ring-0 outline-none transition-all" onClick={() => inputRef.current?.focus()} > <Tag size={12} className="text-[var(--text-tertiary)] flex-shrink-0" /> {tags.map((tag) => ( <span key={tag} 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]" > {tag} <button type="button" onClick={(e) => { e.stopPropagation(); removeTag(tag); }} className="hover:text-[var(--text-primary)] transition-colors" > <X size={10} /> </button> </span> ))} <input ref={inputRef} type="text" value={input} onChange={(e) => { setInput(e.target.value); setShowSuggestions(true); }} onFocus={() => setShowSuggestions(true)} onKeyDown={handleKeyDown} placeholder={tags.length === 0 ? placeholder : ''} className="flex-1 min-w-[60px] bg-transparent border-none outline-none ring-0 text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:outline-none focus:ring-0 focus:border-none focus:shadow-none" style={{ outline: 'none', boxShadow: 'none' }} /> </div>
{showSuggestions && filtered.length > 0 && ( <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"> {filtered.slice(0, 8).map((suggestion, i) => ( <button key={suggestion} type="button" onMouseDown={(e) => { e.preventDefault(); addTag(suggestion); }} className={`w-full text-left px-3 py-1.5 text-[11px] transition-colors ${ i === selectedIndex ? 'bg-[var(--accent-subtle)] text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]' }`} > {suggestion} </button> ))} </div> )} </div> );}