diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 33dcca9da..7ce575512 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -25,3 +25,5 @@ jobs: fetch-depth: 0 persist-credentials: false - uses: millionco/react-doctor@ced746f518f11e8283d488c4ff31c44e478bb0e5 + with: + version: 0.7.4 diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx index 9aadb22e5..6e564a5f8 100644 --- a/docs/examples/debug.tsx +++ b/docs/examples/debug.tsx @@ -1,110 +1,39 @@ import * as React from 'react'; import '../../assets/index.less'; -import type { Locale } from '../../src/interface'; import RangePicker from '../../src/PickerInput/RangePicker'; -import SinglePicker from '../../src/PickerInput/SinglePicker'; -import PickerPanel from '../../src/PickerPanel'; -import dayjs, { type Dayjs } from 'dayjs'; -import 'dayjs/locale/ar'; +import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; -import buddhistEra from 'dayjs/plugin/buddhistEra'; -import LocalizedFormat from 'dayjs/plugin/localizedFormat'; import dayjsGenerateConfig from '../../src/generate/dayjs'; -import dateFnsGenerateConfig from '../../src/generate/dateFns'; import zhCN from '../../src/locale/zh_CN'; dayjs.locale('zh-cn'); -// dayjs.locale('ar'); -dayjs.extend(buddhistEra); -dayjs.extend(LocalizedFormat); - -console.clear(); - -(window as any).dayjs = dayjs; - -const myLocale: Locale = { - ...zhCN, - // cellQuarterFormat: '第Q季度', - // fieldYearFormat: 'BBBB', - // cellYearFormat: 'BBBB', - // yearFormat: 'BBBB', - // cellDateFormat: '!d!', -}; - -const sharedLocale = { - locale: myLocale, - generateConfig: dayjsGenerateConfig, -}; - -const dateFnsSharedLocale = { - locale: myLocale, - generateConfig: dateFnsGenerateConfig, -}; export default () => { + const [changeCount, setChangeCount] = React.useState(0); + return (
- {/* console.error('>>>>>>>', val)} - /> */} - { - // console.log('Date:', info); - // return false; - // }} - // disabledTime={(date, range, info) => { - // // console.log(`Time-${range}`, range, info); - // const { from } = info; - // if (from) { - // console.log( - // `Time-${range}`, - // from.format('YYYY-MM-DD HH:mm:ss'), - // date.format('YYYY-MM-DD HH:mm:ss'), - // ); - // } - - // if (from && from.isSame(date, 'day')) { - // return { - // disabledHours: () => [from.hour()], - // disabledMinutes: () => [0, 1, 2, 3], - // disabledSeconds: () => [0, 1, 2, 3], - // }; - // } - // return {}; - // }} - /> +

Reset unconfirmed end after switching back

+

+ Select and confirm a start date. Select an end date without confirming, switch back to the + start field, then click the input above. Switching back should reset the unconfirmed end and + should not submit the range. The following blur should also keep the change count at 0. +

+
Change count: {changeCount}
- {/* { - console.log('Time Single:', ...args); - return {}; + allowEmpty + onChange={() => { + setChangeCount((count) => count + 1); }} - /> */} - {/* console.error('>>>>>>>', val)} - /> */} + />
); }; diff --git a/src/PickerInput/Popup/index.tsx b/src/PickerInput/Popup/index.tsx index f02810390..cb89a9b77 100644 --- a/src/PickerInput/Popup/index.tsx +++ b/src/PickerInput/Popup/index.tsx @@ -20,9 +20,11 @@ export type PopupShowTimeConfig = Omit< Pick, 'disabledTime'>; export interface PopupProps - extends Pick, 'onFocus' | 'onBlur'>, + extends + Pick, 'onFocus' | 'onBlur'>, FooterProps, PopupPanelProps { + containerRef?: React.Ref; panelRender?: SharedPickerProps['panelRender']; // Presets @@ -52,6 +54,7 @@ export interface PopupProps(props: PopupProps) { const { + containerRef, panelRender, internalMode, picker, @@ -216,6 +219,7 @@ export default function Popup(props: PopupProps(config: T | [T, T] | null | undefined, defaultConfig: T): [T, T] { @@ -233,7 +238,8 @@ function RangePicker( } = filledProps; // ========================= Refs ========================= - const selectorRef = usePickerRef(ref); + const selectorRef = usePickerRef[0], RangeSelectorRef>(ref); + const popupRef = React.useRef(null); // ======================= Semantic ======================= const [mergedClassNames, mergedStyles] = useSemantic(propClassNames, propStyles); @@ -264,34 +270,72 @@ function RangePicker( const calendarValue = getCalendarValue(); - // ======================== Active ======================== + // ======================== Focus ========================= + const isInternalPickerElement = useEvent((element: EventTarget | null) => + isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), + ); + + const [focused, onFieldFocus, onFieldBlur] = useFocusEvents( + isInternalPickerElement, + (index, event) => { + onFocus?.(event, { + range: getActiveRange(index), + }); + }, + (index, event) => { + triggerRangeValueChange(index, 'blur'); + triggerOpen(false); + + onBlur?.(event, { + range: getActiveRange(index), + }); + }, + ); + + // ======================== Value ========================= const [ - focused, - triggerFocus, - lastOperation, - activeIndex, - setActiveIndex, - nextActiveIndex, - activeIndexList, - updateSubmitIndex, - hasActiveSubmitValue, - ] = useRangeActive(disabled, allowEmpty, mergedOpen); - - const onSharedFocus = (event: React.FocusEvent, index?: number) => { - triggerFocus(true); - - onFocus?.(event, { - range: getActiveRange(index ?? activeIndex), - }); - }; + /** Trigger `onChange` by check `disabledDate` */ + flushSubmit, + /** Trigger `onChange` directly without check `disabledDate` */ + triggerSubmitChange, + /** Reset uncommitted values */ + resetValue, + ] = useRangeValue, DateType>( + filledProps, + mergedValue, + setInnerValue, + getCalendarValue, + triggerCalendarChange, + disabled, + formatList, + isInvalidateDate, + ); - const onSharedBlur = (event: React.FocusEvent, index?: number) => { - triggerFocus(false); + const triggerFieldCalendarChange = useEvent((index: number, date: DateType) => { + triggerCalendarChange(fillIndex(getCalendarValue(), index, date)); + }); - onBlur?.(event, { - range: getActiveRange(index ?? activeIndex), - }); - }; + const flushFieldSubmit = useEvent((index: number, needTriggerChange: boolean) => { + flushSubmit(index, needTriggerChange); + + if (needTriggerChange) { + triggerOpen(false, { force: true }); + } + }); + + const enabledFieldCount = disabled.filter((fieldDisabled) => !fieldDisabled).length; + const [rangeValueIndex, activeIndex, triggeredFields, triggerRangeValueChange] = + useRangeValueChange( + enabledFieldCount, + needConfirm, + allowEmpty, + getCalendarValue, + triggerFieldCalendarChange, + flushFieldSubmit, + resetValue, + ); + + useFocusLock(rangeValueIndex, selectorRef, popupRef, triggerOpen); // ======================= ShowTime ======================= /** Used for Popup panel */ @@ -307,7 +351,7 @@ function RangePicker( const proxyDisabledTime = disabledTime ? (date: DateType) => { const range = getActiveRange(activeIndex); - const fromDate = getFromDate(calendarValue, activeIndexList, activeIndex); + const fromDate = getFromDate(calendarValue, triggeredFields, activeIndex); return disabledTime(date, range, { from: fromDate, }); @@ -315,7 +359,7 @@ function RangePicker( : undefined; return { ...showTime, disabledTime: proxyDisabledTime }; - }, [showTime, activeIndex, calendarValue, activeIndexList]); + }, [showTime, activeIndex, calendarValue, triggeredFields]); // ========================= Mode ========================= const [modes, setModes] = useControlledState<[PanelMode, PanelMode]>([picker, picker], mode); @@ -332,30 +376,12 @@ function RangePicker( // ======================= Show Now ======================= const mergedShowNow = useShowNow(picker, mergedMode, showNow, showToday, true); - // ======================== Value ========================= - const [ - /** Trigger `onChange` by check `disabledDate` */ - flushSubmit, - /** Trigger `onChange` directly without check `disabledDate` */ - triggerSubmitChange, - ] = useRangeValue, DateType>( - filledProps, - mergedValue, - setInnerValue, - getCalendarValue, - triggerCalendarChange, - disabled, - formatList, - focused, - mergedOpen, - isInvalidateDate, - ); - // ===================== DisabledDate ===================== const mergedDisabledDate = useRangeDisabledDate( calendarValue, disabled, - activeIndexList, + activeIndex, + triggeredFields, generateConfig, locale, disabledDate, @@ -375,6 +401,7 @@ function RangePicker( calendarValue, modes, mergedOpen, + focused, activeIndex, internalPicker, multiplePanel, @@ -418,25 +445,8 @@ function RangePicker( * - Selector: enter key * - Panel: OK button */ - const triggerPartConfirm = (date?: DateType, skipFocus?: boolean) => { - let nextValue = calendarValue; - - if (date) { - nextValue = fillCalendarValue(date, activeIndex); - } - updateSubmitIndex(activeIndex); - // Get next focus index - const nextIndex = nextActiveIndex(nextValue); - - // Change calendar value and tell flush it - triggerCalendarChange(nextValue); - flushSubmit(activeIndex, nextIndex === null); - - if (nextIndex === null) { - triggerOpen(false, { force: true }); - } else if (!skipFocus) { - selectorRef.current.focus({ index: nextIndex }); - } + const triggerPartConfirm = (date?: DateType, source: RangeValueChangeSource = 'confirm') => { + triggerRangeValueChange(activeIndex, source, date ?? undefined); }; // ======================== Click ========================= @@ -509,7 +519,6 @@ function RangePicker( const passed = triggerSubmitChange(nextValues); if (passed) { - lastOperation('preset-click'); triggerOpen(false, { force: true }); } }; @@ -526,31 +535,22 @@ function RangePicker( // >>> Focus const onPanelFocus: React.FocusEventHandler = (event) => { triggerOpen(true); - onSharedFocus(event); - }; - - // >>> MouseDown - const onPanelMouseDown: React.MouseEventHandler = () => { - lastOperation('panel'); + onFieldFocus(activeIndex, 'panel', event); }; // >>> Calendar const onPanelSelect: PickerPanelProps['onChange'] = (date: DateType) => { - const clone: RangeValueType = fillIndex(calendarValue, activeIndex, date); - - // Only trigger calendar event but not update internal `calendarValue` state - triggerCalendarChange(clone); + const panelFinished = !complexPicker && internalPicker === internalMode; - // >>> Trigger next active if !needConfirm - // Fully logic check `useRangeValue` hook - if (!needConfirm && !complexPicker && internalPicker === internalMode) { - triggerPartConfirm(date); - } + triggerRangeValueChange( + activeIndex, + panelFinished ? 'panel-final' : 'panel-intermediate', + date, + ); }; // >>> Close const onPopupClose = () => { - // Close popup triggerOpen(false); }; @@ -592,6 +592,7 @@ function RangePicker( // >>> Render const panel = ( + containerRef={popupRef} // MISC {...panelProps} showNow={mergedShowNow} @@ -604,8 +605,7 @@ function RangePicker( disabledDate={mergedDisabledDate} // Focus onFocus={onPanelFocus} - onBlur={onSharedBlur} - onPanelMouseDown={onPanelMouseDown} + onBlur={(event) => onFieldBlur(activeIndex, 'panel', event)} // Mode picker={picker} mode={mergedMode} @@ -626,7 +626,7 @@ function RangePicker( onHover={onPanelHover} // Submit needConfirm={needConfirm} - onSubmit={triggerPartConfirm} + onSubmit={(date) => triggerPartConfirm(date, 'confirm')} onOk={triggerOk} // Preset presets={presetList} @@ -648,64 +648,34 @@ function RangePicker( // ======================== Change ======================== const onSelectorChange = (date: DateType, index: number) => { - const clone = fillCalendarValue(date, index); - - triggerCalendarChange(clone); + triggerRangeValueChange(index, 'input', date); }; const onSelectorInputChange = () => { - lastOperation('input'); + triggerRangeValueChange(activeIndex, 'input'); }; // ======================= Selector ======================= const onSelectorFocus: SelectorProps['onFocus'] = (event, index) => { - // Check if `needConfirm` but user not submit yet - const activeListLen = activeIndexList.length; - const lastActiveIndex = activeIndexList[activeListLen - 1]; - if ( - activeListLen && - lastActiveIndex !== index && - needConfirm && - // Not change index if is not filled - !allowEmpty[lastActiveIndex] && - !hasActiveSubmitValue(lastActiveIndex) && - calendarValue[lastActiveIndex] - ) { - selectorRef.current.focus({ index: lastActiveIndex }); - return; - } - - lastOperation('input'); + triggerRangeValueChange(index, 'field-switch'); triggerOpen(true, { inherit: true, }); - // When click input to switch the field, it will not trigger close. - // Which means it will lose the part confirm and we need fill back. - // ref: https://github.com/ant-design/ant-design/issues/49512 - if (activeIndex !== index && mergedOpen && !needConfirm && complexPicker) { - triggerPartConfirm(null, true); - } - - setActiveIndex(index); - - onSharedFocus(event, index); + onFieldFocus(index, 'input', event); }; const onSelectorBlur: SelectorProps['onBlur'] = (event, index) => { - triggerOpen(false); - if (!needConfirm && lastOperation() === 'input') { - const nextIndex = nextActiveIndex(calendarValue); - flushSubmit(activeIndex, nextIndex === null); - } - - onSharedBlur(event, index); + onFieldBlur(index, 'input', event); }; const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => { if (event.key === 'Tab') { - triggerPartConfirm(null, true); + triggerPartConfirm(null, 'keyboard-submit'); + } else if (event.key === 'Escape') { + triggerRangeValueChange(activeIndex, 'esc'); + triggerOpen(false); } onKeyDown?.(event, preventDefault); @@ -743,23 +713,6 @@ function RangePicker( } }, [mergedOpen, activeIndex, picker]); - // >>> For complex picker, we need check if need to focus next one - useLayoutEffect(() => { - const lastOp = lastOperation(); - - // Trade as confirm on field leave - if (!mergedOpen && lastOp === 'input') { - triggerOpen(false); - triggerPartConfirm(null, true); - } - - // Submit with complex picker - if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') { - triggerOpen(true); - triggerPartConfirm(); - } - }, [mergedOpen]); - // ====================== DevWarning ====================== if (process.env.NODE_ENV !== 'production') { const isIndexEmpty = (index: number) => { @@ -815,7 +768,7 @@ function RangePicker( onFocus={onSelectorFocus} onBlur={onSelectorBlur} onKeyDown={onSelectorKeyDown} - onSubmit={triggerPartConfirm} + onSubmit={() => triggerPartConfirm(null, 'keyboard-submit')} // Change value={hoverValues} maskFormat={maskFormat} diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx index 6cc28a74b..7208fbc72 100644 --- a/src/PickerInput/Selector/RangeSelector.tsx +++ b/src/PickerInput/Selector/RangeSelector.tsx @@ -17,6 +17,11 @@ export type SelectorIdType = end?: string; }; +export interface RangeSelectorRef extends RangePickerRef { + startInput: HTMLInputElement; + endInput: HTMLInputElement; +} + export interface RangeSelectorProps extends SelectorProps { id?: SelectorIdType; @@ -49,7 +54,7 @@ export interface RangeSelectorProps extends SelectorProps( props: RangeSelectorProps, - ref: React.Ref, + ref: React.Ref, ) { const { id, @@ -144,6 +149,8 @@ function RangeSelector( React.useImperativeHandle(ref, () => ({ nativeElement: rootRef.current, + startInput: inputStartRef.current.inputElement, + endInput: inputEndRef.current.inputElement, focus: (options) => { if (typeof options === 'object') { const { index = 0, ...rest } = options || {}; diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index 6cdf6be3c..6a4450eea 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -20,12 +20,13 @@ import PickerContext from './context'; import useCellRender from './hooks/useCellRender'; import useFieldsInvalidate from './hooks/useFieldsInvalidate'; import useFilledProps from './hooks/useFilledProps'; +import useFocusEvents, { isTargetInContainers } from './hooks/useFocusEvents'; import useOpen from './hooks/useOpen'; import usePickerRef from './hooks/usePickerRef'; import usePresets from './hooks/usePresets'; -import useRangeActive from './hooks/useRangeActive'; import useRangePickerValue from './hooks/useRangePickerValue'; import useRangeValue, { useInnerValue } from './hooks/useRangeValue'; +import useRangeValueChange, { type RangeValueChangeSource } from './hooks/useRangeValueChange'; import useShowNow from './hooks/useShowNow'; import Popup from './Popup'; import SingleSelector from './Selector/SingleSelector'; @@ -204,6 +205,7 @@ function Picker( // ========================= Refs ========================= const selectorRef = usePickerRef(ref); + const popupRef = React.useRef(null); // ========================= Util ========================= function pickerParam(values: T | T[]) { @@ -253,21 +255,22 @@ function Picker( const calendarValue = getCalendarValue(); - // ======================== Active ======================== - // In SinglePicker, we will always get `activeIndex` is 0. - const [focused, triggerFocus, lastOperation, activeIndex] = useRangeActive([disabled]); - - const onSharedFocus = (event: React.FocusEvent) => { - triggerFocus(true); - - onFocus?.(event, {}); - }; - - const onSharedBlur = (event: React.FocusEvent) => { - triggerFocus(false); + // ======================== Focus ========================= + const isInternalPickerElement = useEvent((element: EventTarget | null) => + isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), + ); - onBlur?.(event, {}); - }; + const [focused, onFieldFocus, onFieldBlur] = useFocusEvents( + isInternalPickerElement, + (_index, event) => { + onFocus?.(event, {}); + }, + (index, event) => { + triggerSingleValueChange(index, 'blur'); + triggerOpen(false); + onBlur?.(event, {}); + }, + ); // ========================= Mode ========================= const [mergedMode, setMode] = useControlledState(picker, mode); @@ -289,6 +292,8 @@ function Picker( , /** Trigger `onChange` directly without check `disabledDate` */ triggerSubmitChange, + /** Reset uncommitted values */ + resetValue, ] = useRangeValue( { ...filledProps, @@ -300,11 +305,43 @@ function Picker( triggerCalendarChange, [], //disabled, formatList, - focused, - mergedOpen, isInvalidateDate, ); + // Treat the complete SinglePicker value list as one field value. This keeps + // multiple dates inside field `0` instead of exposing them as extra fields. + // 将 SinglePicker 的整组值视为一个 field value;multiple 日期仍属于 + // field `0` 内部,不会被当成额外的 field。 + const getFieldCalendarValue = useEvent(() => { + const values = getCalendarValue(); + return [values.length ? values : null]; + }); + + const triggerFieldCalendarChange = useEvent((_index: number, nextValues: DateType[]) => { + triggerCalendarChange(nextValues); + }); + + const flushFieldSubmit = useEvent((_index: number, needTriggerChange: boolean) => { + if (needTriggerChange) { + triggerSubmitChange(getCalendarValue()); + triggerOpen(false, { force: true }); + } + }); + + const resetFieldValue = useEvent(() => { + resetValue(); + }); + + const [, activeIndex, , triggerSingleValueChange] = useRangeValueChange( + 1, + needConfirm, + [false], + getFieldCalendarValue, + triggerFieldCalendarChange, + flushFieldSubmit, + resetFieldValue, + ); + // ======================= Validate ======================= const [submitInvalidates, onSelectorInvalid] = useFieldsInvalidate( calendarValue, @@ -335,6 +372,7 @@ function Picker( calendarValue, [mergedMode], mergedOpen, + false, activeIndex, internalPicker, false, // multiplePanel, @@ -362,13 +400,11 @@ function Picker( // ======================== Submit ======================== /** - * Different with RangePicker, confirm should check `multiple` logic. - * This will never provide `date` instead. + * Submit the complete value list stored in SinglePicker field `0`. + * 提交 SinglePicker field `0` 内保存的整组值。 */ - const triggerConfirm = () => { - triggerSubmitChange(getCalendarValue()); - - triggerOpen(false, { force: true }); + const triggerConfirm = (source: RangeValueChangeSource = 'confirm') => { + triggerSingleValueChange(0, source); }; // ======================== Click ========================= @@ -457,28 +493,20 @@ function Picker( // >>> Focus const onPanelFocus: React.FocusEventHandler = (event) => { triggerOpen(true); - onSharedFocus(event); + onFieldFocus(0, 'panel', event); }; // >>> Calendar const onPanelSelect = (date: DateType) => { - lastOperation('panel'); - // Not change values if multiple and current panel is to match with picker if (multiple && internalMode !== picker) { return; } const nextValues = multiple ? toggleDates(getCalendarValue(), date) : [date]; + const panelFinished = !complexPicker && internalPicker === internalMode; - // Only trigger calendar event but not update internal `calendarValue` state - triggerCalendarChange(nextValues); - - // >>> Trigger next active if !needConfirm - // Fully logic check `useRangeValue` hook - if (!needConfirm && !complexPicker && internalPicker === internalMode) { - triggerConfirm(); - } + triggerSingleValueChange(0, panelFinished ? 'panel-final' : 'panel-intermediate', nextValues); }; // >>> Close @@ -514,6 +542,7 @@ function Picker( // >>> Render const panel = ( + containerRef={popupRef} // MISC {...panelProps} showNow={mergedShowNow} @@ -522,7 +551,7 @@ function Picker( disabledDate={disabledDate} // Focus onFocus={onPanelFocus} - onBlur={onSharedBlur} + onBlur={(event) => onFieldBlur(0, 'panel', event)} // Mode picker={picker} mode={mergedMode} @@ -543,7 +572,7 @@ function Picker( onHover={onPanelHover} // Submit needConfirm={needConfirm} - onSubmit={triggerConfirm} + onSubmit={() => triggerConfirm('confirm')} onOk={triggerOk} // Preset presets={presetList} @@ -564,35 +593,34 @@ function Picker( // ======================== Change ======================== const onSelectorChange = (date: DateType[]) => { - triggerCalendarChange(date); + triggerSingleValueChange(0, 'input', date); }; const onSelectorInputChange = () => { - lastOperation('input'); + triggerSingleValueChange(0, 'input'); }; // ======================= Selector ======================= const onSelectorFocus: SelectorProps['onFocus'] = (event) => { - lastOperation('input'); + triggerSingleValueChange(0, 'field-switch'); triggerOpen(true, { inherit: true, }); - // setActiveIndex(index); - - onSharedFocus(event); + onFieldFocus(0, 'input', event); }; const onSelectorBlur: SelectorProps['onBlur'] = (event) => { - triggerOpen(false); - - onSharedBlur(event); + onFieldBlur(0, 'input', event); }; const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => { if (event.key === 'Tab') { - triggerConfirm(); + triggerConfirm('keyboard-submit'); + } else if (event.key === 'Escape') { + triggerSingleValueChange(0, 'esc'); + triggerOpen(false); } onKeyDown?.(event, preventDefault); @@ -630,22 +658,6 @@ function Picker( } }, [mergedOpen, activeIndex, picker]); - // >>> For complex picker, we need check if need to focus next one - useLayoutEffect(() => { - const lastOp = lastOperation(); - - // Trade as confirm on field leave - if (!mergedOpen && lastOp === 'input') { - triggerOpen(false); - triggerConfirm(); - } - - // Submit with complex picker - if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') { - triggerConfirm(); - } - }, [mergedOpen]); - // ======================== Render ======================== return ( @@ -677,7 +689,7 @@ function Picker( onFocus={onSelectorFocus} onBlur={onSelectorBlur} onKeyDown={onSelectorKeyDown} - onSubmit={triggerConfirm} + onSubmit={() => triggerConfirm('keyboard-submit')} // Change value={selectorValues} maskFormat={maskFormat} diff --git a/src/PickerInput/hooks/useFocusEvents.ts b/src/PickerInput/hooks/useFocusEvents.ts new file mode 100644 index 000000000..d80115f8e --- /dev/null +++ b/src/PickerInput/hooks/useFocusEvents.ts @@ -0,0 +1,79 @@ +import { useEvent } from '@rc-component/util'; +import * as React from 'react'; + +// ============================= Types ============================= +/** Focus event source. / 焦点事件来源。 */ +export type FocusSource = 'input' | 'panel'; + +/** React focus event used by Picker elements. / Picker 元素使用的 React 焦点事件。 */ +export type PickerFocusEvent = React.FocusEvent; + +/** Focus event handler. / 聚焦事件处理函数。 */ +export type FieldFocusHandler = ( + index: number, + source: FocusSource, + event: PickerFocusEvent, +) => void; + +/** Blur event handler. / 失焦事件处理函数。 */ +export type FieldBlurHandler = ( + index: number, + source: FocusSource, + event: PickerFocusEvent, +) => void; + +/** Check whether an element belongs to the current focus scope. / 检查元素是否属于当前焦点范围。 */ +export type IsInternalElement = (element: EventTarget | null) => boolean; + +/** Notify a Picker focus or blur event. / 通知 Picker 的聚焦或失焦事件。 */ +export type FocusEventHandler = (index: number, event: PickerFocusEvent) => void; + +export type UseFocusEventsReturn = [ + focused: boolean, + onFieldFocus: FieldFocusHandler, + onFieldBlur: FieldBlurHandler, +]; + +// ============================= Utils ============================= +/** Check whether the target belongs to any container. / 判断目标是否属于任意一个容器。 */ +export function isTargetInContainers( + target: EventTarget | null, + containers: readonly (Element | null)[], +) { + return containers.some( + (container) => !!container && (container === target || container.contains(target as Node)), + ); +} + +/** + * Handle field focus and blur events. + * 处理 field 的聚焦与失焦事件。 + * + * Ignore blur when `relatedTarget` still belongs to the Picker. + * 当 `relatedTarget` 仍属于 Picker 时,忽略本次 blur。 + */ +export default function useFocusEvents( + isInternalElement: IsInternalElement, + onFocus?: FocusEventHandler, + onBlur?: FocusEventHandler, +): UseFocusEventsReturn { + // Keep the actual focused field so every field focus causes a render. This + // gives `useFocusLock` a commit in which it can correct an invalid switch. + // 记录实际获得焦点的 field,使每次 field focus 都会触发渲染; + // `useFocusLock` 因此可以在 commit 后纠正不允许的切换。 + const [focusedIndex, setFocusedIndex] = React.useState(null); + + const onFieldFocus = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { + setFocusedIndex(index); + onFocus?.(index, event); + }); + + const onFieldBlur = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { + if (!isInternalElement(event.relatedTarget)) { + setFocusedIndex(null); + onBlur?.(index, event); + } + }); + + return [focusedIndex !== null, onFieldFocus, onFieldBlur]; +} diff --git a/src/PickerInput/hooks/useFocusLock.ts b/src/PickerInput/hooks/useFocusLock.ts new file mode 100644 index 000000000..a6fa1fc3a --- /dev/null +++ b/src/PickerInput/hooks/useFocusLock.ts @@ -0,0 +1,56 @@ +import { useEvent, useLayoutEffect } from '@rc-component/util'; +import * as React from 'react'; +import { isTargetInContainers } from './useFocusEvents'; + +interface FocusLockSelectorRef { + startInput: HTMLElement; + endInput: HTMLElement; + focus: (index?: number) => void; +} + +/** + * Keep focus on the specified input field while focus moves inside the Picker. + * 当焦点在 Picker 内移动时,将其锁定在指定的输入框上。 + */ +export default function useFocusLock( + index: number | null, + selectorRef: React.RefObject, + popupRef: React.RefObject, + triggerOpen: (open: boolean) => void, +) { + const openPicker = useEvent(() => { + triggerOpen(true); + }); + + // Open the Picker and focus the controlled field after it changes. + // 当受控 field 发生切换后,重新打开 Picker 并聚焦对应的 field。 + React.useEffect(() => { + if (index !== null) { + openPicker(); + selectorRef.current?.focus(index); + } + }, [index, openPicker]); + + // DOM focus may change while `index` stays the same, so check after every commit. + // DOM 焦点变化时 `index` 可能保持不变,因此每次 commit 后都需要检查。 + useLayoutEffect(() => { + if (index === null) { + return; + } + + const activeElement = document.activeElement; + const inputFields = [selectorRef.current?.startInput, selectorRef.current?.endInput]; + + if (isTargetInContainers(activeElement, [popupRef.current])) { + return; + } + + const focusInOtherField = inputFields.some( + (field, fieldIndex) => fieldIndex !== index && isTargetInContainers(activeElement, [field]), + ); + + if (focusInOtherField) { + inputFields[index]?.focus(); + } + }); +} diff --git a/src/PickerInput/hooks/usePickerRef.ts b/src/PickerInput/hooks/usePickerRef.ts index 513cedaaf..347ec8e8e 100644 --- a/src/PickerInput/hooks/usePickerRef.ts +++ b/src/PickerInput/hooks/usePickerRef.ts @@ -5,8 +5,11 @@ type PickerRefType = Omit & { focus: (options?: OptionType) => void; }; -export default function usePickerRef(ref: React.Ref>) { - const selectorRef = React.useRef>(null); +export default function usePickerRef< + OptionType, + SelectorRefType extends PickerRefType = PickerRefType, +>(ref: React.Ref>) { + const selectorRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ nativeElement: selectorRef.current?.nativeElement, diff --git a/src/PickerInput/hooks/useRangeActive.ts b/src/PickerInput/hooks/useRangeActive.ts deleted file mode 100644 index 4b75d8d58..000000000 --- a/src/PickerInput/hooks/useRangeActive.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as React from 'react'; -import type { RangeValueType } from '../RangePicker'; -import useLockEffect from './useLockEffect'; - -export type OperationType = 'input' | 'panel' | 'preset-click'; - -export type NextActive = (nextValue: RangeValueType) => number | null; - -/** - * When user first focus one input, any submit will trigger focus another one. - * When second time focus one input, submit will not trigger focus again. - * When click outside to close the panel, trigger event if it can trigger onChange. - */ -export default function useRangeActive( - disabled: boolean[], - empty: boolean[] = [], - mergedOpen: boolean = false, -): [ - focused: boolean, - triggerFocus: (focused: boolean) => void, - lastOperation: (type?: OperationType) => OperationType, - activeIndex: number, - setActiveIndex: (index: number) => void, - nextActiveIndex: NextActive, - activeList: number[], - updateSubmitIndex: (index: number | null) => void, - hasActiveSubmitValue: (index: number) => boolean, -] { - const [activeIndex, setActiveIndex] = React.useState(0); - const [focused, setFocused] = React.useState(false); - - const activeListRef = React.useRef([]); - const submitIndexRef = React.useRef(null); - const lastOperationRef = React.useRef(null); - - const updateSubmitIndex = (index: number | null) => { - submitIndexRef.current = index; - }; - - const hasActiveSubmitValue = (index: number) => { - return submitIndexRef.current === index; - }; - - const triggerFocus = (nextFocus: boolean) => { - setFocused(nextFocus); - }; - - // ============================= Record ============================= - const lastOperation = (type?: OperationType) => { - if (type) { - lastOperationRef.current = type; - } - return lastOperationRef.current; - }; - - // ============================ Strategy ============================ - // Trigger when input enter or input blur or panel close - const nextActiveIndex: NextActive = (nextValue: RangeValueType) => { - const list = activeListRef.current; - const filledActiveSet = new Set(list.filter((index) => nextValue[index] || empty[index])); - const nextIndex = list[list.length - 1] === 0 ? 1 : 0; - - if (filledActiveSet.size >= 2 || disabled[nextIndex]) { - return null; - } - - return nextIndex; - }; - - // ============================= Effect ============================= - // Wait in case it's from the click outside to blur - useLockEffect(focused || mergedOpen, () => { - if (!focused) { - activeListRef.current = []; - updateSubmitIndex(null); - } - }); - - React.useEffect(() => { - if (focused) { - activeListRef.current.push(activeIndex); - } - }, [focused, activeIndex]); - - return [ - focused, - triggerFocus, - lastOperation, - activeIndex, - setActiveIndex, - nextActiveIndex, - activeListRef.current, - updateSubmitIndex, - hasActiveSubmitValue, - ]; -} diff --git a/src/PickerInput/hooks/useRangeDisabledDate.ts b/src/PickerInput/hooks/useRangeDisabledDate.ts index 3aca5c1d8..f897010bc 100644 --- a/src/PickerInput/hooks/useRangeDisabledDate.ts +++ b/src/PickerInput/hooks/useRangeDisabledDate.ts @@ -11,19 +11,18 @@ import { getFromDate } from '../../utils/miscUtil'; export default function useRangeDisabledDate( values: RangeValueType, disabled: [boolean, boolean], - activeIndexList: number[], + activeIndex: number, + triggeredFields: number[], generateConfig: GenerateConfig, locale: Locale, disabledDate?: DisabledDate, ) { - const activeIndex = activeIndexList[activeIndexList.length - 1]; - const rangeDisabledDate: DisabledDate = (date, info) => { const [start, end] = values; const mergedInfo = { ...info, - from: getFromDate(values, activeIndexList), + from: getFromDate(values, triggeredFields, activeIndex), }; // ============================ Disabled ============================ diff --git a/src/PickerInput/hooks/useRangePickerValue.ts b/src/PickerInput/hooks/useRangePickerValue.ts index ccf3992b2..6b3586cba 100644 --- a/src/PickerInput/hooks/useRangePickerValue.ts +++ b/src/PickerInput/hooks/useRangePickerValue.ts @@ -39,6 +39,7 @@ export default function useRangePickerValue { @@ -64,7 +68,8 @@ export default function useRangePickerValue { - if (multiplePanel) { - // Basic offset - const SAME_CHECKER: Partial> = { - date: 'month', - week: 'month', - month: 'year', - quarter: 'year', - }; - - const mode = SAME_CHECKER[pickerMode]; - if (mode && !isSame(generateConfig, locale, startDate, endDate, mode)) { - return offsetPanelDate(generateConfig, pickerMode, endDate, -1); - } + // Check whether two dates belong to the same panel. + // 判断两个日期是否属于同一个面板。 + const isSamePanel = (date1: DateType, date2: DateType) => { + if (pickerMode === 'year') { + return ( + Math.floor(generateConfig.getYear(date1) / 10) === + Math.floor(generateConfig.getYear(date2) / 10) + ); + } - // Year offset - if (pickerMode === 'year' && startDate) { - const srcYear = Math.floor(generateConfig.getYear(startDate) / 10); - const tgtYear = Math.floor(generateConfig.getYear(endDate) / 10); - if (srcYear !== tgtYear) { - return offsetPanelDate(generateConfig, pickerMode, endDate, -1); - } - } + const panelMode: PanelMode = + pickerMode === 'month' || pickerMode === 'quarter' ? 'year' : 'month'; + return isSame(generateConfig, locale, date1, date2, panelMode); + }; + + // Keep both values in the two visible panels when possible. Otherwise put + // the end value in the second panel. + // 尽量在双面板内同时展示两个值;无法容纳时,将 end 值放在右侧面板。 + const getEndDatePickerValue = (startDate: DateType, endDate: DateType) => { + if (!multiplePanel || !startDate) { + return endDate; } - return endDate; + const nextPanelDate = offsetPanelDate(generateConfig, pickerMode, startDate, 1); + const endInPanels = isSamePanel(startDate, endDate) || isSamePanel(nextPanelDate, endDate); + + return endInPanels ? startDate : offsetPanelDate(generateConfig, pickerMode, endDate, -1); }; // >>> When switch field, reset the picker value as prev field picker value @@ -164,30 +164,30 @@ export default function useRangePickerValue>> Reset prevActiveIndex when panel closed + // >>> Track previous field only during one continuous Picker focus session React.useEffect(() => { - if (open) { + if (open && preserveOnFieldChange) { prevActiveIndexRef.current = mergedActiveIndex; } else { prevActiveIndexRef.current = null; } - }, [open, mergedActiveIndex]); + }, [open, preserveOnFieldChange, mergedActiveIndex]); // >>> defaultPickerValue: Resync to `defaultPickerValue` for each panel focused useLayoutEffect(() => { diff --git a/src/PickerInput/hooks/useRangeValue.ts b/src/PickerInput/hooks/useRangeValue.ts index 6cd0ac367..5de85f5c3 100644 --- a/src/PickerInput/hooks/useRangeValue.ts +++ b/src/PickerInput/hooks/useRangeValue.ts @@ -7,7 +7,6 @@ import { formatValue, isSame, isSameTimestamp } from '../../utils/dateUtil'; import { fillIndex } from '../../utils/miscUtil'; import type { RangePickerProps } from '../RangePicker'; import type { ReplacedPickerProps } from '../SinglePicker'; -import useLockEffect from './useLockEffect'; const EMPTY_VALUE: any[] = []; @@ -93,7 +92,7 @@ function useCalendarValue(mergedValue: MergedV /** * Control the internal `value` align with prop `value` and provide a temp `calendarValue` for ui. - * `calendarValue` will be reset when blur & focus & open. + * The caller controls the temporary `calendarValue` lifecycle through event handlers. */ export function useInnerValue( generateConfig: GenerateConfig, @@ -173,14 +172,14 @@ export default function useRangeValue, disabled: ReplaceListType, boolean>, formatList: FormatType[], - focused: boolean, - open: boolean, isInvalidateDate: (date: DateType, info?: { from?: DateType; activeIndex: number }) => boolean, ): [ /** Trigger `onChange` by check `disabledDate` */ flushSubmit: (index: number, needTriggerChange: boolean) => void, /** Trigger `onChange` directly without check `disabledDate` */ triggerSubmitChange: (value: ValueType) => boolean, + /** Reset calendar and submit values back to the committed value */ + resetValue: (index?: number) => void, ] { const { // MISC @@ -305,28 +304,17 @@ export default function useRangeValue { - if (interactiveFinished) { - // Always try to trigger submit first - triggerSubmit(); - - // Trigger calendar change since this is a effect reset - // https://github.com/ant-design/ant-design/issues/22351 - triggerCalendarChange(mergedValue); + const resetValue = useEvent((index?: number) => { + if (index === undefined) { + triggerCalendarChange(mergedValue); + syncWithValue(); + return; + } - // Sync with value anyway - syncWithValue(); - } - }, - 2, - ); + triggerCalendarChange(fillIndex(getCalendarValue(), index, mergedValue[index])); + setSubmitValue(fillIndex(submitValue(), index, mergedValue[index])); + }); // ============================ Return ============================ - return [flushSubmit, triggerSubmit]; + return [flushSubmit, triggerSubmit, resetValue]; } diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts new file mode 100644 index 000000000..a5643472b --- /dev/null +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -0,0 +1,442 @@ +import { useEvent, useSyncState } from '@rc-component/util'; +import * as React from 'react'; + +// ============================= Types ============================= + +/** Change source of a field. / Field 的变更来源。 */ +export type RangeValueChangeSource = + | 'input' + | 'keyboard-submit' + | 'esc' + | 'panel-intermediate' + | 'panel-final' + | 'blur' + | 'field-switch' + | 'confirm'; + +/** Resolved operation for one field interaction. / 一次 field 交互最终执行的操作。 */ +export type RangeValueChangeAction = + | 'modify' + | 'switchNext' + | 'finish' + | 'abort' + | 'resetCurrent' + | 'resetCurrentAndSwitchNext' + | 'resetAll'; + +/** Receive a field interaction and its optional value. / 接收 field 交互及可选变更值。 */ +export type TriggerChange = ( + index: number, + source: RangeValueChangeSource, + value?: FieldValue, +) => void; + +/** Read the latest temporary CalendarValue. / 读取最新的临时 CalendarValue。 */ +export type GetCalendarValue = () => readonly (FieldValue | null | undefined)[]; + +/** Update one field in CalendarValue. / 更新 CalendarValue 中的一个 field。 */ +export type TriggerCalendarChange = (index: number, value: FieldValue) => void; + +/** + * Flush one field and optionally emit the final change. + * 提交一个 field,并按需触发最终 change。 + */ +export type FlushSubmit = (index: number, needTriggerChange: boolean) => void; + +/** + * Reset one field, or all fields when index is omitted. + * 重置指定 field;未传 index 时重置全部 field。 + */ +export type ResetValue = (index?: number) => void; + +export type UseRangeValueChangeReturn = [ + currentIndex: number | null, + activeIndex: number, + triggeredFields: number[], + triggerChange: TriggerChange, +]; + +interface TriggeredField { + index: number; + modified: boolean; +} + +// ============================== Hook ============================== + +/** + * Coordinate CalendarValue updates, part submits and final submits for any + * number of fields. + * 统一管理任意数量 field 的 CalendarValue 更新、局部提交与最终提交。 + * + * Flow / 流程: + * Every event is first resolved from `source`, `needConfirm`, `allowEmpty` and + * the field indexes to one action. State changes only happen while executing + * that action, so event sources never submit or reset values on their own. + * 每个事件先根据 `source`、`needConfirm`、`allowEmpty` 与 field index 得到唯一 + * action。状态只在执行 action 时改变,事件来源本身不直接提交或重置值。 + * + * Source resolution / 事件解析: + * + * - `esc` always resolves to `resetAll`. + * `esc` 始终解析为 `resetAll`。 + * - With no current field, a standalone `blur` resolves to `resetAll`; any + * other non-cancel event starts a new interaction from its field. + * 没有当前 field 时,独立的 `blur` 解析为 `resetAll`;其余非撤销事件从 + * 对应 field 开始新一轮交互。 + * - `field-switch` advances exactly one field in circular order. `needConfirm` + * locks an unconfirmed non-empty field unless it allows empty; an allow-empty + * field is reset before advancing. + * `field-switch` 只允许按循环顺序推进一个 field。`needConfirm` 会锁定未确认 + * 且非空的 field;允许空值时先重置再推进。 + * - Other sources must target the current field. `input` and + * `panel-intermediate` modify it; `panel-final` advances only without + * confirmation; `keyboard-submit` and `confirm` advance only when the field + * has a value or allows empty. + * 其余来源必须指向当前 field。`input` 与 `panel-intermediate` 只修改; + * `panel-final` 仅在无需确认时推进;`keyboard-submit` 与 `confirm` 仅在有值 + * 或允许空值时推进。 + * - `blur` finishes an untouched interaction. Without confirmation it submits + * a valid field; with confirmation it submits only after every field has + * participated, otherwise it resets all temporary values. A modified + * allow-empty field is reset before the final submit. + * `blur` 会直接结束未修改的交互。无需确认时提交有效 field;需要确认时仅在 + * 所有 field 都参与过后提交,否则重置全部临时值。当前 field 已修改且允许 + * 为空时,会先重置当前值再完成提交。 + * + * Action execution / Action 执行: + * + * - `modify`: update or record the current CalendarValue. + * 更新或记录当前 CalendarValue。 + * - `switchNext`: submit the current field and advance to the next field. + * 提交当前 field 并推进到下一个 field。 + * - `finish`: end an interaction in which no field was modified without + * resetting values. / 结束所有 field 均未修改的交互,不重置任何值。 + * - `abort`: stop without changing any state. + * 直接短路,不改变任何状态。 + * - `resetCurrent`: discard only the current field. + * 仅撤销当前 field。 + * - `resetCurrentAndSwitchNext`: discard the current temporary value and + * advance without submitting. Revisiting a field starts a new round. + * 撤销当前临时值并直接推进,不触发提交;再次进入已访问 field 时开启新一轮。 + * - `resetAll`: discard all temporary values and end the interaction. + * 撤销全部临时值并结束本轮交互。 + */ +export default function useRangeValueChange( + fieldCount: number, + needConfirm: boolean, + allowEmpty: readonly boolean[], + getCalendarValue: GetCalendarValue, + triggerCalendarChange: TriggerCalendarChange, + flushSubmit: FlushSubmit, + resetValue: ResetValue, +): UseRangeValueChangeReturn { + // ============================= State ============================= + + // Record fields involved in the current interaction and whether each field + // has been modified since it became active. + // 记录当前一轮交互中触发过的 field,以及它从本次激活后是否发生过修改。 + const triggeredFieldsRef = React.useRef([]); + + // Track the last explicitly confirmed field. `triggeredFields` also records + // focus, so it cannot tell confirmed and unconfirmed values apart. + // 记录最后一个明确确认过的 field。`triggeredFields` 同时记录 focus, + // 因此无法单独区分已确认值与未确认值。 + const confirmedIndexRef = React.useRef(null); + + // Keep a render value and a synchronous getter for event handlers. + // 同时保存渲染值,以及供事件处理函数同步读取的 getter。 + const [getCurrentIndex, setCurrentIndex] = useSyncState(null); + + // Keep the latest accepted field for panel and selector rendering. + // 保留最后一个被业务接受的 field,供 panel 和 selector 渲染使用; + const lastValidIndexRef = React.useRef(undefined); + + // ============================= Record ============================ + + // Keep fields unique while preserving their first-triggered order. Omit + // `modified` to keep the existing state, or pass it to start a new field + // visit and record a modification. + // field 保持唯一并保留首次触发顺序。省略 `modified` 时保留原状态;传入时 + // 用于开始一次新的 field 访问,或记录本次修改。 + const recordTriggeredField = (index: number, modified?: boolean) => { + const field = triggeredFieldsRef.current.find((item) => item.index === index); + + if (field) { + if (modified !== undefined) { + field.modified = modified; + } + } else { + triggeredFieldsRef.current = [ + ...triggeredFieldsRef.current, + { index, modified: modified ?? false }, + ]; + } + }; + + // ============================= Submit ============================ + + // Flush the current field, then finish the round or advance to the next one. + // 提交当前 field,随后结束本轮或推进到下一个 field。 + const submitField = (index: number) => { + recordTriggeredField(index); + + // Trigger final change after every field has participated once. + // 所有 field 都参与过一次后,触发最终 change。 + const allFieldsTriggered = triggeredFieldsRef.current.length >= fieldCount; + flushSubmit(index, allFieldsTriggered); + + if (allFieldsTriggered) { + triggeredFieldsRef.current = []; + confirmedIndexRef.current = null; + setCurrentIndex(null); + } else { + const nextIndex = (index + 1) % fieldCount; + setCurrentIndex(nextIndex); + } + + return allFieldsTriggered; + }; + + // ============================= Resolve =========================== + + // Resolve an interaction to one action without changing any state. + // 仅根据当前状态解析 action,不在判断过程中修改任何状态。 + const resolveAction = ( + currentIndex: number | null, + index: number, + source: RangeValueChangeSource, + value?: FieldValue, + ): RangeValueChangeAction => { + if (source === 'esc') { + return 'resetAll'; + } + + if (currentIndex === null) { + // A blur after the interaction has completed still needs to discard any + // temporary CalendarValue left by a controlled value. + // 一轮交互结束后的 blur 仍需清理受控值留下的临时 CalendarValue。 + return source === 'blur' ? 'resetAll' : 'abort'; + } + + const currentValue = value === undefined ? getCalendarValue()[currentIndex] : value; + const currentEmpty = currentValue === null || currentValue === undefined; + const canSwitch = !currentEmpty || allowEmpty[currentIndex]; + + if (source === 'field-switch') { + if (index === currentIndex) { + return 'abort'; + } + + const nextIndex = (currentIndex + 1) % fieldCount; + if (index !== nextIndex) { + return 'abort'; + } + + const nextFieldTriggered = triggeredFieldsRef.current.some( + (field) => field.index === nextIndex, + ); + + if (needConfirm) { + if (confirmedIndexRef.current === currentIndex) { + return 'switchNext'; + } + + // An allowEmpty field may be left without confirmation. Discard any + // unconfirmed CalendarValue before moving to the next field. + // allowEmpty field 可以在未确认时离开;切换前需要丢弃未确认的 + // CalendarValue,再进入下一个 field。 + return allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort'; + } + + if (canSwitch) { + return 'switchNext'; + } + + // Revisiting the next field starts another circular round. Discard the + // invalid current field and finish the old round before entering it. + // 再次进入已触发的 next field 表示开始新一轮循环。进入前先丢弃当前 + // 无效 field,并结束旧的一轮。 + return nextFieldTriggered ? 'resetCurrentAndSwitchNext' : 'resetCurrent'; + } + + if (index !== currentIndex) { + return 'abort'; + } + + if (source === 'blur') { + const interactionModified = triggeredFieldsRef.current.some((field) => field.modified); + + if (!interactionModified) { + return 'finish'; + } + + if (needConfirm) { + const currentModified = + triggeredFieldsRef.current.find((field) => field.index === currentIndex)?.modified ?? + false; + const allFieldsTriggered = triggeredFieldsRef.current.length >= fieldCount; + + // Blur ends the interaction instead of advancing to an unvisited field. + // When the current field allows empty, discard its unconfirmed value + // before finishing the round. + // blur 用于结束交互,不应继续推进到尚未访问的 field。当前 field + // 允许为空时,先丢弃未确认值,再结束本轮。 + if (!allFieldsTriggered || !canSwitch) { + return 'resetAll'; + } + + if (currentModified) { + return allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'resetAll'; + } + + return 'switchNext'; + } + + if (!canSwitch) { + return 'resetAll'; + } + + return 'switchNext'; + } + + if (source === 'input' || source === 'panel-intermediate') { + return 'modify'; + } + + if (source === 'panel-final') { + return needConfirm ? 'modify' : 'switchNext'; + } + + if (source === 'keyboard-submit' || source === 'confirm') { + return canSwitch ? 'switchNext' : 'abort'; + } + + return 'abort'; + }; + + // ============================= Trigger =========================== + + // Route every interaction through action resolution, then execute the + // resolved action in one place. + // 所有交互先统一解析 action,再在一个位置执行对应操作。 + const triggerChange = useEvent( + (index: number, source: RangeValueChangeSource, value?: FieldValue) => { + let currentIndex = getCurrentIndex(); + + // Start a new interaction from the first non-blur event. A standalone + // blur may clean temporary values but must not create an active field. + // 第一条非 blur 事件用于建立新一轮交互;单独的 blur 可以清理临时值, + // 但不应因此创建 currentIndex。 + if (currentIndex === null && source !== 'blur' && source !== 'esc') { + currentIndex = index; + setCurrentIndex(index); + recordTriggeredField(index, false); + } + + const action = resolveAction(currentIndex, index, source, value); + const actionIndex = currentIndex ?? index; + + switch (action) { + case 'modify': + recordTriggeredField(actionIndex, true); + if (confirmedIndexRef.current === actionIndex) { + confirmedIndexRef.current = null; + } + if (value !== undefined) { + triggerCalendarChange(actionIndex, value); + } + break; + + case 'switchNext': + if (source === 'panel-final' || value !== undefined) { + recordTriggeredField(actionIndex, true); + } + if (value !== undefined) { + triggerCalendarChange(actionIndex, value); + } + if (needConfirm && (source === 'keyboard-submit' || source === 'confirm')) { + confirmedIndexRef.current = actionIndex; + } + if (submitField(actionIndex) && source === 'field-switch') { + // The focus switch finishes the previous round and also starts a + // new round from its target field. + // 本次 focus 切换既结束上一轮,也以目标 field 开始新一轮。 + setCurrentIndex(index); + } + if (source === 'field-switch') { + recordTriggeredField(index, false); + } + break; + + case 'finish': + triggeredFieldsRef.current = []; + confirmedIndexRef.current = null; + setCurrentIndex(null); + break; + + case 'resetCurrent': + resetValue(actionIndex); + if (confirmedIndexRef.current === actionIndex) { + confirmedIndexRef.current = null; + } + triggeredFieldsRef.current = triggeredFieldsRef.current.filter( + (field) => field.index !== actionIndex, + ); + break; + + case 'resetCurrentAndSwitchNext': { + resetValue(actionIndex); + if (confirmedIndexRef.current === actionIndex) { + confirmedIndexRef.current = null; + } + + if (source === 'field-switch') { + const nextFieldTriggered = triggeredFieldsRef.current.some( + (field) => field.index === index, + ); + + // A reset never submits or checks whether all fields were handled. + // Revisiting the target discards the old round before starting the + // next one from that target. + // reset 不触发提交,也不判断所有 field 是否已处理。再次进入目标 + // field 时丢弃旧一轮记录,并从该 field 开启新一轮。 + if (nextFieldTriggered) { + triggeredFieldsRef.current = []; + } + + setCurrentIndex(index); + recordTriggeredField(index, false); + } else { + // Blur leaves the whole Picker, so it ends the interaction instead + // of focusing the next field. + // blur 表示离开整个 Picker,因此结束交互,不再聚焦下一个 field。 + triggeredFieldsRef.current = []; + confirmedIndexRef.current = null; + setCurrentIndex(null); + } + break; + } + + case 'resetAll': + resetValue(); + triggeredFieldsRef.current = []; + confirmedIndexRef.current = null; + setCurrentIndex(null); + break; + + case 'abort': + if (source === 'field-switch' && index === actionIndex) { + recordTriggeredField(index); + } + break; + } + }, + ); + + const currentIndex = getCurrentIndex(); + lastValidIndexRef.current = currentIndex ?? lastValidIndexRef.current ?? 0; + + const triggeredFields = triggeredFieldsRef.current.map((field) => field.index); + + return [currentIndex, lastValidIndexRef.current, triggeredFields, triggerChange]; +} diff --git a/src/utils/miscUtil.ts b/src/utils/miscUtil.ts index e2d5efef9..0930e4259 100644 --- a/src/utils/miscUtil.ts +++ b/src/utils/miscUtil.ts @@ -72,12 +72,10 @@ export function getRowFormat( export function getFromDate( calendarValues: DateType[], - activeIndexList: number[], - activeIndex?: number, + triggeredFields: number[], + activeIndex: number, ) { - const mergedActiveIndex = - activeIndex !== undefined ? activeIndex : activeIndexList[activeIndexList.length - 1]; - const firstValuedIndex = activeIndexList.find((index) => calendarValues[index]); + const firstValuedIndex = triggeredFields.find((index) => calendarValues[index]); - return mergedActiveIndex !== firstValuedIndex ? calendarValues[firstValuedIndex] : undefined; + return activeIndex !== firstValuedIndex ? calendarValues[firstValuedIndex] : undefined; } diff --git a/tests/disabledTime.spec.tsx b/tests/disabledTime.spec.tsx index a3872ec08..7149975cf 100644 --- a/tests/disabledTime.spec.tsx +++ b/tests/disabledTime.spec.tsx @@ -47,6 +47,7 @@ describe('Picker.DisabledTime', () => { it('disabledTime on TimeRangePicker', () => { const { container } = render( ({ disabledHours: () => (type === 'start' ? [1, 3, 5] : [2, 4]), diff --git a/tests/keyboard.spec.tsx b/tests/keyboard.spec.tsx index f3fb93352..0e3623c82 100644 --- a/tests/keyboard.spec.tsx +++ b/tests/keyboard.spec.tsx @@ -1,7 +1,14 @@ import { act, fireEvent, render } from '@testing-library/react'; import { resetWarned } from '@rc-component/util'; import React from 'react'; -import { DateFnsSinglePicker, DayPicker, getMoment, isOpen, openPicker } from './util/commonUtil'; +import { + DateFnsSinglePicker, + DayPicker, + getMoment, + isOpen, + openPicker, + triggerFocus, +} from './util/commonUtil'; // TODO: New keyboard interactive describe('Picker.Keyboard', () => { @@ -26,7 +33,7 @@ describe('Picker.Keyboard', () => { const inputEle = container.querySelector('input'); // Focus - fireEvent.focus(inputEle); + triggerFocus(inputEle); expect(isOpen()).toBeFalsy(); // Key to open diff --git a/tests/new-range.spec.tsx b/tests/new-range.spec.tsx index 85a4dd07c..24cfbe0a5 100644 --- a/tests/new-range.spec.tsx +++ b/tests/new-range.spec.tsx @@ -16,6 +16,8 @@ import { isSame, openPicker, selectCell, + triggerBlur, + triggerFocus, waitFakeTimer, } from './util/commonUtil'; @@ -45,7 +47,10 @@ describe('NewPicker.Range', () => { describe('PickerValue', () => { it('defaultPickerValue should reset every time when opened', () => { const { container } = render( - , + , ); // Left @@ -79,6 +84,7 @@ describe('NewPicker.Range', () => { const { container } = render( { const startInput = container.querySelectorAll('input')[0]; const endInput = container.querySelectorAll('input')[1]; - fireEvent.focus(startInput); + triggerFocus(startInput); fireEvent.change(startInput, { target: { value: '00:00:00', @@ -650,7 +656,7 @@ describe('NewPicker.Range', () => { jest.runAllTimers(); }); - container.querySelector('.focus').focus(); + triggerFocus(container.querySelector('.focus')); // Changed by click OK openPicker(container); @@ -673,19 +679,21 @@ describe('NewPicker.Range', () => { , ); - // Change start time (manually focus since fireEvent.focus not change activeElement) + // Change start time openPicker(container); const li6 = document.querySelector('.rc-picker-time-panel-column').querySelectorAll('li')[6]; fireEvent.mouseDown(li6); fireEvent.click(li6); - document.querySelector('.rc-picker-panel-container').focus(); + triggerFocus(document.querySelector('.rc-picker-panel-container')); act(() => { jest.runAllTimers(); }); // Close panel to auto focus next end field + const startFocusedElement = document.activeElement; fireEvent.mouseDown(document.body); + triggerBlur(startFocusedElement as HTMLElement); act(() => { jest.runAllTimers(); }); @@ -706,7 +714,9 @@ describe('NewPicker.Range', () => { }); // Close panel to auto focus next end field + const endFocusedElement = document.activeElement; fireEvent.mouseDown(document.body); + triggerBlur(endFocusedElement as HTMLElement); act(() => { jest.runAllTimers(); @@ -822,7 +832,7 @@ describe('NewPicker.Range', () => { const startInput = container.querySelectorAll('input')[0]; const endInput = container.querySelectorAll('input')[1]; - startInput.focus(); + triggerFocus(startInput); fireEvent.change(startInput, { target: { value: '00:00:00', @@ -832,7 +842,7 @@ describe('NewPicker.Range', () => { key: 'Tab', }); - endInput.focus(); + triggerFocus(endInput); fireEvent.change(endInput, { target: { value: '02:00:00', @@ -852,7 +862,7 @@ describe('NewPicker.Range', () => { const { rerender } = render(renderDemo(true)); await waitFakeTimer(); - fireEvent.focus(document.querySelector('.rc-picker-panel-container')); + triggerFocus(document.querySelector('.rc-picker-panel-container')); selectCell(5); selectCell(10); @@ -861,14 +871,14 @@ describe('NewPicker.Range', () => { // Force close and open again rerender(renderDemo(false)); - fireEvent.blur(document.querySelector('.rc-picker-panel-container')); + triggerBlur(document.querySelector('.rc-picker-panel-container')); await waitFakeTimer(); rerender(renderDemo(true)); - fireEvent.blur(document.querySelector('.rc-picker-panel-container')); + triggerBlur(document.querySelector('.rc-picker-panel-container')); await waitFakeTimer(); - fireEvent.focus(document.querySelector('.rc-picker-panel-container')); + triggerFocus(document.querySelector('.rc-picker-panel-container')); selectCell(7); selectCell(11); @@ -882,7 +892,7 @@ describe('NewPicker.Range', () => { await waitFakeTimer(); - fireEvent.focus(document.querySelector('.rc-picker-panel-container')); + triggerFocus(document.querySelector('.rc-picker-panel-container')); selectCell(5); selectCell(10); @@ -940,7 +950,7 @@ describe('NewPicker.Range', () => { const startInput = container.querySelectorAll('input')[0]; // Year selection - fireEvent.focus(startInput); + triggerFocus(startInput); expect(startInput.selectionStart).toEqual(0); expect(startInput.selectionEnd).toEqual(4); @@ -969,7 +979,7 @@ describe('NewPicker.Range', () => { const startInput = container.querySelectorAll('input')[0]; const endInput = container.querySelectorAll('input')[1]; - fireEvent.focus(startInput); + triggerFocus(startInput); fireEvent.paste(startInput, { clipboardData: { getData: () => '20200903', @@ -981,7 +991,7 @@ describe('NewPicker.Range', () => { // End field await waitFakeTimer(); - fireEvent.focus(endInput); + triggerFocus(endInput); fireEvent.paste(endInput, { clipboardData: { getData: () => '20200905', @@ -1001,7 +1011,7 @@ describe('NewPicker.Range', () => { // Simulate focus gained by mousedown, then paste before mouse up. fireEvent.mouseDown(startInput); - fireEvent.focus(startInput); + triggerFocus(startInput); const pasteEvent = createEvent.paste(startInput, { clipboardData: { @@ -1021,7 +1031,7 @@ describe('NewPicker.Range', () => { const startInput = container.querySelectorAll('input')[0]; // Year selection - fireEvent.focus(startInput); + triggerFocus(startInput); fireEvent.mouseDown(startInput); startInput.selectionStart = 5; @@ -1037,7 +1047,7 @@ describe('NewPicker.Range', () => { // Simulate focus gained by mousedown, then key input before mouse up. fireEvent.mouseDown(startInput); - fireEvent.focus(startInput); + triggerFocus(startInput); const keyDownEvent = createEvent.keyDown(startInput, { key: '1', @@ -1055,7 +1065,7 @@ describe('NewPicker.Range', () => { const startInput = container.querySelectorAll('input')[0]; fireEvent.mouseDown(startInput); - fireEvent.focus(startInput); + triggerFocus(startInput); fireEvent.mouseUp(startInput); expect(startInput.selectionStart).toBeDefined(); @@ -1067,11 +1077,11 @@ describe('NewPicker.Range', () => { const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); expect(firstInput).toHaveValue('YYYYMMDD'); - fireEvent.blur(firstInput); - await waitFakeTimer(); + triggerBlur(firstInput); + await waitFakeTimer(0, 2); expect(firstInput).toHaveValue(''); }); @@ -1081,7 +1091,7 @@ describe('NewPicker.Range', () => { ); const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); fireEvent.keyDown(firstInput, { key: 'Backspace', @@ -1094,7 +1104,7 @@ describe('NewPicker.Range', () => { const { container } = render(); const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); fireEvent.keyDown(firstInput, { key: 'ArrowUp', @@ -1111,7 +1121,7 @@ describe('NewPicker.Range', () => { const { container } = render(); const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); const fullText = '20000309'; @@ -1142,7 +1152,7 @@ describe('NewPicker.Range', () => { openPicker(container); - fireEvent.focus(document.querySelector('.bamboo')); + triggerFocus(document.querySelector('.bamboo')); expect(onOpenChange).not.toHaveBeenCalledWith(false); }); @@ -1252,7 +1262,7 @@ describe('NewPicker.Range', () => { const endInput = container.querySelectorAll('input')[1]; // Start - fireEvent.focus(startInput); + triggerFocus(startInput); fireEvent.change(startInput, { target: { value: '2000-01-07', @@ -1263,7 +1273,7 @@ describe('NewPicker.Range', () => { }); // End - fireEvent.focus(endInput); + triggerFocus(endInput); fireEvent.change(endInput, { target: { value: '2000-01-14', @@ -1284,7 +1294,7 @@ describe('NewPicker.Range', () => { const endInput = container.querySelectorAll('input')[1]; // Start - fireEvent.focus(startInput); + triggerFocus(startInput); fireEvent.change(startInput, { target: { value: '2000-01-07', @@ -1295,7 +1305,7 @@ describe('NewPicker.Range', () => { }); // End - fireEvent.focus(endInput); + triggerFocus(endInput); fireEvent.change(endInput, { target: { value: '2001-01-08', diff --git a/tests/picker.spec.tsx b/tests/picker.spec.tsx index 15b964ede..b33fa2fa4 100644 --- a/tests/picker.spec.tsx +++ b/tests/picker.spec.tsx @@ -24,6 +24,8 @@ import { isSame, openPicker, selectCell, + triggerBlur, + triggerFocus, waitFakeTimer, } from './util/commonUtil'; @@ -256,7 +258,7 @@ describe('Picker.Basic', () => { , ); openPicker(container); - fireEvent.focus(container.querySelector('input')); + triggerFocus(container.querySelector('input')); // Invalidate value fireEvent.change(container.querySelector('input'), { @@ -293,7 +295,7 @@ describe('Picker.Basic', () => { it('should not throw errow when input end year first', () => { const { container } = render(); openPicker(container); - fireEvent.focus(container.querySelectorAll('input')[1]); + triggerFocus(container.querySelectorAll('input')[1]); expect(() => { fireEvent.change(container.querySelectorAll('input')[1], { target: { @@ -311,11 +313,17 @@ describe('Picker.Basic', () => { beforeAll(() => { domMock = spyElementPrototypes(HTMLElement, { - focus: () => { + focus(oriDesc: any, ...rest: any[]) { focused = true; + + // Call origin + oriDesc.value.call(this, ...rest); }, - blur: () => { + blur(oriDesc: any, ...rest: any[]) { blurred = true; + + // Call origin + oriDesc.value.call(this, ...rest); }, }); }); @@ -337,10 +345,10 @@ describe('Picker.Basic', () => { , ); - ref.current!.focus(); + triggerFocus(ref.current!); expect(focused).toBeTruthy(); - ref.current!.blur(); + triggerBlur(ref.current!); expect(blurred).toBeTruthy(); }); @@ -354,11 +362,11 @@ describe('Picker.Basic', () => { , ); - fireEvent.focus(container.querySelector('input')); + triggerFocus(container.querySelector('input')); expect(onFocus).toHaveBeenCalled(); expect(document.querySelector('.rc-picker-focused')).toBeTruthy(); - fireEvent.blur(container.querySelector('input')); + triggerBlur(container.querySelector('input')); expect(onBlur).toHaveBeenCalled(); expect(document.querySelector('.rc-picker-focused')).toBeFalsy(); }); @@ -394,7 +402,7 @@ describe('Picker.Basic', () => { const $input = container.querySelector('input'); openPicker(container); - $input.focus(); + triggerFocus($input); keyDown(KeyCode.ESC); expect(document.activeElement).toBe($input); diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 174a1d64a..e2c597d42 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -28,6 +28,8 @@ import { isSame, openPicker, selectCell, + triggerBlur, + triggerFocus, waitFakeTimer, } from './util/commonUtil'; @@ -555,10 +557,10 @@ describe('Picker.Range', () => { , ); - ref.current!.focus(); + triggerFocus(ref.current!); expect(focused).toBeTruthy(); - ref.current!.blur(); + triggerBlur(ref.current!); expect(blurred).toBeTruthy(); }); @@ -594,7 +596,7 @@ describe('Picker.Range', () => { }); it('mode is array', () => { - const { container } = render(); + const { container } = render(); openPicker(container); expect(document.querySelector('.rc-picker-year-panel')).toBeTruthy(); @@ -739,7 +741,7 @@ describe('Picker.Range', () => { selectCell(11); expect(isOpen()).toBeTruthy(); - fireEvent.blur(container.querySelectorAll('input')[1]); + triggerBlur(container.querySelectorAll('input')[1]); act(() => { jest.runAllTimers(); @@ -814,8 +816,7 @@ describe('Picker.Range', () => { act(() => { fireEvent.mouseDown(container.querySelectorAll('input')[1]); - fireEvent.blur(container.querySelectorAll('input')[0]); - fireEvent.focus(container.querySelectorAll('input')[1]); + triggerFocus(container.querySelectorAll('input')[1]); jest.runAllTimers(); }); @@ -949,6 +950,7 @@ describe('Picker.Range', () => { it('defaultPickerValue', () => { const { container } = render( , @@ -1198,8 +1200,7 @@ describe('Picker.Range', () => { }, }); - // Force blur since fireEvent blur will not change document.activeElement - container.querySelectorAll('input')[1].blur(); + triggerBlur(container.querySelectorAll('input')[1]); closePicker(container, 1); expect(document.querySelectorAll('input')[0].value).toEqual('19890903'); @@ -1613,7 +1614,7 @@ describe('Picker.Range', () => { // back to first panel and clear input value // `testing-lib` fire the `focus` event but not change the `document.activeElement` // We call `focus` manually here - document.querySelectorAll('input')[0].focus(); + triggerFocus(document.querySelectorAll('input')[0]); inputValue('', 0); // reselect date @@ -1684,7 +1685,7 @@ describe('Picker.Range', () => { const { container } = render( , ); - fireEvent.focus(document.querySelector('input')); + triggerFocus(document.querySelector('input')); function pickerKeyDown(keyCode: number) { fireEvent.keyDown(container.querySelector('.rc-picker'), { @@ -1972,7 +1973,7 @@ describe('Picker.Range', () => { it('selected date when open is true should switch panel', () => { const { container } = render(); - fireEvent.focus(container.querySelector('input')); + triggerFocus(container.querySelector('input')); fireEvent.click(document.querySelector('.rc-picker-cell')); expect(document.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active'); @@ -2020,7 +2021,7 @@ describe('Picker.Range', () => { const onOpenChange = jest.fn(); const { container } = render(); - fireEvent.focus(container.querySelector('input')); + triggerFocus(container.querySelector('input')); for (let i = 0; i < 2; i++) { selectCell(24); @@ -2036,15 +2037,13 @@ describe('Picker.Range', () => { const { container } = render(); act(() => { - fireEvent.focus(container.querySelectorAll('input')[0]); + triggerFocus(container.querySelectorAll('input')[0]); fireEvent.change(container.querySelectorAll('input')[0], { target: { value: '2024-06-13', }, }); - fireEvent.blur(container.querySelectorAll('input')[0]); - - fireEvent.focus(container.querySelectorAll('input')[1]); + triggerFocus(container.querySelectorAll('input')[1]); fireEvent.change(container.querySelectorAll('input')[1], { target: { value: '2024-06-15', @@ -2076,7 +2075,9 @@ describe('Picker.Range', () => { selectCell(2, 0); // Click outside to blur + const focusedElement = document.activeElement; fireEvent.mouseDown(document.body); + triggerBlur(focusedElement as HTMLElement); fireEvent.mouseUp(document.body); fireEvent.click(document.body); @@ -2135,6 +2136,92 @@ describe('Picker.Range', () => { expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active'); }); + // https://github.com/ant-design/ant-design/issues/57728 + it('should not submit unconfirmed allowEmpty value on blur', async () => { + const onChange = jest.fn(); + const { container } = render(); + const [startInput, endInput] = container.querySelectorAll('input'); + + // Select start without confirming, then switch to end and back to start. + openPicker(container); + expect(document.activeElement).toBe(startInput); + expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active'); + expect(isOpen()).toBeTruthy(); + + selectCell(5); + openPicker(container, 1); + + expect(document.activeElement).toBe(endInput); + expect(container.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active'); + expect(isOpen()).toBeTruthy(); + expect(startInput).toHaveValue(''); + + openPicker(container); + + expect(document.activeElement).toBe(startInput); + expect(container.querySelectorAll('.rc-picker-input')[0]).toHaveClass('rc-picker-input-active'); + expect(isOpen()).toBeTruthy(); + + // Blur the whole Picker without clicking OK. + fireEvent.mouseDown(document.body); + triggerBlur(document.activeElement as HTMLElement); + await waitFakeTimer(0, 2); + + expect(onChange).not.toHaveBeenCalled(); + expect(startInput).toHaveValue(''); + expect(endInput).toHaveValue(''); + expect(isOpen()).toBeFalsy(); + }); + + it('should reset unconfirmed end after switching back and blurring', async () => { + const onChange = jest.fn(); + const { container } = render(); + const [startInput, endInput] = container.querySelectorAll('input'); + + // Confirm the start date. / 确认开始日期。 + openPicker(container); + selectCell(5); + fireEvent.click(document.querySelector('.rc-picker-ok button')); + + // Select an end date without confirming it. / 选择结束日期,但不确认。 + selectCell(10); + expect(endInput).not.toHaveValue(''); + + // Switch back to the start field and then leave the whole Picker. + // 切回开始 field,然后离开整个 Picker。 + openPicker(container); + expect(document.activeElement).toBe(startInput); + + fireEvent.mouseDown(document.body); + triggerBlur(startInput); + await waitFakeTimer(0, 2); + + expect(onChange).not.toHaveBeenCalled(); + expect(endInput).toHaveValue(''); + }); + + it('should submit confirmed start when allowEmpty end blurs', async () => { + const onChange = jest.fn(); + const { container } = render(); + const [, endInput] = container.querySelectorAll('input'); + + openPicker(container); + selectCell(5); + fireEvent.click(document.querySelector('.rc-picker-ok button')); + + expect(document.activeElement).toBe(endInput); + expect(container.querySelectorAll('.rc-picker-input')[1]).toHaveClass('rc-picker-input-active'); + + fireEvent.mouseDown(document.body); + triggerBlur(endInput); + await waitFakeTimer(0, 2); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0][0]).toBeTruthy(); + expect(onChange.mock.calls[0][0][1]).toBeNull(); + expect(isOpen()).toBeFalsy(); + }); + it('should not update preview value in input when previewValue is false', () => { const { container } = render( { - jest.runAllTimers(); - await Promise.resolve(); +export async function waitFakeTimer(advanceTime = 0, times = 1) { + for (let i = 0; i < times; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + if (advanceTime > 0) { + jest.advanceTimersByTime(advanceTime); + } else { + jest.runAllTimers(); + } + }); + } +} + +interface FocusTarget { + focus: () => void; +} + +interface BlurTarget { + blur: () => void; +} + +/** + * Trigger native focus so jsdom updates `document.activeElement` and blurs the + * previous element with this target as `relatedTarget`. + */ +export function triggerFocus(target: FocusTarget) { + act(() => { + target.focus(); + }); +} + +/** + * Trigger native blur so jsdom clears its internal focused element. Use this + * only for focus leaving without another focus target. + */ +export function triggerBlur(target: BlurTarget) { + act(() => { + target.blur(); }); } @@ -79,16 +116,16 @@ export function openPicker(container: HTMLElement | ShadowRoot, index = 0) { const input = container.querySelectorAll('input')[index]; fireEvent.mouseDown(input); - // Testing lib not trigger real focus - act(() => { - input.focus(); - }); + triggerFocus(input); fireEvent.click(input); } export function closePicker(container: HTMLElement | ShadowRoot, index = 0) { const input = container.querySelectorAll('input')[index]; - fireEvent.blur(input); + const root = input.getRootNode() as Document | ShadowRoot; + const activeElement = root.activeElement || document.activeElement; + + triggerBlur(activeElement as HTMLElement); // Loop to pass all the timer (includes raf) for (let i = 0; i < 5; i += 1) {