浏览代码

feat: DatePicker support onClickOutside, open, close methods (#1470)

* feat: DatePicker support onClickOutside, open, close methods

* feat: DatePicker add focus and blur methods #566

* feat: export BaseDatePicker in DatePicker entry #566

---------

Co-authored-by: shijia.me <[email protected]>
走鹃 2 年之前
父节点
当前提交
685ea938f3

+ 43 - 2
content/input/datepicker/index-en-US.md

@@ -915,17 +915,58 @@ function Demo() {
 | type               | Type, optional value: "date", "dateRange", "dateTime", "dateTimeRange", "month"                                                                                                           | string                                                                                                                                                                                                    | 'date'                                                                                |  |
 | value              | Controlled value                                                                                                                                                                          | ValueType                                                                                                                                                     |                                                                                       |                           |
 | weekStartsOn       | Take the day of the week as the first day of the week, 0 for Sunday, 1 for Monday, and so on.                                                                                             | number                                                                                                                                                                                                    | 0                                                                                     |                           |
-| onBlur             | Callback when focus is lost                                                                                                                                                               | (event) => void                                                                                                                                                                                     | () => {}                                                                              | **1.0.0**                 |
+| onBlur | Callback when focus is lost. It is not recommended to use this API in range selection | (event) => void | () => {} | **1.0.0** |
 | onCancel           | Cancel the callback when selected, enter the reference as the value of the last confirmed selection, only `type` equals "dateTime"or "dateTimeRange" and `needConfirm` equals true        | <ApiType detail='(date: DateType, dateStr: StringType) => void'>(date, dateString) => void</ApiType>                                                              |                                                                                       | **0.18.0**                |
 | onChange           | A callback when the value changes |   <ApiType detail='(date: DateType, dateString: StringType) => void'>(date, dateString) => void</ApiType>       |                                                                                       |                           |
 | onClear            | A callback when click the clear button                                                                                                                                                    | (event) => void                                                                                                                                                                                     | () => {}                                                                              | **1.16.0**           |
+| onClickOutSide    | When the pop-up layer is in a display state, click the non-popup layer and trigger callback | () => void | () => {} | **2.31.0** |
 | onConfirm          | Confirm the callback at the time of selection, enter the reference as the value of the current selection, only `type` equals "dateTime" or "dateTimeRange" and `needConfirm` equals true  |  <ApiType detail='(date: DateType, dateStr: StringType) => void'>(date, dateString) => void</ApiType>|                                                                                       | **0.18.0**                |
-| onFocus            | Callback when focus is obtained                                                                                                                                                           | (event) => void                                                                                                                                                                                     | () => {}                                                                              | **1.0.0**                 |
+| onFocus | Callback when focus is obtained. It is not recommended to use this API in range selection  | (event) => void | () => {} | **1.0.0** |
 | onOpenChange       | Callback when popup open or close                                                                                                                                 | (isOpen) => void                                                                                                                                                                                 |                                                                                       |                           |
 | onPanelChange      | Callback when the year or date of the panel is switched|  <ApiType detail='(date: DateType \| DateType[], dateStr: StringType \| StringType[])=>void'>(date, dateStr) => void</ApiType>  |  |**1.28.0**|
 | onPresetClick      | Callback when click preset button                                                                          | <ApiType detail='(item: Object, e: Event) => void'>(item, e) => void</ApiType>       |   **1.24.0**                           |
 | yearAndMonthOpts | Other parameters that can be transparently passed to the year-month selector, see details in [ScrollList#API](/zh-CN/show/scrolllist#ScrollItem)|  | object | **2.22.0** |
 
+## Methods
+
+| Methods | Description                                       | Version |
+|---------|---------------------------------------------------|---------|
+| open    | The dropdown can be manually opened when calling  | 2.31.0  |
+| close   | The dropdown can be manually closed when calling  | 2.31.0  |
+| focus   | The input box can be manually focused when called | 2.31.0  |
+| blur    | The input box can be manually blurred when called | 2.31.0  |
+
+```jsx live=true
+import React, { useRef } from 'react';
+import { DatePicker, Space, Button } from '@douyinfe/semi-ui';
+// import type { BaseDatePicker } from '@douyinfe/semi-ui/lib/es/datePicker';
+
+function Demo() {
+    const ref = useRef();
+    // Typescript
+    // const ref = useRef<BaseDatePicker>();
+    // Why not import the DatePicker exported by the entry? -> The entry component is a forwardRef component, and the ref is transparently passed to this component
+
+    const handleClickOutside = () => {
+        console.log('click outside');
+    };
+
+    return (
+        <Space vertical align={'start'}>
+            <Space>
+                <Button onClick={() => ref.current.open()}>open</Button>
+                <Button onClick={() => ref.current.close()}>close</Button>
+                <Button onClick={() => ref.current.focus()}>focus</Button>
+                <Button onClick={() => ref.current.blur()}>blur</Button>
+            </Space>
+            <div>
+                <DatePicker type="dateTime" ref={ref} onClickOutSide={handleClickOutside} />
+            </div>
+        </Space>
+    );
+}
+```
+
 
 ## Interface Define
 

+ 44 - 2
content/input/datepicker/index.md

@@ -884,18 +884,60 @@ function Demo() {
 | value | 受控的值 | ValueType |  |  |
 | weekStartsOn | 以周几作为每周第一天,0 代表周日,1 代表周一,以此类推 | number | 0 |  |
 | zIndex | 弹出面板的 zIndex | number | 1030 |  |
-| onBlur | 失去焦点时的回调 | (e: event) => void | () => {} | **1.0.0** |
+| onBlur | 失去焦点时的回调,范围选择时不推荐使用 | (e: event) => void | () => {} | **1.0.0** |
 | onCancel | 取消选择时的回调,入参为上次确认选择的值,仅 type="dateTime"\|"dateTimeRange" 且 needConfirm=true 时有效。<br/>0.x版本入参顺序与新版有所不同 | <ApiType detail='(date: DateType, dateStr: StringType) => void'>(date, dateString) => void</ApiType> |  | **0.18.0** |
 | onChange | 值变化时的回调。<br/>0.x版本入参顺序与新版有所不同 | <ApiType detail='(date: DateType, dateString: StringType) => void'>(date, dateString) => void</ApiType> |  |  |
 | onChangeWithDateFirst | 0.x 中 onChange(string, Date), 1.0 后(Date, string)。此开关设为 false 时,入参顺序将与 0.x 版本保持一致 | boolean | true | **1.0.0** |
 | onClear | 点击 clear 按钮时触发 | (e: event) => void | () => {} | **1.16.0** |
+| onClickOutSide | 当弹出层处于展示状态,点击非弹出层、触发器的回调 | () => void | () => {} | **2.31.0** |
 | onConfirm | 确认选择时的回调,入参为当前选择的值,仅 type="dateTime"\|"dateTimeRange" 且 needConfirm=true 时有效。<br/>0.x版本入参顺序与新版有所不同 | <ApiType detail='(date: DateType, dateStr: StringType) => void'>(date, dateString) => void</ApiType>|  | **0.18.0** |
-| onFocus | 获得焦点时的回调 | (e: event) => void | () => {} | **1.0.0** |
+| onFocus | 获得焦点时的回调,范围选择时不推荐使用 | (e: event) => void | () => {} | **1.0.0** |
 | onOpenChange | 面板显示或隐藏状态切换的回调 | <ApiType detail='(isOpen: boolean) => void'>(isOpen) => void</ApiType> |  |  |
 | onPanelChange | 切换面板的年份或者日期时的回调 | <ApiType detail='(date: DateType \| DateType[], dateStr: StringType \| StringType[])=>void'>(date, dateStr) => void</ApiType> | function | **1.28.0** |
 | onPresetClick | 点击快捷选择按钮的回调 | <ApiType detail='(item: Object, e: Event) => void'>(item, e) => void</ApiType> | () => {}  | **1.24.0** |
 | yearAndMonthOpts | 其他可以透传给年月选择器的参数,详见 [ScrollList#API](/zh-CN/show/scrolllist#ScrollItem)|  | object | **2.20.0** |
 
+## Methods
+
+| 方法  | 说明                       | 类型                                             | 版本   |
+|-------|--------------------------|--------------------------------------------------|--------|
+| open  | 调用时可以手动展开下拉列表 | () => void                                       | 2.31.0 |
+| close | 调用时可以手动关闭下拉列表 | () => void                                       | 2.31.0 |
+| focus | 调用时可以手动聚焦输入框   | (focusType?: 'rangeStart' \| 'rangeEnd') => void | 2.31.0 |
+| blur  | 调用时可以手动失焦输入框   | () => void                                       | 2.31.0 |
+
+```jsx live=true
+import React, { useRef } from 'react';
+import { DatePicker, Space, Button } from '@douyinfe/semi-ui';
+// import type { BaseDatePicker } from '@douyinfe/semi-ui/lib/es/datePicker';
+
+function Demo() {
+    const ref = useRef();
+    // Typescript 写法
+    // const ref = useRef<BaseDatePicker>();
+    // 为什么不引用入口导出的 DatePicker?-> 入口组件是个 forwardRef 组件,ref 透传到了这个组件上
+
+
+    const handleClickOutside = () => {
+        console.log('click outside');
+    };
+
+    return (
+        <Space vertical align={'start'}>
+            <Space>
+                <Button onClick={() => ref.current.open()}>open</Button>
+                <Button onClick={() => ref.current.close()}>close</Button>
+                <Button onClick={() => ref.current.focus()}>focus</Button>
+                <Button onClick={() => ref.current.blur()}>blur</Button>
+            </Space>
+            <div>
+                <DatePicker type="dateTime" ref={ref} onClickOutSide={handleClickOutside} />
+            </div>
+        </Space>
+    );
+}
+```
+
 ## 类型定义
 
 ```typescript

+ 63 - 0
cypress/integration/datePicker.spec.js

@@ -665,4 +665,67 @@ describe('DatePicker', () => {
         cy.get('.semi-datepicker-month-grid-left .semi-datepicker-day').contains('20').click();
         cy.get('.semi-datepicker-navigation-month').contains("2019年 8月");
     });
+
+    it('test clickOutSide', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=datepicker--feat-on-click-outside&args=&viewMode=story', {
+            onBeforeLoad(win) {
+                cy.stub(win.console, 'log').as('consoleLog');
+            },
+        });
+        cy.get('.semi-input').eq(0).click();
+        cy.get('.semi-datepicker-footer').click();
+        cy.get('body').click();
+        cy.get('@consoleLog').should('be.calledOnce');
+    });
+
+    it('test open & close', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=datepicker--feat-ref-open&args=&viewMode=story');
+        cy.get('button').contains('open').eq(0).click();
+        cy.get('.semi-popover .semi-datepicker');
+        cy.get('button').contains('close').eq(0).click();
+        cy.get('.semi-popover .semi-datepicker').should('not.exist');
+    });
+
+    it('test needConfirm + close', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=datepicker--fix-need-confirm-in-tabs&args=&viewMode=story');
+        cy.get('.semi-input').eq(0).click();
+        cy.get('.semi-popover .semi-datepicker');
+        cy.get('.semi-tabs-tab').contains('快速起步').click();
+        cy.get('.semi-popover .semi-datepicker').should('not.exist');
+    });
+
+    it('test focus + blur + date type', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=datepicker--feat-ref-focus&args=&viewMode=story');
+        cy.get('[data-cy=single] .semi-button').contains('focus').eq(0).click();
+        cy.get('[data-cy=single] .semi-input-wrapper-focus');
+        cy.get('[data-cy=single] input').should('be.focused');
+        cy.get('[data-cy=single] .semi-button').contains('blur').eq(0).click();
+        cy.get('[data-cy=single] .semi-input-wrapper-focus').should('not.exist');
+        cy.get('[data-cy=single] input').should('not.be.focused');
+    });
+
+    it('test focus + blur + dateRange type', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=datepicker--feat-ref-focus&args=&viewMode=story');
+        cy.get('[data-cy=range] .semi-button').contains('focus default').click();
+        cy.get('[data-cy=range] .semi-datepicker-range-input-wrapper-start .semi-input-wrapper-focus');
+        cy.get('[data-cy=range] .semi-datepicker-range-input-wrapper-start input').should('be.focused');
+        cy.get('[data-cy=range] .semi-button').contains('focus end').click();
+        cy.get('[data-cy=range] .semi-datepicker-range-input-wrapper-end .semi-input-wrapper-focus');
+        cy.get('[data-cy=range] .semi-datepicker-range-input-wrapper-end input').should('be.focused');
+        cy.get('[data-cy=range] .semi-button').contains('blur').eq(0).click();
+        cy.get('[data-cy=range] .semi-input-wrapper-focus').should('not.exist');
+        cy.get('[data-cy=range] input').eq(0).should('not.be.focused');
+        cy.get('[data-cy=range] input').eq(1).should('not.be.focused');
+    });
+
+    it('test focus + blur + inset type', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=datepicker--feat-ref-focus&args=&viewMode=story');
+        cy.get('[data-cy=inset] .semi-button').contains('focus start + open').click();
+        cy.get('.semi-datepicker-inset-input-wrapper input').eq(0).should('be.focused');
+        cy.get('[data-cy=inset] .semi-button').contains('focus end + open').click({ force: true });
+        cy.get('.semi-datepicker-inset-input-wrapper input').eq(1).should('be.focused');
+        cy.get('[data-cy=inset] .semi-button').contains('blur + close').eq(0).click({ force: true });
+        cy.get('[data-cy=inset] input').eq(0).should('not.be.focused');
+        cy.get('[data-cy=inset] input').eq(1).should('not.be.focused');
+    });
 });

+ 117 - 69
packages/semi-foundation/datePicker/foundation.ts

@@ -109,13 +109,11 @@ export interface EventHandlerProps {
     onPanelChange?: OnPanelChangeType;
     onConfirm?: OnConfirmType;
     // properties below need overwrite
-    // onBlur?: React.MouseEventHandler<HTMLInputElement>;
     onBlur?: (e: any) => void;
-    // onClear?: React.MouseEventHandler<HTMLDivElement>;
     onClear?: (e: any) => void;
-    // onFocus?: React.MouseEventHandler<HTMLInputElement>;
     onFocus?: (e: any, rangType: RangeType) => void;
-    onPresetClick?: OnPresetClickType
+    onPresetClick?: OnPresetClickType;
+    onClickOutSide?: () => void
 }
 
 export interface DatePickerFoundationProps extends ElementProps, RenderProps, EventHandlerProps {
@@ -190,7 +188,7 @@ export interface DatePickerFoundationState {
 export { Type, DateInputFoundationProps };
 
 export interface DatePickerAdapter extends DefaultAdapter<DatePickerFoundationProps, DatePickerFoundationState> {
-    togglePanel: (panelShow: boolean) => void;
+    togglePanel: (panelShow: boolean, cb?: () => void) => void;
     registerClickOutSide: () => void;
     unregisterClickOutSide: () => void;
     notifyBlur: DatePickerFoundationProps['onBlur'];
@@ -212,7 +210,10 @@ export interface DatePickerAdapter extends DefaultAdapter<DatePickerFoundationPr
     isEventTarget: (e: any) => boolean;
     updateInsetInputValue: (insetInputValue: InsetInputValue) => void;
     setInsetInputFocus: () => void;
-    setTriggerDisabled: (disabled: boolean) => void
+    setTriggerDisabled: (disabled: boolean) => void;
+    setInputFocus: () => void;
+    setInputBlur: () => void;
+    setRangeInputBlur: () => void
 }
  
 
@@ -223,6 +224,7 @@ export interface DatePickerAdapter extends DefaultAdapter<DatePickerFoundationPr
  */
 export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapter> {
 
+    clickConfirmButton: boolean;
     constructor(adapter: DatePickerAdapter) {
         super({ ...adapter });
     }
@@ -251,8 +253,7 @@ export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapt
         const result = this.parseWithTimezone(_value, timeZone, prevTimeZone);
         this._adapter.updatePrevTimezone(prevTimeZone);
         // reset input value when value update
-        this._adapter.updateInputValue(null);
-        this._adapter.updateInsetInputValue(null);
+        this.clearInputValue();
         this._adapter.updateValue(result);
         this.resetCachedSelectedValue(result);
         this.initRangeInputFocus(result);
@@ -327,7 +328,7 @@ export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapt
 
     destroy() {
         // Ensure that event listeners will be uninstalled and users may not trigger closePanel
-        // this._adapter.togglePanel(false);
+        this._adapter.togglePanel(false);
         this._adapter.unregisterClickOutSide();
     }
 
@@ -344,45 +345,46 @@ export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapt
     openPanel() {
         if (!this.getProp('disabled')) {
             if (!this._isControlledComponent('open')) {
-                this._adapter.togglePanel(true);
-                this._adapter.registerClickOutSide();
+                this.open();
             }
             this._adapter.notifyOpenChange(true);
         }
     }
 
     /**
+     * @deprecated
      * do these side effects when type is dateRange or dateTimeRange
      *   1. trigger input blur, if input value is invalid, set input value and state value to previous status
      *   2. set cachedSelectedValue using given dates(in needConfirm mode)
      *      - directly closePanel without click confirm will set cachedSelectedValue to state value
      *      - select one date(which means that the selection value is incomplete) and click confirm also set cachedSelectedValue to state value
      */
-    rangeTypeSideEffectsWhenClosePanel(inputValue: string, willUpdateDates: Date[]) {
-        if (this._isRangeType()) {
-            this._adapter.setRangeInputFocus(false);
-            /**
-             * inputValue is string when it is not disabled or can't parsed
-             * when inputValue is null, picker value will back to last selected value
-             */
-            this.handleInputBlur(inputValue);
-            this.resetCachedSelectedValue(willUpdateDates);
-        }
-    }
+    // rangeTypeSideEffectsWhenClosePanel(inputValue: string, willUpdateDates: Date[]) {
+    //     if (this._isRangeType()) {
+    //         this._adapter.setRangeInputFocus(false);
+    //         /**
+    //          * inputValue is string when it is not disabled or can't parsed
+    //          * when inputValue is null, picker value will back to last selected value
+    //          */
+    //         this.handleInputBlur(inputValue);
+    //         this.resetCachedSelectedValue(willUpdateDates);
+    //     }
+    // }
 
     /**
+     * @deprecated
      * clear input value when selected date is not confirmed
      */
-    needConfirmSideEffectsWhenClosePanel(willUpdateDates: Date[] | null | undefined) {
-        if (this._adapter.needConfirm() && !this._isRangeType()) {
-            /**
-             * if `null` input element will show `cachedSelectedValue` formatted value(format in DateInput render)
-             * if `` input element will show `` directly
-             */
-            this._adapter.updateInputValue(null);
-            this.resetCachedSelectedValue(willUpdateDates);
-        }
-    }
+    // needConfirmSideEffectsWhenClosePanel(willUpdateDates: Date[] | null | undefined) {
+    //     if (this._adapter.needConfirm() && !this._isRangeType()) {
+    //         /**
+    //          * if `null` input element will show `cachedSelectedValue` formatted value(format in DateInput render)
+    //          * if `` input element will show `` directly
+    //          */
+    //         this._adapter.updateInputValue(null);
+    //         this.resetCachedSelectedValue(willUpdateDates);
+    //     }
+    // }
 
     /**
      * clear inset input value when close panel
@@ -421,17 +423,92 @@ export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapt
         const { value } = this._adapter.getStates();
         const willUpdateDates = isNullOrUndefined(dates) ? value : dates;
         if (!this._isControlledComponent('open')) {
-            this._adapter.togglePanel(false);
-            this._adapter.unregisterClickOutSide();
+            this.close();
+        } else {
+            this.resetInnerSelectedStates(willUpdateDates);
         }
-        // range type picker, closing panel requires the following side effects
-        this.rangeTypeSideEffectsWhenClosePanel(inputValue, willUpdateDates as Date[]);
-        this.needConfirmSideEffectsWhenClosePanel(willUpdateDates as Date[]);
-        this.clearInsetInputValue();
         this._adapter.notifyOpenChange(false);
+    }
+
+    open() {
+        this._adapter.togglePanel(true);
+        this._adapter.registerClickOutSide();
+    }
+
+    close() {
+        this._adapter.togglePanel(false, () => this.resetInnerSelectedStates());
+        this._adapter.unregisterClickOutSide();
+    }
+
+    focus(focusType?: Exclude<RangeType, false>) {
+        if (this._isRangeType()) {
+            const rangeInputFocus = focusType ?? 'rangeStart';
+            this._adapter.setRangeInputFocus(rangeInputFocus);
+        } else {
+            this._adapter.setInputFocus();
+        }
+    }
+
+    blur() {
+        if (this._isRangeType()) {
+            this._adapter.setRangeInputBlur();
+        } else {
+            this._adapter.setInputBlur();
+        }
+    }
+
+    /**
+     * reset cachedSelectedValue, inputValue when close panel
+     */
+    resetInnerSelectedStates(willUpdateDates?: Date[]) {
+        const { value } = this._adapter.getStates();
+        const needResetCachedSelectedValue = !this.isCachedSelectedValueValid(willUpdateDates) || this._adapter.needConfirm() && !this.clickConfirmButton;
+        if (needResetCachedSelectedValue) {
+            this.resetCachedSelectedValue(value);
+        }
+        this.resetFocus();
+        this.clearInputValue();
+        this.clickConfirmButton = false;
+    }
+
+    resetFocus(e?: any) {
+        this._adapter.setRangeInputFocus(false);
         this._adapter.notifyBlur(e);
     }
 
+    /**
+     * cachedSelectedValue can be `(Date|null)[]` or `null`
+     */
+    isCachedSelectedValueValid(dates: Date[]) {
+        const cachedSelectedValue = dates || this._adapter.getState('cachedSelectedValue');
+        const { type } = this._adapter.getProps();
+        let isValid = true;
+        switch (true) {
+            case type === 'dateRange':
+            case type === 'dateTimeRange':
+                if (!this._isRangeValueComplete(cachedSelectedValue)) {
+                    isValid = false;
+                }
+                break;
+            default:
+                const value = cachedSelectedValue?.filter(item => item);
+                if (!(Array.isArray(value) && value.length)) {
+                    isValid = false;
+                }
+                break;
+        }
+        return isValid;
+    }
+
+    /**
+     * 将输入框内容置空
+     */
+    clearInputValue() {
+        this._adapter.updateInputValue(null);
+        this._adapter.updateInsetInputValue(null);
+    }
+
+
     /**
      * clear range input focus when open is controlled
      * fixed github 1375
@@ -519,38 +596,8 @@ export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapt
      * @param {String} input
      * @param {Event} e
      */
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
     handleInputBlur(input = '', e?: any) {
-        const parsedResult = input ?
-            this._isMultiple() ?
-                this.parseMultipleInput(input, ',', true) :
-                this.parseInput(input) :
-            [];
-
-        const stateValue = this.getState('value');
-
-        // console.log(input, parsedResult);
-
-        if (parsedResult && parsedResult.length) {
-            this._updateValueAndInput(parsedResult, input === '');
-        } else if (input === '') {
-            // if clear input, set input to `''`
-            this._updateValueAndInput('' as any, true, '');
-        } else {
-            this._updateValueAndInput(stateValue);
-        }
-
-        /**
-         * 当不是范围类型且不需要确认时,使用 stateValue 重置 cachedSelectedValue
-         * 这样做的目的是,在输入非法值时,使用上次选中的值作为已选值
-         * needConfirm 或者 range type 时,我们在 close panel 时调用 resetCachedSelectedValue,这里不用重复调用
-         * 
-         * Use stateValue to reset cachedSelectedValue when it is not a range type and does not require confirmation
-         * The purpose of this is to use the last selected value as the selected value when an invalid value is entered
-         * When needConfirm or range type, we call resetCachedSelectedValue when close panel, no need to call repeatedly here
-         */
-        if (!this._adapter.needConfirm() && !this._isRangeType()) {
-            this.resetCachedSelectedValue(stateValue);
-        }
     }
 
     /**
@@ -1023,6 +1070,7 @@ export default class DatePickerFoundation extends BaseFoundation<DatePickerAdapt
     }
 
     handleConfirm() {
+        this.clickConfirmButton = true;
         const { cachedSelectedValue, value } = this._adapter.getStates();
         const isRangeValueComplete = this._isRangeValueComplete(cachedSelectedValue);
         const newValue = isRangeValueComplete ? cachedSelectedValue : value;

+ 24 - 0
packages/semi-ui/datePicker/_story/v2/FeatOnClickOutside.tsx

@@ -0,0 +1,24 @@
+import React, { useRef } from 'react';
+import type { BaseDatePicker } from '../../index';
+import { DatePicker, Space } from '../../../index';
+
+/**
+ * test in cypress
+ */
+export default function Demo() {
+    const ref = useRef<BaseDatePicker>();
+
+    const handleClickOutside = () => {
+        console.log('click outside');
+        ref.current && ref.current.close();
+    };
+
+    return (
+        <DatePicker motion={false} type="dateTime" needConfirm ref={ref} onClickOutSide={handleClickOutside} />
+    );
+}
+
+Demo.storyName = 'onClickOutside';
+Demo.parameters = {
+    chromatic: { disableSnapshot: false },
+};

+ 39 - 0
packages/semi-ui/datePicker/_story/v2/FeatRefClass.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+import { DatePicker, Space, Button } from '../../../index';
+import type { BaseDatePicker } from '../../index';
+
+class FeatRefClass extends React.Component {
+    ref: React.RefObject<BaseDatePicker>;
+    constructor(props) {
+        super(props);
+        this.ref = React.createRef();
+    }
+
+    handleFocus() {
+        console.log('focus');
+    }
+
+    render() {
+        return (
+            <Space vertical align={'start'}>
+                <Space>
+                    <Button onClick={() => this.ref.current.open()}>open</Button>
+                    <Button onClick={() => this.ref.current.close()}>close</Button>
+                </Space>
+                <div>
+                    <DatePicker motion={false} type="dateTime" needConfirm ref={this.ref} />
+                </div>
+            </Space>
+        );
+    }
+}
+
+Demo.storyName = 'ref class 写法';
+Demo.parameters = {
+    chromatic: { disableSnapshot: false },
+};
+export default function Demo() {
+    return (
+        <FeatRefClass />
+    );
+}

+ 64 - 0
packages/semi-ui/datePicker/_story/v2/FeatRefFocus.tsx

@@ -0,0 +1,64 @@
+import React, { useRef } from 'react';
+import type { BaseDatePicker } from '../../index';
+import { DatePicker, Space, Button } from '../../../index';
+
+/**
+ * test in cypress
+ */
+export default function Demo() {
+    const ref = useRef<BaseDatePicker>();
+    const rangeRef = useRef<BaseDatePicker>();
+    const insetRef = useRef<BaseDatePicker>();
+
+    const handleFocusInset = (focusType) => {
+        insetRef.current.focus(focusType);
+        insetRef.current.open();
+    };
+
+    const handleBlurInset = () => {
+        insetRef.current.blur();
+        insetRef.current.close();
+    };
+
+    return (
+        <Space vertical align={'start'}>
+            <Space vertical align={'start'} data-cy="single">
+                <h4>单个输入框</h4>
+                <Space>
+                    <Button onClick={() => ref.current.focus()}>focus</Button>
+                    <Button onClick={() => ref.current.blur()}>blur</Button>
+                </Space>
+                <div>
+                    <DatePicker motion={false} type="dateTime" ref={ref} />
+                </div>
+            </Space>
+            <Space vertical align={'start'} data-cy="range">
+                <h4>两个输入框</h4>
+                <Space>
+                    <Button onClick={() => rangeRef.current.focus()}>focus default</Button>
+                    <Button onClick={() => rangeRef.current.focus('rangeEnd')}>focus end</Button>
+                    <Button onClick={() => rangeRef.current.blur()}>blur</Button>
+                </Space>
+                <div>
+                    <DatePicker motion={false} type="dateRange" ref={rangeRef} />
+                </div>
+            </Space>
+            <Space vertical align={'start'} data-cy="inset">
+                <h4>内嵌输入框</h4>
+                <Space>
+                    <Button onClick={handleFocusInset}>focus start + open</Button>
+                    <Button onClick={() => handleFocusInset('rangeEnd')}>focus end + open</Button>
+                    <Button onClick={handleBlurInset}>blur + close</Button>
+                </Space>
+                <div>
+                    <DatePicker motion={false} insetInput type="dateRange" ref={insetRef} />
+                </div>
+            </Space>
+        </Space>
+    );
+}
+
+Demo.storyName = 'ref focus & blur';
+Demo.parameters = {
+    chromatic: { disableSnapshot: false },
+};

+ 31 - 0
packages/semi-ui/datePicker/_story/v2/FeatRefOpen.tsx

@@ -0,0 +1,31 @@
+import React, { useRef } from 'react';
+import type { BaseDatePicker } from '../../index';
+import { DatePicker, Space, Button } from '../../../index';
+
+/**
+ * test in cypress
+ */
+export default function Demo() {
+    const ref = useRef<BaseDatePicker>();
+
+    const handleClickOutside = () => {
+        console.log('click outside');
+    };
+
+    return (
+        <Space vertical align={'start'}>
+            <Space>
+                <Button onClick={() => ref.current.open()}>open</Button>
+                <Button onClick={() => ref.current.close()}>close</Button>
+            </Space>
+            <div>
+                <DatePicker motion={false} type="dateTime" needConfirm ref={ref} onClickOutSide={handleClickOutside} />
+            </div>
+        </Space>
+    );
+}
+
+Demo.storyName = 'ref open & close';
+Demo.parameters = {
+    chromatic: { disableSnapshot: false },
+};

+ 46 - 0
packages/semi-ui/datePicker/_story/v2/FixNeedConfirmInTabs.tsx

@@ -0,0 +1,46 @@
+import React, { useRef } from 'react';
+import type { BaseDatePicker } from '../../index';
+import { DatePicker, Tabs, Space, Button } from '../../../index';
+
+/**
+ * test in cypress
+ */
+export default function Demo() {
+    const ref = useRef<BaseDatePicker>();
+    const ref2 = useRef<BaseDatePicker>();
+    const TabPane = Tabs.TabPane;
+
+    const handleClickOutside = () => {
+        console.log('click outside');
+    };
+
+    const handleTabChange = (activeKey: string) => {
+        if (activeKey === '1') {
+            ref2.current?.close();
+        }
+        if (activeKey === '2') {
+            ref.current?.close();
+        }
+    };
+
+    return (
+        <Space vertical align="start">
+            <Space>
+                <Button onClick={() => ref.current.close()}>close</Button>
+            </Space>
+            <Tabs type="line" onChange={handleTabChange}>
+                <TabPane tab="文档" itemKey="1">
+                    <DatePicker motion={false} type="dateTime" needConfirm ref={ref} onClickOutSide={handleClickOutside} />
+                </TabPane>
+                <TabPane tab="快速起步" itemKey="2">
+                    <DatePicker motion={false} type="dateTimeRange" needConfirm ref={ref2} onClickOutSide={handleClickOutside} />
+                </TabPane>
+            </Tabs>
+        </Space>
+    );
+}
+
+Demo.storyName = 'fix needConfirm in Tabs';
+Demo.parameters = {
+    chromatic: { disableSnapshot: false },
+};

+ 5 - 0
packages/semi-ui/datePicker/_story/v2/index.js

@@ -15,3 +15,8 @@ export { default as FixRangePanelShift } from './FixRangePanelShift';
 export { default as InsetInputControlled } from './InsetInputControlled';
 export { default as FeatInsetInputProps } from './FeatInsetInputProps';
 export { default as FixMultiplePanelShift } from './FixMultiplePanelShift';
+export { default as FeatRefOpen } from './FeatRefOpen';
+export { default as FeatRefFocus } from './FeatRefFocus';
+export { default as FeatOnClickOutside } from './FeatOnClickOutside';
+export { default as FeatRefClass } from './FeatRefClass';
+export { default as FixNeedConfirmInTabs } from './FixNeedConfirmInTabs';

+ 6 - 1
packages/semi-ui/datePicker/dateInput.tsx

@@ -35,7 +35,10 @@ export interface DateInputProps extends DateInputFoundationProps, BaseProps {
     onFocus?: (e: React.MouseEvent<HTMLInputElement>, rangeType?: RangeType) => void;
     onClear?: (e: React.MouseEvent<HTMLDivElement>) => void;
     onInsetInputChange?: (options: InsetInputChangeProps) => void;
-    value?: Date[]
+    value?: Date[];
+    inputRef?: React.RefObject<HTMLInputElement>;
+    rangeInputStartRef?: React.RefObject<HTMLInputElement>;
+    rangeInputEndRef?: React.RefObject<HTMLInputElement>
 }
 
 // eslint-disable-next-line @typescript-eslint/ban-types
@@ -393,6 +396,7 @@ export default class DateInput extends BaseComponent<DateInputProps, {}> {
             prefix,
             autofocus,
             size,
+            inputRef,
             // range input support props, no need passing to not range type
             rangeInputStartRef,
             rangeInputEndRef,
@@ -429,6 +433,7 @@ export default class DateInput extends BaseComponent<DateInputProps, {}> {
         ) : (
             <Input
                 {...rest}
+                ref={inputRef}
                 insetLabel={insetLabel}
                 disabled={disabled}
                 readonly={inputReadOnly}

+ 77 - 12
packages/semi-ui/datePicker/datePicker.tsx

@@ -40,10 +40,21 @@ export interface DatePickerProps extends DatePickerFoundationProps {
     renderDate?: (dayNumber?: number, fullDate?: string) => React.ReactNode;
     renderFullDate?: (dayNumber?: number, fullDate?: string, dayStatus?: DayStatusType) => React.ReactNode;
     triggerRender?: (props: DatePickerProps) => React.ReactNode;
+    /**
+     * There are multiple input boxes when selecting a range, and the input boxes will be out of focus multiple times. 
+     * 
+     * Use `onOpenChange` or `onClickOutSide` instead
+     */
     onBlur?: React.MouseEventHandler<HTMLInputElement>;
     onClear?: React.MouseEventHandler<HTMLDivElement>;
+    /**
+     * There are multiple input boxes when selecting a range, and the input boxes will be focused multiple times.
+     * 
+     * Use `onOpenChange` or `triggerRender` instead
+     */
     onFocus?: (e: React.MouseEvent, rangeType: RangeType) => void;
     onPresetClick?: (item: PresetType, e: React.MouseEvent<HTMLDivElement>) => void;
+    onClickOutSide?: () => void;
     locale?: Locale['DatePicker'];
     dateFnsLocale?: Locale['dateFnsLocale'];
     yearAndMonthOpts?: ScrollItemProps<any>;
@@ -133,7 +144,8 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
         onPanelChange: PropTypes.func,
         rangeSeparator: PropTypes.string,
         preventScroll: PropTypes.bool,
-        yearAndMonthOpts: PropTypes.object
+        yearAndMonthOpts: PropTypes.object,
+        onClickOutSide: PropTypes.func,
     };
 
     static defaultProps = {
@@ -172,13 +184,15 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
         syncSwitchMonth: false,
         rangeSeparator: strings.DEFAULT_SEPARATOR_RANGE,
         insetInput: false,
+        onClickOutSide: noop,
     };
 
     triggerElRef: React.MutableRefObject<HTMLElement>;
     panelRef: React.RefObject<HTMLDivElement>;
     monthGrid: React.RefObject<MonthsGrid>;
-    rangeInputStartRef: React.RefObject<HTMLElement>;
-    rangeInputEndRef: React.RefObject<HTMLElement>;
+    inputRef: DateInputProps['inputRef'];
+    rangeInputStartRef: DateInputProps['rangeInputStartRef'];
+    rangeInputEndRef: DateInputProps['rangeInputEndRef'];
     focusRecordsRef: React.RefObject<{ rangeStart: boolean; rangeEnd: boolean }>;
     clickOutSideHandler: (e: MouseEvent) => void;
     _mounted: boolean;
@@ -205,6 +219,7 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
         this.triggerElRef = React.createRef();
         this.panelRef = React.createRef();
         this.monthGrid = React.createRef();
+        this.inputRef = React.createRef();
         this.rangeInputStartRef = React.createRef();
         this.rangeInputEndRef = React.createRef();
         this.focusRecordsRef = React.createRef();
@@ -220,8 +235,8 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
     get adapter(): DatePickerAdapter {
         return {
             ...super.adapter,
-            togglePanel: panelShow => {
-                this.setState({ panelShow });
+            togglePanel: (panelShow, cb) => {
+                this.setState({ panelShow }, cb);
                 if (!panelShow) {
                     this.focusRecordsRef.current.rangeEnd = false;
                     this.focusRecordsRef.current.rangeStart = false;
@@ -233,15 +248,19 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
                     this.clickOutSideHandler = null;
                 }
                 this.clickOutSideHandler = e => {
-                    if (this.adapter.needConfirm()) {
-                        return;
-                    }
                     const triggerEl = this.triggerElRef && this.triggerElRef.current;
                     const panelEl = this.panelRef && this.panelRef.current;
                     const isInTrigger = triggerEl && triggerEl.contains(e.target as Node);
                     const isInPanel = panelEl && panelEl.contains(e.target as Node);
-                    if (!isInTrigger && !isInPanel && this._mounted) {
-                        this.foundation.closePanel(e);
+                    const clickOutSide = !isInTrigger && !isInPanel && this._mounted;
+                    if (this.adapter.needConfirm()) {
+                        clickOutSide && this.props.onClickOutSide();
+                        return;
+                    } else {
+                        if (clickOutSide) {
+                            this.props.onClickOutSide();
+                            this.foundation.closePanel(e);
+                        }
                     }
                 };
                 document.addEventListener('mousedown', this.clickOutSideHandler);
@@ -349,6 +368,26 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
                         break;
                 }
             },
+            setInputFocus: () => {
+                const { preventScroll } = this.props;
+                const inputNode = get(this, 'inputRef.current');
+                inputNode && inputNode.focus({ preventScroll });
+            },
+            setInputBlur: () => {
+                const inputNode = get(this, 'inputRef.current');
+                inputNode && inputNode.blur();
+            },
+            setRangeInputBlur: () => {
+                const { rangeInputFocus } = this.state;
+                if (rangeInputFocus === 'rangeStart') {
+                    const inputStartNode = get(this, 'rangeInputStartRef.current');
+                    inputStartNode && inputStartNode.blur();
+                } else if (rangeInputFocus === 'rangeEnd') {
+                    const inputEndNode = get(this, 'rangeInputEndRef.current');
+                    inputEndNode && inputEndNode.blur();
+                }
+                this.adapter.setRangeInputFocus(false);
+            },
             setTriggerDisabled: (disabled: boolean) => {
                 this.setState({ triggerDisabled: disabled });
             }
@@ -390,6 +429,32 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
         super.componentWillUnmount();
     }
 
+    open() {
+        this.foundation.open();
+    }
+
+    close() {
+        this.foundation.close();
+    }
+
+    /**
+     *
+     * When selecting a range, the default focus is on the start input box, passing in `rangeEnd` can focus on the end input box
+     *
+     * When `insetInput` is `true`, due to trigger disabled, the cursor will focus on the input box of the popup layer panel
+     *
+     * 范围选择时,默认聚焦在开始输入框,传入 `rangeEnd` 可以聚焦在结束输入框
+     *
+     * `insetInput` 打开时,由于 trigger 禁用,会把焦点放在弹出面板的输入框上
+     */
+    focus(focusType?: Exclude<RangeType, false>) {
+        this.foundation.focus(focusType);
+    }
+
+    blur() {
+        this.foundation.blur();
+    }
+
     setTriggerRef = (node: HTMLDivElement) => (this.triggerElRef.current = node);
 
     // Called when changes are selected by clicking on the selected date
@@ -547,7 +612,7 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
         this.foundation.handlePanelVisibleChange(visible);
     }
 
-    renderInner(extraProps?: Partial<DatePickerProps>) {
+    renderInner() {
         const {
             clearIcon,
             type,
@@ -584,7 +649,6 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
         const phText = placeholder || locale.placeholder[type]; // i18n
         // These values should be passed to triggerRender, do not delete any key if it is not necessary
         const props = {
-            ...extraProps,
             placeholder: phText,
             clearIcon,
             disabled: inputDisabled,
@@ -619,6 +683,7 @@ export default class DatePicker extends BaseComponent<DatePickerProps, DatePicke
             onRangeEndTabPress: this.handleRangeEndTabPress,
             rangeInputStartRef: insetInput ? null : this.rangeInputStartRef,
             rangeInputEndRef: insetInput ? null : this.rangeInputEndRef,
+            inputRef: this.inputRef,
         };
 
         return (

+ 1 - 0
packages/semi-ui/datePicker/index.tsx

@@ -28,6 +28,7 @@ export type { MonthsGridProps } from './monthsGrid';
 export type { QuickControlProps } from './quickControl';
 export type { YearAndMonthProps } from './yearAndMonth';
 export type { InsetInputProps } from '@douyinfe/semi-foundation/datePicker/inputFoundation';
+export type { DatePicker as BaseDatePicker };
 
 export default forwardStatics(
     React.forwardRef<DatePicker, DatePickerProps>((props, ref) => {

+ 3 - 1
packages/semi-ui/space/index.tsx

@@ -5,6 +5,7 @@ import { strings, cssClasses } from '@douyinfe/semi-foundation/space/constants';
 import '@douyinfe/semi-foundation/space/space.scss';
 import { isString, isArray, isNumber } from 'lodash';
 import { flatten } from './utils';
+import getDataAttr from '@douyinfe/semi-foundation/utils/getDataAttr';
 
 const prefixCls = cssClasses.PREFIX;
 
@@ -84,8 +85,9 @@ class Space extends PureComponent<SpaceProps> {
             [`${prefixCls}-loose-vertical`]: spacingVerticalType === strings.SPACING_LOOSE,
         });
         const childrenNodes = flatten(children);
+        const dataAttributes = getDataAttr(this.props);
         return (
-            <div className={classNames} style={realStyle} x-semi-prop="children">
+            <div {...dataAttributes} className={classNames} style={realStyle} x-semi-prop="children">
                 {childrenNodes}
             </div>
         );