|
| 1 | +import { useEffect, useRef, useState } from "react"; |
| 2 | +import { createPortal } from "react-dom"; |
| 3 | +import { Icon } from "@iconify/react"; |
| 4 | +import { DropdownMenuItem } from "./DropdownMenuItem"; |
| 5 | +import { formInputStyle } from "./Panel"; |
| 6 | + |
| 7 | +interface Option { |
| 8 | + value: string; |
| 9 | + label: string; |
| 10 | +} |
| 11 | + |
| 12 | +interface Props { |
| 13 | + value: string; |
| 14 | + options: Option[]; |
| 15 | + onChange: (value: string) => void; |
| 16 | + className?: string; |
| 17 | +} |
| 18 | + |
| 19 | +export function FormSelect({ value, options, onChange, className = "" }: Props) { |
| 20 | + const [open, setOpen] = useState(false); |
| 21 | + const [menuRect, setMenuRect] = useState({ top: 0, left: 0, width: 0 }); |
| 22 | + const triggerRef = useRef<HTMLButtonElement>(null); |
| 23 | + const menuRef = useRef<HTMLDivElement>(null); |
| 24 | + |
| 25 | + const selectedLabel = options.find((o) => o.value === value)?.label ?? value; |
| 26 | + |
| 27 | + useEffect(() => { |
| 28 | + if (!open) return; |
| 29 | + const handler = (e: MouseEvent) => { |
| 30 | + const target = e.target as Node; |
| 31 | + if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) { |
| 32 | + setOpen(false); |
| 33 | + } |
| 34 | + }; |
| 35 | + document.addEventListener("mousedown", handler); |
| 36 | + return () => document.removeEventListener("mousedown", handler); |
| 37 | + }, [open]); |
| 38 | + |
| 39 | + const handleToggle = () => { |
| 40 | + if (!open && triggerRef.current) { |
| 41 | + const r = triggerRef.current.getBoundingClientRect(); |
| 42 | + setMenuRect({ top: r.bottom + 4, left: r.left, width: r.width }); |
| 43 | + } |
| 44 | + setOpen((o) => !o); |
| 45 | + }; |
| 46 | + |
| 47 | + return ( |
| 48 | + <div className={className}> |
| 49 | + <button |
| 50 | + ref={triggerRef} |
| 51 | + type="button" |
| 52 | + onClick={handleToggle} |
| 53 | + className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors" |
| 54 | + style={formInputStyle} |
| 55 | + onFocus={(e) => (e.currentTarget.style.borderColor = "var(--t-accent)")} |
| 56 | + onBlur={(e) => (e.currentTarget.style.borderColor = "var(--t-border)")} |
| 57 | + > |
| 58 | + <span className="text-[var(--t-text-primary)]">{selectedLabel}</span> |
| 59 | + <Icon |
| 60 | + icon="lucide:chevron-down" |
| 61 | + width={14} |
| 62 | + className="text-[var(--t-text-dim)] shrink-0" |
| 63 | + style={{ transition: "transform 150ms", transform: open ? "rotate(180deg)" : "rotate(0deg)" }} |
| 64 | + /> |
| 65 | + </button> |
| 66 | + |
| 67 | + {open && createPortal( |
| 68 | + <div |
| 69 | + ref={menuRef} |
| 70 | + className="fixed p-1.5 rounded-xl z-[9999] flex flex-col bg-[var(--t-bg-card)] border border-[var(--t-bg-card-hover)] max-h-[240px] overflow-y-auto" |
| 71 | + style={{ top: menuRect.top, left: menuRect.left, width: menuRect.width, boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }} |
| 72 | + > |
| 73 | + {options.map((opt) => ( |
| 74 | + <DropdownMenuItem |
| 75 | + key={opt.value} |
| 76 | + label={opt.label} |
| 77 | + iconSize={15} |
| 78 | + checked={value === opt.value} |
| 79 | + onClick={() => { onChange(opt.value); setOpen(false); }} |
| 80 | + /> |
| 81 | + ))} |
| 82 | + </div>, |
| 83 | + document.body, |
| 84 | + )} |
| 85 | + </div> |
| 86 | + ); |
| 87 | +} |
0 commit comments