refactor: modularize application domains
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
export default function BatchMultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
options: string[],
|
||||
selected: string[],
|
||||
onChange: (val: string[]) => void,
|
||||
placeholder: string
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return options;
|
||||
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
|
||||
}, [options, search]);
|
||||
const label = selected.length === 0
|
||||
? placeholder
|
||||
: selected.length === options.length
|
||||
? '全部批次'
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `已选 ${selected.length} 个批次`;
|
||||
|
||||
const toggle = (opt: string) => {
|
||||
if (selectedSet.has(opt)) {
|
||||
onChange(selected.filter(item => item !== opt));
|
||||
} else {
|
||||
onChange([...selected, opt]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
|
||||
onClick={() => setIsOpen(open => !open)}
|
||||
>
|
||||
<span className="block truncate">{label}</span>
|
||||
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
<div className="p-2 border-b border-slate-50">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
|
||||
placeholder="搜索批次"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
onClick={() => onChange(options)}
|
||||
>
|
||||
全选批次
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
不限
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{filtered.map((opt: string) => {
|
||||
const checked = selectedSet.has(opt);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={opt}
|
||||
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
<span className="truncate">{opt}</span>
|
||||
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
|
||||
<Check size={10} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
|
||||
无匹配项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user