211 lines
7.1 KiB
TypeScript
211 lines
7.1 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
||
import { normalize } from "../lib/normalize";
|
||
import { cn } from "../lib/utils";
|
||
import { useClickOutside } from "./useClickOutside";
|
||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||
|
||
export interface MultiSelectComboboxProps {
|
||
values: string[];
|
||
onChange: (values: string[]) => void;
|
||
options?: string[]; // گزینههای کششده (مشخصههای استفادهشده قبلی)
|
||
placeholder?: string;
|
||
className?: string;
|
||
inputClassName?: string;
|
||
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
||
}
|
||
|
||
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
||
// - چیپهای قابل حذف با ×
|
||
// - input بدون حاشیه بعد از آخرین چیپ (فوکوس خودکار)
|
||
// - منوی autocomplete با فیلتر زنده + گزینهی «ایجاد '{query}'»
|
||
// - Enter برای افزودن، Backspace برای حذف آخرین چیپ، جلوگیری از تکراری
|
||
export function MultiSelectCombobox({
|
||
values,
|
||
onChange,
|
||
options = [],
|
||
placeholder,
|
||
className,
|
||
inputClassName,
|
||
allowCreate = true,
|
||
}: MultiSelectComboboxProps) {
|
||
const [text, setText] = useState("");
|
||
const [open, setOpen] = useState(false);
|
||
const [highlight, setHighlight] = useState(-1);
|
||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||
|
||
// گزینهها = کش + مقادیر انتخابشده (تا چیپهای قبلی در لیست هم دیده شوند)
|
||
const all = useMemo(() => {
|
||
const set = new Set<string>();
|
||
options.forEach((o) => o && set.add(o));
|
||
values.forEach((v) => v && set.add(v));
|
||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||
}, [options, values]);
|
||
|
||
const q = useMemo(() => normalize(text), [text]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!q) return all;
|
||
return all.filter((x) => normalize(x).includes(q));
|
||
}, [all, q]);
|
||
|
||
const exact = q.length > 0 && filtered.some((x) => normalize(x) === q);
|
||
const showCreate = allowCreate && q.length > 0 && !exact;
|
||
const listCount = filtered.length + (showCreate ? 1 : 0);
|
||
|
||
const add = (val: string) => {
|
||
const v = val.trim();
|
||
if (!v) return;
|
||
if (values.some((x) => normalize(x) === normalize(v))) {
|
||
setText("");
|
||
setHighlight(-1);
|
||
inputRef.current?.focus();
|
||
return; // تکراری — اضافه نمیشود
|
||
}
|
||
onChange([...values, v]);
|
||
setText("");
|
||
setHighlight(-1);
|
||
inputRef.current?.focus();
|
||
};
|
||
|
||
const remove = (v: string) => {
|
||
onChange(values.filter((x) => x !== v));
|
||
inputRef.current?.focus();
|
||
};
|
||
|
||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (highlight >= 0 && highlight < filtered.length) {
|
||
add(filtered[highlight]);
|
||
} else if (text.trim()) {
|
||
add(text.trim());
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === "Backspace" && !text && values.length) {
|
||
e.preventDefault();
|
||
remove(values[values.length - 1]);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
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 === "Escape") {
|
||
setOpen(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div ref={ref} className={cn("relative", className)}>
|
||
<div
|
||
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
|
||
onClick={() => inputRef.current?.focus()}
|
||
>
|
||
{values.map((v) => (
|
||
<span key={v} className="spec-chip">
|
||
<span className="max-w-[140px] truncate">{v}</span>
|
||
<button
|
||
type="button"
|
||
className="spec-chip-x"
|
||
title="حذف"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setPendingRemove(v);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className={cn(
|
||
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
|
||
inputClassName,
|
||
)}
|
||
value={text}
|
||
placeholder={values.length ? "" : placeholder}
|
||
autoComplete="off"
|
||
onChange={(e) => {
|
||
setText(e.target.value);
|
||
setHighlight(-1);
|
||
setOpen(true);
|
||
}}
|
||
onFocus={() => setOpen(true)}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
</div>
|
||
|
||
{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">
|
||
{allowCreate && text.trim() ? (
|
||
<span>
|
||
برای افزودن «<b>{text.trim()}</b>» Enter بزنید
|
||
</span>
|
||
) : (
|
||
"موردی یافت نشد"
|
||
)}
|
||
</div>
|
||
)}
|
||
{filtered.map((it, i) => (
|
||
<button
|
||
type="button"
|
||
key={it}
|
||
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
|
||
add(it);
|
||
}}
|
||
onMouseEnter={() => setHighlight(i)}
|
||
>
|
||
{it}
|
||
</button>
|
||
))}
|
||
{showCreate && (
|
||
<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();
|
||
add(text.trim());
|
||
}}
|
||
onMouseEnter={() => setHighlight(filtered.length)}
|
||
>
|
||
<span className="font-bold">+</span>
|
||
<span>
|
||
افزودن «<b>{text.trim()}</b>»
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmDeleteDialog
|
||
open={pendingRemove !== null}
|
||
itemName={pendingRemove || ""}
|
||
onConfirm={() => {
|
||
if (pendingRemove !== null) remove(pendingRemove);
|
||
setPendingRemove(null);
|
||
}}
|
||
onCancel={() => setPendingRemove(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|