Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}