Skip to content

Commit 5a54b26

Browse files
committed
feat: create TagMultiSelect component for reusable tag selection
1 parent 0789dc4 commit 5a54b26

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { Badge } from '@/components/ui/badge';
3+
import { Button } from '@/components/ui/button';
4+
import { Input } from '@/components/ui/input';
5+
import { TagMultiSelectProps } from '@/components/utils/types';
6+
import { ChevronDown, Plus } from 'lucide-react';
7+
8+
export const TagMultiSelect = ({
9+
availableTags,
10+
selectedTags,
11+
onTagsChange,
12+
placeholder = 'Select or create tags',
13+
disabled = false,
14+
className = '',
15+
}: TagMultiSelectProps) => {
16+
const [isOpen, setIsOpen] = useState(false);
17+
const [searchTerm, setSearchTerm] = useState('');
18+
const dropdownRef = useRef<HTMLDivElement>(null);
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
21+
useEffect(() => {
22+
const handleClickOutside = (event: MouseEvent) => {
23+
if (
24+
dropdownRef.current &&
25+
!dropdownRef.current.contains(event.target as Node)
26+
) {
27+
setIsOpen(false);
28+
setSearchTerm('');
29+
}
30+
};
31+
32+
document.addEventListener('mousedown', handleClickOutside);
33+
return () => document.removeEventListener('mousedown', handleClickOutside);
34+
}, []);
35+
36+
const getFilteredTags = () => {
37+
return availableTags.filter(
38+
(tag) =>
39+
tag.toLowerCase().includes(searchTerm.toLowerCase()) &&
40+
!selectedTags.includes(tag)
41+
);
42+
};
43+
44+
const handleTagSelect = (tag: string) => {
45+
if (!selectedTags.includes(tag)) {
46+
onTagsChange([...selectedTags, tag]);
47+
}
48+
setSearchTerm('');
49+
};
50+
51+
const handleTagRemove = (tagToRemove: string) => {
52+
onTagsChange(selectedTags.filter((tag) => tag !== tagToRemove));
53+
};
54+
55+
const handleNewTagCreate = () => {
56+
const trimmedTerm = searchTerm.trim();
57+
if (
58+
trimmedTerm &&
59+
!selectedTags.includes(trimmedTerm) &&
60+
!availableTags.includes(trimmedTerm)
61+
) {
62+
onTagsChange([...selectedTags, trimmedTerm]);
63+
setSearchTerm('');
64+
}
65+
};
66+
67+
const handleKeyDown = (e: React.KeyboardEvent) => {
68+
if (e.key === 'Enter') {
69+
e.preventDefault();
70+
const filteredTags = getFilteredTags();
71+
if (filteredTags.length > 0) {
72+
handleTagSelect(filteredTags[0]);
73+
} else if (searchTerm.trim()) {
74+
handleNewTagCreate();
75+
}
76+
} else if (e.key === 'Escape') {
77+
setIsOpen(false);
78+
setSearchTerm('');
79+
}
80+
};
81+
82+
const showCreateOption =
83+
searchTerm.trim() &&
84+
!availableTags.includes(searchTerm.trim()) &&
85+
!selectedTags.includes(searchTerm.trim());
86+
87+
return (
88+
<div className={`relative ${className}`} ref={dropdownRef}>
89+
<Button
90+
type="button"
91+
variant="outline"
92+
onClick={() => setIsOpen(!isOpen)}
93+
disabled={disabled}
94+
className="w-full justify-between text-left font-normal"
95+
>
96+
<span className="truncate">
97+
{selectedTags.length > 0
98+
? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected`
99+
: placeholder}
100+
</span>
101+
<ChevronDown className="h-4 w-4 opacity-50" />
102+
</Button>
103+
104+
{isOpen && (
105+
<div className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-hidden">
106+
<div className="p-2 border-b">
107+
<Input
108+
ref={inputRef}
109+
value={searchTerm}
110+
onChange={(e) => setSearchTerm(e.target.value)}
111+
onKeyDown={handleKeyDown}
112+
placeholder="Search or create tags..."
113+
className="h-8"
114+
autoFocus
115+
/>
116+
</div>
117+
118+
<div className="max-h-40 overflow-y-auto">
119+
{getFilteredTags().map((tag) => (
120+
<div
121+
key={tag}
122+
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors"
123+
onClick={() => handleTagSelect(tag)}
124+
>
125+
<input
126+
type="checkbox"
127+
checked={false}
128+
readOnly
129+
className="h-4 w-4"
130+
/>
131+
<span className="flex-1 text-sm">{tag}</span>
132+
</div>
133+
))}
134+
135+
{showCreateOption && (
136+
<div
137+
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors border-t"
138+
onClick={handleNewTagCreate}
139+
>
140+
<Plus className="h-4 w-4 text-muted-foreground" />
141+
<span className="flex-1 text-sm">
142+
Create "{searchTerm.trim()}"
143+
</span>
144+
</div>
145+
)}
146+
147+
{getFilteredTags().length === 0 && !showCreateOption && (
148+
<div className="px-3 py-2 text-sm text-muted-foreground text-center">
149+
No tags found
150+
</div>
151+
)}
152+
</div>
153+
</div>
154+
)}
155+
156+
{selectedTags.length > 0 && (
157+
<div className="flex flex-wrap gap-2 mt-2">
158+
{selectedTags.map((tag) => (
159+
<Badge
160+
key={tag}
161+
variant="secondary"
162+
className="flex items-center gap-1"
163+
>
164+
<span>{tag}</span>
165+
<button
166+
type="button"
167+
onClick={() => handleTagRemove(tag)}
168+
className="ml-1 text-red-500 hover:text-red-700 text-xs"
169+
disabled={disabled}
170+
>
171+
172+
</button>
173+
</Badge>
174+
))}
175+
</div>
176+
)}
177+
</div>
178+
);
179+
};

frontend/src/components/utils/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,15 @@ export interface AddTaskDialogProps {
128128
allTasks?: Task[];
129129
}
130130

131+
export interface TagMultiSelectProps {
132+
availableTags: string[];
133+
selectedTags: string[];
134+
onTagsChange: (tags: string[]) => void;
135+
placeholder?: string;
136+
disabled?: boolean;
137+
className?: string;
138+
}
139+
131140
export interface EditTaskDialogProps {
132141
index: number;
133142
task: Task;

0 commit comments

Comments
 (0)