import React, { useState, useCallback, useEffect, useRef, forwardRef, useImperativeHandle, } from 'react'; import { Cascader, Input, Space, Button } from 'kts-xui'; import type { CascaderProps } from 'kts-components-antd-x4/es/cascader'; import { DownOutlined, CloseOutlined } from '@ant-design/icons'; export interface CustomCascaderOption { [key: string]: any; } export interface CustomCascaderFieldNames { label?: string; value?: string; children?: string; } export interface CustomCascaderProps extends Omit< CascaderProps, 'value' | 'onChange' | 'options' | 'open' | 'onDropdownVisibleChange' | 'fieldNames' | 'getPopupContainer' > { options?: CustomCascaderOption[]; fieldNames?: CustomCascaderFieldNames; placeholder?: string; onChange?: (value: (string | number)[] | undefined, selectedOptions?: CustomCascaderOption[]) => void; value?: (string | number)[]; onBlur?: (e: React.FocusEvent) => void; } const DROPDOWN_CLASS = 'custom-cascader-dropdown'; const ARROW_CLASS = 'custom-cascader-arrow'; const CLEAR_CLASS = 'custom-cascader-clear'; const CustomCascader = forwardRef( ( { options = [], fieldNames = { label: 'label', value: 'value', children: 'children' }, placeholder = '请选择或输入地址', onChange, value: externalValue, className = '', style = {}, onBlur: onBlurProp, ...restProps }, ref ) => { const [open, setOpen] = useState(false); const [inputText, setInputText] = useState(''); const isComposingRef = useRef(false); const inputRef = useRef(null); const containerRef = useRef(null); const blurTimerRef = useRef(null); const isMenuClickRef = useRef(false); const preventOpenRef = useRef(false); const labelKey = fieldNames.label || 'label'; const valueKey = fieldNames.value || 'value'; const childrenKey = fieldNames.children || 'children'; // ---------- 工具函数 ---------- const getPathLabel = useCallback( (val: (string | number)[] | undefined): string => { if (!val || val.length === 0) return ''; let current = options; const labels: string[] = []; for (const v of val) { const found = current.find((node) => node[valueKey] === v); if (!found) break; labels.push(found[labelKey]?.toString() || ''); current = found[childrenKey] || []; } return labels.length === val.length ? labels.join('') : val[val.length - 1].toString(); }, [options, valueKey, labelKey, childrenKey] ); const findInTree = useCallback( (searchVal: string, nodes: CustomCascaderOption[]): boolean => { if (!searchVal || !nodes) return false; for (const node of nodes) { const nodeLabel = node[labelKey]?.toString() || ''; if (nodeLabel === searchVal) return true; if (node[childrenKey] && findInTree(searchVal, node[childrenKey])) return true; } return false; }, [labelKey, childrenKey] ); // ---------- 同步外部值 ---------- useEffect(() => { if (externalValue && externalValue.length > 0) { const pathLabel = getPathLabel(externalValue); setInputText(pathLabel); } else { setInputText(''); } }, [externalValue, getPathLabel]); // ---------- 清理定时器 ---------- useEffect(() => { return () => { if (blurTimerRef.current) clearTimeout(blurTimerRef.current); }; }, []); // ---------- 核心逻辑 ---------- const processText = useCallback( (text: string) => { if (!text.trim()) { onChange?.(undefined, []); setOpen(false); } else { const matched = findInTree(text, options); const newValue: (string | number)[] = [text]; onChange?.(newValue, [{ [labelKey]: text, [valueKey]: text, isCustom: true }]); setOpen(matched); } }, [options, findInTree, labelKey, valueKey, onChange] ); // ---------- 全局 mousedown 监听 ---------- useEffect(() => { const handleMouseDown = (e: MouseEvent) => { const target = e.target as HTMLElement; const container = containerRef.current; if (!container) return; const inputElement = inputRef.current?.input; const hasFocus = inputElement && document.activeElement === inputElement; // 检查是否点击在级联菜单内 const isMenu = target.closest?.(`.${DROPDOWN_CLASS}`); if (isMenu) { isMenuClickRef.current = true; if (hasFocus) { requestAnimationFrame(() => { inputRef.current?.focus(); }); } return; } // 检查是否点击在自定义内部元素(箭头、清除按钮) const isInternal = target.closest?.(`.${ARROW_CLASS}, .${CLEAR_CLASS}`); if (isInternal) { // 如果是箭头,允许 toggleOpen 处理,不干预焦点 // 如果是清除,也不干预 return; } isMenuClickRef.current = false; if (container.contains(target)) { return; } if (hasFocus) { if (blurTimerRef.current) clearTimeout(blurTimerRef.current); blurTimerRef.current = setTimeout(() => { if (inputElement && document.activeElement === inputElement) { inputRef.current?.blur(); } blurTimerRef.current = null; }, 0); } }; document.addEventListener('mousedown', handleMouseDown); return () => { document.removeEventListener('mousedown', handleMouseDown); }; }, []); // ---------- 事件处理 ---------- const handleInputChange = (e: React.ChangeEvent) => { const text = e.target.value; setInputText(text); if (isComposingRef.current) return; processText(text); }; const handleCompositionStart = () => { isComposingRef.current = true; }; const handleCompositionEnd = (e: React.CompositionEvent) => { isComposingRef.current = false; const text = (e.target as HTMLInputElement).value; processText(text); }; const handleInputFocus = () => { if (preventOpenRef.current) { preventOpenRef.current = false; return; } setOpen(true); }; const handleInputBlur = (e: React.FocusEvent) => { if (isMenuClickRef.current) { isMenuClickRef.current = false; return; } const relatedTarget = e.relatedTarget as HTMLElement | null; if (relatedTarget) { // 如果焦点移向菜单或内部自定义元素(箭头、清除),忽略失焦 const isMenu = relatedTarget.closest?.(`.${DROPDOWN_CLASS}`); const isInternal = relatedTarget.closest?.(`.${ARROW_CLASS}, .${CLEAR_CLASS}`); if (isMenu || isInternal) { return; } } setOpen(false); onBlurProp?.(e); }; const handleCascaderChange = ( selectedValue: (any)[], selectedOptions: CustomCascaderOption[] ) => { const path = selectedOptions.map((opt) => opt[labelKey]?.toString() || '').join(''); setInputText(path); onChange?.(selectedValue, selectedOptions); const lastOption = selectedOptions[selectedOptions.length - 1]; const hasChildren = lastOption?.[childrenKey] && lastOption[childrenKey].length > 0; if (hasChildren) { setOpen(true); setTimeout(() => { inputRef.current?.focus(); }, 0); } else { setOpen(false); preventOpenRef.current = true; setTimeout(() => { inputRef.current?.focus(); }, 0); } }; const handleClear = (e?: React.MouseEvent) => { e?.stopPropagation(); setInputText(''); setOpen(false); onChange?.(undefined, []); // 保持焦点 inputRef.current?.focus(); }; const toggleOpen = () => { if (open) { setOpen(false); // 保持焦点 inputRef.current?.focus(); } else { // 打开弹窗,清除阻止标记,确保 onFocus 能正常工作 preventOpenRef.current = false; setOpen(true); inputRef.current?.focus(); } }; const showClear = externalValue && externalValue.length > 0; useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), blur: () => inputRef.current?.blur(), })); return (
{showClear && ( )} } /> {...restProps} options={options} fieldNames={fieldNames} value={externalValue} onChange={handleCascaderChange} open={open} getPopupContainer={() => document.body} dropdownClassName={DROPDOWN_CLASS} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, pointerEvents: 'none', zIndex: -1, }} showSearch={false} expandIcon={null} />
); } ); CustomCascader.displayName = 'CustomCascader'; export default CustomCascader;