| 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 | } |