265 lines
8.2 KiB
TypeScript
Executable File
265 lines
8.2 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Check, ChevronDown, X } from 'lucide-react';
|
|
import { cn } from '@nice/ui/lib/utils';
|
|
import { Button } from '@nice/ui/components/button';
|
|
import { Input } from '@nice/ui/components/input';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@nice/ui/components/popover';
|
|
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@nice/ui/components/command';
|
|
import { useTranslation } from '@nice/i18n';
|
|
|
|
interface DutySelectProps {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
allowClear?: boolean;
|
|
}
|
|
|
|
export function DutySelect({
|
|
value = '',
|
|
onChange,
|
|
placeholder = '请选择或输入职务',
|
|
disabled = false,
|
|
className,
|
|
allowClear = true,
|
|
}: DutySelectProps) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = React.useState(false);
|
|
const [inputValue, setInputValue] = React.useState(value);
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
const isSelectingRef = React.useRef(false);
|
|
// 添加一个ref来跟踪是否是通过点击打开的
|
|
const isOpeningRef = React.useRef(false);
|
|
|
|
// 获取预定义职务选项
|
|
const dutyOptions = React.useMemo(() => {
|
|
const dutyEntries = Object.entries({});
|
|
return dutyEntries.map(([value, _]) => ({
|
|
value,
|
|
label: t(`duty.${value}`),
|
|
}));
|
|
}, [t]);
|
|
|
|
// 同步外部value到内部inputValue
|
|
React.useEffect(() => {
|
|
setInputValue(value);
|
|
}, [value]);
|
|
|
|
// 处理输入值变化
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newValue = e.target.value;
|
|
setInputValue(newValue);
|
|
onChange?.(newValue);
|
|
// 输入时显示下拉选项
|
|
if (!open) {
|
|
setOpen(true);
|
|
}
|
|
};
|
|
|
|
// 处理选项选择
|
|
const handleSelect = (optionLabel: string) => {
|
|
isSelectingRef.current = true; // 标记正在选择
|
|
setInputValue(optionLabel);
|
|
onChange?.(optionLabel);
|
|
|
|
// 延迟重置标记和重新聚焦
|
|
setTimeout(() => {
|
|
isSelectingRef.current = false;
|
|
inputRef.current?.focus(); // 重新聚焦到输入框
|
|
}, 50);
|
|
};
|
|
|
|
// 清除值
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
setInputValue('');
|
|
onChange?.('');
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
// 处理输入框获得焦点 - 显示下拉选项
|
|
const handleInputFocus = () => {
|
|
if (!disabled && dutyOptions.length > 0) {
|
|
// 延迟设置,避免与点击事件冲突
|
|
setTimeout(() => {
|
|
if (!isOpeningRef.current) {
|
|
setOpen(true);
|
|
}
|
|
isOpeningRef.current = false;
|
|
}, 0);
|
|
}
|
|
};
|
|
|
|
// 处理输入框点击
|
|
const handleInputClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!disabled && dutyOptions.length > 0) {
|
|
isOpeningRef.current = true;
|
|
setOpen(true);
|
|
}
|
|
};
|
|
|
|
// 处理输入框失去焦点
|
|
const handleInputBlur = (e: React.FocusEvent) => {
|
|
// 如果正在选择选项,不要关闭菜单
|
|
if (isSelectingRef.current) {
|
|
return;
|
|
}
|
|
|
|
// 延迟关闭,避免点击选项时立即关闭
|
|
setTimeout(() => {
|
|
// 再次检查是否正在选择选项
|
|
if (isSelectingRef.current) {
|
|
return;
|
|
}
|
|
|
|
// 检查焦点是否移动到了下拉选项中
|
|
const activeElement = document.activeElement;
|
|
const popoverElement = document.querySelector('[data-radix-popover-content]');
|
|
|
|
if (!popoverElement?.contains(activeElement)) {
|
|
setOpen(false);
|
|
}
|
|
}, 150);
|
|
};
|
|
|
|
// 处理键盘事件
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (!open) {
|
|
setOpen(true);
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setOpen(false);
|
|
inputRef.current?.blur();
|
|
}
|
|
};
|
|
|
|
// 处理下拉箭头点击
|
|
const handleChevronClick = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (disabled) return;
|
|
|
|
setOpen(!open);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
// 过滤选项(根据输入值进行模糊匹配)
|
|
const filteredOptions = React.useMemo(() => {
|
|
if (!inputValue.trim()) return dutyOptions;
|
|
|
|
return dutyOptions.filter(option =>
|
|
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
|
option.value.toLowerCase().includes(inputValue.toLowerCase())
|
|
);
|
|
}, [dutyOptions, inputValue]);
|
|
|
|
return (
|
|
<div className={cn('relative', className)}>
|
|
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
|
<PopoverTrigger asChild>
|
|
<div className="relative">
|
|
<Input
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
onChange={handleInputChange}
|
|
onFocus={handleInputFocus}
|
|
onClick={handleInputClick} // 添加点击处理
|
|
onBlur={handleInputBlur}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'pr-10',
|
|
disabled && 'cursor-not-allowed'
|
|
)}
|
|
autoComplete="off"
|
|
// 添加这个属性防止输入框点击时关闭 Popover
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
/>
|
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
{allowClear && inputValue && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 hover:bg-transparent cursor-pointer"
|
|
onClick={handleClear}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}}
|
|
tabIndex={2}
|
|
>
|
|
<X className="h-3 w-3 opacity-50 hover:opacity-100" />
|
|
</Button>
|
|
)}
|
|
<div
|
|
role="button"
|
|
tabIndex={-1}
|
|
onClick={handleChevronClick}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}}
|
|
className="flex items-center justify-center p-1 hover:bg-gray-100 rounded cursor-pointer"
|
|
>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-full p-0 max-h-[210px] overflow-y-auto"
|
|
align="start"
|
|
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
onOpenAutoFocus={(e) => e.preventDefault()} // 防止内容获得焦点
|
|
>
|
|
<Command>
|
|
<CommandList>
|
|
{filteredOptions.length === 0 ? (
|
|
<CommandEmpty>
|
|
{inputValue.trim() ? '当前无匹配的职务,可手动输入职务' : '请输入内容搜索职务'}
|
|
</CommandEmpty>
|
|
) : (
|
|
<CommandGroup>
|
|
{filteredOptions.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
onSelect={() => handleSelect(option.label)}
|
|
className={cn(
|
|
"cursor-pointer",
|
|
inputValue === option.label && "bg-accent text-accent-foreground"
|
|
)}
|
|
onMouseDown={(e) => {
|
|
// 防止鼠标按下时输入框失去焦点
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
'mr-2 h-4 w-4',
|
|
inputValue === option.label ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
/>
|
|
{option.label}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|