Skip to content

refactor(RangePicker): centralize interaction flow - #994

Merged
zombieJ merged 45 commits into
masterfrom
fix/range-picker-confirm-flow
Jul 27, 2026
Merged

refactor(RangePicker): centralize interaction flow#994
zombieJ merged 45 commits into
masterfrom
fix/range-picker-confirm-flow

Conversation

@zombieJ

@zombieJ zombieJ commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

  • add dedicated focus and range value change hooks
  • route input, keyboard, panel, field switch, confirm, and blur interactions through one event-driven flow
  • distinguish intermediate and final panel selections
  • reset unconfirmed calendar values without coupling popup close to value submission
  • keep PickerTrigger.onClose responsible only for popup visibility

Motivation

RangePicker currently spreads part-submit and reset behavior across event handlers and an effect. This refactor prepares a single event-driven decision point for confirm and non-confirm flows, following the behavior discussed around #966.

Impact

This changes RangePicker's internal interaction handling only. The existing useRangeValue hook remains responsible for value validation and final submission.

Validation

  • ut lint:tsc
  • ut lint — 0 errors
  • ✅ full test suite — 15 suites, 453 passed, 2 skipped

Interaction flow

canSwitch = 当前值非空 || allowEmpty[currentIndex]

allFieldsTriggered = triggeredFields.length >= fieldCount

  • ✏️ modify
  • ➡️ switchNext
  • ⬅️ switchPrevious
  • finish
  • abort
  • ↩️ resetCurrent
  • 🔄 resetCurrentAndSwitchNext
  • 🗑️ resetAll
flowchart TB
  START["① 入口与 field-switch<br/>triggerChange(index, source, value?)"] --> ESC{"🔎 source === esc?"}

  ESC -- "是" --> ESC_RESET["🗑️ action = resetAll"]
  ESC -- "否" --> IDLE{"🔎 currentIndex === null?"}

  IDLE -- "是,source = blur" --> IDLE_RESET["🗑️ action = resetAll"]
  IDLE -- "是,其他事件" --> INIT["📌 初始化 currentIndex = index<br/>记录 triggered field"]
  IDLE -- "否" --> ROUTE{"🔎 source === field-switch?"}
  INIT --> ROUTE

  ROUTE -- "否" --> CURRENT_ENTRY["进入 ② 当前 field 事件"]
  ROUTE -- "是" --> FS_SAME{"🔎 index === currentIndex?"}

  FS_SAME -- "是" --> FS_ABORT_SAME["⛔ action = abort"]
  FS_SAME -- "否" --> FS_PREVIOUS{"🔎 是否为已访问的上一个 field?"}

  FS_PREVIOUS -- "是" --> FS_PREVIOUS_LOCK{"🔎 needConfirm<br/>且当前值非空、未确认<br/>且不允许空?"}
  FS_PREVIOUS_LOCK -- "是" --> FS_ABORT_PREVIOUS["⛔ action = abort"]
  FS_PREVIOUS_LOCK -- "否" --> FS_SWITCH_PREVIOUS["⬅️ action = switchPrevious"]

  FS_PREVIOUS -- "否" --> FS_NEXT{"🔎 index === nextIndex?"}
  FS_NEXT -- "否" --> FS_ABORT_INVALID["⛔ action = abort"]
  FS_NEXT -- "是" --> FS_CONFIRM{"🔎 needConfirm?"}

  FS_CONFIRM -- "否" --> FS_CAN_SWITCH{"🔎 canSwitch?"}
  FS_CAN_SWITCH -- "是" --> FS_SWITCH_NEXT["➡️ action = switchNext"]
  FS_CAN_SWITCH -- "否" --> FS_RESET_CURRENT["↩️ action = resetCurrent"]

  FS_CONFIRM -- "是" --> FS_CONFIRMED{"🔎 当前 field 已确认?"}
  FS_CONFIRMED -- "是" --> FS_SWITCH_CONFIRMED["➡️ action = switchNext"]
  FS_CONFIRMED -- "否" --> FS_ALLOW_EMPTY{"🔎 allowEmpty[currentIndex]?"}
  FS_ALLOW_EMPTY -- "是" --> FS_RESET_SWITCH["🔄 action = resetCurrentAndSwitchNext"]
  FS_ALLOW_EMPTY -- "否" --> FS_ABORT_LOCKED["⛔ action = abort"]
Loading
flowchart TB
  START["② 当前 field 事件<br/>非 field-switch"] --> CURRENT_INDEX{"🔎 index === currentIndex?"}

  CURRENT_INDEX -- "否" --> CURRENT_ABORT["⛔ action = abort"]
  CURRENT_INDEX -- "是" --> SOURCE{"🔎 source 类型"}

  SOURCE -- "input / panel-intermediate" --> MODIFY["✏️ action = modify"]

  SOURCE -- "panel-final" --> PANEL_CONFIRM{"🔎 needConfirm?"}
  PANEL_CONFIRM -- "是" --> PANEL_MODIFY["✏️ action = modify"]
  PANEL_CONFIRM -- "否" --> PANEL_SWITCH["➡️ action = switchNext"]

  SOURCE -- "keyboard-submit / confirm" --> SUBMIT_VALID{"🔎 canSwitch?"}
  SUBMIT_VALID -- "是" --> SUBMIT_SWITCH["➡️ action = switchNext"]
  SUBMIT_VALID -- "否" --> SUBMIT_ABORT["⛔ action = abort"]

  SOURCE -- "blur" --> BLUR_ENTRY["进入 ③ blur 分支"]
Loading
flowchart TB
  START["③ blur 分支"] --> BLUR_MODIFIED{"🔎 本轮是否修改过任意 field?"}

  BLUR_MODIFIED -- "否" --> BLUR_FINISH["✅ action = finish"]
  BLUR_MODIFIED -- "是" --> BLUR_CONFIRM{"🔎 needConfirm?"}

  BLUR_CONFIRM -- "否" --> BLUR_CAN_SWITCH{"🔎 canSwitch?"}
  BLUR_CAN_SWITCH -- "是" --> BLUR_SWITCH["➡️ action = switchNext"]
  BLUR_CAN_SWITCH -- "否" --> BLUR_RESET_INVALID["🗑️ action = resetAll"]

  BLUR_CONFIRM -- "是" --> BLUR_READY{"🔎 allFieldsTriggered<br/>且 canSwitch?"}
  BLUR_READY -- "否" --> BLUR_RESET_ALL["🗑️ action = resetAll"]
  BLUR_READY -- "是" --> BLUR_CURRENT_MODIFIED{"🔎 当前 field 是否修改过?"}

  BLUR_CURRENT_MODIFIED -- "否" --> BLUR_FINAL_SWITCH["➡️ action = switchNext"]
  BLUR_CURRENT_MODIFIED -- "是" --> BLUR_ALLOW_EMPTY{"🔎 allowEmpty[currentIndex]?"}
  BLUR_ALLOW_EMPTY -- "是" --> BLUR_RESET_SWITCH["🔄 action = resetCurrentAndSwitchNext"]
  BLUR_ALLOW_EMPTY -- "否" --> BLUR_RESET_UNCONFIRMED["🗑️ action = resetAll"]
Loading

Summary by CodeRabbit

  • New Features
    • 为弹层新增可选 containerRef,便于外部获取/控制容器 DOM 引用。
    • RangeSelector 的 ref 现可直接访问起止输入元素。
    • 单日期选择器的 onChange 现在可区分触发来源(如输入/移除)。
  • Bug Fixes
    • 优化单日期与日期范围的焦点切换、失焦处理及确认/取消提交链路,避免未确认值错误提交或无法重置。
    • 改善键盘(Tab/Escape)与可选空值(allowEmpty)相关交互一致性,增强 Shadow DOM 下的焦点保持。
    • 修复多选跨月份面板状态与最后一项移除后的回调/状态问题。
  • Tests
    • 新增并扩展键盘、焦点、范围、多选及 Shadow DOM 场景用例,覆盖更细交互路径。
  • Chores
    • 固定 React Doctor 的版本以提升诊断一致性。

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
picker Ready Ready Preview, Comment Jul 27, 2026 4:14am

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@zombieJ, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bbccf47-9097-4a92-9e67-1b0ef37df1de

📥 Commits

Reviewing files that changed from the base of the PR and between 0e9ef93 and 9504304.

📒 Files selected for processing (2)
  • src/PickerInput/hooks/useRangeValueChange.ts
  • tests/keyboard.spec.tsx

Walkthrough

RangePicker 与 SinglePicker 改用统一的焦点事件和字段值变更流程,支持弹层关闭、键盘、清空、确认及 allowEmpty 场景下的提交与重置。新增相关 Hook、引用契约和交互测试,并更新调试示例及 React Doctor 版本。

Changes

Picker 交互状态重构

Layer / File(s) Summary
焦点与引用契约
src/PickerInput/Popup/index.tsx, src/PickerInput/Selector/RangeSelector.tsx, src/PickerInput/hooks/useFocusEvents.ts, src/PickerInput/hooks/useFocusLock.ts, src/PickerInput/hooks/usePickerRef.ts
新增 Popup 容器引用、RangeSelector 输入引用,以及焦点跟踪和 Shadow DOM 焦点锁定逻辑。
字段值变更状态机
src/PickerInput/hooks/useRangeValueChange.ts, src/PickerInput/hooks/useRangeValue.ts
统一处理输入、面板、键盘、确认、取消、移除、弹层关闭和字段切换的值变更、提交与重置。
面板值与禁用日期计算
src/PickerInput/hooks/useRangePickerValue.ts, src/PickerInput/hooks/useRangeDisabledDate.ts, src/utils/miscUtil.ts
基于当前字段、起止日历值和触发字段调整面板值同步及 disabled date 的起始日期计算。
RangePicker 与 SinglePicker 接入
src/PickerInput/RangePicker.tsx, src/PickerInput/SinglePicker.tsx, src/PickerInput/Selector/SingleSelector/index.tsx
将输入、面板、键盘、清空、确认和关闭事件接入 source 驱动的提交流程,并扩展多值移除来源。
交互测试与示例支持
tests/*, docs/examples/debug.tsx, .github/workflows/react-doctor.yml
新增焦点、失焦、allowEmpty、Shadow DOM 和跨月份行为测试;更新调试示例并锁定 React Doctor 版本。

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: qdyanbing

Poem

小兔挥爪改焦点,
输入面板不迷路。
未确认值会重置,
弹层关闭再提交。
Shadow DOM 也稳稳,
测试月光照新途。

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了本次围绕 RangePicker 交互流重构并集中处理输入、键盘、面板与失焦事件的主要变更。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/range-picker-confirm-flow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

React Doctor found 2 new issues in 2 files · 2 warnings · score 69 / 100 (Needs work) · 2 fixed · vs master

2 warnings

src/PickerInput/RangePicker.tsx

  • ⚠️ L354 Missing effect dependencies exhaustive-deps

src/PickerInput/SinglePicker.tsx

  • ⚠️ L356 Missing effect dependencies exhaustive-deps

Reviewed by React Doctor for commit 9504304. See inline comments for fixes.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

❌ Deploy failed

PR preview ❌ Failed ❌ Failed
🔗 Preview https://react-component-picker-preview-pr-994.surge.sh (may be unavailable)
📝 Commit9504304
🪵 LogsView logs
📋 Build log (last lines)
npm error
npm error Could not resolve dependency:
npm error peer eslint@"^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" from eslint-plugin-react@7.37.5
npm error node_modules/eslint-plugin-react
npm error   dev eslint-plugin-react@"^7.37.5" from the root project
npm error   eslint-plugin-react@"^7.32.2" from @umijs/fabric@4.0.1
npm error   node_modules/@umijs/fabric
npm error     @umijs/fabric@"^4.0.0" from rc-test@7.1.3
npm error     node_modules/rc-test
npm error       dev rc-test@"^7.1.3" from the root project
npm error
npm error Conflicting peer dependency: eslint@9.39.5
npm error node_modules/eslint
npm error   peer eslint@"^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" from eslint-plugin-react@7.37.5
npm error   node_modules/eslint-plugin-react
npm error     dev eslint-plugin-react@"^7.37.5" from the root project
npm error     eslint-plugin-react@"^7.32.2" from @umijs/fabric@4.0.1
npm error     node_modules/@umijs/fabric
npm error       @umijs/fabric@"^4.0.0" from rc-test@7.1.3
npm error       node_modules/rc-test
npm error         dev rc-test@"^7.1.3" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
npm error
npm error
npm error For a full report see:
npm error /home/runner/.npm/_logs/2026-07-27T04_15_02_104Z-eresolve-report.txt
npm error A complete log of this run can be found in: /home/runner/.npm/_logs/2026-07-27T04_15_02_104Z-debug-0.log

🤖 Powered by surge-preview

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors focus and value change handling in the RangePicker component by introducing the useFocusControl and useRangeValueChange hooks. These hooks streamline the coordination of calendar value updates, partial/final submissions, and focus/blur states across multiple fields. The feedback suggests a critical fix in useRangeValueChange to allow smooth field switching when needConfirm is false and the previous field is empty, as well as adding optional chaining to selectorRef.current accesses in RangePicker.tsx to prevent potential runtime errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/PickerInput/hooks/useRangeValueChange.ts Outdated
Comment thread src/PickerInput/RangePicker.tsx Outdated
Comment thread src/PickerInput/RangePicker.tsx Outdated
Comment thread src/PickerInput/RangePicker.tsx Outdated

// ============================= Utils =============================
/** Check whether the target is the container itself or inside it. / 判断目标是否为容器自身或其子元素。 */
function containsElement(container: Element | null, target: EventTarget | null) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个函数直接在 isTargetInContainers 里内联掉,不要额外写个函数

Comment on lines +381 to +404
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.findIndex(
(field) => field.index === index,
);
triggeredFieldsRef.current = triggeredFieldsRef.current.slice(0, previousPosition + 1);
recordTriggeredField(index, false);
setCurrentIndex(index);
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needConfirm 场景切回 previous field 时,这里不会 reset 当前未确认值,但后面会移除对应的 tracking。这样 CalendarValue 仍然保留,状态机却不再记录,后续 blur 是否可能直接走 finish,导致未确认值残留?

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
tests/util/commonUtil.tsx (1)

123-128: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

closePickerindex 参数语义已被弱化。

现在 index 只用于定位 getRootNode(),实际 blur 的是当前 activeElement,因此 closePicker(container, 1)closePicker(container, 0) 行为等价。建议补一行注释说明,避免调用方误以为可以指定失焦的输入框。

♻️ 建议补充注释
 export function closePicker(container: HTMLElement | ShadowRoot, index = 0) {
+  // `index` is only used to resolve the root node. The blur always applies to
+  // the currently focused element to mimic a real focus-leaving behavior.
   const input = container.querySelectorAll('input')[index];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/util/commonUtil.tsx` around lines 123 - 128, Add a concise comment
above or beside the index handling in closePicker explaining that index selects
the input’s root for active-element lookup, not the element that receives blur,
so callers do not interpret it as choosing a specific input to blur.
docs/examples/debug.tsx (1)

12-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

调试示例被裁剪为单一验证场景。

原有的多组 Picker 配置被替换成本次修复对应的复现场景。若该文件仍作为通用调试入口,建议合并前确认是否需要恢复其他示例配置。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/examples/debug.tsx` around lines 12 - 36, 恢复 docs/examples/debug.tsx
中被替换的其他 Picker 调试配置,保留现有通用调试入口场景;将当前基于 RangePicker、changeCount
的复现用例作为新增或并列场景,而不是删除原有示例。
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/PickerInput/hooks/useRangeValue.ts`:
- Around line 307-316: Update resetValue in the indexed single-field reset path
to normalize a missing mergedValue[index] to null before passing it to fillIndex
and setSubmitValue. Keep triggerCalendarChange and the existing behavior for
defined values unchanged, ensuring submitValue uses null rather than undefined
for empty range fields.

In `@src/PickerInput/hooks/useRangeValueChange.ts`:
- Around line 264-299: 修正 field-switch 分支中 nextIndex 的计算:不要用 enabledFieldCount
对绝对 currentIndex 取模,而应基于可用字段的绝对 index 列表循环推进并跳过 disabled 字段。处理没有可用字段的情况,避免生成 NaN
或非法 activeIndex/currentIndex;同时保持后续 nextFieldTriggered、确认和切换结果逻辑不变。

In `@src/PickerInput/RangePicker.tsx`:
- Around line 326-342: 更新 RangePicker 中传给 useRangeValueChange 的字段数量:当
enabledFieldCount 为 0 时改用 disabled.length,确保全禁用场景不会让 hook 的索引计算以 0
为除数;非全禁用场景继续使用 enabledFieldCount。

In `@tests/range.spec.tsx`:
- Line 599: Restore the default non-allowEmpty coverage in tests/range.spec.tsx
at lines 599 and 1002, and add separate allowEmpty variants rather than
modifying the existing cases. Apply the same pattern to tests/new-range.spec.tsx
lines 50-53 and 87: preserve the original “defaultPickerValue should reset every
time when opened” and “onPickerValueChange” cases, then add explicit allowEmpty
variants.

---

Nitpick comments:
In `@docs/examples/debug.tsx`:
- Around line 12-36: 恢复 docs/examples/debug.tsx 中被替换的其他 Picker
调试配置,保留现有通用调试入口场景;将当前基于 RangePicker、changeCount 的复现用例作为新增或并列场景,而不是删除原有示例。

In `@tests/util/commonUtil.tsx`:
- Around line 123-128: Add a concise comment above or beside the index handling
in closePicker explaining that index selects the input’s root for active-element
lookup, not the element that receives blur, so callers do not interpret it as
choosing a specific input to blur.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 48e70e4e-6273-4b86-92ba-9e7206537086

📥 Commits

Reviewing files that changed from the base of the PR and between fc53277 and 7f04b89.

📒 Files selected for processing (23)
  • .github/workflows/react-doctor.yml
  • docs/examples/debug.tsx
  • src/PickerInput/Popup/index.tsx
  • src/PickerInput/RangePicker.tsx
  • src/PickerInput/Selector/RangeSelector.tsx
  • src/PickerInput/Selector/SingleSelector/index.tsx
  • src/PickerInput/SinglePicker.tsx
  • src/PickerInput/hooks/useFocusEvents.ts
  • src/PickerInput/hooks/useFocusLock.ts
  • src/PickerInput/hooks/usePickerRef.ts
  • src/PickerInput/hooks/useRangeActive.ts
  • src/PickerInput/hooks/useRangeDisabledDate.ts
  • src/PickerInput/hooks/useRangePickerValue.ts
  • src/PickerInput/hooks/useRangeValue.ts
  • src/PickerInput/hooks/useRangeValueChange.ts
  • src/utils/miscUtil.ts
  • tests/disabledTime.spec.tsx
  • tests/keyboard.spec.tsx
  • tests/multiple.spec.tsx
  • tests/new-range.spec.tsx
  • tests/picker.spec.tsx
  • tests/range.spec.tsx
  • tests/util/commonUtil.tsx
💤 Files with no reviewable changes (1)
  • src/PickerInput/hooks/useRangeActive.ts

Comment on lines +307 to +316
const resetValue = useEvent((index?: number) => {
if (index === undefined) {
triggerCalendarChange(mergedValue);
syncWithValue();
return;
}

// Sync with value anyway
syncWithValue();
}
},
2,
);
triggerCalendarChange(fillIndex(getCalendarValue(), index, mergedValue[index]));
setSubmitValue(fillIndex(submitValue(), index, mergedValue[index]));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

单字段重置时 mergedValue[index] 可能是 undefined 而非 null

mergedValue 在无值时是 EMPTY_VALUE(空数组),mergedValue[index] 取到 undefinedtriggerCalendarChange 在 range 模式下会把 undefined 归一成 null,但 setSubmitValue(fillIndex(submitValue(), index, mergedValue[index])) 直接把 undefined 写进了 submitValue,后续 triggerSubmit 会把它原样带进 onChange 的入参,与 range 场景下一贯的 null 约定不一致。

🐛 建议归一为 null
-    triggerCalendarChange(fillIndex(getCalendarValue(), index, mergedValue[index]));
-    setSubmitValue(fillIndex(submitValue(), index, mergedValue[index]));
+    const resetFieldValue = mergedValue[index] ?? null;
+    triggerCalendarChange(fillIndex(getCalendarValue(), index, resetFieldValue));
+    setSubmitValue(fillIndex(submitValue(), index, resetFieldValue));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const resetValue = useEvent((index?: number) => {
if (index === undefined) {
triggerCalendarChange(mergedValue);
syncWithValue();
return;
}
// Sync with value anyway
syncWithValue();
}
},
2,
);
triggerCalendarChange(fillIndex(getCalendarValue(), index, mergedValue[index]));
setSubmitValue(fillIndex(submitValue(), index, mergedValue[index]));
});
const resetValue = useEvent((index?: number) => {
if (index === undefined) {
triggerCalendarChange(mergedValue);
syncWithValue();
return;
}
const resetFieldValue = mergedValue[index] ?? null;
triggerCalendarChange(fillIndex(getCalendarValue(), index, resetFieldValue));
setSubmitValue(fillIndex(submitValue(), index, resetFieldValue));
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PickerInput/hooks/useRangeValue.ts` around lines 307 - 316, Update
resetValue in the indexed single-field reset path to normalize a missing
mergedValue[index] to null before passing it to fillIndex and setSubmitValue.
Keep triggerCalendarChange and the existing behavior for defined values
unchanged, ensuring submitValue uses null rather than undefined for empty range
fields.

Comment on lines +264 to +299
if (source === 'field-switch') {
if (index === currentIndex) {
return 'abort';
}

const nextIndex = (currentIndex + 1) % fieldCount;
if (index !== nextIndex) {
return 'abort';
}

const nextFieldTriggered = triggeredFieldsRef.current.some(
(field) => field.index === nextIndex,
);

if (needConfirm) {
if (confirmedIndexRef.current === currentIndex) {
return 'switchNext';
}

// An allowEmpty field may be left without confirmation. Discard any
// unconfirmed CalendarValue before moving to the next field.
// allowEmpty field 可以在未确认时离开;切换前需要丢弃未确认的
// CalendarValue,再进入下一个 field。
return allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort';
}

if (canSwitch) {
return 'switchNext';
}

// Revisiting the next field starts another circular round. Discard the
// invalid current field and finish the old round before entering it.
// 再次进入已触发的 next field 表示开始新一轮循环。进入前先丢弃当前
// 无效 field,并结束旧的一轮。
return nextFieldTriggered ? 'resetCurrentAndSwitchNext' : 'resetCurrent';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 确认 enabledFieldCount 的定义与取值范围,以及 disabled 字段是否会参与 field-switch
rg -nP -C6 'enabledFieldCount' src/PickerInput/RangePicker.tsx

Repository: react-component/picker

Length of output: 737


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
fd -a 'useRangeValueChange\.ts|RangePicker\.tsx' src | sed 's#^\./##'

echo
echo "useRangeValueChange outline:"
ast-grep outline src/PickerInput/hooks/useRangeValueChange.ts --view expanded | sed -n '1,220p'

echo
echo "Relevant useRangeValueChange sections:"
sed -n '1,360p' src/PickerInput/hooks/useRangeValueChange.ts
echo
sed -n '360,470p' src/PickerInput/hooks/useRangeValueChange.ts

echo
echo "RangePicker relevant sections around hooks call:"
sed -n '1,80p' src/PickerInput/RangePicker.tsx
echo
sed -n '260,360p' src/PickerInput/RangePicker.tsx

echo
echo "Search field-switch related symbols:"
rg -n -C4 'field-switch|setTriggeredFields|triggeredFields|enabledFieldCount|index ===|currentIndex' src/PickerInput src > /tmp/search_range_picker_output.txt
sed -n '1,220p' /tmp/search_range_picker_output.txt

Repository: react-component/picker

Length of output: 40031


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "useRangeValue.ts relevant disable/index handling:"
sed -n '1,380p' src/PickerInput/hooks/useRangeValue.ts

echo
echo "RangeSelector relevant field-switch calls:"
rg -n -C8 'onFocus|field-switch|index' src/PickerInput/Selector/RangeSelector.tsx

echo
echo "Behavioral probe for modulo/index mapping modeled on current hook:"
node - <<'JS'
function resolveFieldSwitch({fieldCount, currentIndex, index, needConfirm, allowEmpty, triggeredAbsolute, confirmedIndex}) {
  if (index === currentIndex) return 'abort';
  const nextIndex = Number.isFinite(fieldCount) ? (currentIndex + 1) % fieldCount : NaN;
  if (index !== nextIndex) return 'abort';
  const nextFieldTriggered = triggeredAbsolute.includes(nextIndex);
  if (needConfirm) {
    if (confirmedIndex === currentIndex) return 'switchNext';
    return allowEmpty[currentIndex] ? 'resetCurrentAndSwitchNext' : 'abort';
  }
  return 'switchNext';
}
for (const [disabled, actions] of Object.entries({
  '[true,false]': [{enabledFieldCount:1}, ['switchNext']],
  '[false,true]': [{enabledFieldCount:1}, ['abort']],
  '[true,true]': [{enabledFieldCount:0}, ['NaN currentIndex']],
})) {
  for (const [from, to] of [
    ['start(0)', 'end(1)'],
    ['end(1)', 'start(0)'],
    ['start(0)', 'start(0)'],
  ]) {
    const [aIndex, bIndex] = from.split('(')[1].slice(0,-1),
          [nIndex] = to.split('(')[1].slice(0,-1);
    // In current hook, field-switch is called with index=aIndex/NaN, currentIndex=last active absolute.
    const cases = [
      {desc:`disabled=${disabled} from ${from} to ${to} currentIndex=${aIndex} index=${nIndex}`,
       result: (() => {
        const current = Number(aIndex), index = parseInt(nIndex,10);
        return JSON.stringify(resolveFieldSplit = resolveFieldSwitch({
          fieldCount: Number(aIndex) === 0 && Number(nIndex) === 0 && true ? 0 : Number(aIndex)+1,
          currentIndex: current,
          index: index,
          needConfirm: false,
          allowEmpty: [false,false],
          triggeredAbsolute: [],
          confirmedIndex: null,
        })
        ))})()}
    ];
  }
}
console.log(JSON.stringify({
  disabledStartTrue: resolveFieldSwitch({fieldCount:1,currentIndex:1,index:0,needConfirm:false,allowEmpty:[false,false],triggeredAbsolute:[],confirmedIndex:null}),
  disabledStartFalse: resolveFieldSwitch({fieldCount:1,currentIndex:0,index:1,needConfirm:false,allowEmpty:[false,false],triggeredAbsolute:[],confirmedIndex:null}),
  disabledAllTrue: {currentIndex:1,index:0,fieldCount:0,nextIndex:NaN},
}, null, 2));
JS

echo
echo "Current hook execution modulo paths (read-only static extraction):"
python3 - <<'PY'
from pathlib import Path
p=Path('src/PickerInput/hooks/useRangeValueChange.ts')
text=p.read_text()
for word in ['fieldCount', '% fieldCount', 'setCurrentIndex(nextIndex)', 'setCurrentIndex(index)', 'setCurrentIndex(NaN)', 'disabled', 'enabledFieldCount']:
    print(f'{word}: {text.count(word)}')
PY

Repository: react-component/picker

Length of output: 13267


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "RangeSelector lines 160-300:"
sed -n '160,300p' src/PickerInput/Selector/RangeSelector.tsx

echo
echo "focused/onFocus handling around selector activeIndex:"
rg -n -C6 'focused|onFocus|activeIndex|onFieldFocus|triggerRangeValueChange|disabled' src/PickerInput/RangePicker.tsx src/PickerInput/Selector/RangeSelector.tsx src/PickerInput/hooks/useFocusEvents.ts

echo
echo "Clean behavioral probe for modulo/index mapping:"
node - <<'JS'
function resolveFieldSwitch({fieldCount, currentIndex, index, needConfirm, allowEmpty, triggeredAbsolute, confirmedIndex}) {
  if (Number.isNaN(fieldCount) || fieldCount <= 0) return {nextIndex: NaN};
  if (index === currentIndex) return {action: 'abort'};
  const nextIndex = (currentIndex + 1) % fieldCount;
  if (index !== nextIndex) return {action: 'abort'};
  const nextFieldTriggered = triggeredAbsolute.includes(nextIndex);
  if (needConfirm) {
    if (confirmedIndex === currentIndex) return {action: 'switchNext'};
    return allowEmpty[currentIndex] ? {action: 'resetCurrentAndSwitchNext'} : {action: 'abort'};
  }
  return {action: 'switchNext', nextIndex};
}
console.log(JSON.stringify({
  disabledStartTrue_endActive: resolveFieldSwitch({fieldCount:1,currentIndex:1,index:0,needConfirm:false,allowEmpty:[false,false],triggeredAbsolute:[],confirmedIndex:null}),
  disabledStartFalse_endActive: resolveFieldSwitch({fieldCount:1,currentIndex:1,index:1,needConfirm:false,allowEmpty:[false,false],triggeredAbsolute:[],confirmedIndex:null}),
  disabledAllTrue_startActive: {currentIndex:1,index:0,fieldCount:0},
  disabledAllTrue_endActive: {currentIndex:1,index:1,fieldCount:0},
}, null, 2));
JS

Repository: react-component/picker

Length of output: 45371


避免在 disabled 字段的绝对 index 上按 enabledFieldCount 环形推进。

调用方传入的是可用字段数量,但 field-switch / switchNext 仍用 (currentIndex + 1) % fieldCount 得到 0/1 绝对 index。例如 disabled = [true, false] 时,end field 切换到被禁用的 start field 会被当做 switchNextdisabled = [true, true]enabledFieldCount === 0 时取模产生 NaN,会把 currentIndex/activeIndex 染成非法焦点。建议循环推进时使用可用字段的绝对 index 列表,或先跳过 disabled 字段。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PickerInput/hooks/useRangeValueChange.ts` around lines 264 - 299, 修正
field-switch 分支中 nextIndex 的计算:不要用 enabledFieldCount 对绝对 currentIndex
取模,而应基于可用字段的绝对 index 列表循环推进并跳过 disabled 字段。处理没有可用字段的情况,避免生成 NaN 或非法
activeIndex/currentIndex;同时保持后续 nextFieldTriggered、确认和切换结果逻辑不变。

Comment on lines +326 to +342
const enabledFieldCount = disabled.filter((fieldDisabled) => !fieldDisabled).length;
const [
rangeValueIndex,
activeIndex,
setActiveIndex,
nextActiveIndex,
activeIndexList,
updateSubmitIndex,
hasActiveSubmitValue,
] = useRangeActive(disabled, allowEmpty, mergedOpen);

const onSharedFocus = (event: React.FocusEvent<HTMLElement>, index?: number) => {
triggerFocus(true);

onFocus?.(event, {
range: getActiveRange(index ?? activeIndex),
});
};
forceFocus,
triggeredFields,
triggerRangeValueChange,
resetRangeValueChange,
] = useRangeValueChange(
enabledFieldCount,
needConfirm,
allowEmpty,
getCalendarValue,
triggerFieldCalendarChange,
flushFieldSubmit,
resetValue,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C5 'fieldCount' src/PickerInput/hooks/useRangeValueChange.ts
rg -nP -C3 'enabledFieldCount' src/PickerInput

Repository: react-component/picker

Length of output: 3215


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- locate files ---\n'
git ls-files | rg '(^|/)RangePicker\.tsx$|(^|/)useRangeValueChange\.ts$' || true

printf '\n--- RangePicker relevant sections ---\n'
if [ -f src/PickerInput/RangePicker.tsx ]; then
  sed -n '280,380p' src/PickerInput/RangePicker.tsx | cat -n
fi

printf '\n--- useRangeValueChange outline ---\n'
if [ -f src/PickerInput/hooks/useRangeValueChange.ts ]; then
  ast-grep outline src/PickerInput/hooks/useRangeValueChange.ts || true
fi

printf '\n--- useRangeValueChange relevant sections ---\n'
if [ -f src/PickerInput/hooks/useRangeValueChange.ts ]; then
  sed -n '1,380p' src/PickerInput/hooks/useRangeValueChange.ts | cat -n
fi

Repository: react-component/picker

Length of output: 251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- locate files ---'
git ls-files | rg '(^|/)RangePicker\.tsx$|(^|/)useRangeValueChange\.ts$' || true

echo
echo '--- RangePicker relevant sections ---'
if [ -f src/PickerInput/RangePicker.tsx ]; then
  sed -n '280,380p' src/PickerInput/RangePicker.tsx | cat -n
fi

echo
echo '--- useRangeValueChange outline ---'
if [ -f src/PickerInput/hooks/useRangeValueChange.ts ]; then
  ast-grep outline src/PickerInput/hooks/useRangeValueChange.ts || true
fi

echo
echo '--- useRangeValueChange relevant sections ---'
if [ -f src/PickerInput/hooks/useRangeValueChange.ts ]; then
  sed -n '1,380p' src/PickerInput/hooks/useRangeValueChange.ts | cat -n
fi

Repository: react-component/picker

Length of output: 20869


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- useRangeValueChange execute/action call sites ---'
sed -n '360,560p' src/PickerInput/hooks/useRangeValueChange.ts | cat -n

echo
echo '--- selector callbacks definitions/usages ---'
rg -n -C4 'onSelectorFocus|onSelectorBlur|onSelectorClick|onSelectorKeyDown|selectorProps|triggerOpen\(\s*false' src/PickerInput/RangePicker.tsx

echo
echo '--- all triggerRangeValueChange usages in RangePicker ---'
rg -n -C3 'triggerRangeValueChange|resetRangeValueChange' src/PickerInput/RangePicker.tsx

Repository: react-component/picker

Length of output: 12401


避免在全禁用时向 useRangeValueChange 传入 0。

disabled=[true, true] 时,enabledFieldCount=0 会让 useRangeValueChange 中的 currentIndex + 1 % fieldCount 变为 NaN;虽然 onSelectorFocus 会先 triggerOpen(true, { disabled: [true, true] }) 即不打开,但焦点切换仍已进入 hook 流程,建议用 0 替换为 disabled.length,或在调用前返回默认 activeIndex,避免分母为 0。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PickerInput/RangePicker.tsx` around lines 326 - 342, 更新 RangePicker 中传给
useRangeValueChange 的字段数量:当 enabledFieldCount 为 0 时改用 disabled.length,确保全禁用场景不会让
hook 的索引计算以 0 为除数;非全禁用场景继续使用 enabledFieldCount。

Comment thread tests/range.spec.tsx

it('mode is array', () => {
const { container } = render(<DayRangePicker mode={['year', 'month']} />);
const { container } = render(<DayRangePicker allowEmpty mode={['year', 'month']} />);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

多处既有回归用例被直接加上 allowEmpty,导致默认(非 allowEmpty)路径失去覆盖。 共同根因是新的字段切换规则要求字段有值或 allowEmpty 才能切换,测试通过放开 allowEmpty 来适配,而不是新增变体用例。

  • tests/range.spec.tsx#L599-L599mode is array 保留无 allowEmpty 的原始断言,另加 allowEmpty 变体。
  • tests/range.spec.tsx#L1002-L1002defaultPickerValue 用例同上,保留原路径覆盖。
  • tests/new-range.spec.tsx#L50-L53defaultPickerValue should reset every time when opened 保留原用例,另加 allowEmpty 变体。
  • tests/new-range.spec.tsx#L87-L87onPickerValueChange 用例同上。
📍 Affects 2 files
  • tests/range.spec.tsx#L599-L599 (this comment)
  • tests/range.spec.tsx#L1002-L1002
  • tests/new-range.spec.tsx#L50-L53
  • tests/new-range.spec.tsx#L87-L87
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/range.spec.tsx` at line 599, Restore the default non-allowEmpty
coverage in tests/range.spec.tsx at lines 599 and 1002, and add separate
allowEmpty variants rather than modifying the existing cases. Apply the same
pattern to tests/new-range.spec.tsx lines 50-53 and 87: preserve the original
“defaultPickerValue should reset every time when opened” and
“onPickerValueChange” cases, then add explicit allowEmpty variants.

triggerRangeValueChange(rangeValueIndex ?? activeIndex, 'popupClose');
}
},
[mergedOpen],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/exhaustive-deps (warning)

useLayoutEffect can run with a stale triggerRangeValueChange, rangeValueIndex, activeIndex & show your users old data.

Fix → Don't blindly add missing dependencies. Read the hook callback first.

Bad:
useEffect(() => {
setCount(count + 1);
}, [count]);

Better:
useEffect(() => {
setCount((currentCount) => currentCount + 1);
}, []);

If the missing value is recreated every render, move it inside the hook or stabilize it before adding it to deps.

Docs

triggerSingleValueChange(0, 'popupClose');
}
},
[mergedOpen],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/exhaustive-deps (warning)

useLayoutEffect can run with a stale triggerSingleValueChange & show your users old data.

Fix → Don't blindly add missing dependencies. Read the hook callback first.

Bad:
useEffect(() => {
setCount(count + 1);
}, [count]);

Better:
useEffect(() => {
setCount((currentCount) => currentCount + 1);
}, []);

If the missing value is recreated every render, move it inside the hook or stabilize it before adding it to deps.

Docs

@zombieJ
zombieJ merged commit 0a1a433 into master Jul 27, 2026
16 checks passed
@zombieJ
zombieJ deleted the fix/range-picker-confirm-flow branch July 27, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants