Files
ERP/frontend/src/components/Combobox.tsx
T

247 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}