File size: 4,010 Bytes
68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | // 自定义下拉选择组件 - MD3 风格
import { useState, useRef, useEffect } from 'react';
export interface SelectOption<T = string> {
value: T;
label: string;
}
interface CustomSelectProps<T = string> {
options: SelectOption<T>[];
value: T;
onChange: (value: T) => void;
label: string;
className?: string;
disabled?: boolean;
placement?: 'top' | 'bottom';
labelLayout?: 'floating' | 'stacked';
}
export function CustomSelect<T = string>({
options,
value,
onChange,
label,
className = '',
disabled = false,
placement = 'bottom',
labelLayout = 'floating'
}: CustomSelectProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find(opt => {
if (typeof opt.value === 'number' && typeof value === 'number') {
return opt.value === value;
}
return opt.value === value;
});
// 点击外部关闭下拉菜单
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const isStacked = labelLayout === 'stacked';
return (
<div
className={`relative ${isStacked ? 'flex flex-col gap-1.5' : ''} ${className}`}
ref={dropdownRef}
>
<label
className={
isStacked
? 'text-xs font-medium text-text-secondary/80'
: 'absolute z-10 left-3 -top-2 px-1.5 bg-bg-primary text-xs font-medium text-text-secondary'
}
>
{label}
</label>
<div className="relative">
{/* 触发按钮 */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className="w-full px-4 py-3.5 pr-10 bg-bg-secondary/50 rounded-2xl text-left text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:bg-bg-secondary/70 transition-colors duration-200 ease-ui cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-secondary/60"
>
<span>{selectedOption?.label}</span>
</button>
{/* 下拉箭头:相对按钮行定位,stacked 标签不参与偏移 */}
<svg
className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary pointer-events-none transition-transform duration-200 ease-move ${
isOpen ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{/* 下拉菜单 */}
{isOpen && (
<div className={`menu-in absolute left-0 right-0 z-50 overflow-hidden rounded-2xl bg-bg-secondary shadow-xl shadow-black/10 ${placement === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}`}>
{options.map((option) => {
const isSelected = typeof option.value === 'number' && typeof value === 'number'
? option.value === value
: option.value === value;
return (
<button
key={String(option.value)}
type="button"
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
className={`w-full px-4 py-3.5 text-left transition-colors duration-150 ease-ui hover:bg-bg-secondary/70 ${
isSelected ? 'bg-bg-secondary/50' : ''
}`}
>
<span className="text-text-primary">{option.label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
|