From 5257563c31c06a4814c6e5a7f9bf5fefd2eea763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 20 Jul 2026 17:19:06 +0800 Subject: [PATCH 01/27] refactor(RangePicker): add interaction hooks --- src/PickerInput/hooks/useFocusControl.ts | 68 +++++++++ src/PickerInput/hooks/useRangeValueChange.ts | 147 +++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 src/PickerInput/hooks/useFocusControl.ts create mode 100644 src/PickerInput/hooks/useRangeValueChange.ts diff --git a/src/PickerInput/hooks/useFocusControl.ts b/src/PickerInput/hooks/useFocusControl.ts new file mode 100644 index 000000000..9b3dfa9e6 --- /dev/null +++ b/src/PickerInput/hooks/useFocusControl.ts @@ -0,0 +1,68 @@ +import { useEvent } from '@rc-component/util'; +import type * as React from 'react'; + +// ============================= Types ============================= +/** Focus event source. / 焦点事件来源。 */ +export type FocusSource = 'input' | 'panel'; + +/** Focus state change type. / 焦点状态变更类型。 */ +export type FocusChangeType = 'focus' | 'blur'; + +/** 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 Picker. / 检查元素是否属于 Picker。 */ +export type IsPickerElement = (element: EventTarget | null) => boolean; + +/** Notify the final focus state change. / 通知最终确认的焦点状态变更。 */ +export type TriggerFocusChange = (index: number, type: FocusChangeType) => void; + +/** Notify a Picker focus or blur event. / 通知 Picker 的聚焦或失焦事件。 */ +export type FocusEventHandler = (index: number, event: PickerFocusEvent) => void; + +export type UseFocusControlReturn = [ + onFieldFocus: FieldFocusHandler, + onFieldBlur: FieldBlurHandler, +]; + +/** + * Control field focus and blur events. + * 控制 field 的聚焦与失焦事件。 + * + * Ignore blur when `relatedTarget` still belongs to the Picker. + * 当 `relatedTarget` 仍属于 Picker 时,忽略本次 blur。 + */ +export default function useFocusControl( + isPickerElement: IsPickerElement, + triggerFocusChange: TriggerFocusChange, + onFocus?: FocusEventHandler, + onBlur?: FocusEventHandler, +): UseFocusControlReturn { + const onFieldFocus = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { + triggerFocusChange(index, 'focus'); + onFocus?.(index, event); + }); + + const onFieldBlur = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { + if (!isPickerElement(event.relatedTarget)) { + triggerFocusChange(index, 'blur'); + onBlur?.(index, event); + } + }); + + return [onFieldFocus, onFieldBlur]; +} diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts new file mode 100644 index 000000000..01ef66ba8 --- /dev/null +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -0,0 +1,147 @@ +import { useEvent } from '@rc-component/util'; +import * as React from 'react'; + +/** Change source of a field. / Field 的变更来源。 */ +export type RangeValueChangeSource = + 'input' | 'keyboard-submit' | 'panel' | 'blur' | 'field-switch' | 'confirm'; + +export type TriggerChange = ( + index: number, + source: RangeValueChangeSource, + date?: DateType, +) => boolean; + +export type GetCalendarValue = () => readonly (DateType | null | undefined)[]; + +export type TriggerCalendarChange = (index: number, date: DateType) => void; + +export type FlushSubmit = (index: number, needTriggerChange: boolean) => void; + +export type ResetValue = (index?: number) => void; + +export type UseRangeValueChangeReturn = [ + currentIndex: number | null, + triggerChange: TriggerChange, +]; + +/** + * Coordinate CalendarValue updates, part submits and final submits for any + * number of fields. + * 统一管理任意数量 field 的 CalendarValue 更新、局部提交与最终提交。 + * + * Flow / 流程: + * 📝 update / 更新 · ✅ submit / 提交 · ↩️ reset / 回滚 · ⏸️ stay / 保持 + * + * triggerChange(index, source, date) + * | + * +-- 📝 date exists / 存在 date + * | `-- update CalendarValue[index] + * | + * +-- field-switch / 切换 field + * | |-- needConfirm, or empty and not allowEmpty + * | | 需要确认,或值为空且不允许为空 + * | | `-- ↩️ reset current field, reject switch + * | | 回滚当前 field,拒绝切换 + * | `-- has value or allowEmpty / 有值或允许为空 + * | `-- ✅ allow switch and submit / 允许切换并提交 + * | + * +-- ✅ other sources can submit / 其他来源可以提交 + * | |-- flush current field / 提交当前 field + * | |-- all fields completed / 所有 field 已完成 + * | | `-- final submit, clear round / 最终提交并结束本轮 + * | `-- fields remain / 仍有 field 未完成 + * | `-- move to next index / 推进到下一个 index + * | + * +-- ↩️ blur without submit / 失焦但不能提交 + * | `-- reset all CalendarValue, end round + * | 回滚全部 CalendarValue,结束本轮 + * | + * `-- ⏸️ other sources / 其他来源 + * `-- keep current field / 停留在当前 field + * + * Explicit submit sources are `keyboard-submit` and `confirm`. When + * `needConfirm` is false, `panel`, `blur` and `field-switch` also submit. + * 显式提交来源为 `keyboard-submit` 和 `confirm`;无需确认时,`panel`、`blur` + * 与 `field-switch` 也会提交。 + */ +export default function useRangeValueChange( + fieldCount: number, + needConfirm: boolean, + allowEmpty: readonly boolean[], + getCalendarValue: GetCalendarValue, + triggerCalendarChange: TriggerCalendarChange, + flushSubmit: FlushSubmit, + resetValue: ResetValue, +): UseRangeValueChangeReturn { + // Record fields involved in the current interaction. + // 记录当前一轮交互中触发过的 field。 + const triggeredFieldsRef = React.useRef([]); + const [currentIndex, setCurrentIndex] = React.useState(null); + + const triggerChange = useEvent( + (index: number, source: RangeValueChangeSource, date?: DateType) => { + // The first operation starts from its field. Other operations do not + // change currentIndex until the source completes the current field. + // 第一次操作从对应 field 开始;后续操作只有完成当前 field 时才推进 currentIndex。 + setCurrentIndex((current) => current ?? index); + + // A provided date updates the temporary CalendarValue. + // 传入 date 时更新临时 CalendarValue。 + if (date !== undefined) { + triggerCalendarChange(index, date); + } + + // A field switch is rejected when explicit confirmation is required, or + // when the current field is empty and cannot be empty. + // 需要显式确认,或当前 field 为空且不允许为空时,拒绝切换 field。 + if (source === 'field-switch') { + const currentValue = getCalendarValue()[index]; + const canSwitch = + !needConfirm && + ((currentValue !== null && currentValue !== undefined) || allowEmpty[index]); + + if (!canSwitch) { + resetValue(index); + return false; + } + } + + // Explicit operations always submit. Panel, blur and field switch submit + // automatically only when explicit confirmation is not required. + // 显式操作始终提交;无需确认时,panel、blur 和 field switch 也会自动提交。 + const isExplicitSubmit = source === 'keyboard-submit' || source === 'confirm'; + const isAutoSubmit = + !needConfirm && (source === 'panel' || source === 'blur' || source === 'field-switch'); + + if (isExplicitSubmit || isAutoSubmit) { + // Record a field only after it completes part submit. + // 仅在 field 完成 part submit 后记录。 + if (!triggeredFieldsRef.current.includes(index)) { + triggeredFieldsRef.current.push(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 = []; + setCurrentIndex(null); + } else { + setCurrentIndex((index + 1) % fieldCount); + } + } else if (source === 'blur') { + // Outside blur without submit ends and clears the current interaction. + // 外侧 blur 未触发提交时,结束并清理当前一轮交互。 + resetValue(); + triggeredFieldsRef.current = []; + setCurrentIndex(null); + } + + return true; + }, + ); + + return [currentIndex, triggerChange]; +} From d33c1c6132aeab1d3d6d879a90ccbc976e638d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 20 Jul 2026 18:03:04 +0800 Subject: [PATCH 02/27] refactor(RangePicker): centralize interaction flow --- src/PickerInput/RangePicker.tsx | 189 +++++++++---------- src/PickerInput/hooks/useRangeValue.ts | 29 ++- src/PickerInput/hooks/useRangeValueChange.ts | 182 ++++++++++++------ tests/new-range.spec.tsx | 10 +- tests/range.spec.tsx | 7 +- 5 files changed, 247 insertions(+), 170 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 8bf7d9e76..8774dd7a3 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -29,6 +29,7 @@ import PickerContext from './context'; import useCellRender from './hooks/useCellRender'; import useFieldsInvalidate from './hooks/useFieldsInvalidate'; import useFilledProps from './hooks/useFilledProps'; +import useFocusControl from './hooks/useFocusControl'; import useOpen from './hooks/useOpen'; import usePickerRef from './hooks/usePickerRef'; import usePresets from './hooks/usePresets'; @@ -36,6 +37,7 @@ import useRangeActive from './hooks/useRangeActive'; import useRangeDisabledDate from './hooks/useRangeDisabledDate'; 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, { type PopupShowTimeConfig } from './Popup'; import RangeSelector, { type SelectorIdType } from './Selector/RangeSelector'; @@ -265,33 +267,8 @@ function RangePicker( const calendarValue = getCalendarValue(); // ======================== Active ======================== - 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), - }); - }; - - const onSharedBlur = (event: React.FocusEvent, index?: number) => { - triggerFocus(false); - - onBlur?.(event, { - range: getActiveRange(index ?? activeIndex), - }); - }; + const [focused, triggerFocus, , activeIndex, setActiveIndex, nextActiveIndex, activeIndexList] = + useRangeActive(disabled, allowEmpty, mergedOpen); // ======================= ShowTime ======================= /** Used for Popup panel */ @@ -338,6 +315,8 @@ function RangePicker( flushSubmit, /** Trigger `onChange` directly without check `disabledDate` */ triggerSubmitChange, + /** Reset uncommitted values */ + resetValue, ] = useRangeValue, DateType>( filledProps, mergedValue, @@ -349,6 +328,57 @@ function RangePicker( focused, mergedOpen, isInvalidateDate, + false, + ); + + const triggerFieldCalendarChange = useEvent((index: number, date: DateType) => { + triggerCalendarChange(fillIndex(getCalendarValue(), index, date)); + }); + + const enabledFieldCount = disabled.filter((fieldDisabled) => !fieldDisabled).length; + const [rangeValueIndex, triggerRangeValueChange] = useRangeValueChange( + enabledFieldCount, + needConfirm, + allowEmpty, + getCalendarValue, + triggerFieldCalendarChange, + flushSubmit, + resetValue, + ); + + const isPickerElement = useEvent((element: EventTarget | null) => { + const target = element as HTMLElement; + + return ( + !!target && + (selectorRef.current.nativeElement.contains(target) || + !!target.closest?.(`.${prefixCls}-panel-container`)) + ); + }); + + const triggerFocusChange = useEvent((index: number, type: 'focus' | 'blur') => { + const nextFocused = type === 'focus'; + triggerFocus(nextFocused); + + if (!nextFocused) { + triggerRangeValueChange(index, 'blur'); + triggerOpen(false); + } + }); + + const [onFieldFocus, onFieldBlur] = useFocusControl( + isPickerElement, + triggerFocusChange, + (index, event) => { + onFocus?.(event, { + range: getActiveRange(index), + }); + }, + (index, event) => { + onBlur?.(event, { + range: getActiveRange(index), + }); + }, ); // ===================== DisabledDate ===================== @@ -418,24 +448,19 @@ function RangePicker( * - Selector: enter key * - Panel: OK button */ - const triggerPartConfirm = (date?: DateType, skipFocus?: boolean) => { + const triggerPartConfirm = (date?: DateType, source: RangeValueChangeSource = 'confirm') => { 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); + triggerRangeValueChange(activeIndex, source, date ?? undefined); if (nextIndex === null) { triggerOpen(false, { force: true }); - } else if (!skipFocus) { - selectorRef.current.focus({ index: nextIndex }); } }; @@ -509,7 +534,6 @@ function RangePicker( const passed = triggerSubmitChange(nextValues); if (passed) { - lastOperation('preset-click'); triggerOpen(false, { force: true }); } }; @@ -526,31 +550,35 @@ 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); + const panelFinished = !complexPicker && internalPicker === internalMode; - // Only trigger calendar event but not update internal `calendarValue` state - triggerCalendarChange(clone); + triggerRangeValueChange( + activeIndex, + panelFinished ? 'panel-final' : 'panel-intermediate', + date, + ); // >>> Trigger next active if !needConfirm // Fully logic check `useRangeValue` hook - if (!needConfirm && !complexPicker && internalPicker === internalMode) { - triggerPartConfirm(date); + if (!needConfirm && panelFinished) { + const nextIndex = nextActiveIndex(clone); + + if (nextIndex === null) { + triggerOpen(false, { force: true }); + } else { + selectorRef.current.focus({ index: nextIndex }); + } } }; // >>> Close const onPopupClose = () => { - // Close popup triggerOpen(false); }; @@ -604,8 +632,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 +653,7 @@ function RangePicker( onHover={onPanelHover} // Submit needConfirm={needConfirm} - onSubmit={triggerPartConfirm} + onSubmit={(date) => triggerPartConfirm(date, 'confirm')} onOk={triggerOk} // Preset presets={presetList} @@ -648,64 +675,33 @@ 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'); } onKeyDown?.(event, preventDefault); @@ -743,22 +739,13 @@ function RangePicker( } }, [mergedOpen, activeIndex, picker]); - // >>> For complex picker, we need check if need to focus next one + // Keep focus on the field selected by the value flow. Field switch itself + // stays event-driven; this effect only restores DOM focus after React commits. useLayoutEffect(() => { - const lastOp = lastOperation(); - - // Trade as confirm on field leave - if (!mergedOpen && lastOp === 'input') { - triggerOpen(false); - triggerPartConfirm(null, true); + if (rangeValueIndex !== null && activeIndex !== rangeValueIndex) { + selectorRef.current.focus({ index: rangeValueIndex }); } - - // Submit with complex picker - if (!mergedOpen && complexPicker && !needConfirm && lastOp === 'panel') { - triggerOpen(true); - triggerPartConfirm(); - } - }, [mergedOpen]); + }, [rangeValueIndex, activeIndex]); // ====================== DevWarning ====================== if (process.env.NODE_ENV !== 'production') { @@ -815,7 +802,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/hooks/useRangeValue.ts b/src/PickerInput/hooks/useRangeValue.ts index 6cd0ac367..b86b6fac1 100644 --- a/src/PickerInput/hooks/useRangeValue.ts +++ b/src/PickerInput/hooks/useRangeValue.ts @@ -176,11 +176,14 @@ export default function useRangeValue boolean, + useEffectSubmit?: 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 @@ -196,6 +199,8 @@ export default function useRangeValue d) ? false : order; // ============================= Util ============================= @@ -305,28 +310,34 @@ export default function useRangeValue { + if (index === undefined) { + triggerCalendarChange(mergedValue); + syncWithValue(); + return; + } + + triggerCalendarChange(fillIndex(getCalendarValue(), index, mergedValue[index])); + setSubmitValue(fillIndex(submitValue(), index, mergedValue[index])); + }); + // ============================ Effect ============================ // All finished action trigger after 2 frames const interactiveFinished = !focused && !open; useLockEffect( - !interactiveFinished, + enableEffectSubmit && !interactiveFinished, () => { - if (interactiveFinished) { + if (enableEffectSubmit && 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); - - // Sync with value anyway - syncWithValue(); + resetValue(); } }, 2, ); // ============================ Return ============================ - return [flushSubmit, triggerSubmit]; + return [flushSubmit, triggerSubmit, resetValue]; } diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 01ef66ba8..fe1351d04 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -3,13 +3,19 @@ import * as React from 'react'; /** Change source of a field. / Field 的变更来源。 */ export type RangeValueChangeSource = - 'input' | 'keyboard-submit' | 'panel' | 'blur' | 'field-switch' | 'confirm'; + | 'input' + | 'keyboard-submit' + | 'panel-intermediate' + | 'panel-final' + | 'blur' + | 'field-switch' + | 'confirm'; export type TriggerChange = ( index: number, source: RangeValueChangeSource, date?: DateType, -) => boolean; +) => void; export type GetCalendarValue = () => readonly (DateType | null | undefined)[]; @@ -38,12 +44,21 @@ export type UseRangeValueChangeReturn = [ * | `-- update CalendarValue[index] * | * +-- field-switch / 切换 field - * | |-- needConfirm, or empty and not allowEmpty - * | | 需要确认,或值为空且不允许为空 - * | | `-- ↩️ reset current field, reject switch - * | | 回滚当前 field,拒绝切换 - * | `-- has value or allowEmpty / 有值或允许为空 - * | `-- ✅ allow switch and submit / 允许切换并提交 + * | |-- first focus / 首次聚焦 + * | | `-- use target index / 使用目标 index + * | |-- can process field / 可以处理当前 field + * | | `-- ✅ record field and switch / 记录 field 并切换 + * | `-- cannot process field / 无法处理当前 field + * | `-- ⏸️ keep current index / 保持当前 index + * | + * +-- panel-intermediate / 面板中间操作 + * | `-- ⏸️ update only / 仅更新 CalendarValue + * | + * +-- panel-final / 面板最终操作 + * | |-- needConfirm / 需要确认 + * | | `-- ⏸️ update only / 仅更新 CalendarValue + * | `-- no needConfirm / 无需确认 + * | `-- ✅ submit current field / 提交当前 field * | * +-- ✅ other sources can submit / 其他来源可以提交 * | |-- flush current field / 提交当前 field @@ -59,10 +74,10 @@ export type UseRangeValueChangeReturn = [ * `-- ⏸️ other sources / 其他来源 * `-- keep current field / 停留在当前 field * - * Explicit submit sources are `keyboard-submit` and `confirm`. When - * `needConfirm` is false, `panel`, `blur` and `field-switch` also submit. - * 显式提交来源为 `keyboard-submit` 和 `confirm`;无需确认时,`panel`、`blur` - * 与 `field-switch` 也会提交。 + * Explicit submit sources are `keyboard-submit` and `confirm`. A + * `panel-final` operation submits only when `needConfirm` is false. + * 显式提交来源为 `keyboard-submit` 和 `confirm`;仅在无需确认时, + * `panel-final` 操作才会提交。 */ export default function useRangeValueChange( fieldCount: number, @@ -76,14 +91,107 @@ export default function useRangeValueChange( // Record fields involved in the current interaction. // 记录当前一轮交互中触发过的 field。 const triggeredFieldsRef = React.useRef([]); + const currentIndexRef = React.useRef(null); + // Track whether the current field has received an input or panel change. + // 记录当前 field 是否发生过输入或面板变更。 + const currentChangedRef = React.useRef(false); const [currentIndex, setCurrentIndex] = React.useState(null); + const updateCurrentIndex = (index: number | null) => { + currentIndexRef.current = index; + currentChangedRef.current = false; + setCurrentIndex(index); + }; + + const submitField = (index: number, nextIndex?: number) => { + // Record a field only after it completes part submit. + // 仅在 field 完成 part submit 后记录。 + if (!triggeredFieldsRef.current.includes(index)) { + triggeredFieldsRef.current.push(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 = []; + updateCurrentIndex(nextIndex ?? null); + } else { + updateCurrentIndex(nextIndex ?? (index + 1) % fieldCount); + } + }; + const triggerChange = useEvent( (index: number, source: RangeValueChangeSource, date?: DateType) => { + // For field switch, `index` is the target field. The previous field must + // pass the switch check before focus can move. + // field-switch 的 `index` 表示目标 field;前一个 field 通过检查后才能移动焦点。 + if (source === 'field-switch') { + const previousIndex = currentIndexRef.current; + + if (previousIndex === null) { + updateCurrentIndex(index); + return; + } + + if (previousIndex === index) { + return; + } + + const previousValue = getCalendarValue()[previousIndex]; + const previousEmpty = previousValue === null || previousValue === undefined; + + if (!needConfirm && (!previousEmpty || allowEmpty[previousIndex])) { + submitField(previousIndex, index); + } else if (needConfirm && previousEmpty && allowEmpty[previousIndex]) { + resetValue(previousIndex); + submitField(previousIndex, index); + } else if (!needConfirm) { + resetValue(previousIndex); + } + + return; + } + + // Blur ends the Picker interaction, so always handle the field tracked by + // this hook instead of the DOM element that happened to emit blur. + // blur 会结束 Picker 交互,因此始终处理 hooks 记录的当前 field,而不是触发 + // DOM blur 的旧元素。 + if (source === 'blur') { + const blurIndex = currentIndexRef.current; + + if (blurIndex === null) { + return; + } + + const blurValue = getCalendarValue()[blurIndex]; + const blurEmpty = blurValue === null || blurValue === undefined; + + if (!needConfirm && !currentChangedRef.current && !triggeredFieldsRef.current.length) { + updateCurrentIndex(null); + } else if (needConfirm || (blurEmpty && !allowEmpty[blurIndex])) { + resetValue(); + triggeredFieldsRef.current = []; + updateCurrentIndex(null); + } else { + submitField(blurIndex); + } + + return; + } + // The first operation starts from its field. Other operations do not // change currentIndex until the source completes the current field. // 第一次操作从对应 field 开始;后续操作只有完成当前 field 时才推进 currentIndex。 - setCurrentIndex((current) => current ?? index); + if (currentIndexRef.current === null) { + updateCurrentIndex(index); + } + + if (source === 'input' || source === 'panel-intermediate' || source === 'panel-final') { + currentChangedRef.current = true; + } // A provided date updates the temporary CalendarValue. // 传入 date 时更新临时 CalendarValue。 @@ -91,55 +199,15 @@ export default function useRangeValueChange( triggerCalendarChange(index, date); } - // A field switch is rejected when explicit confirmation is required, or - // when the current field is empty and cannot be empty. - // 需要显式确认,或当前 field 为空且不允许为空时,拒绝切换 field。 - if (source === 'field-switch') { - const currentValue = getCalendarValue()[index]; - const canSwitch = - !needConfirm && - ((currentValue !== null && currentValue !== undefined) || allowEmpty[index]); - - if (!canSwitch) { - resetValue(index); - return false; - } - } - - // Explicit operations always submit. Panel, blur and field switch submit + // Explicit operations always submit. Final panel operations submit // automatically only when explicit confirmation is not required. - // 显式操作始终提交;无需确认时,panel、blur 和 field switch 也会自动提交。 + // 显式操作始终提交;无需确认时,panel-final 也会自动提交。 const isExplicitSubmit = source === 'keyboard-submit' || source === 'confirm'; - const isAutoSubmit = - !needConfirm && (source === 'panel' || source === 'blur' || source === 'field-switch'); + const isAutoSubmit = !needConfirm && source === 'panel-final'; if (isExplicitSubmit || isAutoSubmit) { - // Record a field only after it completes part submit. - // 仅在 field 完成 part submit 后记录。 - if (!triggeredFieldsRef.current.includes(index)) { - triggeredFieldsRef.current.push(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 = []; - setCurrentIndex(null); - } else { - setCurrentIndex((index + 1) % fieldCount); - } - } else if (source === 'blur') { - // Outside blur without submit ends and clears the current interaction. - // 外侧 blur 未触发提交时,结束并清理当前一轮交互。 - resetValue(); - triggeredFieldsRef.current = []; - setCurrentIndex(null); + submitField(index); } - - return true; }, ); diff --git a/tests/new-range.spec.tsx b/tests/new-range.spec.tsx index 85a4dd07c..514a827fd 100644 --- a/tests/new-range.spec.tsx +++ b/tests/new-range.spec.tsx @@ -45,7 +45,10 @@ describe('NewPicker.Range', () => { describe('PickerValue', () => { it('defaultPickerValue should reset every time when opened', () => { const { container } = render( - , + , ); // Left @@ -79,6 +82,7 @@ describe('NewPicker.Range', () => { const { container } = render( { }); // Close panel to auto focus next end field + const startFocusedElement = document.activeElement; fireEvent.mouseDown(document.body); + fireEvent.blur(startFocusedElement); act(() => { jest.runAllTimers(); }); @@ -706,7 +712,9 @@ describe('NewPicker.Range', () => { }); // Close panel to auto focus next end field + const endFocusedElement = document.activeElement; fireEvent.mouseDown(document.body); + fireEvent.blur(endFocusedElement); act(() => { jest.runAllTimers(); diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 174a1d64a..8e85152dd 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -594,7 +594,7 @@ describe('Picker.Range', () => { }); it('mode is array', () => { - const { container } = render(); + const { container } = render(); openPicker(container); expect(document.querySelector('.rc-picker-year-panel')).toBeTruthy(); @@ -949,6 +949,7 @@ describe('Picker.Range', () => { it('defaultPickerValue', () => { const { container } = render( , @@ -1603,7 +1604,7 @@ describe('Picker.Range', () => { // https://github.com/ant-design/ant-design/issues/26024 it('panel should keep open when nextValue is empty', () => { - const { container } = render(); + const { container } = render(); openPicker(container, 0); @@ -2076,7 +2077,9 @@ describe('Picker.Range', () => { selectCell(2, 0); // Click outside to blur + const focusedElement = document.activeElement; fireEvent.mouseDown(document.body); + fireEvent.blur(focusedElement); fireEvent.mouseUp(document.body); fireEvent.click(document.body); From 555604e55bccc4a9a0499148c2472ee38906f622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 20 Jul 2026 19:05:15 +0800 Subject: [PATCH 03/27] refactor(RangePicker): track focus containers by ref --- src/PickerInput/Popup/index.tsx | 6 +++++- src/PickerInput/RangePicker.tsx | 18 +++++++----------- src/PickerInput/hooks/useFocusControl.ts | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 12 deletions(-) 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( // ========================= Refs ========================= const selectorRef = usePickerRef(ref); + const popupRef = React.useRef(null); // ======================= Semantic ======================= const [mergedClassNames, mergedStyles] = useSemantic(propClassNames, propStyles); @@ -346,15 +347,9 @@ function RangePicker( resetValue, ); - const isPickerElement = useEvent((element: EventTarget | null) => { - const target = element as HTMLElement; - - return ( - !!target && - (selectorRef.current.nativeElement.contains(target) || - !!target.closest?.(`.${prefixCls}-panel-container`)) - ); - }); + const isInternalPickerElement = useEvent((element: EventTarget | null) => + isPickerElement(element, selectorRef.current.nativeElement, popupRef.current), + ); const triggerFocusChange = useEvent((index: number, type: 'focus' | 'blur') => { const nextFocused = type === 'focus'; @@ -367,7 +362,7 @@ function RangePicker( }); const [onFieldFocus, onFieldBlur] = useFocusControl( - isPickerElement, + isInternalPickerElement, triggerFocusChange, (index, event) => { onFocus?.(event, { @@ -620,6 +615,7 @@ function RangePicker( // >>> Render const panel = ( + containerRef={popupRef} // MISC {...panelProps} showNow={mergedShowNow} diff --git a/src/PickerInput/hooks/useFocusControl.ts b/src/PickerInput/hooks/useFocusControl.ts index 9b3dfa9e6..dd017ed02 100644 --- a/src/PickerInput/hooks/useFocusControl.ts +++ b/src/PickerInput/hooks/useFocusControl.ts @@ -39,6 +39,21 @@ export type UseFocusControlReturn = [ onFieldBlur: FieldBlurHandler, ]; +// ============================= Utils ============================= +/** Check whether the target is the container itself or inside it. / 判断目标是否为容器自身或其子元素。 */ +function containsElement(container: HTMLElement | null, target: EventTarget | null) { + return !!container && (container === target || container.contains(target as Node)); +} + +/** Check whether the target belongs to the selector or popup. / 判断目标是否属于输入区域或弹出区域。 */ +export function isPickerElement( + target: EventTarget | null, + selectorElement: HTMLElement | null, + popupElement: HTMLElement | null, +) { + return containsElement(selectorElement, target) || containsElement(popupElement, target); +} + /** * Control field focus and blur events. * 控制 field 的聚焦与失焦事件。 From 1715d99ca61465c0f11ef3197f6c245061337383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 10:47:30 +0800 Subject: [PATCH 04/27] refactor(RangePicker): clarify focus target helpers --- src/PickerInput/RangePicker.tsx | 4 ++-- src/PickerInput/hooks/useFocusControl.ts | 19 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 1747736f1..e01e83258 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -29,7 +29,7 @@ import PickerContext from './context'; import useCellRender from './hooks/useCellRender'; import useFieldsInvalidate from './hooks/useFieldsInvalidate'; import useFilledProps from './hooks/useFilledProps'; -import useFocusControl, { isPickerElement } from './hooks/useFocusControl'; +import useFocusControl, { isTargetInContainers } from './hooks/useFocusControl'; import useOpen from './hooks/useOpen'; import usePickerRef from './hooks/usePickerRef'; import usePresets from './hooks/usePresets'; @@ -348,7 +348,7 @@ function RangePicker( ); const isInternalPickerElement = useEvent((element: EventTarget | null) => - isPickerElement(element, selectorRef.current.nativeElement, popupRef.current), + isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), ); const triggerFocusChange = useEvent((index: number, type: 'focus' | 'blur') => { diff --git a/src/PickerInput/hooks/useFocusControl.ts b/src/PickerInput/hooks/useFocusControl.ts index dd017ed02..cb8d7eb98 100644 --- a/src/PickerInput/hooks/useFocusControl.ts +++ b/src/PickerInput/hooks/useFocusControl.ts @@ -25,8 +25,8 @@ export type FieldBlurHandler = ( event: PickerFocusEvent, ) => void; -/** Check whether an element belongs to the Picker. / 检查元素是否属于 Picker。 */ -export type IsPickerElement = (element: EventTarget | null) => boolean; +/** Check whether an element belongs to the current focus scope. / 检查元素是否属于当前焦点范围。 */ +export type IsInternalElement = (element: EventTarget | null) => boolean; /** Notify the final focus state change. / 通知最终确认的焦点状态变更。 */ export type TriggerFocusChange = (index: number, type: FocusChangeType) => void; @@ -41,17 +41,16 @@ export type UseFocusControlReturn = [ // ============================= Utils ============================= /** Check whether the target is the container itself or inside it. / 判断目标是否为容器自身或其子元素。 */ -function containsElement(container: HTMLElement | null, target: EventTarget | null) { +function containsElement(container: Element | null, target: EventTarget | null) { return !!container && (container === target || container.contains(target as Node)); } -/** Check whether the target belongs to the selector or popup. / 判断目标是否属于输入区域或弹出区域。 */ -export function isPickerElement( +/** Check whether the target belongs to any container. / 判断目标是否属于任意一个容器。 */ +export function isTargetInContainers( target: EventTarget | null, - selectorElement: HTMLElement | null, - popupElement: HTMLElement | null, + containers: readonly (Element | null)[], ) { - return containsElement(selectorElement, target) || containsElement(popupElement, target); + return containers.some((container) => containsElement(container, target)); } /** @@ -62,7 +61,7 @@ export function isPickerElement( * 当 `relatedTarget` 仍属于 Picker 时,忽略本次 blur。 */ export default function useFocusControl( - isPickerElement: IsPickerElement, + isInternalElement: IsInternalElement, triggerFocusChange: TriggerFocusChange, onFocus?: FocusEventHandler, onBlur?: FocusEventHandler, @@ -73,7 +72,7 @@ export default function useFocusControl( }); const onFieldBlur = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { - if (!isPickerElement(event.relatedTarget)) { + if (!isInternalElement(event.relatedTarget)) { triggerFocusChange(index, 'blur'); onBlur?.(index, event); } From 446ef71a3ec1e46e38133416aece18fe44722449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 10:53:55 +0800 Subject: [PATCH 05/27] refactor(RangePicker): inline focus container check --- src/PickerInput/hooks/useFocusControl.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/PickerInput/hooks/useFocusControl.ts b/src/PickerInput/hooks/useFocusControl.ts index cb8d7eb98..f1d3d98af 100644 --- a/src/PickerInput/hooks/useFocusControl.ts +++ b/src/PickerInput/hooks/useFocusControl.ts @@ -40,17 +40,14 @@ export type UseFocusControlReturn = [ ]; // ============================= Utils ============================= -/** Check whether the target is the container itself or inside it. / 判断目标是否为容器自身或其子元素。 */ -function containsElement(container: Element | null, target: EventTarget | null) { - return !!container && (container === target || container.contains(target as Node)); -} - /** Check whether the target belongs to any container. / 判断目标是否属于任意一个容器。 */ export function isTargetInContainers( target: EventTarget | null, containers: readonly (Element | null)[], ) { - return containers.some((container) => containsElement(container, target)); + return containers.some( + (container) => !!container && (container === target || container.contains(target as Node)), + ); } /** From 27091ef7a15bafb48429e9a387ab3afba8ea18ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 11:29:12 +0800 Subject: [PATCH 06/27] refactor(RangePicker): separate focus events and lock --- src/PickerInput/RangePicker.tsx | 19 +++++----- src/PickerInput/Selector/RangeSelector.tsx | 6 ++++ .../{useFocusControl.ts => useFocusEvents.ts} | 13 +++---- src/PickerInput/hooks/useFocusLock.ts | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 18 deletions(-) rename src/PickerInput/hooks/{useFocusControl.ts => useFocusEvents.ts} (90%) create mode 100644 src/PickerInput/hooks/useFocusLock.ts diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index e01e83258..a25bda811 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -29,7 +29,8 @@ import PickerContext from './context'; import useCellRender from './hooks/useCellRender'; import useFieldsInvalidate from './hooks/useFieldsInvalidate'; import useFilledProps from './hooks/useFilledProps'; -import useFocusControl, { isTargetInContainers } from './hooks/useFocusControl'; +import useFocusEvents, { isTargetInContainers } from './hooks/useFocusEvents'; +import useFocusLock from './hooks/useFocusLock'; import useOpen from './hooks/useOpen'; import usePickerRef from './hooks/usePickerRef'; import usePresets from './hooks/usePresets'; @@ -236,6 +237,9 @@ function RangePicker( // ========================= Refs ========================= const selectorRef = usePickerRef(ref); + const inputStartRef = React.useRef(null); + const inputEndRef = React.useRef(null); + const inputFieldRefs = [inputStartRef, inputEndRef]; const popupRef = React.useRef(null); // ======================= Semantic ======================= @@ -347,6 +351,8 @@ function RangePicker( resetValue, ); + useFocusLock(rangeValueIndex, inputFieldRefs, popupRef); + const isInternalPickerElement = useEvent((element: EventTarget | null) => isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), ); @@ -361,7 +367,7 @@ function RangePicker( } }); - const [onFieldFocus, onFieldBlur] = useFocusControl( + const [onFieldFocus, onFieldBlur] = useFocusEvents( isInternalPickerElement, triggerFocusChange, (index, event) => { @@ -735,14 +741,6 @@ function RangePicker( } }, [mergedOpen, activeIndex, picker]); - // Keep focus on the field selected by the value flow. Field switch itself - // stays event-driven; this effect only restores DOM focus after React commits. - useLayoutEffect(() => { - if (rangeValueIndex !== null && activeIndex !== rangeValueIndex) { - selectorRef.current.focus({ index: rangeValueIndex }); - } - }, [rangeValueIndex, activeIndex]); - // ====================== DevWarning ====================== if (process.env.NODE_ENV !== 'production') { const isIndexEmpty = (index: number) => { @@ -785,6 +783,7 @@ function RangePicker( {...filledProps} // Ref ref={selectorRef} + inputRefs={inputFieldRefs} // Style className={clsx(filledProps.className, rootClassName, mergedClassNames.root)} style={{ ...mergedStyles.root, ...filledProps.style }} diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx index 6cc28a74b..035ea2e0d 100644 --- a/src/PickerInput/Selector/RangeSelector.tsx +++ b/src/PickerInput/Selector/RangeSelector.tsx @@ -20,6 +20,8 @@ export type SelectorIdType = export interface RangeSelectorProps extends SelectorProps { id?: SelectorIdType; + inputRefs?: readonly React.Ref[]; + activeIndex: number | null; separator?: React.ReactNode; @@ -53,6 +55,7 @@ function RangeSelector( ) { const { id, + inputRefs, prefix, clearIcon, @@ -140,6 +143,9 @@ function RangeSelector( const inputStartRef = React.useRef(null); const inputEndRef = React.useRef(null); + React.useImperativeHandle(inputRefs?.[0], () => inputStartRef.current.inputElement); + React.useImperativeHandle(inputRefs?.[1], () => inputEndRef.current.inputElement); + const getInput = (index: number) => [inputStartRef, inputEndRef][index]?.current; React.useImperativeHandle(ref, () => ({ diff --git a/src/PickerInput/hooks/useFocusControl.ts b/src/PickerInput/hooks/useFocusEvents.ts similarity index 90% rename from src/PickerInput/hooks/useFocusControl.ts rename to src/PickerInput/hooks/useFocusEvents.ts index f1d3d98af..7ebaf9435 100644 --- a/src/PickerInput/hooks/useFocusControl.ts +++ b/src/PickerInput/hooks/useFocusEvents.ts @@ -34,10 +34,7 @@ export type TriggerFocusChange = (index: number, type: FocusChangeType) => void; /** Notify a Picker focus or blur event. / 通知 Picker 的聚焦或失焦事件。 */ export type FocusEventHandler = (index: number, event: PickerFocusEvent) => void; -export type UseFocusControlReturn = [ - onFieldFocus: FieldFocusHandler, - onFieldBlur: FieldBlurHandler, -]; +export type UseFocusEventsReturn = [onFieldFocus: FieldFocusHandler, onFieldBlur: FieldBlurHandler]; // ============================= Utils ============================= /** Check whether the target belongs to any container. / 判断目标是否属于任意一个容器。 */ @@ -51,18 +48,18 @@ export function isTargetInContainers( } /** - * Control field focus and blur events. - * 控制 field 的聚焦与失焦事件。 + * Handle field focus and blur events. + * 处理 field 的聚焦与失焦事件。 * * Ignore blur when `relatedTarget` still belongs to the Picker. * 当 `relatedTarget` 仍属于 Picker 时,忽略本次 blur。 */ -export default function useFocusControl( +export default function useFocusEvents( isInternalElement: IsInternalElement, triggerFocusChange: TriggerFocusChange, onFocus?: FocusEventHandler, onBlur?: FocusEventHandler, -): UseFocusControlReturn { +): UseFocusEventsReturn { const onFieldFocus = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { triggerFocusChange(index, 'focus'); onFocus?.(index, event); diff --git a/src/PickerInput/hooks/useFocusLock.ts b/src/PickerInput/hooks/useFocusLock.ts new file mode 100644 index 000000000..25609632d --- /dev/null +++ b/src/PickerInput/hooks/useFocusLock.ts @@ -0,0 +1,36 @@ +import { useLayoutEffect } from '@rc-component/util'; +import type * as React from 'react'; +import { isTargetInContainers } from './useFocusEvents'; + +/** + * Keep focus on the specified input field while focus moves inside the Picker. + * 当焦点在 Picker 内移动时,将其锁定在指定的输入框上。 + */ +export default function useFocusLock( + index: number | null, + inputFieldRefs: readonly React.RefObject[], + popupRef: React.RefObject, +) { + // 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; + + if (isTargetInContainers(activeElement, [popupRef.current])) { + return; + } + + const focusInOtherField = inputFieldRefs.some( + (fieldRef, fieldIndex) => + fieldIndex !== index && isTargetInContainers(activeElement, [fieldRef.current]), + ); + + if (focusInOtherField) { + inputFieldRefs[index]?.current?.focus(); + } + }); +} From a9159507662e3f09ea0cf93030f33fc3e36d4c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 11:38:06 +0800 Subject: [PATCH 07/27] refactor(RangePicker): suspend effect submit flow --- src/PickerInput/RangePicker.tsx | 2 +- src/PickerInput/hooks/useRangeValue.ts | 37 +++++++++++++------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index a25bda811..3ec8a9493 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -333,7 +333,7 @@ function RangePicker( focused, mergedOpen, isInvalidateDate, - false, + // false, ); const triggerFieldCalendarChange = useEvent((index: number, date: DateType) => { diff --git a/src/PickerInput/hooks/useRangeValue.ts b/src/PickerInput/hooks/useRangeValue.ts index b86b6fac1..9039df878 100644 --- a/src/PickerInput/hooks/useRangeValue.ts +++ b/src/PickerInput/hooks/useRangeValue.ts @@ -7,7 +7,7 @@ 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'; +// import useLockEffect from './useLockEffect'; const EMPTY_VALUE: any[] = []; @@ -176,7 +176,7 @@ export default function useRangeValue boolean, - useEffectSubmit?: boolean, + // useEffectSubmit?: boolean, ): [ /** Trigger `onChange` by check `disabledDate` */ flushSubmit: (index: number, needTriggerChange: boolean) => void, @@ -199,7 +199,7 @@ export default function useRangeValue d) ? false : order; @@ -322,21 +322,22 @@ export default function useRangeValue { - if (enableEffectSubmit && interactiveFinished) { - // Always try to trigger submit first - triggerSubmit(); - - resetValue(); - } - }, - 2, - ); + // TODO: Keep the legacy effect submit flow for reference during the event-driven refactor. + // TODO: 事件驱动提交重构期间,暂时保留旧 effect 提交流程作为参考。 + // const interactiveFinished = !focused && !open; + // + // useLockEffect( + // enableEffectSubmit && !interactiveFinished, + // () => { + // if (enableEffectSubmit && interactiveFinished) { + // // Always try to trigger submit first + // triggerSubmit(); + // + // resetValue(); + // } + // }, + // 2, + // ); // ============================ Return ============================ return [flushSubmit, triggerSubmit, resetValue]; From 3398678ffbfccf4e9d4f63c576bc0869db1ed9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 11:48:11 +0800 Subject: [PATCH 08/27] refactor(RangePicker): use synchronized index state --- src/PickerInput/hooks/useRangeValueChange.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index fe1351d04..5c5ac799a 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -1,4 +1,4 @@ -import { useEvent } from '@rc-component/util'; +import { useEvent, useSyncState } from '@rc-component/util'; import * as React from 'react'; /** Change source of a field. / Field 的变更来源。 */ @@ -91,14 +91,12 @@ export default function useRangeValueChange( // Record fields involved in the current interaction. // 记录当前一轮交互中触发过的 field。 const triggeredFieldsRef = React.useRef([]); - const currentIndexRef = React.useRef(null); + const [getCurrentIndex, setCurrentIndex] = useSyncState(null); // Track whether the current field has received an input or panel change. // 记录当前 field 是否发生过输入或面板变更。 const currentChangedRef = React.useRef(false); - const [currentIndex, setCurrentIndex] = React.useState(null); const updateCurrentIndex = (index: number | null) => { - currentIndexRef.current = index; currentChangedRef.current = false; setCurrentIndex(index); }; @@ -129,7 +127,7 @@ export default function useRangeValueChange( // pass the switch check before focus can move. // field-switch 的 `index` 表示目标 field;前一个 field 通过检查后才能移动焦点。 if (source === 'field-switch') { - const previousIndex = currentIndexRef.current; + const previousIndex = getCurrentIndex(); if (previousIndex === null) { updateCurrentIndex(index); @@ -160,7 +158,7 @@ export default function useRangeValueChange( // blur 会结束 Picker 交互,因此始终处理 hooks 记录的当前 field,而不是触发 // DOM blur 的旧元素。 if (source === 'blur') { - const blurIndex = currentIndexRef.current; + const blurIndex = getCurrentIndex(); if (blurIndex === null) { return; @@ -185,7 +183,7 @@ export default function useRangeValueChange( // The first operation starts from its field. Other operations do not // change currentIndex until the source completes the current field. // 第一次操作从对应 field 开始;后续操作只有完成当前 field 时才推进 currentIndex。 - if (currentIndexRef.current === null) { + if (getCurrentIndex() === null) { updateCurrentIndex(index); } @@ -211,5 +209,5 @@ export default function useRangeValueChange( }, ); - return [currentIndex, triggerChange]; + return [getCurrentIndex(), triggerChange]; } From 10dbcd97ed26cacc968278d51cd23f94b8762829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 11:54:42 +0800 Subject: [PATCH 09/27] refactor(RangePicker): simplify triggered field tracking --- src/PickerInput/hooks/useRangeValueChange.ts | 40 ++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 5c5ac799a..79dcff556 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -92,21 +92,20 @@ export default function useRangeValueChange( // 记录当前一轮交互中触发过的 field。 const triggeredFieldsRef = React.useRef([]); const [getCurrentIndex, setCurrentIndex] = useSyncState(null); - // Track whether the current field has received an input or panel change. - // 记录当前 field 是否发生过输入或面板变更。 - const currentChangedRef = React.useRef(false); - const updateCurrentIndex = (index: number | null) => { - currentChangedRef.current = false; - setCurrentIndex(index); + // Keep fields unique and move the latest field to the end, so the same list + // records both completed fields and the most recently changed field. + // field 保持唯一,并将最近触发的 field 移到末尾;同一份列表即可同时记录 + // 已处理的 field 和最近发生变更的 field。 + const recordTriggeredField = (index: number) => { + triggeredFieldsRef.current = [ + ...triggeredFieldsRef.current.filter((fieldIndex) => fieldIndex !== index), + index, + ]; }; const submitField = (index: number, nextIndex?: number) => { - // Record a field only after it completes part submit. - // 仅在 field 完成 part submit 后记录。 - if (!triggeredFieldsRef.current.includes(index)) { - triggeredFieldsRef.current.push(index); - } + recordTriggeredField(index); // Trigger final change after every field has participated once. // 所有 field 都参与过一次后,触发最终 change。 @@ -115,9 +114,9 @@ export default function useRangeValueChange( if (allFieldsTriggered) { triggeredFieldsRef.current = []; - updateCurrentIndex(nextIndex ?? null); + setCurrentIndex(nextIndex ?? null); } else { - updateCurrentIndex(nextIndex ?? (index + 1) % fieldCount); + setCurrentIndex(nextIndex ?? (index + 1) % fieldCount); } }; @@ -130,7 +129,7 @@ export default function useRangeValueChange( const previousIndex = getCurrentIndex(); if (previousIndex === null) { - updateCurrentIndex(index); + setCurrentIndex(index); return; } @@ -166,13 +165,16 @@ export default function useRangeValueChange( const blurValue = getCalendarValue()[blurIndex]; const blurEmpty = blurValue === null || blurValue === undefined; + const triggeredFields = triggeredFieldsRef.current; + const lastTriggeredIndex = triggeredFields[triggeredFields.length - 1]; - if (!needConfirm && !currentChangedRef.current && !triggeredFieldsRef.current.length) { - updateCurrentIndex(null); + if (!needConfirm && lastTriggeredIndex !== blurIndex) { + triggeredFieldsRef.current = []; + setCurrentIndex(null); } else if (needConfirm || (blurEmpty && !allowEmpty[blurIndex])) { resetValue(); triggeredFieldsRef.current = []; - updateCurrentIndex(null); + setCurrentIndex(null); } else { submitField(blurIndex); } @@ -184,11 +186,11 @@ export default function useRangeValueChange( // change currentIndex until the source completes the current field. // 第一次操作从对应 field 开始;后续操作只有完成当前 field 时才推进 currentIndex。 if (getCurrentIndex() === null) { - updateCurrentIndex(index); + setCurrentIndex(index); } if (source === 'input' || source === 'panel-intermediate' || source === 'panel-final') { - currentChangedRef.current = true; + recordTriggeredField(index); } // A provided date updates the temporary CalendarValue. From ee1f555299f66a0ec0754254709ebc9111df201b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 11:57:36 +0800 Subject: [PATCH 10/27] docs(RangePicker): clarify value change helpers --- src/PickerInput/hooks/useRangeValueChange.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 79dcff556..4fdf83cab 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -11,18 +11,29 @@ export type RangeValueChangeSource = | 'field-switch' | 'confirm'; +/** Receive a field interaction and its optional value. / 接收 field 交互及可选变更值。 */ export type TriggerChange = ( index: number, source: RangeValueChangeSource, date?: DateType, ) => void; +/** Read the latest temporary CalendarValue. / 读取最新的临时 CalendarValue。 */ export type GetCalendarValue = () => readonly (DateType | null | undefined)[]; +/** Update one field in CalendarValue. / 更新 CalendarValue 中的一个 field。 */ export type TriggerCalendarChange = (index: number, date: DateType) => 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 = [ @@ -91,6 +102,9 @@ export default function useRangeValueChange( // Record fields involved in the current interaction. // 记录当前一轮交互中触发过的 field。 const triggeredFieldsRef = React.useRef([]); + + // Keep a render value and a synchronous getter for event handlers. + // 同时保存渲染值,以及供事件处理函数同步读取的 getter。 const [getCurrentIndex, setCurrentIndex] = useSyncState(null); // Keep fields unique and move the latest field to the end, so the same list @@ -104,6 +118,8 @@ export default function useRangeValueChange( ]; }; + // Flush the current field, then finish the round or advance to the next one. + // 提交当前 field,随后结束本轮或推进到下一个 field。 const submitField = (index: number, nextIndex?: number) => { recordTriggeredField(index); @@ -120,6 +136,9 @@ export default function useRangeValueChange( } }; + // Route every interaction through the same event entry and decide whether + // to update, reset, stay on the current field, or submit it. + // 将所有交互统一收口,并判断应更新、重置、停留还是提交当前 field。 const triggerChange = useEvent( (index: number, source: RangeValueChangeSource, date?: DateType) => { // For field switch, `index` is the target field. The previous field must From 2a86a4f1a565663e4e539c942af87a7bb9678b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 12:03:24 +0800 Subject: [PATCH 11/27] fix(RangePicker): guard value changes by field index --- src/PickerInput/hooks/useRangeValueChange.ts | 26 +++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 4fdf83cab..76e30541b 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -62,6 +62,10 @@ export type UseRangeValueChangeReturn = [ * | `-- cannot process field / 无法处理当前 field * | `-- ⏸️ keep current index / 保持当前 index * | + * +-- index differs from current / 与当前 index 不一致 + * | `-- ⏸️ ignore until current field completes + * | 等待当前 field 完成,忽略本次操作 + * | * +-- panel-intermediate / 面板中间操作 * | `-- ⏸️ update only / 仅更新 CalendarValue * | @@ -171,12 +175,22 @@ export default function useRangeValueChange( return; } - // Blur ends the Picker interaction, so always handle the field tracked by - // this hook instead of the DOM element that happened to emit blur. - // blur 会结束 Picker 交互,因此始终处理 hooks 记录的当前 field,而不是触发 - // DOM blur 的旧元素。 + const currentIndex = getCurrentIndex(); + + // Only the current field may receive changes. A different field becomes + // valid only after submitField completes the previous one and advances + // currentIndex. + // 只有当前 field 可以接收变更。必须由 submitField 完成前一个 field 并推进 + // currentIndex 后,另一个 field 的事件才会生效。 + if (currentIndex !== null && currentIndex !== index) { + return; + } + + // Blur can end only the current field. A stale blur from another field + // has already been ignored by the index guard above. + // blur 只能结束当前 field;其他 field 的过期 blur 已由上方 index 门禁忽略。 if (source === 'blur') { - const blurIndex = getCurrentIndex(); + const blurIndex = currentIndex; if (blurIndex === null) { return; @@ -204,7 +218,7 @@ export default function useRangeValueChange( // The first operation starts from its field. Other operations do not // change currentIndex until the source completes the current field. // 第一次操作从对应 field 开始;后续操作只有完成当前 field 时才推进 currentIndex。 - if (getCurrentIndex() === null) { + if (currentIndex === null) { setCurrentIndex(index); } From 419e49f95dd24190e70696b049a8073e21a39333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 14:26:09 +0800 Subject: [PATCH 12/27] refactor(RangePicker): initialize field flow centrally --- src/PickerInput/hooks/useRangeValueChange.ts | 96 +++++++++++--------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 76e30541b..936607c4b 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -51,22 +51,43 @@ export type UseRangeValueChangeReturn = [ * * triggerChange(index, source, date) * | - * +-- 📝 date exists / 存在 date - * | `-- update CalendarValue[index] + * +-- currentIndex is null / currentIndex 为空 + * | |-- blur / 失焦 + * | | `-- ⏸️ ignore / 忽略 + * | `-- other sources / 其他来源 + * | `-- set currentIndex = index, continue + * | 建立 currentIndex,继续处理 * | * +-- field-switch / 切换 field - * | |-- first focus / 首次聚焦 - * | | `-- use target index / 使用目标 index - * | |-- can process field / 可以处理当前 field - * | | `-- ✅ record field and switch / 记录 field 并切换 - * | `-- cannot process field / 无法处理当前 field + * | |-- target is current / 目标就是当前 field + * | | `-- ⏸️ stay / 保持 + * | |-- no needConfirm and value is valid + * | | 无需确认且值有效 + * | | `-- ✅ submit current, switch / 提交并切换 + * | |-- needConfirm and empty is allowed + * | | 需要确认且允许空值 + * | | `-- ↩️ reset, submit, switch / 重置、提交并切换 + * | `-- otherwise / 其他情况 * | `-- ⏸️ keep current index / 保持当前 index * | * +-- index differs from current / 与当前 index 不一致 * | `-- ⏸️ ignore until current field completes * | 等待当前 field 完成,忽略本次操作 * | - * +-- panel-intermediate / 面板中间操作 + * +-- blur / 失焦 + * | |-- no needConfirm and current field was not changed + * | | 无需确认且当前 field 未发生变更 + * | | `-- ⏸️ end round / 结束本轮 + * | |-- needConfirm or invalid empty value + * | | 需要确认或空值无效 + * | | `-- ↩️ reset all, end round / 全部重置并结束 + * | `-- otherwise / 其他情况 + * | `-- ✅ submit current field / 提交当前 field + * | + * +-- 📝 date exists / 存在 date + * | `-- update CalendarValue[index] + * | + * +-- input or panel-intermediate / 输入或面板中间操作 * | `-- ⏸️ update only / 仅更新 CalendarValue * | * +-- panel-final / 面板最终操作 @@ -75,19 +96,14 @@ export type UseRangeValueChangeReturn = [ * | `-- no needConfirm / 无需确认 * | `-- ✅ submit current field / 提交当前 field * | - * +-- ✅ other sources can submit / 其他来源可以提交 - * | |-- flush current field / 提交当前 field - * | |-- all fields completed / 所有 field 已完成 - * | | `-- final submit, clear round / 最终提交并结束本轮 - * | `-- fields remain / 仍有 field 未完成 - * | `-- move to next index / 推进到下一个 index + * +-- keyboard-submit or confirm / 键盘提交或确认按钮 + * | `-- ✅ submit current field / 提交当前 field * | - * +-- ↩️ blur without submit / 失焦但不能提交 - * | `-- reset all CalendarValue, end round - * | 回滚全部 CalendarValue,结束本轮 - * | - * `-- ⏸️ other sources / 其他来源 - * `-- keep current field / 停留在当前 field + * `-- after submit / 提交后 + * |-- all fields completed / 所有 field 已完成 + * | `-- final submit, clear round / 最终提交并结束本轮 + * `-- fields remain / 仍有 field 未完成 + * `-- move to next index / 推进到下一个 index * * Explicit submit sources are `keyboard-submit` and `confirm`. A * `panel-final` operation submits only when `needConfirm` is false. @@ -145,21 +161,30 @@ export default function useRangeValueChange( // 将所有交互统一收口,并判断应更新、重置、停留还是提交当前 field。 const triggerChange = useEvent( (index: number, source: RangeValueChangeSource, date?: DateType) => { - // For field switch, `index` is the target field. The previous field must - // pass the switch check before focus can move. - // field-switch 的 `index` 表示目标 field;前一个 field 通过检查后才能移动焦点。 - if (source === 'field-switch') { - const previousIndex = getCurrentIndex(); + let currentIndex = getCurrentIndex(); - if (previousIndex === null) { - setCurrentIndex(index); + // Start a new interaction from the first non-blur event. A standalone + // blur has no active field to finish and must not create one. + // 第一条非 blur 事件用于建立新一轮交互;单独的 blur 没有可结束的 field, + // 也不应因此创建 currentIndex。 + if (currentIndex === null) { + if (source === 'blur') { return; } - if (previousIndex === index) { + currentIndex = index; + setCurrentIndex(index); + } + + // For field switch, `index` is the target field. The previous field must + // pass the switch check before focus can move. + // field-switch 的 `index` 表示目标 field;前一个 field 通过检查后才能移动焦点。 + if (source === 'field-switch') { + if (currentIndex === index) { return; } + const previousIndex = currentIndex; const previousValue = getCalendarValue()[previousIndex]; const previousEmpty = previousValue === null || previousValue === undefined; @@ -175,14 +200,12 @@ export default function useRangeValueChange( return; } - const currentIndex = getCurrentIndex(); - // Only the current field may receive changes. A different field becomes // valid only after submitField completes the previous one and advances // currentIndex. // 只有当前 field 可以接收变更。必须由 submitField 完成前一个 field 并推进 // currentIndex 后,另一个 field 的事件才会生效。 - if (currentIndex !== null && currentIndex !== index) { + if (currentIndex !== index) { return; } @@ -192,10 +215,6 @@ export default function useRangeValueChange( if (source === 'blur') { const blurIndex = currentIndex; - if (blurIndex === null) { - return; - } - const blurValue = getCalendarValue()[blurIndex]; const blurEmpty = blurValue === null || blurValue === undefined; const triggeredFields = triggeredFieldsRef.current; @@ -215,13 +234,6 @@ export default function useRangeValueChange( return; } - // The first operation starts from its field. Other operations do not - // change currentIndex until the source completes the current field. - // 第一次操作从对应 field 开始;后续操作只有完成当前 field 时才推进 currentIndex。 - if (currentIndex === null) { - setCurrentIndex(index); - } - if (source === 'input' || source === 'panel-intermediate' || source === 'panel-final') { recordTriggeredField(index); } From 2400a334305dcbf6dbaab01299d42c55f2bff15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 14:31:18 +0800 Subject: [PATCH 13/27] refactor(RangePicker): unify field index routing --- src/PickerInput/hooks/useRangeValueChange.ts | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 936607c4b..f97d6a558 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -58,9 +58,15 @@ export type UseRangeValueChangeReturn = [ * | `-- set currentIndex = index, continue * | 建立 currentIndex,继续处理 * | - * +-- field-switch / 切换 field - * | |-- target is current / 目标就是当前 field + * +-- index is current / 是当前 index + * | |-- field-switch / 切换到当前 field * | | `-- ⏸️ stay / 保持 + * | `-- other sources / 其他来源 + * | `-- continue / 继续处理 + * | + * +-- index differs from current / 与当前 index 不一致 + * | |-- not field-switch / 非 field-switch + * | | `-- ⏸️ ignore / 忽略 * | |-- no needConfirm and value is valid * | | 无需确认且值有效 * | | `-- ✅ submit current, switch / 提交并切换 @@ -70,10 +76,6 @@ export type UseRangeValueChangeReturn = [ * | `-- otherwise / 其他情况 * | `-- ⏸️ keep current index / 保持当前 index * | - * +-- index differs from current / 与当前 index 不一致 - * | `-- ⏸️ ignore until current field completes - * | 等待当前 field 完成,忽略本次操作 - * | * +-- blur / 失焦 * | |-- no needConfirm and current field was not changed * | | 无需确认且当前 field 未发生变更 @@ -176,11 +178,12 @@ export default function useRangeValueChange( setCurrentIndex(index); } - // For field switch, `index` is the target field. The previous field must - // pass the switch check before focus can move. - // field-switch 的 `index` 表示目标 field;前一个 field 通过检查后才能移动焦点。 - if (source === 'field-switch') { - if (currentIndex === index) { + // A different field can be reached only through field-switch. The current + // field must pass the switch check before currentIndex can move. + // 只有 field-switch 可以进入其他 field;当前 field 通过切换检查后, + // currentIndex 才能移动。 + if (currentIndex !== index) { + if (source !== 'field-switch') { return; } @@ -200,12 +203,9 @@ export default function useRangeValueChange( return; } - // Only the current field may receive changes. A different field becomes - // valid only after submitField completes the previous one and advances - // currentIndex. - // 只有当前 field 可以接收变更。必须由 submitField 完成前一个 field 并推进 - // currentIndex 后,另一个 field 的事件才会生效。 - if (currentIndex !== index) { + // Focusing the current field does not change its value or submit state. + // 聚焦当前 field 不会改变它的值或提交状态。 + if (source === 'field-switch') { return; } From 43a0bb5f0e85ad0f107b882bd0a8477bd5ac2d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 14:53:26 +0800 Subject: [PATCH 14/27] feat(RangePicker): reset interaction on Escape --- src/PickerInput/RangePicker.tsx | 2 ++ src/PickerInput/hooks/useRangeValueChange.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 3ec8a9493..d0d15ca4f 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -704,6 +704,8 @@ function RangePicker( const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => { if (event.key === 'Tab') { triggerPartConfirm(null, 'keyboard-submit'); + } else if (event.key === 'Escape') { + triggerRangeValueChange(activeIndex, 'esc'); } onKeyDown?.(event, preventDefault); diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index f97d6a558..68678333a 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -5,6 +5,7 @@ import * as React from 'react'; export type RangeValueChangeSource = | 'input' | 'keyboard-submit' + | 'esc' | 'panel-intermediate' | 'panel-final' | 'blur' @@ -51,6 +52,9 @@ export type UseRangeValueChangeReturn = [ * * triggerChange(index, source, date) * | + * +-- esc / 退出键 + * | `-- ↩️ reset all, end round / 全部重置并结束本轮 + * | * +-- currentIndex is null / currentIndex 为空 * | |-- blur / 失焦 * | | `-- ⏸️ ignore / 忽略 @@ -163,6 +167,16 @@ export default function useRangeValueChange( // 将所有交互统一收口,并判断应更新、重置、停留还是提交当前 field。 const triggerChange = useEvent( (index: number, source: RangeValueChangeSource, date?: DateType) => { + // Escape always cancels the whole interaction without checking index or + // needConfirm. + // Escape 始终撤销整轮交互,不受 index 或 needConfirm 限制。 + if (source === 'esc') { + resetValue(); + triggeredFieldsRef.current = []; + setCurrentIndex(null); + return; + } + let currentIndex = getCurrentIndex(); // Start a new interaction from the first non-blur event. A standalone From ad4135b7223b238797524b83635062c2087ecab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 15:11:53 +0800 Subject: [PATCH 15/27] refactor(RangePicker): resolve value change actions --- src/PickerInput/hooks/useRangeValueChange.ts | 280 ++++++++----------- 1 file changed, 124 insertions(+), 156 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 68678333a..3277ca55d 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -12,6 +12,10 @@ export type RangeValueChangeSource = | 'field-switch' | 'confirm'; +/** Resolved operation for one field interaction. / 一次 field 交互最终执行的操作。 */ +export type RangeValueChangeAction = + 'modify' | 'switchNext' | 'abort' | 'resetCurrent' | 'resetCurrentAndSwitchNext' | 'resetAll'; + /** Receive a field interaction and its optional value. / 接收 field 交互及可选变更值。 */ export type TriggerChange = ( index: number, @@ -48,73 +52,23 @@ export type UseRangeValueChangeReturn = [ * 统一管理任意数量 field 的 CalendarValue 更新、局部提交与最终提交。 * * Flow / 流程: - * 📝 update / 更新 · ✅ submit / 提交 · ↩️ reset / 回滚 · ⏸️ stay / 保持 - * - * triggerChange(index, source, date) - * | - * +-- esc / 退出键 - * | `-- ↩️ reset all, end round / 全部重置并结束本轮 - * | - * +-- currentIndex is null / currentIndex 为空 - * | |-- blur / 失焦 - * | | `-- ⏸️ ignore / 忽略 - * | `-- other sources / 其他来源 - * | `-- set currentIndex = index, continue - * | 建立 currentIndex,继续处理 - * | - * +-- index is current / 是当前 index - * | |-- field-switch / 切换到当前 field - * | | `-- ⏸️ stay / 保持 - * | `-- other sources / 其他来源 - * | `-- continue / 继续处理 - * | - * +-- index differs from current / 与当前 index 不一致 - * | |-- not field-switch / 非 field-switch - * | | `-- ⏸️ ignore / 忽略 - * | |-- no needConfirm and value is valid - * | | 无需确认且值有效 - * | | `-- ✅ submit current, switch / 提交并切换 - * | |-- needConfirm and empty is allowed - * | | 需要确认且允许空值 - * | | `-- ↩️ reset, submit, switch / 重置、提交并切换 - * | `-- otherwise / 其他情况 - * | `-- ⏸️ keep current index / 保持当前 index - * | - * +-- blur / 失焦 - * | |-- no needConfirm and current field was not changed - * | | 无需确认且当前 field 未发生变更 - * | | `-- ⏸️ end round / 结束本轮 - * | |-- needConfirm or invalid empty value - * | | 需要确认或空值无效 - * | | `-- ↩️ reset all, end round / 全部重置并结束 - * | `-- otherwise / 其他情况 - * | `-- ✅ submit current field / 提交当前 field - * | - * +-- 📝 date exists / 存在 date - * | `-- update CalendarValue[index] - * | - * +-- input or panel-intermediate / 输入或面板中间操作 - * | `-- ⏸️ update only / 仅更新 CalendarValue - * | - * +-- panel-final / 面板最终操作 - * | |-- needConfirm / 需要确认 - * | | `-- ⏸️ update only / 仅更新 CalendarValue - * | `-- no needConfirm / 无需确认 - * | `-- ✅ submit current field / 提交当前 field - * | - * +-- keyboard-submit or confirm / 键盘提交或确认按钮 - * | `-- ✅ submit current field / 提交当前 field - * | - * `-- after submit / 提交后 - * |-- all fields completed / 所有 field 已完成 - * | `-- final submit, clear round / 最终提交并结束本轮 - * `-- fields remain / 仍有 field 未完成 - * `-- move to next index / 推进到下一个 index + * First resolve `source`, `needConfirm`, `allowEmpty` and the field indexes to + * one action, then execute that action in a single switch statement. + * 先根据 `source`、`needConfirm`、`allowEmpty` 与 field index 得到唯一 action, + * 再通过统一的 switch 执行 action。 * - * Explicit submit sources are `keyboard-submit` and `confirm`. A - * `panel-final` operation submits only when `needConfirm` is false. - * 显式提交来源为 `keyboard-submit` 和 `confirm`;仅在无需确认时, - * `panel-final` 操作才会提交。 + * - `modify`: update or record the current CalendarValue. + * 更新或记录当前 CalendarValue。 + * - `switchNext`: submit the current field and advance to the next field. + * 提交当前 field 并推进到下一个 field。 + * - `abort`: stop without changing any state. + * 直接短路,不改变任何状态。 + * - `resetCurrent`: discard only the current field. + * 仅撤销当前 field。 + * - `resetCurrentAndSwitchNext`: discard the current temporary value, mark the + * field as handled, then advance. / 撤销当前临时值,将 field 标记为已处理后推进。 + * - `resetAll`: discard all temporary values and end the interaction. + * 撤销全部临时值并结束本轮交互。 */ export default function useRangeValueChange( fieldCount: number, @@ -146,7 +100,7 @@ export default function useRangeValueChange( // Flush the current field, then finish the round or advance to the next one. // 提交当前 field,随后结束本轮或推进到下一个 field。 - const submitField = (index: number, nextIndex?: number) => { + const submitField = (index: number) => { recordTriggeredField(index); // Trigger final change after every field has participated once. @@ -156,116 +110,130 @@ export default function useRangeValueChange( if (allFieldsTriggered) { triggeredFieldsRef.current = []; - setCurrentIndex(nextIndex ?? null); + setCurrentIndex(null); } else { - setCurrentIndex(nextIndex ?? (index + 1) % fieldCount); + setCurrentIndex((index + 1) % fieldCount); } }; - // Route every interaction through the same event entry and decide whether - // to update, reset, stay on the current field, or submit it. - // 将所有交互统一收口,并判断应更新、重置、停留还是提交当前 field。 - const triggerChange = useEvent( - (index: number, source: RangeValueChangeSource, date?: DateType) => { - // Escape always cancels the whole interaction without checking index or - // needConfirm. - // Escape 始终撤销整轮交互,不受 index 或 needConfirm 限制。 - if (source === 'esc') { - resetValue(); - triggeredFieldsRef.current = []; - setCurrentIndex(null); - return; - } + // Resolve an interaction to one action without changing any state. + // 仅根据当前状态解析 action,不在判断过程中修改任何状态。 + const resolveAction = ( + currentIndex: number | null, + index: number, + source: RangeValueChangeSource, + date?: DateType, + ): RangeValueChangeAction => { + if (source === 'esc') { + return 'resetAll'; + } - let currentIndex = getCurrentIndex(); + if (currentIndex === null) { + return 'abort'; + } - // Start a new interaction from the first non-blur event. A standalone - // blur has no active field to finish and must not create one. - // 第一条非 blur 事件用于建立新一轮交互;单独的 blur 没有可结束的 field, - // 也不应因此创建 currentIndex。 - if (currentIndex === null) { - if (source === 'blur') { - return; - } + const currentValue = date === undefined ? getCalendarValue()[currentIndex] : date; + const currentEmpty = currentValue === null || currentValue === undefined; + const canSwitch = !currentEmpty || allowEmpty[currentIndex]; - currentIndex = index; - setCurrentIndex(index); + if (source === 'field-switch') { + if (index === currentIndex) { + return 'abort'; } - // A different field can be reached only through field-switch. The current - // field must pass the switch check before currentIndex can move. - // 只有 field-switch 可以进入其他 field;当前 field 通过切换检查后, - // currentIndex 才能移动。 - if (currentIndex !== index) { - if (source !== 'field-switch') { - return; - } - - const previousIndex = currentIndex; - const previousValue = getCalendarValue()[previousIndex]; - const previousEmpty = previousValue === null || previousValue === undefined; - - if (!needConfirm && (!previousEmpty || allowEmpty[previousIndex])) { - submitField(previousIndex, index); - } else if (needConfirm && previousEmpty && allowEmpty[previousIndex]) { - resetValue(previousIndex); - submitField(previousIndex, index); - } else if (!needConfirm) { - resetValue(previousIndex); - } - - return; + const nextIndex = (currentIndex + 1) % fieldCount; + if (index !== nextIndex) { + return 'abort'; } - // Focusing the current field does not change its value or submit state. - // 聚焦当前 field 不会改变它的值或提交状态。 - if (source === 'field-switch') { - return; + if (needConfirm) { + return currentEmpty && allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort'; } - // Blur can end only the current field. A stale blur from another field - // has already been ignored by the index guard above. - // blur 只能结束当前 field;其他 field 的过期 blur 已由上方 index 门禁忽略。 - if (source === 'blur') { - const blurIndex = currentIndex; - - const blurValue = getCalendarValue()[blurIndex]; - const blurEmpty = blurValue === null || blurValue === undefined; - const triggeredFields = triggeredFieldsRef.current; - const lastTriggeredIndex = triggeredFields[triggeredFields.length - 1]; + return canSwitch ? 'switchNext' : 'resetCurrent'; + } - if (!needConfirm && lastTriggeredIndex !== blurIndex) { - triggeredFieldsRef.current = []; - setCurrentIndex(null); - } else if (needConfirm || (blurEmpty && !allowEmpty[blurIndex])) { - resetValue(); - triggeredFieldsRef.current = []; - setCurrentIndex(null); - } else { - submitField(blurIndex); - } + if (index !== currentIndex) { + return 'abort'; + } - return; + if (source === 'blur') { + if (needConfirm || !canSwitch) { + return 'resetAll'; } - if (source === 'input' || source === 'panel-intermediate' || source === 'panel-final') { - recordTriggeredField(index); - } + 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'; + }; + + // Route every interaction through action resolution, then execute the + // resolved action in one place. + // 所有交互先统一解析 action,再在一个位置执行对应操作。 + const triggerChange = useEvent( + (index: number, source: RangeValueChangeSource, date?: DateType) => { + let currentIndex = getCurrentIndex(); - // A provided date updates the temporary CalendarValue. - // 传入 date 时更新临时 CalendarValue。 - if (date !== undefined) { - triggerCalendarChange(index, date); + // Start a new interaction from the first non-blur event. A standalone + // blur has no active field to finish and must not create one. + // 第一条非 blur 事件用于建立新一轮交互;单独的 blur 没有可结束的 field, + // 也不应因此创建 currentIndex。 + if (currentIndex === null && source !== 'blur' && source !== 'esc') { + currentIndex = index; + setCurrentIndex(index); } - // Explicit operations always submit. Final panel operations submit - // automatically only when explicit confirmation is not required. - // 显式操作始终提交;无需确认时,panel-final 也会自动提交。 - const isExplicitSubmit = source === 'keyboard-submit' || source === 'confirm'; - const isAutoSubmit = !needConfirm && source === 'panel-final'; + const action = resolveAction(currentIndex, index, source, date); + const actionIndex = currentIndex ?? index; + + switch (action) { + case 'modify': + recordTriggeredField(actionIndex); + if (date !== undefined) { + triggerCalendarChange(actionIndex, date); + } + break; + + case 'switchNext': + if (date !== undefined) { + triggerCalendarChange(actionIndex, date); + } + submitField(actionIndex); + break; + + case 'resetCurrent': + resetValue(actionIndex); + triggeredFieldsRef.current = triggeredFieldsRef.current.filter( + (fieldIndex) => fieldIndex !== actionIndex, + ); + break; + + case 'resetCurrentAndSwitchNext': + resetValue(actionIndex); + submitField(actionIndex); + break; + + case 'resetAll': + resetValue(); + triggeredFieldsRef.current = []; + setCurrentIndex(null); + break; - if (isExplicitSubmit || isAutoSubmit) { - submitField(index); + case 'abort': + break; } }, ); From f42d2deafa1ec1f0fe6dc9b08e24bc394a70efb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 15:28:03 +0800 Subject: [PATCH 16/27] style(RangePicker): organize value change hook --- src/PickerInput/hooks/useRangeValueChange.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 3277ca55d..a121bcf35 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -1,6 +1,8 @@ import { useEvent, useSyncState } from '@rc-component/util'; import * as React from 'react'; +// ============================= Types ============================= + /** Change source of a field. / Field 的变更来源。 */ export type RangeValueChangeSource = | 'input' @@ -46,6 +48,8 @@ export type UseRangeValueChangeReturn = [ triggerChange: TriggerChange, ]; +// ============================== Hook ============================== + /** * Coordinate CalendarValue updates, part submits and final submits for any * number of fields. @@ -79,6 +83,8 @@ export default function useRangeValueChange( flushSubmit: FlushSubmit, resetValue: ResetValue, ): UseRangeValueChangeReturn { + // ============================= State ============================= + // Record fields involved in the current interaction. // 记录当前一轮交互中触发过的 field。 const triggeredFieldsRef = React.useRef([]); @@ -87,6 +93,8 @@ export default function useRangeValueChange( // 同时保存渲染值,以及供事件处理函数同步读取的 getter。 const [getCurrentIndex, setCurrentIndex] = useSyncState(null); + // ============================= Record ============================ + // Keep fields unique and move the latest field to the end, so the same list // records both completed fields and the most recently changed field. // field 保持唯一,并将最近触发的 field 移到末尾;同一份列表即可同时记录 @@ -98,6 +106,8 @@ export default function useRangeValueChange( ]; }; + // ============================= Submit ============================ + // Flush the current field, then finish the round or advance to the next one. // 提交当前 field,随后结束本轮或推进到下一个 field。 const submitField = (index: number) => { @@ -116,6 +126,8 @@ export default function useRangeValueChange( } }; + // ============================= Resolve =========================== + // Resolve an interaction to one action without changing any state. // 仅根据当前状态解析 action,不在判断过程中修改任何状态。 const resolveAction = ( @@ -180,6 +192,8 @@ export default function useRangeValueChange( return 'abort'; }; + // ============================= Trigger =========================== + // Route every interaction through action resolution, then execute the // resolved action in one place. // 所有交互先统一解析 action,再在一个位置执行对应操作。 From 5a77c97bf3e40cecc7366a164d1aa5363f45bd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 15:43:36 +0800 Subject: [PATCH 17/27] refactor(RangePicker): expose inputs from selector ref --- src/PickerInput/RangePicker.tsx | 13 ++++++------- src/PickerInput/Selector/RangeSelector.tsx | 15 ++++++++------- src/PickerInput/hooks/useFocusLock.ts | 15 ++++++++++----- src/PickerInput/hooks/usePickerRef.ts | 7 +++++-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index d0d15ca4f..750367e3f 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -41,7 +41,10 @@ import useRangeValue, { useInnerValue } from './hooks/useRangeValue'; import useRangeValueChange, { type RangeValueChangeSource } from './hooks/useRangeValueChange'; import useShowNow from './hooks/useShowNow'; import Popup, { type PopupShowTimeConfig } from './Popup'; -import RangeSelector, { type SelectorIdType } from './Selector/RangeSelector'; +import RangeSelector, { + type RangeSelectorRef, + type SelectorIdType, +} from './Selector/RangeSelector'; import useSemantic from '../hooks/useSemantic'; function separateConfig(config: T | [T, T] | null | undefined, defaultConfig: T): [T, T] { @@ -236,10 +239,7 @@ function RangePicker( } = filledProps; // ========================= Refs ========================= - const selectorRef = usePickerRef(ref); - const inputStartRef = React.useRef(null); - const inputEndRef = React.useRef(null); - const inputFieldRefs = [inputStartRef, inputEndRef]; + const selectorRef = usePickerRef[0], RangeSelectorRef>(ref); const popupRef = React.useRef(null); // ======================= Semantic ======================= @@ -351,7 +351,7 @@ function RangePicker( resetValue, ); - useFocusLock(rangeValueIndex, inputFieldRefs, popupRef); + useFocusLock(rangeValueIndex, selectorRef, popupRef); const isInternalPickerElement = useEvent((element: EventTarget | null) => isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), @@ -785,7 +785,6 @@ function RangePicker( {...filledProps} // Ref ref={selectorRef} - inputRefs={inputFieldRefs} // Style className={clsx(filledProps.className, rootClassName, mergedClassNames.root)} style={{ ...mergedStyles.root, ...filledProps.style }} diff --git a/src/PickerInput/Selector/RangeSelector.tsx b/src/PickerInput/Selector/RangeSelector.tsx index 035ea2e0d..7208fbc72 100644 --- a/src/PickerInput/Selector/RangeSelector.tsx +++ b/src/PickerInput/Selector/RangeSelector.tsx @@ -17,11 +17,14 @@ export type SelectorIdType = end?: string; }; +export interface RangeSelectorRef extends RangePickerRef { + startInput: HTMLInputElement; + endInput: HTMLInputElement; +} + export interface RangeSelectorProps extends SelectorProps { id?: SelectorIdType; - inputRefs?: readonly React.Ref[]; - activeIndex: number | null; separator?: React.ReactNode; @@ -51,11 +54,10 @@ export interface RangeSelectorProps extends SelectorProps( props: RangeSelectorProps, - ref: React.Ref, + ref: React.Ref, ) { const { id, - inputRefs, prefix, clearIcon, @@ -143,13 +145,12 @@ function RangeSelector( const inputStartRef = React.useRef(null); const inputEndRef = React.useRef(null); - React.useImperativeHandle(inputRefs?.[0], () => inputStartRef.current.inputElement); - React.useImperativeHandle(inputRefs?.[1], () => inputEndRef.current.inputElement); - const getInput = (index: number) => [inputStartRef, inputEndRef][index]?.current; 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/hooks/useFocusLock.ts b/src/PickerInput/hooks/useFocusLock.ts index 25609632d..cfd0df3b1 100644 --- a/src/PickerInput/hooks/useFocusLock.ts +++ b/src/PickerInput/hooks/useFocusLock.ts @@ -2,13 +2,18 @@ import { useLayoutEffect } from '@rc-component/util'; import type * as React from 'react'; import { isTargetInContainers } from './useFocusEvents'; +interface FocusLockSelectorRef { + startInput: HTMLElement; + endInput: HTMLElement; +} + /** * Keep focus on the specified input field while focus moves inside the Picker. * 当焦点在 Picker 内移动时,将其锁定在指定的输入框上。 */ export default function useFocusLock( index: number | null, - inputFieldRefs: readonly React.RefObject[], + selectorRef: React.RefObject, popupRef: React.RefObject, ) { // DOM focus may change while `index` stays the same, so check after every commit. @@ -19,18 +24,18 @@ export default function useFocusLock( } const activeElement = document.activeElement; + const inputFields = [selectorRef.current?.startInput, selectorRef.current?.endInput]; if (isTargetInContainers(activeElement, [popupRef.current])) { return; } - const focusInOtherField = inputFieldRefs.some( - (fieldRef, fieldIndex) => - fieldIndex !== index && isTargetInContainers(activeElement, [fieldRef.current]), + const focusInOtherField = inputFields.some( + (field, fieldIndex) => fieldIndex !== index && isTargetInContainers(activeElement, [field]), ); if (focusInOtherField) { - inputFieldRefs[index]?.current?.focus(); + 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, From 71b4039c4639678fd3daa37bf08e7b6d6612a72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 15:47:57 +0800 Subject: [PATCH 18/27] refactor(Picker): suspend last operation tracking --- src/PickerInput/RangePicker.tsx | 2 +- src/PickerInput/SinglePicker.tsx | 43 ++++++++++++++----------- src/PickerInput/hooks/useRangeActive.ts | 20 ++++++------ 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 750367e3f..375b7f256 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -272,7 +272,7 @@ function RangePicker( const calendarValue = getCalendarValue(); // ======================== Active ======================== - const [focused, triggerFocus, , activeIndex, setActiveIndex, nextActiveIndex, activeIndexList] = + const [focused, triggerFocus, activeIndex, setActiveIndex, nextActiveIndex, activeIndexList] = useRangeActive(disabled, allowEmpty, mergedOpen); // ======================= ShowTime ======================= diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index 6cdf6be3c..2258f1116 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -255,7 +255,12 @@ function Picker( // ======================== Active ======================== // In SinglePicker, we will always get `activeIndex` is 0. - const [focused, triggerFocus, lastOperation, activeIndex] = useRangeActive([disabled]); + const [ + focused, + triggerFocus, + // lastOperation, + activeIndex, + ] = useRangeActive([disabled]); const onSharedFocus = (event: React.FocusEvent) => { triggerFocus(true); @@ -462,7 +467,7 @@ function Picker( // >>> Calendar const onPanelSelect = (date: DateType) => { - lastOperation('panel'); + // lastOperation('panel'); // Not change values if multiple and current panel is to match with picker if (multiple && internalMode !== picker) { @@ -568,12 +573,12 @@ function Picker( }; const onSelectorInputChange = () => { - lastOperation('input'); + // lastOperation('input'); }; // ======================= Selector ======================= const onSelectorFocus: SelectorProps['onFocus'] = (event) => { - lastOperation('input'); + // lastOperation('input'); triggerOpen(true, { inherit: true, @@ -630,21 +635,23 @@ function Picker( } }, [mergedOpen, activeIndex, picker]); + // TODO: Keep the legacy last-operation submit flow for reference during refactor. + // TODO: 重构期间暂时保留基于 lastOperation 的旧提交逻辑作为参考。 // >>> 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]); + // 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 ( diff --git a/src/PickerInput/hooks/useRangeActive.ts b/src/PickerInput/hooks/useRangeActive.ts index 4b75d8d58..976554ecb 100644 --- a/src/PickerInput/hooks/useRangeActive.ts +++ b/src/PickerInput/hooks/useRangeActive.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import type { RangeValueType } from '../RangePicker'; import useLockEffect from './useLockEffect'; -export type OperationType = 'input' | 'panel' | 'preset-click'; +// export type OperationType = 'input' | 'panel' | 'preset-click'; export type NextActive = (nextValue: RangeValueType) => number | null; @@ -18,7 +18,7 @@ export default function useRangeActive( ): [ focused: boolean, triggerFocus: (focused: boolean) => void, - lastOperation: (type?: OperationType) => OperationType, + // lastOperation: (type?: OperationType) => OperationType, activeIndex: number, setActiveIndex: (index: number) => void, nextActiveIndex: NextActive, @@ -31,7 +31,7 @@ export default function useRangeActive( const activeListRef = React.useRef([]); const submitIndexRef = React.useRef(null); - const lastOperationRef = React.useRef(null); + // const lastOperationRef = React.useRef(null); const updateSubmitIndex = (index: number | null) => { submitIndexRef.current = index; @@ -46,12 +46,12 @@ export default function useRangeActive( }; // ============================= Record ============================= - const lastOperation = (type?: OperationType) => { - if (type) { - lastOperationRef.current = type; - } - return lastOperationRef.current; - }; + // const lastOperation = (type?: OperationType) => { + // if (type) { + // lastOperationRef.current = type; + // } + // return lastOperationRef.current; + // }; // ============================ Strategy ============================ // Trigger when input enter or input blur or panel close @@ -85,7 +85,7 @@ export default function useRangeActive( return [ focused, triggerFocus, - lastOperation, + // lastOperation, activeIndex, setActiveIndex, nextActiveIndex, From 2177fcd6f4828f44eecdb6752abee528247d8e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 16:10:42 +0800 Subject: [PATCH 19/27] refactor(RangePicker): centralize active field state --- src/PickerInput/RangePicker.tsx | 167 ++++++++---------- src/PickerInput/hooks/useFocusEvents.ts | 27 +-- src/PickerInput/hooks/useRangeDisabledDate.ts | 7 +- src/PickerInput/hooks/useRangeValueChange.ts | 32 ++-- src/utils/miscUtil.ts | 10 +- 5 files changed, 114 insertions(+), 129 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 375b7f256..0d922c6ad 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -34,7 +34,6 @@ import useFocusLock from './hooks/useFocusLock'; import useOpen from './hooks/useOpen'; import usePickerRef from './hooks/usePickerRef'; import usePresets from './hooks/usePresets'; -import useRangeActive from './hooks/useRangeActive'; import useRangeDisabledDate from './hooks/useRangeDisabledDate'; import useRangePickerValue from './hooks/useRangePickerValue'; import useRangeValue, { useInnerValue } from './hooks/useRangeValue'; @@ -271,48 +270,27 @@ function RangePicker( const calendarValue = getCalendarValue(); - // ======================== Active ======================== - const [focused, triggerFocus, activeIndex, setActiveIndex, nextActiveIndex, activeIndexList] = - useRangeActive(disabled, allowEmpty, mergedOpen); - - // ======================= ShowTime ======================= - /** Used for Popup panel */ - const mergedShowTime = React.useMemo< - PopupShowTimeConfig & Pick, 'defaultOpenValue'> - >(() => { - if (!showTime) { - return null; - } - - const { disabledTime } = showTime; - - const proxyDisabledTime = disabledTime - ? (date: DateType) => { - const range = getActiveRange(activeIndex); - const fromDate = getFromDate(calendarValue, activeIndexList, activeIndex); - return disabledTime(date, range, { - from: fromDate, - }); - } - : undefined; - - return { ...showTime, disabledTime: proxyDisabledTime }; - }, [showTime, activeIndex, calendarValue, activeIndexList]); - - // ========================= Mode ========================= - const [modes, setModes] = useControlledState<[PanelMode, PanelMode]>([picker, picker], mode); - - const mergedMode = modes[activeIndex] || picker; - - /** Extends from `mergedMode` to patch `datetime` mode */ - const internalMode: InternalMode = - mergedMode === 'date' && mergedShowTime ? 'datetime' : mergedMode; + // ======================== Focus ========================= + const isInternalPickerElement = useEvent((element: EventTarget | null) => + isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), + ); - // ====================== PanelCount ====================== - const multiplePanel = internalMode === picker && internalMode !== 'time'; + const [focused, onFieldFocus, onFieldBlur] = useFocusEvents( + isInternalPickerElement, + (index, event) => { + onFocus?.(event, { + range: getActiveRange(index), + }); + }, + (index, event) => { + triggerRangeValueChange(index, 'blur'); + triggerOpen(false); - // ======================= Show Now ======================= - const mergedShowNow = useShowNow(picker, mergedMode, showNow, showToday, true); + onBlur?.(event, { + range: getActiveRange(index), + }); + }, + ); // ======================== Value ========================= const [ @@ -340,53 +318,76 @@ function RangePicker( triggerCalendarChange(fillIndex(getCalendarValue(), index, date)); }); + 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, triggerRangeValueChange] = useRangeValueChange( + const [rangeValueIndex, triggeredFields, triggerRangeValueChange] = useRangeValueChange( enabledFieldCount, needConfirm, allowEmpty, getCalendarValue, triggerFieldCalendarChange, - flushSubmit, + flushFieldSubmit, resetValue, ); + // Panel calculations always require a concrete index while the interaction + // state intentionally uses `null` between rounds. + const activeIndex = rangeValueIndex ?? 0; + useFocusLock(rangeValueIndex, selectorRef, popupRef); - const isInternalPickerElement = useEvent((element: EventTarget | null) => - isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]), - ); + // ======================= ShowTime ======================= + /** Used for Popup panel */ + const mergedShowTime = React.useMemo< + PopupShowTimeConfig & Pick, 'defaultOpenValue'> + >(() => { + if (!showTime) { + return null; + } - const triggerFocusChange = useEvent((index: number, type: 'focus' | 'blur') => { - const nextFocused = type === 'focus'; - triggerFocus(nextFocused); + const { disabledTime } = showTime; - if (!nextFocused) { - triggerRangeValueChange(index, 'blur'); - triggerOpen(false); - } - }); + const proxyDisabledTime = disabledTime + ? (date: DateType) => { + const range = getActiveRange(activeIndex); + const fromDate = getFromDate(calendarValue, triggeredFields, activeIndex); + return disabledTime(date, range, { + from: fromDate, + }); + } + : undefined; - const [onFieldFocus, onFieldBlur] = useFocusEvents( - isInternalPickerElement, - triggerFocusChange, - (index, event) => { - onFocus?.(event, { - range: getActiveRange(index), - }); - }, - (index, event) => { - onBlur?.(event, { - range: getActiveRange(index), - }); - }, - ); + return { ...showTime, disabledTime: proxyDisabledTime }; + }, [showTime, activeIndex, calendarValue, triggeredFields]); + + // ========================= Mode ========================= + const [modes, setModes] = useControlledState<[PanelMode, PanelMode]>([picker, picker], mode); + + const mergedMode = modes[activeIndex] || picker; + + /** Extends from `mergedMode` to patch `datetime` mode */ + const internalMode: InternalMode = + mergedMode === 'date' && mergedShowTime ? 'datetime' : mergedMode; + + // ====================== PanelCount ====================== + const multiplePanel = internalMode === picker && internalMode !== 'time'; + + // ======================= Show Now ======================= + const mergedShowNow = useShowNow(picker, mergedMode, showNow, showToday, true); // ===================== DisabledDate ===================== const mergedDisabledDate = useRangeDisabledDate( calendarValue, disabled, - activeIndexList, + activeIndex, + triggeredFields, generateConfig, locale, disabledDate, @@ -450,19 +451,7 @@ function RangePicker( * - Panel: OK button */ const triggerPartConfirm = (date?: DateType, source: RangeValueChangeSource = 'confirm') => { - let nextValue = calendarValue; - - if (date) { - nextValue = fillCalendarValue(date, activeIndex); - } - // Get next focus index - const nextIndex = nextActiveIndex(nextValue); - triggerRangeValueChange(activeIndex, source, date ?? undefined); - - if (nextIndex === null) { - triggerOpen(false, { force: true }); - } }; // ======================== Click ========================= @@ -556,7 +545,6 @@ function RangePicker( // >>> Calendar const onPanelSelect: PickerPanelProps['onChange'] = (date: DateType) => { - const clone: RangeValueType = fillIndex(calendarValue, activeIndex, date); const panelFinished = !complexPicker && internalPicker === internalMode; triggerRangeValueChange( @@ -564,18 +552,6 @@ function RangePicker( panelFinished ? 'panel-final' : 'panel-intermediate', date, ); - - // >>> Trigger next active if !needConfirm - // Fully logic check `useRangeValue` hook - if (!needConfirm && panelFinished) { - const nextIndex = nextActiveIndex(clone); - - if (nextIndex === null) { - triggerOpen(false, { force: true }); - } else { - selectorRef.current.focus({ index: nextIndex }); - } - } }; // >>> Close @@ -692,8 +668,6 @@ function RangePicker( inherit: true, }); - setActiveIndex(index); - onFieldFocus(index, 'input', event); }; @@ -706,6 +680,7 @@ function RangePicker( triggerPartConfirm(null, 'keyboard-submit'); } else if (event.key === 'Escape') { triggerRangeValueChange(activeIndex, 'esc'); + triggerOpen(false); } onKeyDown?.(event, preventDefault); diff --git a/src/PickerInput/hooks/useFocusEvents.ts b/src/PickerInput/hooks/useFocusEvents.ts index 7ebaf9435..d80115f8e 100644 --- a/src/PickerInput/hooks/useFocusEvents.ts +++ b/src/PickerInput/hooks/useFocusEvents.ts @@ -1,13 +1,10 @@ import { useEvent } from '@rc-component/util'; -import type * as React from 'react'; +import * as React from 'react'; // ============================= Types ============================= /** Focus event source. / 焦点事件来源。 */ export type FocusSource = 'input' | 'panel'; -/** Focus state change type. / 焦点状态变更类型。 */ -export type FocusChangeType = 'focus' | 'blur'; - /** React focus event used by Picker elements. / Picker 元素使用的 React 焦点事件。 */ export type PickerFocusEvent = React.FocusEvent; @@ -28,13 +25,14 @@ export type FieldBlurHandler = ( /** Check whether an element belongs to the current focus scope. / 检查元素是否属于当前焦点范围。 */ export type IsInternalElement = (element: EventTarget | null) => boolean; -/** Notify the final focus state change. / 通知最终确认的焦点状态变更。 */ -export type TriggerFocusChange = (index: number, type: FocusChangeType) => void; - /** Notify a Picker focus or blur event. / 通知 Picker 的聚焦或失焦事件。 */ export type FocusEventHandler = (index: number, event: PickerFocusEvent) => void; -export type UseFocusEventsReturn = [onFieldFocus: FieldFocusHandler, onFieldBlur: FieldBlurHandler]; +export type UseFocusEventsReturn = [ + focused: boolean, + onFieldFocus: FieldFocusHandler, + onFieldBlur: FieldBlurHandler, +]; // ============================= Utils ============================= /** Check whether the target belongs to any container. / 判断目标是否属于任意一个容器。 */ @@ -56,21 +54,26 @@ export function isTargetInContainers( */ export default function useFocusEvents( isInternalElement: IsInternalElement, - triggerFocusChange: TriggerFocusChange, 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) => { - triggerFocusChange(index, 'focus'); + setFocusedIndex(index); onFocus?.(index, event); }); const onFieldBlur = useEvent((index: number, _source: FocusSource, event: PickerFocusEvent) => { if (!isInternalElement(event.relatedTarget)) { - triggerFocusChange(index, 'blur'); + setFocusedIndex(null); onBlur?.(index, event); } }); - return [onFieldFocus, onFieldBlur]; + return [focusedIndex !== null, onFieldFocus, onFieldBlur]; } 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/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index a121bcf35..a9bedaf7f 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -45,6 +45,7 @@ export type ResetValue = (index?: number) => void; export type UseRangeValueChangeReturn = [ currentIndex: number | null, + triggeredFields: number[], triggerChange: TriggerChange, ]; @@ -95,15 +96,14 @@ export default function useRangeValueChange( // ============================= Record ============================ - // Keep fields unique and move the latest field to the end, so the same list - // records both completed fields and the most recently changed field. - // field 保持唯一,并将最近触发的 field 移到末尾;同一份列表即可同时记录 - // 已处理的 field 和最近发生变更的 field。 + // Keep fields unique while preserving their first-triggered order. The + // current field is tracked separately by `currentIndex`. + // field 保持唯一,同时保留首次触发顺序;当前 field 由 `currentIndex` + // 单独记录。 const recordTriggeredField = (index: number) => { - triggeredFieldsRef.current = [ - ...triggeredFieldsRef.current.filter((fieldIndex) => fieldIndex !== index), - index, - ]; + if (!triggeredFieldsRef.current.includes(index)) { + triggeredFieldsRef.current = [...triggeredFieldsRef.current, index]; + } }; // ============================= Submit ============================ @@ -124,6 +124,8 @@ export default function useRangeValueChange( } else { setCurrentIndex((index + 1) % fieldCount); } + + return allFieldsTriggered; }; // ============================= Resolve =========================== @@ -208,6 +210,7 @@ export default function useRangeValueChange( if (currentIndex === null && source !== 'blur' && source !== 'esc') { currentIndex = index; setCurrentIndex(index); + recordTriggeredField(index); } const action = resolveAction(currentIndex, index, source, date); @@ -225,7 +228,9 @@ export default function useRangeValueChange( if (date !== undefined) { triggerCalendarChange(actionIndex, date); } - submitField(actionIndex); + if (!submitField(actionIndex) && source === 'field-switch') { + recordTriggeredField(index); + } break; case 'resetCurrent': @@ -237,7 +242,9 @@ export default function useRangeValueChange( case 'resetCurrentAndSwitchNext': resetValue(actionIndex); - submitField(actionIndex); + if (!submitField(actionIndex) && source === 'field-switch') { + recordTriggeredField(index); + } break; case 'resetAll': @@ -247,10 +254,13 @@ export default function useRangeValueChange( break; case 'abort': + if (source === 'field-switch' && index === actionIndex) { + recordTriggeredField(index); + } break; } }, ); - return [getCurrentIndex(), triggerChange]; + return [getCurrentIndex(), triggeredFieldsRef.current, 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; } From b53b7d10d2cb62455f2c07788ce5c6c03dc40f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 16:17:25 +0800 Subject: [PATCH 20/27] refactor(Picker): restore range active hook --- src/PickerInput/SinglePicker.tsx | 7 +------ src/PickerInput/hooks/useRangeActive.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index 2258f1116..a488dcb5a 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -255,12 +255,7 @@ function Picker( // ======================== Active ======================== // In SinglePicker, we will always get `activeIndex` is 0. - const [ - focused, - triggerFocus, - // lastOperation, - activeIndex, - ] = useRangeActive([disabled]); + const [focused, triggerFocus, , activeIndex] = useRangeActive([disabled]); const onSharedFocus = (event: React.FocusEvent) => { triggerFocus(true); diff --git a/src/PickerInput/hooks/useRangeActive.ts b/src/PickerInput/hooks/useRangeActive.ts index 976554ecb..4b75d8d58 100644 --- a/src/PickerInput/hooks/useRangeActive.ts +++ b/src/PickerInput/hooks/useRangeActive.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import type { RangeValueType } from '../RangePicker'; import useLockEffect from './useLockEffect'; -// export type OperationType = 'input' | 'panel' | 'preset-click'; +export type OperationType = 'input' | 'panel' | 'preset-click'; export type NextActive = (nextValue: RangeValueType) => number | null; @@ -18,7 +18,7 @@ export default function useRangeActive( ): [ focused: boolean, triggerFocus: (focused: boolean) => void, - // lastOperation: (type?: OperationType) => OperationType, + lastOperation: (type?: OperationType) => OperationType, activeIndex: number, setActiveIndex: (index: number) => void, nextActiveIndex: NextActive, @@ -31,7 +31,7 @@ export default function useRangeActive( const activeListRef = React.useRef([]); const submitIndexRef = React.useRef(null); - // const lastOperationRef = React.useRef(null); + const lastOperationRef = React.useRef(null); const updateSubmitIndex = (index: number | null) => { submitIndexRef.current = index; @@ -46,12 +46,12 @@ export default function useRangeActive( }; // ============================= Record ============================= - // const lastOperation = (type?: OperationType) => { - // if (type) { - // lastOperationRef.current = type; - // } - // return lastOperationRef.current; - // }; + const lastOperation = (type?: OperationType) => { + if (type) { + lastOperationRef.current = type; + } + return lastOperationRef.current; + }; // ============================ Strategy ============================ // Trigger when input enter or input blur or panel close @@ -85,7 +85,7 @@ export default function useRangeActive( return [ focused, triggerFocus, - // lastOperation, + lastOperation, activeIndex, setActiveIndex, nextActiveIndex, From 82163788805133be10d746f0de537f85d1798aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 16:43:14 +0800 Subject: [PATCH 21/27] fix(RangePicker): preserve active field on submit --- src/PickerInput/RangePicker.tsx | 23 +++++++++----------- src/PickerInput/hooks/useRangeValueChange.ts | 14 ++++++++++-- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 0d922c6ad..e05fcf74c 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -327,19 +327,16 @@ function RangePicker( }); const enabledFieldCount = disabled.filter((fieldDisabled) => !fieldDisabled).length; - const [rangeValueIndex, triggeredFields, triggerRangeValueChange] = useRangeValueChange( - enabledFieldCount, - needConfirm, - allowEmpty, - getCalendarValue, - triggerFieldCalendarChange, - flushFieldSubmit, - resetValue, - ); - - // Panel calculations always require a concrete index while the interaction - // state intentionally uses `null` between rounds. - const activeIndex = rangeValueIndex ?? 0; + const [rangeValueIndex, activeIndex, triggeredFields, triggerRangeValueChange] = + useRangeValueChange( + enabledFieldCount, + needConfirm, + allowEmpty, + getCalendarValue, + triggerFieldCalendarChange, + flushFieldSubmit, + resetValue, + ); useFocusLock(rangeValueIndex, selectorRef, popupRef); diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index a9bedaf7f..8cfc15913 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -45,6 +45,7 @@ export type ResetValue = (index?: number) => void; export type UseRangeValueChangeReturn = [ currentIndex: number | null, + activeIndex: number, triggeredFields: number[], triggerChange: TriggerChange, ]; @@ -94,6 +95,12 @@ export default function useRangeValueChange( // 同时保存渲染值,以及供事件处理函数同步读取的 getter。 const [getCurrentIndex, setCurrentIndex] = useSyncState(null); + // Keep the latest accepted field for panel and selector rendering. Unlike + // `currentIndex`, this value remains unchanged after the round finishes. + // 保留最后一个被业务接受的 field,供 panel 和 selector 渲染使用; + // 与 `currentIndex` 不同,本轮结束后不会清空。 + const [getActiveIndex, setActiveIndex] = useSyncState(0); + // ============================= Record ============================ // Keep fields unique while preserving their first-triggered order. The @@ -122,7 +129,9 @@ export default function useRangeValueChange( triggeredFieldsRef.current = []; setCurrentIndex(null); } else { - setCurrentIndex((index + 1) % fieldCount); + const nextIndex = (index + 1) % fieldCount; + setCurrentIndex(nextIndex); + setActiveIndex(nextIndex); } return allFieldsTriggered; @@ -210,6 +219,7 @@ export default function useRangeValueChange( if (currentIndex === null && source !== 'blur' && source !== 'esc') { currentIndex = index; setCurrentIndex(index); + setActiveIndex(index); recordTriggeredField(index); } @@ -262,5 +272,5 @@ export default function useRangeValueChange( }, ); - return [getCurrentIndex(), triggeredFieldsRef.current, triggerChange]; + return [getCurrentIndex(), getActiveIndex(), triggeredFieldsRef.current, triggerChange]; } From 4d2208145bcdb96c99a06c2b9008fdae31186911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 16:46:05 +0800 Subject: [PATCH 22/27] refactor(RangePicker): retain active field with ref --- src/PickerInput/hooks/useRangeValueChange.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 8cfc15913..dd58e5b41 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -95,11 +95,11 @@ export default function useRangeValueChange( // 同时保存渲染值,以及供事件处理函数同步读取的 getter。 const [getCurrentIndex, setCurrentIndex] = useSyncState(null); - // Keep the latest accepted field for panel and selector rendering. Unlike - // `currentIndex`, this value remains unchanged after the round finishes. + // Keep the latest accepted field for panel and selector rendering. Changes + // to this ref always accompany a `currentIndex` update, so it needs no state. // 保留最后一个被业务接受的 field,供 panel 和 selector 渲染使用; - // 与 `currentIndex` 不同,本轮结束后不会清空。 - const [getActiveIndex, setActiveIndex] = useSyncState(0); + // ref 的变更总是伴随 `currentIndex` 更新,因此不需要额外的 state。 + const lastValidIndexRef = React.useRef(null); // ============================= Record ============================ @@ -130,8 +130,8 @@ export default function useRangeValueChange( setCurrentIndex(null); } else { const nextIndex = (index + 1) % fieldCount; + lastValidIndexRef.current = nextIndex; setCurrentIndex(nextIndex); - setActiveIndex(nextIndex); } return allFieldsTriggered; @@ -218,8 +218,8 @@ export default function useRangeValueChange( // 也不应因此创建 currentIndex。 if (currentIndex === null && source !== 'blur' && source !== 'esc') { currentIndex = index; + lastValidIndexRef.current = index; setCurrentIndex(index); - setActiveIndex(index); recordTriggeredField(index); } @@ -272,5 +272,8 @@ export default function useRangeValueChange( }, ); - return [getCurrentIndex(), getActiveIndex(), triggeredFieldsRef.current, triggerChange]; + const currentIndex = getCurrentIndex(); + const activeIndex = currentIndex ?? lastValidIndexRef.current ?? 0; + + return [currentIndex, activeIndex, triggeredFieldsRef.current, triggerChange]; } From b029ae39f3b22adf3c395f2f40252b3e77e4755d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 16:50:14 +0800 Subject: [PATCH 23/27] refactor(RangePicker): simplify active field tracking --- src/PickerInput/hooks/useRangeValueChange.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index dd58e5b41..5b382be49 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -95,11 +95,9 @@ export default function useRangeValueChange( // 同时保存渲染值,以及供事件处理函数同步读取的 getter。 const [getCurrentIndex, setCurrentIndex] = useSyncState(null); - // Keep the latest accepted field for panel and selector rendering. Changes - // to this ref always accompany a `currentIndex` update, so it needs no state. + // Keep the latest accepted field for panel and selector rendering. // 保留最后一个被业务接受的 field,供 panel 和 selector 渲染使用; - // ref 的变更总是伴随 `currentIndex` 更新,因此不需要额外的 state。 - const lastValidIndexRef = React.useRef(null); + const lastValidIndexRef = React.useRef(undefined); // ============================= Record ============================ @@ -130,7 +128,6 @@ export default function useRangeValueChange( setCurrentIndex(null); } else { const nextIndex = (index + 1) % fieldCount; - lastValidIndexRef.current = nextIndex; setCurrentIndex(nextIndex); } @@ -218,7 +215,6 @@ export default function useRangeValueChange( // 也不应因此创建 currentIndex。 if (currentIndex === null && source !== 'blur' && source !== 'esc') { currentIndex = index; - lastValidIndexRef.current = index; setCurrentIndex(index); recordTriggeredField(index); } @@ -273,7 +269,7 @@ export default function useRangeValueChange( ); const currentIndex = getCurrentIndex(); - const activeIndex = currentIndex ?? lastValidIndexRef.current ?? 0; + lastValidIndexRef.current = currentIndex ?? lastValidIndexRef.current ?? 0; - return [currentIndex, activeIndex, triggeredFieldsRef.current, triggerChange]; + return [currentIndex, lastValidIndexRef.current, triggeredFieldsRef.current, triggerChange]; } From a03ed7e1c0af707d0c9cf172a3ed588e56eeedd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 17:01:42 +0800 Subject: [PATCH 24/27] refactor(Picker): unify single field submission --- src/PickerInput/SinglePicker.tsx | 117 ++++++++++++------- src/PickerInput/hooks/useRangeValueChange.ts | 36 +++--- 2 files changed, 92 insertions(+), 61 deletions(-) diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index a488dcb5a..64271322c 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, , 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, @@ -305,6 +310,40 @@ function Picker( 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, @@ -362,13 +401,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,7 +494,7 @@ function Picker( // >>> Focus const onPanelFocus: React.FocusEventHandler = (event) => { triggerOpen(true); - onSharedFocus(event); + onFieldFocus(0, 'panel', event); }; // >>> Calendar @@ -470,15 +507,9 @@ function Picker( } 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 +545,7 @@ function Picker( // >>> Render const panel = ( + containerRef={popupRef} // MISC {...panelProps} showNow={mergedShowNow} @@ -522,7 +554,7 @@ function Picker( disabledDate={disabledDate} // Focus onFocus={onPanelFocus} - onBlur={onSharedBlur} + onBlur={(event) => onFieldBlur(0, 'panel', event)} // Mode picker={picker} mode={mergedMode} @@ -543,7 +575,7 @@ function Picker( onHover={onPanelHover} // Submit needConfirm={needConfirm} - onSubmit={triggerConfirm} + onSubmit={() => triggerConfirm('confirm')} onOk={triggerOk} // Preset presets={presetList} @@ -564,35 +596,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); @@ -679,7 +710,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/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 5b382be49..a0edeb554 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -19,17 +19,17 @@ export type RangeValueChangeAction = 'modify' | 'switchNext' | 'abort' | 'resetCurrent' | 'resetCurrentAndSwitchNext' | 'resetAll'; /** Receive a field interaction and its optional value. / 接收 field 交互及可选变更值。 */ -export type TriggerChange = ( +export type TriggerChange = ( index: number, source: RangeValueChangeSource, - date?: DateType, + value?: FieldValue, ) => void; /** Read the latest temporary CalendarValue. / 读取最新的临时 CalendarValue。 */ -export type GetCalendarValue = () => readonly (DateType | null | undefined)[]; +export type GetCalendarValue = () => readonly (FieldValue | null | undefined)[]; /** Update one field in CalendarValue. / 更新 CalendarValue 中的一个 field。 */ -export type TriggerCalendarChange = (index: number, date: DateType) => void; +export type TriggerCalendarChange = (index: number, value: FieldValue) => void; /** * Flush one field and optionally emit the final change. @@ -43,11 +43,11 @@ export type FlushSubmit = (index: number, needTriggerChange: boolean) => void; */ export type ResetValue = (index?: number) => void; -export type UseRangeValueChangeReturn = [ +export type UseRangeValueChangeReturn = [ currentIndex: number | null, activeIndex: number, triggeredFields: number[], - triggerChange: TriggerChange, + triggerChange: TriggerChange, ]; // ============================== Hook ============================== @@ -76,15 +76,15 @@ export type UseRangeValueChangeReturn = [ * - `resetAll`: discard all temporary values and end the interaction. * 撤销全部临时值并结束本轮交互。 */ -export default function useRangeValueChange( +export default function useRangeValueChange( fieldCount: number, needConfirm: boolean, allowEmpty: readonly boolean[], - getCalendarValue: GetCalendarValue, - triggerCalendarChange: TriggerCalendarChange, + getCalendarValue: GetCalendarValue, + triggerCalendarChange: TriggerCalendarChange, flushSubmit: FlushSubmit, resetValue: ResetValue, -): UseRangeValueChangeReturn { +): UseRangeValueChangeReturn { // ============================= State ============================= // Record fields involved in the current interaction. @@ -142,7 +142,7 @@ export default function useRangeValueChange( currentIndex: number | null, index: number, source: RangeValueChangeSource, - date?: DateType, + value?: FieldValue, ): RangeValueChangeAction => { if (source === 'esc') { return 'resetAll'; @@ -152,7 +152,7 @@ export default function useRangeValueChange( return 'abort'; } - const currentValue = date === undefined ? getCalendarValue()[currentIndex] : date; + const currentValue = value === undefined ? getCalendarValue()[currentIndex] : value; const currentEmpty = currentValue === null || currentValue === undefined; const canSwitch = !currentEmpty || allowEmpty[currentIndex]; @@ -206,7 +206,7 @@ export default function useRangeValueChange( // resolved action in one place. // 所有交互先统一解析 action,再在一个位置执行对应操作。 const triggerChange = useEvent( - (index: number, source: RangeValueChangeSource, date?: DateType) => { + (index: number, source: RangeValueChangeSource, value?: FieldValue) => { let currentIndex = getCurrentIndex(); // Start a new interaction from the first non-blur event. A standalone @@ -219,20 +219,20 @@ export default function useRangeValueChange( recordTriggeredField(index); } - const action = resolveAction(currentIndex, index, source, date); + const action = resolveAction(currentIndex, index, source, value); const actionIndex = currentIndex ?? index; switch (action) { case 'modify': recordTriggeredField(actionIndex); - if (date !== undefined) { - triggerCalendarChange(actionIndex, date); + if (value !== undefined) { + triggerCalendarChange(actionIndex, value); } break; case 'switchNext': - if (date !== undefined) { - triggerCalendarChange(actionIndex, date); + if (value !== undefined) { + triggerCalendarChange(actionIndex, value); } if (!submitField(actionIndex) && source === 'field-switch') { recordTriggeredField(index); From 898dfd27079fd8845e4b9c12a3ed183aa6730b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 21 Jul 2026 17:54:26 +0800 Subject: [PATCH 25/27] refactor(RangePicker): refine field transitions --- src/PickerInput/RangePicker.tsx | 2 +- src/PickerInput/hooks/useFocusLock.ts | 17 +++- src/PickerInput/hooks/useRangeValueChange.ts | 101 +++++++++++++++++-- tests/disabledTime.spec.tsx | 1 + tests/keyboard.spec.tsx | 11 +- tests/new-range.spec.tsx | 62 ++++++------ tests/picker.spec.tsx | 26 +++-- tests/range.spec.tsx | 32 +++--- tests/util/commonUtil.tsx | 38 ++++++- 9 files changed, 217 insertions(+), 73 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index e05fcf74c..610b0ebc3 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -338,7 +338,7 @@ function RangePicker( resetValue, ); - useFocusLock(rangeValueIndex, selectorRef, popupRef); + useFocusLock(rangeValueIndex, selectorRef, popupRef, triggerOpen); // ======================= ShowTime ======================= /** Used for Popup panel */ diff --git a/src/PickerInput/hooks/useFocusLock.ts b/src/PickerInput/hooks/useFocusLock.ts index cfd0df3b1..3b945af4a 100644 --- a/src/PickerInput/hooks/useFocusLock.ts +++ b/src/PickerInput/hooks/useFocusLock.ts @@ -1,5 +1,5 @@ -import { useLayoutEffect } from '@rc-component/util'; -import type * as React from 'react'; +import { useEvent, useLayoutEffect } from '@rc-component/util'; +import * as React from 'react'; import { isTargetInContainers } from './useFocusEvents'; interface FocusLockSelectorRef { @@ -15,7 +15,20 @@ 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 after the controlled field changes. + // 当受控 field 发生切换后,重新打开 Picker。 + React.useEffect(() => { + if (index !== null) { + openPicker(); + } + }, [index, openPicker]); + // DOM focus may change while `index` stays the same, so check after every commit. // DOM 焦点变化时 `index` 可能保持不变,因此每次 commit 后都需要检查。 useLayoutEffect(() => { diff --git a/src/PickerInput/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index a0edeb554..b6b73b393 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -16,7 +16,13 @@ export type RangeValueChangeSource = /** Resolved operation for one field interaction. / 一次 field 交互最终执行的操作。 */ export type RangeValueChangeAction = - 'modify' | 'switchNext' | 'abort' | 'resetCurrent' | 'resetCurrentAndSwitchNext' | 'resetAll'; + | 'modify' + | 'switchNext' + | 'switchPrevious' + | 'abort' + | 'resetCurrent' + | 'resetCurrentAndSwitchNext' + | 'resetAll'; /** Receive a field interaction and its optional value. / 接收 field 交互及可选变更值。 */ export type TriggerChange = ( @@ -67,6 +73,9 @@ export type UseRangeValueChangeReturn = [ * 更新或记录当前 CalendarValue。 * - `switchNext`: submit the current field and advance to the next field. * 提交当前 field 并推进到下一个 field。 + * - `switchPrevious`: settle the current field and return to the previously + * handled field without a final submit. / 处理当前 field 后返回上一个已处理 + * field,且不触发最终提交。 * - `abort`: stop without changing any state. * 直接短路,不改变任何状态。 * - `resetCurrent`: discard only the current field. @@ -91,6 +100,12 @@ export default function useRangeValueChange( // 记录当前一轮交互中触发过的 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); @@ -125,6 +140,7 @@ export default function useRangeValueChange( if (allFieldsTriggered) { triggeredFieldsRef.current = []; + confirmedIndexRef.current = null; setCurrentIndex(null); } else { const nextIndex = (index + 1) % fieldCount; @@ -149,7 +165,10 @@ export default function useRangeValueChange( } if (currentIndex === null) { - return 'abort'; + // 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; @@ -161,12 +180,33 @@ export default function useRangeValueChange( return 'abort'; } + const previousIndex = (currentIndex - 1 + fieldCount) % fieldCount; + const isPreviousField = index === previousIndex && triggeredFieldsRef.current.includes(index); + + if (isPreviousField) { + const currentUnconfirmed = !currentEmpty && confirmedIndexRef.current !== currentIndex; + + // Keep the legacy needConfirm behavior: only an unconfirmed value locks + // the current field. Empty, confirmed or allowEmpty fields may go back. + // 保持旧版 needConfirm 行为:仅未确认值会锁定当前 field;当前为空、 + // 已确认或允许空值时,都可以返回上一个 field。 + if (needConfirm && currentUnconfirmed && !allowEmpty[currentIndex]) { + return 'abort'; + } + + return 'switchPrevious'; + } + const nextIndex = (currentIndex + 1) % fieldCount; if (index !== nextIndex) { return 'abort'; } if (needConfirm) { + if (confirmedIndexRef.current === currentIndex) { + return 'switchNext'; + } + return currentEmpty && allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort'; } @@ -210,9 +250,9 @@ export default function useRangeValueChange( let currentIndex = getCurrentIndex(); // Start a new interaction from the first non-blur event. A standalone - // blur has no active field to finish and must not create one. - // 第一条非 blur 事件用于建立新一轮交互;单独的 blur 没有可结束的 field, - // 也不应因此创建 currentIndex。 + // 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); @@ -225,6 +265,9 @@ export default function useRangeValueChange( switch (action) { case 'modify': recordTriggeredField(actionIndex); + if (confirmedIndexRef.current === actionIndex) { + confirmedIndexRef.current = null; + } if (value !== undefined) { triggerCalendarChange(actionIndex, value); } @@ -234,13 +277,47 @@ export default function useRangeValueChange( if (value !== undefined) { triggerCalendarChange(actionIndex, value); } - if (!submitField(actionIndex) && source === 'field-switch') { + 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); } break; + case 'switchPrevious': { + if (!needConfirm) { + const currentValue = getCalendarValue()[actionIndex]; + const currentEmpty = currentValue === null || currentValue === undefined; + + if (!currentEmpty || allowEmpty[actionIndex]) { + // Going back may part-submit the current field, but must never + // finish the whole round before the previous field is edited. + // 返回上一个 field 时可以局部提交当前 field,但不能在用户修改 + // 上一个 field 前结束整轮提交。 + flushSubmit(actionIndex, false); + } else { + resetValue(actionIndex); + } + } + + const previousPosition = triggeredFieldsRef.current.indexOf(index); + triggeredFieldsRef.current = triggeredFieldsRef.current.slice(0, previousPosition + 1); + setCurrentIndex(index); + break; + } + case 'resetCurrent': resetValue(actionIndex); + if (confirmedIndexRef.current === actionIndex) { + confirmedIndexRef.current = null; + } triggeredFieldsRef.current = triggeredFieldsRef.current.filter( (fieldIndex) => fieldIndex !== actionIndex, ); @@ -248,7 +325,16 @@ export default function useRangeValueChange( case 'resetCurrentAndSwitchNext': resetValue(actionIndex); - if (!submitField(actionIndex) && source === 'field-switch') { + if (confirmedIndexRef.current === actionIndex) { + confirmedIndexRef.current = null; + } + if (submitField(actionIndex) && source === 'field-switch') { + // The target field belongs to the next round after the previous + // round is completed. + // 上一轮结束后,切换目标 field 应归入新一轮。 + setCurrentIndex(index); + } + if (source === 'field-switch') { recordTriggeredField(index); } break; @@ -256,6 +342,7 @@ export default function useRangeValueChange( case 'resetAll': resetValue(); triggeredFieldsRef.current = []; + confirmedIndexRef.current = null; setCurrentIndex(null); break; 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 514a827fd..0dac9b684 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'; @@ -509,7 +511,7 @@ describe('NewPicker.Range', () => { 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', @@ -654,7 +656,7 @@ describe('NewPicker.Range', () => { jest.runAllTimers(); }); - container.querySelector('.focus').focus(); + triggerFocus(container.querySelector('.focus')); // Changed by click OK openPicker(container); @@ -677,12 +679,12 @@ 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(); @@ -691,7 +693,7 @@ describe('NewPicker.Range', () => { // Close panel to auto focus next end field const startFocusedElement = document.activeElement; fireEvent.mouseDown(document.body); - fireEvent.blur(startFocusedElement); + triggerBlur(startFocusedElement as HTMLElement); act(() => { jest.runAllTimers(); }); @@ -714,7 +716,7 @@ describe('NewPicker.Range', () => { // Close panel to auto focus next end field const endFocusedElement = document.activeElement; fireEvent.mouseDown(document.body); - fireEvent.blur(endFocusedElement); + triggerBlur(endFocusedElement as HTMLElement); act(() => { jest.runAllTimers(); @@ -830,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', @@ -840,7 +842,7 @@ describe('NewPicker.Range', () => { key: 'Tab', }); - endInput.focus(); + triggerFocus(endInput); fireEvent.change(endInput, { target: { value: '02:00:00', @@ -860,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); @@ -869,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); @@ -890,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); @@ -948,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); @@ -977,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', @@ -989,7 +991,7 @@ describe('NewPicker.Range', () => { // End field await waitFakeTimer(); - fireEvent.focus(endInput); + triggerFocus(endInput); fireEvent.paste(endInput, { clipboardData: { getData: () => '20200905', @@ -1009,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: { @@ -1029,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; @@ -1045,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', @@ -1063,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(); @@ -1075,10 +1077,10 @@ describe('NewPicker.Range', () => { const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); expect(firstInput).toHaveValue('YYYYMMDD'); - fireEvent.blur(firstInput); + triggerBlur(firstInput); await waitFakeTimer(); expect(firstInput).toHaveValue(''); }); @@ -1089,7 +1091,7 @@ describe('NewPicker.Range', () => { ); const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); fireEvent.keyDown(firstInput, { key: 'Backspace', @@ -1102,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', @@ -1119,7 +1121,7 @@ describe('NewPicker.Range', () => { const { container } = render(); const firstInput = container.querySelectorAll('input')[0]; - fireEvent.focus(firstInput); + triggerFocus(firstInput); const fullText = '20000309'; @@ -1150,7 +1152,7 @@ describe('NewPicker.Range', () => { openPicker(container); - fireEvent.focus(document.querySelector('.bamboo')); + triggerFocus(document.querySelector('.bamboo')); expect(onOpenChange).not.toHaveBeenCalledWith(false); }); @@ -1260,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', @@ -1271,7 +1273,7 @@ describe('NewPicker.Range', () => { }); // End - fireEvent.focus(endInput); + triggerFocus(endInput); fireEvent.change(endInput, { target: { value: '2000-01-14', @@ -1292,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', @@ -1303,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 8e85152dd..094f1be05 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(); }); @@ -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(); }); @@ -1199,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'); @@ -1604,7 +1604,7 @@ describe('Picker.Range', () => { // https://github.com/ant-design/ant-design/issues/26024 it('panel should keep open when nextValue is empty', () => { - const { container } = render(); + const { container } = render(); openPicker(container, 0); @@ -1614,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 @@ -1685,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'), { @@ -1973,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'); @@ -2021,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); @@ -2037,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', @@ -2079,7 +2077,7 @@ describe('Picker.Range', () => { // Click outside to blur const focusedElement = document.activeElement; fireEvent.mouseDown(document.body); - fireEvent.blur(focusedElement); + triggerBlur(focusedElement as HTMLElement); fireEvent.mouseUp(document.body); fireEvent.click(document.body); diff --git a/tests/util/commonUtil.tsx b/tests/util/commonUtil.tsx index 17ca5c113..20eb31b17 100644 --- a/tests/util/commonUtil.tsx +++ b/tests/util/commonUtil.tsx @@ -75,20 +75,48 @@ export async function waitFakeTimer() { }); } +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(); + }); +} + 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) { From 4faf62d6c3a5a99da2fbb0ef29d1b455c220ad42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 22 Jul 2026 11:12:29 +0800 Subject: [PATCH 26/27] fix(RangePicker): refine value and focus transitions --- src/PickerInput/RangePicker.tsx | 1 + src/PickerInput/SinglePicker.tsx | 1 + src/PickerInput/hooks/useFocusLock.ts | 6 +- src/PickerInput/hooks/useRangePickerValue.ts | 88 ++++++++++---------- src/PickerInput/hooks/useRangeValueChange.ts | 77 +++++++++++++---- tests/new-range.spec.tsx | 2 +- tests/range.spec.tsx | 22 +++++ tests/util/commonUtil.tsx | 19 +++-- 8 files changed, 146 insertions(+), 70 deletions(-) diff --git a/src/PickerInput/RangePicker.tsx b/src/PickerInput/RangePicker.tsx index 610b0ebc3..4f65cda12 100644 --- a/src/PickerInput/RangePicker.tsx +++ b/src/PickerInput/RangePicker.tsx @@ -404,6 +404,7 @@ function RangePicker( calendarValue, modes, mergedOpen, + focused, activeIndex, internalPicker, multiplePanel, diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index 64271322c..6a3523f0d 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -374,6 +374,7 @@ function Picker( calendarValue, [mergedMode], mergedOpen, + false, activeIndex, internalPicker, false, // multiplePanel, diff --git a/src/PickerInput/hooks/useFocusLock.ts b/src/PickerInput/hooks/useFocusLock.ts index 3b945af4a..a6fa1fc3a 100644 --- a/src/PickerInput/hooks/useFocusLock.ts +++ b/src/PickerInput/hooks/useFocusLock.ts @@ -5,6 +5,7 @@ import { isTargetInContainers } from './useFocusEvents'; interface FocusLockSelectorRef { startInput: HTMLElement; endInput: HTMLElement; + focus: (index?: number) => void; } /** @@ -21,11 +22,12 @@ export default function useFocusLock( triggerOpen(true); }); - // Open the Picker after the controlled field changes. - // 当受控 field 发生切换后,重新打开 Picker。 + // 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]); 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/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index b6b73b393..84ca4e990 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -19,6 +19,7 @@ export type RangeValueChangeAction = | 'modify' | 'switchNext' | 'switchPrevious' + | 'finish' | 'abort' | 'resetCurrent' | 'resetCurrentAndSwitchNext' @@ -56,6 +57,11 @@ export type UseRangeValueChangeReturn = [ triggerChange: TriggerChange, ]; +interface TriggeredField { + index: number; + modified: boolean; +} + // ============================== Hook ============================== /** @@ -76,6 +82,8 @@ export type UseRangeValueChangeReturn = [ * - `switchPrevious`: settle the current field and return to the previously * handled field without a final submit. / 处理当前 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. @@ -96,9 +104,10 @@ export default function useRangeValueChange( ): UseRangeValueChangeReturn { // ============================= State ============================= - // Record fields involved in the current interaction. - // 记录当前一轮交互中触发过的 field。 - const triggeredFieldsRef = React.useRef([]); + // 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. @@ -116,13 +125,23 @@ export default function useRangeValueChange( // ============================= Record ============================ - // Keep fields unique while preserving their first-triggered order. The - // current field is tracked separately by `currentIndex`. - // field 保持唯一,同时保留首次触发顺序;当前 field 由 `currentIndex` - // 单独记录。 - const recordTriggeredField = (index: number) => { - if (!triggeredFieldsRef.current.includes(index)) { - triggeredFieldsRef.current = [...triggeredFieldsRef.current, index]; + // 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 }, + ]; } }; @@ -181,7 +200,9 @@ export default function useRangeValueChange( } const previousIndex = (currentIndex - 1 + fieldCount) % fieldCount; - const isPreviousField = index === previousIndex && triggeredFieldsRef.current.includes(index); + const isPreviousField = + index === previousIndex && + triggeredFieldsRef.current.some((field) => field.index === index); if (isPreviousField) { const currentUnconfirmed = !currentEmpty && confirmedIndexRef.current !== currentIndex; @@ -218,6 +239,12 @@ export default function useRangeValueChange( } if (source === 'blur') { + const interactionModified = triggeredFieldsRef.current.some((field) => field.modified); + + if (!interactionModified) { + return 'finish'; + } + if (needConfirm || !canSwitch) { return 'resetAll'; } @@ -256,7 +283,7 @@ export default function useRangeValueChange( if (currentIndex === null && source !== 'blur' && source !== 'esc') { currentIndex = index; setCurrentIndex(index); - recordTriggeredField(index); + recordTriggeredField(index, false); } const action = resolveAction(currentIndex, index, source, value); @@ -264,7 +291,7 @@ export default function useRangeValueChange( switch (action) { case 'modify': - recordTriggeredField(actionIndex); + recordTriggeredField(actionIndex, true); if (confirmedIndexRef.current === actionIndex) { confirmedIndexRef.current = null; } @@ -274,6 +301,9 @@ export default function useRangeValueChange( break; case 'switchNext': + if (source === 'panel-final' || value !== undefined) { + recordTriggeredField(actionIndex, true); + } if (value !== undefined) { triggerCalendarChange(actionIndex, value); } @@ -287,7 +317,7 @@ export default function useRangeValueChange( setCurrentIndex(index); } if (source === 'field-switch') { - recordTriggeredField(index); + recordTriggeredField(index, false); } break; @@ -307,19 +337,28 @@ export default function useRangeValueChange( } } - const previousPosition = triggeredFieldsRef.current.indexOf(index); + const previousPosition = triggeredFieldsRef.current.findIndex( + (field) => field.index === index, + ); triggeredFieldsRef.current = triggeredFieldsRef.current.slice(0, previousPosition + 1); + recordTriggeredField(index, false); setCurrentIndex(index); 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( - (fieldIndex) => fieldIndex !== actionIndex, + (field) => field.index !== actionIndex, ); break; @@ -335,7 +374,7 @@ export default function useRangeValueChange( setCurrentIndex(index); } if (source === 'field-switch') { - recordTriggeredField(index); + recordTriggeredField(index, false); } break; @@ -358,5 +397,7 @@ export default function useRangeValueChange( const currentIndex = getCurrentIndex(); lastValidIndexRef.current = currentIndex ?? lastValidIndexRef.current ?? 0; - return [currentIndex, lastValidIndexRef.current, triggeredFieldsRef.current, triggerChange]; + const triggeredFields = triggeredFieldsRef.current.map((field) => field.index); + + return [currentIndex, lastValidIndexRef.current, triggeredFields, triggerChange]; } diff --git a/tests/new-range.spec.tsx b/tests/new-range.spec.tsx index 0dac9b684..24cfbe0a5 100644 --- a/tests/new-range.spec.tsx +++ b/tests/new-range.spec.tsx @@ -1081,7 +1081,7 @@ describe('NewPicker.Range', () => { expect(firstInput).toHaveValue('YYYYMMDD'); triggerBlur(firstInput); - await waitFakeTimer(); + await waitFakeTimer(0, 2); expect(firstInput).toHaveValue(''); }); diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 094f1be05..294baf916 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -2136,6 +2136,28 @@ 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); + selectCell(5); + openPicker(container, 1); + openPicker(container); + + // 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(''); + }); + 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 { From 87c4c0732db8f26ab2a088b2239b033a6fce4a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 22 Jul 2026 11:56:02 +0800 Subject: [PATCH 27/27] fix(RangePicker): reset unconfirmed allow-empty field --- docs/examples/debug.tsx | 103 +++---------------- src/PickerInput/hooks/useRangeValueChange.ts | 6 +- tests/range.spec.tsx | 15 +++ 3 files changed, 36 insertions(+), 88 deletions(-) diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx index 9aadb22e5..d7499fe6c 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 {}; - // }} - /> +

Issue #57728: showTime with allowEmpty

+

+ Select a start date without confirming, switch to the end field, switch back to the start + field, then click the input above. Both fields should reset and the change count should stay + 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/hooks/useRangeValueChange.ts b/src/PickerInput/hooks/useRangeValueChange.ts index 84ca4e990..0351b2cc8 100644 --- a/src/PickerInput/hooks/useRangeValueChange.ts +++ b/src/PickerInput/hooks/useRangeValueChange.ts @@ -228,7 +228,11 @@ export default function useRangeValueChange( return 'switchNext'; } - return currentEmpty && allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort'; + // 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'; } return canSwitch ? 'switchNext' : 'resetCurrent'; diff --git a/tests/range.spec.tsx b/tests/range.spec.tsx index 294baf916..8e3f1ec4e 100644 --- a/tests/range.spec.tsx +++ b/tests/range.spec.tsx @@ -2144,10 +2144,24 @@ describe('Picker.Range', () => { // 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); @@ -2156,6 +2170,7 @@ describe('Picker.Range', () => { expect(onChange).not.toHaveBeenCalled(); expect(startInput).toHaveValue(''); expect(endInput).toHaveValue(''); + expect(isOpen()).toBeFalsy(); }); it('should not update preview value in input when previewValue is false', () => {