247 lines
8.5 KiB
TypeScript
247 lines
8.5 KiB
TypeScript
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<HTMLInputElement | null>;
|
||
// با 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<ComboboxItem[]>(() => {
|
||
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<HTMLDivElement>(() => 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<HTMLInputElement>) => {
|
||
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 (
|
||
<div ref={ref} className={cn("relative", className)}>
|
||
<input
|
||
type="text"
|
||
ref={inputRef}
|
||
className={cn(
|
||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||
showClear && "pe-8",
|
||
inputClassName,
|
||
)}
|
||
value={text}
|
||
placeholder={placeholder}
|
||
autoComplete="off"
|
||
disabled={disabled}
|
||
onChange={(e) => {
|
||
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 && (
|
||
<button
|
||
type="button"
|
||
aria-label="پاک کردن"
|
||
title="پاک کردن"
|
||
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setText("");
|
||
setHighlight(-1);
|
||
setOpen(true);
|
||
onClear?.();
|
||
}}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
{open && (
|
||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||
{listCount === 0 && (
|
||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||
)}
|
||
{filtered.map((it, i) => (
|
||
<button
|
||
type="button"
|
||
key={it.id + "|" + i}
|
||
className={cn(
|
||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||
i === highlight && "bg-slate-bg",
|
||
)}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault(); // حفظ فوکوس input
|
||
pick(it);
|
||
}}
|
||
onMouseEnter={() => setHighlight(i)}
|
||
>
|
||
{it.label}
|
||
</button>
|
||
))}
|
||
{showAdd && (
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"flex w-full cursor-pointer items-center gap-1 border-t border-line px-3 py-1.5 text-start text-[13px] text-accent hover:bg-slate-bg",
|
||
highlight === filtered.length && "bg-slate-bg",
|
||
)}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
addCurrent();
|
||
}}
|
||
onMouseEnter={() => setHighlight(filtered.length)}
|
||
>
|
||
<span className="font-bold">+</span>
|
||
{addPrefix ? (
|
||
<span>
|
||
{addPrefix} «<b>{text.trim()}</b>»
|
||
</span>
|
||
) : (
|
||
<span>
|
||
«<b>{text.trim()}</b>» {addSuffix}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|