unis_crm/frontend/src/components/AdaptiveSelect.tsx

294 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type AdaptiveSelectOption = {
value: string;
label: string;
disabled?: boolean;
};
type AdaptiveSelectBaseProps = {
options: AdaptiveSelectOption[];
placeholder?: string;
sheetTitle?: string;
disabled?: boolean;
className?: string;
searchable?: boolean;
searchPlaceholder?: string;
};
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
multiple?: false;
value?: string;
onChange: (value: string) => void;
};
type AdaptiveSelectMultipleProps = AdaptiveSelectBaseProps & {
multiple: true;
value?: string[];
onChange: (value: string[]) => void;
};
type AdaptiveSelectProps = AdaptiveSelectSingleProps | AdaptiveSelectMultipleProps;
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia("(max-width: 639px)").matches;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 639px)");
const handleChange = () => setIsMobile(mediaQuery.matches);
handleChange();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, []);
return isMobile;
}
export function AdaptiveSelect({
options,
placeholder = "请选择",
sheetTitle,
disabled = false,
className,
searchable = false,
searchPlaceholder = "请输入关键字搜索",
value,
multiple = false,
onChange,
}: AdaptiveSelectProps) {
const [open, setOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const containerRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const selectedValues = multiple
? Array.isArray(value) ? value : []
: typeof value === "string" && value ? [value] : [];
const selectedLabel = selectedValues.length > 0
? selectedValues
.map((selectedValue) => options.find((option) => option.value === selectedValue)?.label)
.filter((label): label is string => Boolean(label))
.join("、")
: placeholder;
const normalizedSearchKeyword = searchKeyword.trim().toLowerCase();
const visibleOptions = searchable && normalizedSearchKeyword
? options.filter((option) => {
const label = option.label.toLowerCase();
const optionValue = option.value.toLowerCase();
return label.includes(normalizedSearchKeyword) || optionValue.includes(normalizedSearchKeyword);
})
: options;
useEffect(() => {
if (!open || isMobile) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [isMobile, open]);
useEffect(() => {
if (!open || !isMobile) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile, open]);
useEffect(() => {
if (!open && searchKeyword) {
setSearchKeyword("");
}
}, [open, searchKeyword]);
const handleSelect = (nextValue: string) => {
if (multiple) {
const currentValues = Array.isArray(value) ? value : [];
const nextValues = currentValues.includes(nextValue)
? currentValues.filter((item) => item !== nextValue)
: [...currentValues, nextValue];
(onChange as (value: string[]) => void)(nextValues);
return;
}
(onChange as (value: string) => void)(nextValue);
setOpen(false);
};
const renderOption = (option: AdaptiveSelectOption) => {
const isSelected = multiple
? selectedValues.includes(option.value)
: option.value === value;
return (
<button
key={`${option.value}-${option.label}`}
type="button"
disabled={option.disabled}
onClick={() => handleSelect(option.value)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-3 text-left text-sm transition-colors",
isSelected
? "bg-violet-600 text-white shadow-sm"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
option.disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
<span className="break-anywhere">{option.label}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
);
};
return (
<div ref={containerRef} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) {
setOpen((current) => !current);
}
}}
className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
>
<span className={selectedValues.length > 0 ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
{selectedLabel}
</span>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
</button>
<AnimatePresence>
{open && !isMobile ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute z-30 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
{searchable ? (
<div className="mb-2">
<input
type="text"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
/>
</div>
) : null}
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
</div>
)}
</div>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence>
{open && isMobile ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
onClick={() => setOpen(false)}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
>
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{sheetTitle || placeholder}</p>
<p className="crm-field-note mt-1">{multiple ? "可多选" : "请选择一个选项"}</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[60vh] space-y-2 overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{searchable ? (
<input
type="text"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
/>
) : null}
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
<div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
</div>
)}
{multiple ? (
<button
type="button"
onClick={() => setOpen(false)}
className="mt-2 w-full rounded-2xl bg-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-500"
>
</button>
) : null}
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
</div>
);
}