|
|
@@ -2,6 +2,7 @@
|
|
|
|
|
|
import { X } from "lucide-react";
|
|
|
import * as React from "react";
|
|
|
+import { createPortal } from "react-dom";
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
|
import { cn } from "@/lib/utils";
|
|
|
import { Badge } from "./badge";
|
|
|
@@ -66,8 +67,14 @@ export function TagInput({
|
|
|
const [inputValue, setInputValue] = React.useState("");
|
|
|
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
|
|
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
|
|
+ const [dropdownPosition, setDropdownPosition] = React.useState<{
|
|
|
+ top: number;
|
|
|
+ left: number;
|
|
|
+ width: number;
|
|
|
+ } | null>(null);
|
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
|
+ const dropdownRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const normalizedMaxVisible = React.useMemo(() => {
|
|
|
if (maxVisibleTags === undefined) return undefined;
|
|
|
@@ -93,6 +100,63 @@ export function TagInput({
|
|
|
previousShowSuggestions.current = showSuggestions;
|
|
|
}, [showSuggestions, onSuggestionsClose]);
|
|
|
|
|
|
+ // Calculate dropdown position when showing suggestions
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (showSuggestions && containerRef.current) {
|
|
|
+ const rect = containerRef.current.getBoundingClientRect();
|
|
|
+ setDropdownPosition({
|
|
|
+ top: rect.bottom + window.scrollY + 4,
|
|
|
+ left: rect.left + window.scrollX,
|
|
|
+ width: rect.width,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, [showSuggestions]);
|
|
|
+
|
|
|
+ // Update position on scroll/resize
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (!showSuggestions) return;
|
|
|
+
|
|
|
+ const updatePosition = () => {
|
|
|
+ if (containerRef.current) {
|
|
|
+ const rect = containerRef.current.getBoundingClientRect();
|
|
|
+ setDropdownPosition({
|
|
|
+ top: rect.bottom + window.scrollY + 4,
|
|
|
+ left: rect.left + window.scrollX,
|
|
|
+ width: rect.width,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ window.addEventListener("scroll", updatePosition, true);
|
|
|
+ window.addEventListener("resize", updatePosition);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener("scroll", updatePosition, true);
|
|
|
+ window.removeEventListener("resize", updatePosition);
|
|
|
+ };
|
|
|
+ }, [showSuggestions]);
|
|
|
+
|
|
|
+ // Close dropdown when clicking outside
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (!showSuggestions) return;
|
|
|
+
|
|
|
+ const handleClickOutside = (e: MouseEvent) => {
|
|
|
+ const target = e.target as Node;
|
|
|
+ if (
|
|
|
+ containerRef.current &&
|
|
|
+ !containerRef.current.contains(target) &&
|
|
|
+ dropdownRef.current &&
|
|
|
+ !dropdownRef.current.contains(target)
|
|
|
+ ) {
|
|
|
+ setShowSuggestions(false);
|
|
|
+ setHighlightedIndex(-1);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ document.addEventListener("mousedown", handleClickOutside);
|
|
|
+ return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
|
+ }, [showSuggestions]);
|
|
|
+
|
|
|
const inputMinWidthClass = normalizedMaxVisible === undefined ? "min-w-[120px]" : "min-w-[60px]";
|
|
|
|
|
|
// Normalize suggestions so callers can provide either strings or { value, label } objects.
|
|
|
@@ -404,27 +468,40 @@ export function TagInput({
|
|
|
</button>
|
|
|
)}
|
|
|
{/* 建议下拉列表 */}
|
|
|
- {showSuggestions && filteredSuggestions.length > 0 && (
|
|
|
- <div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-48 overflow-auto">
|
|
|
- {filteredSuggestions.map((suggestion, index) => (
|
|
|
- <button
|
|
|
- key={suggestion.value}
|
|
|
- type="button"
|
|
|
- className={cn(
|
|
|
- "w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer",
|
|
|
- index === highlightedIndex && "bg-accent text-accent-foreground"
|
|
|
- )}
|
|
|
- onMouseDown={(e) => {
|
|
|
- e.preventDefault(); // 阻止 blur 事件
|
|
|
- handleSuggestionClick(suggestion.value);
|
|
|
- }}
|
|
|
- onMouseEnter={() => setHighlightedIndex(index)}
|
|
|
- >
|
|
|
- {suggestion.label}
|
|
|
- </button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ {showSuggestions &&
|
|
|
+ filteredSuggestions.length > 0 &&
|
|
|
+ dropdownPosition &&
|
|
|
+ typeof document !== "undefined" &&
|
|
|
+ createPortal(
|
|
|
+ <div
|
|
|
+ ref={dropdownRef}
|
|
|
+ className="fixed z-[9999] rounded-md border bg-popover shadow-md max-h-48 overflow-auto"
|
|
|
+ style={{
|
|
|
+ top: dropdownPosition.top,
|
|
|
+ left: dropdownPosition.left,
|
|
|
+ width: dropdownPosition.width,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {filteredSuggestions.map((suggestion, index) => (
|
|
|
+ <button
|
|
|
+ key={suggestion.value}
|
|
|
+ type="button"
|
|
|
+ className={cn(
|
|
|
+ "w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer",
|
|
|
+ index === highlightedIndex && "bg-accent text-accent-foreground"
|
|
|
+ )}
|
|
|
+ onMouseDown={(e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ handleSuggestionClick(suggestion.value);
|
|
|
+ }}
|
|
|
+ onMouseEnter={() => setHighlightedIndex(index)}
|
|
|
+ >
|
|
|
+ {suggestion.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>,
|
|
|
+ document.body
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|