import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { X } from "lucide-react"; import { normalize } from "../lib/normalize"; import { cn } from "../lib/utils"; import { useClickOutside } from "./useClickOutside"; export interface ComboboxItem { id: string; label: string; } // منبع آیتم‌ها: رشته، {id,name} یا {id,label} export type ComboboxSourceItem = string | { id: string; name?: string; label?: string }; export type ComboboxSource = ComboboxSourceItem[] | (() => ComboboxSourceItem[]); export interface ComboboxProps { value?: string; onChange?: (item: ComboboxItem) => void; items: ComboboxSource; // آرایه از string یا {id, name} allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد addSuffix?: string; // متن بعد از + placeholder?: string; className?: string; disabled?: boolean; inputClassName?: string; onAdd?: (val: string) => void; onInputChange?: (val: string) => void; onBlur?: () => void; // با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی // (و فیلتر وابسته به آن) به‌تنهایی پاک شود. onClear?: () => void; // برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته) inputRef?: RefObject; // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — // فقط کلیک یا تایپِ صریح کاربر بازش می‌کند. openOnFocus?: boolean; } function toItem(x: ComboboxSourceItem): ComboboxItem { if (x == null) return { id: "", label: "" }; if (typeof x === "string") return { id: x, label: x }; const label = String(x.name != null ? x.name : x.label != null ? x.label : x.id ?? ""); return { id: String(x.id ?? label), label }; } // جستجو-و-انتخاب با تایپ: فیلتر با normalize، ناوبری با کلیدها، // گزینه‌ی "+ افزودن به عنوان جدید" وقتی متنی با هیچ آیتمی match نمی‌شود export function Combobox({ value, onChange, items, allowAdd = true, addPrefix, addSuffix = "به عنوان گزینه جدید", placeholder, className, disabled, inputClassName, onAdd, onInputChange, onBlur, onClear, inputRef, openOnFocus = true, }: ComboboxProps) { const [text, setText] = useState(value ?? ""); const [open, setOpen] = useState(false); const [highlight, setHighlight] = useState(-1); // کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز // می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک // دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته. const justOpenedByFocus = useRef(false); useEffect(() => { setText(value ?? ""); }, [value]); const all = useMemo(() => { const src = typeof items === "function" ? items() : items; return src.map(toItem); }, [items]); const q = useMemo(() => normalize(text), [text]); const filtered = useMemo(() => { if (!q) return all; return all.filter((x) => normalize(x.label).includes(q)); }, [all, q]); const showAdd = allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q); const showClear = Boolean(onClear) && !disabled && text.length > 0; const listCount = filtered.length + (showAdd ? 1 : 0); const ref = useClickOutside(() => setOpen(false)); const pick = (it: ComboboxItem) => { setText(it.label); setOpen(false); setHighlight(-1); onChange?.(it); onInputChange?.(it.label); }; const addCurrent = () => { const val = text.trim(); if (!val) return; setOpen(false); setHighlight(-1); onAdd?.(val); onInputChange?.(val); }; const onKeyDown = (e: React.KeyboardEvent) => { if (!open) { if (e.key === "ArrowDown" || e.key === "Enter") setOpen(true); return; } if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => (h + 1) % Math.max(listCount, 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1)); } else if (e.key === "Enter") { e.preventDefault(); if (highlight >= 0 && highlight < filtered.length) { pick(filtered[highlight]); } else if (highlight === filtered.length && showAdd) { addCurrent(); } } else if (e.key === "Escape") { setOpen(false); } }; return (
{ setText(e.target.value); setHighlight(-1); if (!open) setOpen(true); onInputChange?.(e.target.value); }} onFocus={() => { if (openOnFocus && !open) { setOpen(true); justOpenedByFocus.current = true; } }} onClick={() => { // اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیک‌های بعدی تاگل می‌کنند if (justOpenedByFocus.current) { justOpenedByFocus.current = false; return; } setOpen((o) => !o); }} onBlur={onBlur} onKeyDown={onKeyDown} /> {showClear && ( )} {open && (
{listCount === 0 && (
موردی یافت نشد
)} {filtered.map((it, i) => ( ))} {showAdd && ( )}
)}
); }