main
tsx 173 lines 5.45 KB
Raw
1 import React, { useEffect, useMemo, useRef, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { Button } from "@/components/ui/button";
4 import { cn } from "@/lib/utils";
5
6 type TagComboboxProps = {
7 availableTags: string[];
8 selectedTags: string[];
9 onAdd: (tag: string) => void;
10 onRemove: (tag: string) => void;
11 };
12
13 export function TagCombobox({
14 availableTags,
15 selectedTags,
16 onAdd,
17 onRemove,
18 }: TagComboboxProps) {
19 const [inputValue, setInputValue] = useState("");
20 const [open, setOpen] = useState(false);
21 const [activeIndex, setActiveIndex] = useState(0);
22 const listId = "tag-combobox-list";
23 const inputRef = useRef<HTMLInputElement | null>(null);
24 const containerRef = useRef<HTMLDivElement | null>(null);
25 const [panelStyle, setPanelStyle] = useState<React.CSSProperties>();
26
27 const filtered = useMemo(() => {
28 const query = inputValue.trim().toLowerCase();
29 const pool = availableTags.filter((tag) => !selectedTags.includes(tag));
30 if (!query) return pool.slice(0, 8);
31 return pool.filter((tag) => tag.toLowerCase().includes(query)).slice(0, 8);
32 }, [availableTags, selectedTags, inputValue]);
33
34 useEffect(() => {
35 setActiveIndex(0);
36 }, [filtered.length]);
37
38 useEffect(() => {
39 if (!open) return;
40 const updatePosition = () => {
41 const el = containerRef.current;
42 if (!el) return;
43 const rect = el.getBoundingClientRect();
44 setPanelStyle({
45 position: "absolute",
46 top: rect.bottom + window.scrollY + 4,
47 left: rect.left + window.scrollX,
48 width: rect.width,
49 zIndex: 50,
50 });
51 };
52 updatePosition();
53 window.addEventListener("resize", updatePosition);
54 window.addEventListener("scroll", updatePosition, true);
55 return () => {
56 window.removeEventListener("resize", updatePosition);
57 window.removeEventListener("scroll", updatePosition, true);
58 };
59 }, [open]);
60
61 const addTag = (tag: string) => {
62 if (!tag || selectedTags.includes(tag)) return;
63 onAdd(tag);
64 setInputValue("");
65 setOpen(false);
66 };
67
68 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
69 if (e.key === "ArrowDown") {
70 e.preventDefault();
71 setOpen(true);
72 setActiveIndex((i) => (i + 1) % Math.max(filtered.length, 1));
73 } else if (e.key === "ArrowUp") {
74 e.preventDefault();
75 setOpen(true);
76 setActiveIndex((i) =>
77 filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length
78 );
79 } else if (e.key === "Enter") {
80 e.preventDefault();
81 if (open && filtered[activeIndex]) {
82 addTag(filtered[activeIndex]);
83 } else if (inputValue.trim()) {
84 addTag(inputValue.trim());
85 }
86 } else if (e.key === "Escape") {
87 setOpen(false);
88 }
89 };
90
91 const handleBlur = () => {
92 // allow option click before closing
93 setTimeout(() => setOpen(false), 100);
94 };
95
96 return (
97 <div
98 ref={containerRef}
99 className="flex w-full sm:w-auto sm:min-w-[320px] flex-1 items-center gap-2 overflow-visible"
100 >
101 <div className="flex min-w-0 flex-1 items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5 min-h-10">
102 <div className="flex flex-1 flex-wrap items-center gap-2 overflow-hidden">
103 {selectedTags.map((tag) => (
104 <Button
105 key={tag}
106 variant="secondary"
107 size="sm"
108 className="h-7 rounded-md px-3 bg-secondary text-primary text-xs"
109 onClick={() => onRemove(tag)}
110 aria-label={`Remove tag ${tag}`}
111 >
112 {tag}
113 <span className="ml-2 bg-secondary text-primary">×</span>
114 </Button>
115 ))}
116 <input
117 ref={inputRef}
118 value={inputValue}
119 onChange={(e) => {
120 setInputValue(e.target.value);
121 setOpen(true);
122 }}
123 onFocus={() => setOpen(true)}
124 onClick={() => setOpen(true)}
125 onBlur={handleBlur}
126 onKeyDown={handleKeyDown}
127 placeholder="Add tag…"
128 className="min-w-30 flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted"
129 role="combobox"
130 aria-expanded={open}
131 aria-controls={listId}
132 aria-autocomplete="list"
133 />
134 </div>
135 </div>
136
137 {open &&
138 filtered.length > 0 &&
139 panelStyle &&
140 createPortal(
141 <div
142 style={panelStyle}
143 className="rounded-md border border-border bg-background shadow-lg overflow-hidden"
144 >
145 <ul
146 id={listId}
147 role="listbox"
148 className="max-h-56 overflow-auto py-1"
149 >
150 {filtered.map((tag, idx) => (
151 <li key={tag} role="option" aria-selected={idx === activeIndex}>
152 <button
153 type="button"
154 className={cn(
155 "flex w-full items-center px-3 py-2 text-left text-sm transition-colors",
156 idx === activeIndex
157 ? "bg-primary/20 text-foreground"
158 : "hover:bg-border/60"
159 )}
160 onMouseDown={(e) => e.preventDefault()}
161 onClick={() => addTag(tag)}
162 >
163 {tag}
164 </button>
165 </li>
166 ))}
167 </ul>
168 </div>,
169 document.body
170 )}
171 </div>
172 );
173 }