日期选择器 新增非范围日期选择组件

This commit is contained in:
2025-07-16 22:11:27 +08:00
parent d10309db99
commit b30e4c92c8
+175
View File
@@ -0,0 +1,175 @@
import React, { useState, useEffect, useRef } from 'react';
type Option = {
value: string;
label: string;
children?: Option[];
};
type MultiCascaderProps = {
options: Option[];
defaultValue?: string[];
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
};
// 获取所有叶子节点的值
const getAllLeafValues = (option: Option): string[] => {
if (!option.children || option.children.length === 0) {
return [option.value];
}
return option.children.flatMap(getAllLeafValues);
};
// 检查节点是否所有子节点都被选中
const isAllChildrenChecked = (option: Option, selected: string[]): boolean => {
if (!option.children || option.children.length === 0) {
return selected.includes(option.value);
}
// 对于有子节点的节点,检查其所有叶子节点是否都被选中
const leafValues = getAllLeafValues(option);
return leafValues.every(value => selected.includes(value));
};
// 检查节点是否有部分子节点被选中
const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => {
if (!option.children || option.children.length === 0) {
return selected.includes(option.value);
}
// 对于有子节点的节点,检查其叶子节点是否有部分被选中
const leafValues = getAllLeafValues(option);
return leafValues.some(value => selected.includes(value));
};
const MultiCascader: React.FC<MultiCascaderProps> = ({
options,
defaultValue = [],
value,
onChange,
placeholder = '请选择'
}) => {
const [visible, setVisible] = useState(false);
const [selected, setSelected] = useState<string[]>(value ?? defaultValue);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
// 当外部 value 变化时,同步内部状态
useEffect(() => {
if (value !== undefined) {
setSelected(value);
}
}, [value]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setVisible(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleItemCheck = (option: Option, checked: boolean) => {
const leafValues = getAllLeafValues(option);
let newSelected: string[];
if (checked) {
newSelected = Array.from(new Set([...selected, ...leafValues]));
} else {
newSelected = selected.filter(v => !leafValues.includes(v));
}
setSelected(newSelected);
onChange?.(newSelected);
};
const getDisplayText = () => {
if (selected.length === 0) return placeholder;
return `已选择 ${selected.length}`;
};
const toggleExpand = (key: string) => {
setExpandedKeys(prev => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
};
const renderOption = (option: Option, level: number = 0) => {
const allChecked = isAllChildrenChecked(option, selected);
const someChecked = isSomeChildrenChecked(option, selected);
const hasChildren = option.children && option.children.length > 0;
const isExpanded = expandedKeys.has(option.value);
return (
<div key={option.value} className={level > 0 ? "pl-4" : ""}>
<div className="flex items-center py-1">
<input
type="checkbox"
className="form-checkbox h-4 w-4 text-[var(--color-primary,#00684a)] rounded border-gray-300 focus:ring-[var(--color-primary,#00684a)]"
checked={allChecked}
ref={el => { if (el) el.indeterminate = !allChecked && someChecked; }}
onChange={e => handleItemCheck(option, e.target.checked)}
id={`cascader-${option.value}`}
/>
<label htmlFor={`cascader-${option.value}`} className="ml-2 text-sm flex-1">
{option.label}
</label>
{hasChildren && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600"
onClick={(e) => {
e.stopPropagation();
toggleExpand(option.value);
}}
>
{isExpanded ? '▼' : '▶'}
</button>
)}
</div>
{hasChildren && isExpanded && (
<div className="ml-4 border-l border-gray-200 pl-2">
{option.children?.map(child => (
<React.Fragment key={child.value}>
{renderOption(child, level + 1)}
</React.Fragment>
))}
</div>
)}
</div>
);
};
return (
<div ref={containerRef} className="relative inline-block w-full">
<div
className="border border-gray-300 rounded px-3 py-2 cursor-pointer bg-white flex justify-between items-center"
onClick={() => setVisible(!visible)}
>
<span className={selected.length === 0 ? 'text-gray-400' : ''}>
{getDisplayText()}
</span>
<span className="text-gray-400">
<i className={visible ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"}></i>
</span>
</div>
{visible && (
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full max-h-100 overflow-y-auto">
<div className="p-2">
{options.map(option => renderOption(option))}
</div>
</div>
)}
</div>
);
};
export default MultiCascader;