VTable icon indicating copy to clipboard operation
VTable copied to clipboard

[Bug] 非缺陷,可关闭。用antd的select做自定义下拉框时,会出现选完选项后,select的onChange还未触发,先触发了onEnd事件,导致选项切换不成功问题

Open sunshineLing opened this issue 5 months ago • 2 comments

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 avatar Jul 16 '25 11:07 sunshineLing

@sunshineLing 建个codesanbox复现下吧

fangsmile avatar Jul 17 '25 09:07 fangsmile

我贴一下我自己的实现, 代码目前运行正常, 暂时没发现什么问题:

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;
    }
  }
}

SilentFlute avatar Sep 17 '25 11:09 SilentFlute