[Bug] 非缺陷,可关闭。用antd的select做自定义下拉框时,会出现选完选项后,select的onChange还未触发,先触发了onEnd事件,导致选项切换不成功问题
Version
最新版本
Link to Minimal Reproduction
暂无
Steps to Reproduce
自定义下拉选择器,想使用antd的select接入。用官方例子做的修改,官方例子使用的是arco-design/web-react,没有问题,但是antd的select,选完选项就会触发失去焦点事件 `import ReactDom from 'react-dom/client'; import { Select } from 'antd'; import { IEditor } from '@visactor/vtable-editors';
import classNames from 'classnames/bind'; import styles from './SelectEditor.module.less';
const cx = classNames.bind(styles);
export interface ListEditorConfig { values: string[]; mode: 'single' | 'multiple'; options: { value: string; label: string; }[]; }
export class SelectEditor implements IEditor { editorConfig?: ListEditorConfig; constructor(editorConfig: ListEditorConfig) { this.root = null; this.element = null; this.container = null; this.editorConfig = editorConfig; } onStart(editorContext) { const { container, referencePosition, value } = editorContext; this.container = container; this.createElement(value); value && this.setValue(value); (null == referencePosition ? void 0 : referencePosition.rect) && this.adjustPosition(referencePosition.rect); }
createElement(defaultValue) { const div = document.createElement('div'); div.style.position = 'absolute'; div.style.width = '100%'; div.style.boxSizing = 'border-box'; div.style.backgroundColor = '#FFFFFF'; this.container.appendChild(div); const options = this.editorConfig?.options ?? []; this.root = ReactDom.createRoot(div); this.root.render( <div // style={{ width: '100%', height: '100%', backgroundColor: 'red' }} > <Select className={cx('select-wrapper')} // getPopupContainer={(triggerNode) => triggerNode.parentNode} placeholder="请选择供应商名称" defaultValue={defaultValue} onChange={(value) => { console.log(' onChangeValue ==', value); this.currentValue = value; }} showSearch optionFilterProp="label" options={options} onBlur={() => { console.log('触发了失去焦点事件'); }} ></Select> {/* <ArcoDesign.Select placeholder="Select city" defaultValue={defaultValue} onChange={(value) => { this.currentValue = value; }} > {options.map((option, index) => ( <ArcoDesign.Select.Option key={option} value={option} className="arco-select-vtable"> {option} </ArcoDesign.Select.Option> ))} </ArcoDesign.Select> */} ); this.element = div; }
getValue() { console.log('getValue', this.currentValue); return this.currentValue; }
setValue(value) { console.log('setValue==', value); this.currentValue = value; }
adjustPosition(rect) { if (this.element) { ((this.element.style.top = rect.top + 'px'), (this.element.style.left = rect.left + 'px'), (this.element.style.width = rect.width + 'px'), (this.element.style.height = rect.height + 'px')); } }
onEnd() { console.log('onEnd'); this.container.removeChild(this.element); }
isEditorElement(target) { // cascader创建时时在cavas后追加一个dom,而popup append在body尾部。不论popup还是dom,都应该被认为是点击到了editor区域 return this.element.contains(target) || this.isClickPopUp(target); }
isClickPopUp(target) { console.log('targer==', target); while (target) { if (target.classList && target.classList.contains('arco-select-vtable')) { return true; } // 如果到达了DOM树的顶部,则停止搜索 target = target.parentNode; } // 如果遍历结束也没有找到符合条件的父元素,则返回false return false; } } `
Current Behavior
选完选项就触发了失去焦点和onEnd
Expected Behavior
想知道为什么会提前触发了onEnd,在getValue前
Environment
- OS:
- Browser:
- Framework:
Any additional comments?
No response
@sunshineLing 建个codesanbox复现下吧
我贴一下我自己的实现, 代码目前运行正常, 暂时没发现什么问题:
import { Select } from 'antd';
import { createRoot } from 'react-dom/client';
import type { IEditor, EditContext } from '@visactor/vtable-editors';
type Datum = {
value: string | number;
label: string;
};
type Config = {
// 数据
data: Datum[];
// 下拉列表的类名
selectClassName?: string;
// 下拉选择器展开的下拉菜单的容器的类名
popupClassName?: string;
// 下拉选择器展开的下拉菜单的容器的关闭延时,单位 ms
popupCloseDelay?: number;
};
/**
* 下拉选择器编辑器
* 参考链接:
* https://visactor.io/vtable/demo-react/functional-components/arco-select-editor?version=1.19.4
* https://visactor.io/vtable/demo/component/dropdown?version=1.19.4
* 使用示例:
const options = [
{
value: '1',
label: 'Beijing',
},
{
value: '2',
label: 'Shanghai',
},
{
value: '3',
label: 'Guangzhou',
},
];
const optionsMap = options.reduce((acc, cur) => {
acc[cur.value] = cur;
return acc;
}, {});
const columns: ColumnsDefine = [
// ...
{
// ...
editor: new SelectEditor({ data: options }),
// 显示层:把 value -> label
fieldFormat(record) {
const v = record?.xxx;
return optionsMap[v]?.label ?? v;
},
// ...
}
// ...
*/
export class SelectEditor implements IEditor {
private data: Datum[] = [];
private root: ReturnType<typeof createRoot> | null = null;
private element: HTMLDivElement | null = null;
private container: HTMLElement | null = null;
private endEdit: (() => void) | null = null;
private currentValue: string | number = '';
private popupClassName: string;
private selectClassName: string;
private styleId: string = 'vtable-select-editor-styleSheet';
private popupCloseDelay: number = 400;
// 为“等待下拉收起动画”增加的内部状态
private closingTimer: number | null = null;
private endScheduled: boolean = false;
private popupCleanup: (() => void) | null = null;
constructor(config: Config) {
const { data, popupClassName, selectClassName } = config;
this.data = data;
this.popupClassName = popupClassName ?? 'vtable-select-editor-popup';
this.selectClassName = selectClassName ?? 'vtable-select-editor';
}
onStart(editorContext: EditContext) {
const { container, referencePosition, value, endEdit } = editorContext;
this.container = container;
this.endEdit = endEdit;
this.createElement(value);
if (value) {
this.setValue(value);
}
if (referencePosition.rect) {
this.adjustPosition(referencePosition.rect as DOMRect);
}
}
createElement(defaultValue: string | number) {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.width = '100%';
div.style.padding = '0'; // 填满单元格
div.style.boxSizing = 'border-box';
// 之前的深色背景会露出黑块,这里设为透明
div.style.backgroundColor = 'transparent';
this.container?.appendChild(div);
this.root = createRoot(div);
// 注入一次性的样式,保证 Select 充满单元格且垂直居中
const STYLE_ID = this.styleId;
if (!document.getElementById(STYLE_ID)) {
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.innerHTML = `
.${this.selectClassName}, .${this.selectClassName} .ant-select-selector { height: 100%; }
.${this.selectClassName} .ant-select-selector { display: flex; align-items: center; padding: 0 8px; }
`;
document.head.appendChild(styleEl);
}
this.root.render(
<div style={{ width: '100%', height: '100%' }}>
<Select
className={this.selectClassName}
style={{ width: '100%' }}
// 关键:给弹层一个 class,isEditorElement 用它判断是否仍在编辑器内
classNames={{
popup: {
root: this.popupClassName,
},
}}
// 选中即提交并结束编辑(VTable 会在 onEnd 前读取 getValue)
defaultValue={defaultValue}
onChange={(value) => {
// 关键:内部存 value,显示交给列的 fieldFormat
this.currentValue = value;
// 等待下拉的关闭动画结束后再结束编辑
this.waitPopupCloseThenEnd();
}}
options={this.data}
/>
</div>,
);
this.element = div;
}
getValue() {
return this.currentValue;
}
setValue(value: string | number) {
this.currentValue = value;
}
adjustPosition(rect: DOMRect) {
if (this.element) {
this.element.style.top = `${rect.top}px`;
this.element.style.left = `${rect.left}px`;
this.element.style.width = `${rect.width}px`;
this.element.style.height = `${rect.height}px`;
}
}
onEnd() {
// 结束时清理监听与定时器
this.cleanupPopupListeners();
this.endScheduled = false;
if (this.root) {
this.root.unmount?.();
}
this.container?.removeChild(this.element as Node);
this.element = null;
this.root = null;
}
isEditorElement(target: HTMLElement) {
// 允许点击编辑器本体或下拉弹层时继续处于编辑态
return this.element?.contains(target) || this.isClickPopUp(target);
}
isClickPopUp(target: HTMLElement) {
let finalTarget = target;
while (finalTarget) {
if (finalTarget.classList && finalTarget.classList.contains(this.popupClassName)) {
return true;
}
// 如果到达了DOM树的顶部,则停止搜索
finalTarget = finalTarget.parentElement as HTMLElement;
}
// 如果遍历结束也没有找到符合条件的父元素,则返回false
return false;
}
// 等待 antd Select 下拉层动画结束再 endEdit
private waitPopupCloseThenEnd() {
if (this.endScheduled) return; // 防抖,避免重复
// 通过你设置的类名精确找到下拉弹层
const popup = document.querySelector(`.${this.popupClassName}`);
// 清理旧的监听与定时器(防止重复绑定)
this.cleanupPopupListeners();
const onAnimationDone = () => {
this.cleanupPopupListeners();
this.safeEndEdit();
};
if (popup) {
// 双保险:同时监听 animation 和 transition
popup.addEventListener('animationend', onAnimationDone, { once: true });
popup.addEventListener('transitionend', onAnimationDone, { once: true });
// 兜底超时(防止某些主题/浏览器未触发动画事件)
this.closingTimer = window.setTimeout(onAnimationDone, this.popupCloseDelay);
// 保存清理函数
this.popupCleanup = () => {
popup.removeEventListener('animationend', onAnimationDone);
popup.removeEventListener('transitionend', onAnimationDone);
};
} else {
// 没找到弹层 DOM,直接用兜底延时
this.closingTimer = window.setTimeout(onAnimationDone, this.popupCloseDelay);
}
}
private safeEndEdit() {
if (this.endScheduled) return;
this.endScheduled = true;
if (this.endEdit) {
this.endEdit();
}
}
private cleanupPopupListeners() {
if (this.popupCleanup) {
this.popupCleanup();
this.popupCleanup = null;
}
if (this.closingTimer) {
clearTimeout(this.closingTimer);
this.closingTimer = null;
}
}
}