2026-03-26 09:29:55 +00:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-27 09:05:41 +00:00
|
|
|
type AdaptiveSelectBaseProps = {
|
2026-03-26 09:29:55 +00:00
|
|
|
options: AdaptiveSelectOption[];
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
sheetTitle?: string;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
className?: string;
|
2026-04-01 09:24:06 +00:00
|
|
|
searchable?: boolean;
|
|
|
|
|
searchPlaceholder?: string;
|
2026-03-27 09:05:41 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
|
|
|
|
|
multiple?: false;
|
|
|
|
|
value?: string;
|
2026-03-26 09:29:55 +00:00
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-27 09:05:41 +00:00
|
|
|
type AdaptiveSelectMultipleProps = AdaptiveSelectBaseProps & {
|
|
|
|
|
multiple: true;
|
|
|
|
|
value?: string[];
|
|
|
|
|
onChange: (value: string[]) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type AdaptiveSelectProps = AdaptiveSelectSingleProps | AdaptiveSelectMultipleProps;
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
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,
|
2026-04-01 09:24:06 +00:00
|
|
|
searchable = false,
|
|
|
|
|
searchPlaceholder = "请输入关键字搜索",
|
2026-03-27 09:05:41 +00:00
|
|
|
value,
|
|
|
|
|
multiple = false,
|
2026-03-26 09:29:55 +00:00
|
|
|
onChange,
|
|
|
|
|
}: AdaptiveSelectProps) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
2026-04-01 09:24:06 +00:00
|
|
|
const [searchKeyword, setSearchKeyword] = useState("");
|
2026-03-26 09:29:55 +00:00
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const isMobile = useIsMobileViewport();
|
2026-03-27 09:05:41 +00:00
|
|
|
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;
|
2026-04-01 09:24:06 +00:00
|
|
|
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;
|
2026-03-26 09:29:55 +00:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-04-01 09:24:06 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open && searchKeyword) {
|
|
|
|
|
setSearchKeyword("");
|
|
|
|
|
}
|
|
|
|
|
}, [open, searchKeyword]);
|
|
|
|
|
|
2026-03-26 09:29:55 +00:00
|
|
|
const handleSelect = (nextValue: string) => {
|
2026-03-27 09:05:41 +00:00
|
|
|
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);
|
2026-03-26 09:29:55 +00:00
|
|
|
setOpen(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderOption = (option: AdaptiveSelectOption) => {
|
2026-03-27 09:05:41 +00:00
|
|
|
const isSelected = multiple
|
|
|
|
|
? selectedValues.includes(option.value)
|
|
|
|
|
: option.value === value;
|
2026-03-26 09:29:55 +00:00
|
|
|
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,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-03-27 09:05:41 +00:00
|
|
|
<span className={selectedValues.length > 0 ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
|
2026-03-26 09:29:55 +00:00
|
|
|
{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"
|
|
|
|
|
>
|
2026-04-01 09:24:06 +00:00
|
|
|
{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>
|
2026-03-26 09:29:55 +00:00
|
|
|
</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>
|
2026-03-27 09:05:41 +00:00
|
|
|
<p className="crm-field-note mt-1">{multiple ? "可多选" : "请选择一个选项"}</p>
|
2026-03-26 09:29:55 +00:00
|
|
|
</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))]">
|
2026-04-01 09:24:06 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-03-27 09:05:41 +00:00
|
|
|
{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}
|
2026-03-26 09:29:55 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|