Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@
&-clear {
position: absolute;
top: 0;
inset-inline-end: 4px;
inset-inline-end: 0;
cursor: pointer;

&-btn::after {
Expand Down
31 changes: 31 additions & 0 deletions src/PickerInput/Selector/ClearIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as React from 'react';
import PickerContext from '../context';
import { clsx } from 'clsx';

export interface ClearIconProps extends React.HtmlHTMLAttributes<HTMLElement> {
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.

medium

Since the ClearIcon component renders a native <button> element and forwards ...restProps to it, the props interface should extend React.ButtonHTMLAttributes<HTMLButtonElement> instead of React.HtmlHTMLAttributes<HTMLElement>. This provides accurate type safety for button-specific attributes.

Suggested change
export interface ClearIconProps extends React.HtmlHTMLAttributes<HTMLElement> {
export interface ClearIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {

icon?: React.ReactNode;
onClear: VoidFunction;
}

export default function ClearIcon({ icon, onClear, ...restProps }: ClearIconProps) {
const { prefixCls, classNames, styles, locale } = React.useContext(PickerContext);

return (
<button
{...restProps}
type="button"
aria-label={locale.clear}
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.

medium

If locale is not defined in the PickerContext (for example, in custom context providers or test environments), accessing locale.clear will throw a runtime error. Using optional chaining prevents this crash.

Suggested change
aria-label={locale.clear}
aria-label={locale?.clear}

className={clsx(`${prefixCls}-clear`, classNames.suffix)}
style={styles.suffix}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
>
{icon}
</button>
);
}
27 changes: 2 additions & 25 deletions src/PickerInput/Selector/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,18 @@ import { clsx } from 'clsx';

export interface IconProps extends React.HtmlHTMLAttributes<HTMLElement> {
icon?: React.ReactNode;
type: 'suffix' | 'clear';
}

export default function Icon(props: IconProps) {
const { icon, type, ...restProps } = props;
export default function Icon({ icon, ...restProps }: IconProps) {
const { prefixCls, classNames, styles } = React.useContext(PickerContext);

return icon ? (
<span
className={clsx(`${prefixCls}-${type}`, classNames.suffix)}
className={clsx(`${prefixCls}-suffix`, classNames.suffix)}
style={styles.suffix}
{...restProps}
>
{icon}
</span>
) : null;
}

export interface ClearIconProps extends Omit<IconProps, 'type'> {
onClear: VoidFunction;
}

export function ClearIcon({ onClear, ...restProps }: ClearIconProps) {
return (
<Icon
{...restProps}
type="clear"
role="button"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
/>
);
}
2 changes: 1 addition & 1 deletion src/PickerInput/Selector/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ const Input = React.forwardRef<InputRef, InputProps>((props, ref) => {
className={classNames.input}
style={styles.input}
/>
<Icon type="suffix" icon={suffixIcon} />
<Icon icon={suffixIcon} />
{clearIcon}
</div>
);
Expand Down
5 changes: 3 additions & 2 deletions src/PickerInput/Selector/RangeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type { RangePickerRef, SelectorProps } from '../../interface';
import PickerContext from '../context';
import useInputProps from './hooks/useInputProps';
import useRootProps from './hooks/useRootProps';
import Icon, { ClearIcon } from './Icon';
import Icon from './Icon';
import ClearIcon from './ClearIcon';
import Input, { type InputRef } from './Input';

export type SelectorIdType =
Expand Down Expand Up @@ -261,7 +262,7 @@ function RangeSelector<DateType extends object = any>(
date-range="end"
/>
<div className={`${prefixCls}-active-bar`} style={activeBarStyle} />
<Icon type="suffix" icon={suffixIcon} />
<Icon icon={suffixIcon} />
{showClear && <ClearIcon icon={clearIcon} onClear={onClear} />}
</div>
</ResizeObserver>
Expand Down
5 changes: 3 additions & 2 deletions src/PickerInput/Selector/SingleSelector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { InternalMode, PickerRef, SelectorProps } from '../../../interface'
import { isSame } from '../../../utils/dateUtil';
import type { PickerProps } from '../../SinglePicker';
import PickerContext from '../../context';
import Icon, { ClearIcon } from '../Icon';
import Icon from '../Icon';
import ClearIcon from '../ClearIcon';
import Input, { type InputRef } from '../Input';
import useInputProps from '../hooks/useInputProps';
import useRootProps from '../hooks/useRootProps';
Expand Down Expand Up @@ -183,7 +184,7 @@ function SingleSelector<DateType extends object = any>(
autoFocus={autoFocus}
tabIndex={tabIndex}
/>
<Icon type="suffix" icon={suffixIcon} />
<Icon icon={suffixIcon} />
{showClear && <ClearIcon icon={clearIcon} onClear={onClear} />}
</>
) : (
Expand Down
1 change: 1 addition & 0 deletions src/PickerInput/SinglePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ function Picker<DateType extends object = any>(
const onSelectorClear = () => {
triggerSubmitChange(null);
triggerOpen(false, { force: true });
selectorRef.current.focus();
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.

medium

The selectorRef.current reference can be null or undefined (e.g., during initial render or if unmounted). To prevent potential runtime TypeError crashes, use optional chaining when calling .focus().

Suggested change
selectorRef.current.focus();
selectorRef.current?.focus();

Comment on lines 388 to +391
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 26, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

清除后立即 focus 会把弹层重新打开

在 Line 391 调用 focus() 后,会走到 Line 575 的 onSelectorFocus,并在 Line 578 再次 triggerOpen(true),导致 Line 390 的关闭逻辑被抵消。建议对“清除触发的这一次 focus”加开关,跳过自动打开。

建议修复(示例)
+  const skipOpenOnNextFocusRef = React.useRef(false);

   const onSelectorClear = () => {
     triggerSubmitChange(null);
     triggerOpen(false, { force: true });
+    skipOpenOnNextFocusRef.current = true;
     selectorRef.current.focus();
   };

   const onSelectorFocus: SelectorProps['onFocus'] = (event) => {
     lastOperation('input');
+    if (skipOpenOnNextFocusRef.current) {
+      skipOpenOnNextFocusRef.current = false;
+      onSharedFocus(event);
+      return;
+    }

     triggerOpen(true, {
       inherit: true,
     });

     onSharedFocus(event);
   };

Also applies to: 575-581

🤖 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/SinglePicker.tsx` around lines 388 - 391, The clear handler
onSelectorClear currently calls selectorRef.current.focus() which immediately
triggers onSelectorFocus and re-opens the panel; fix by adding a one-time skip
flag (e.g., ignoreNextFocus or skipOpenOnNextFocus) set in onSelectorClear
before calling focus, then check that flag at the start of onSelectorFocus and,
if set, clear it and return without calling triggerOpen(true); ensure the flag
is stored on the component instance (ref or state) so it survives the
synchronous focus event and is cleared after the single use.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

看下这个。

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.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'useOpen' --exec cat {}

Length of output: 13105


🏁 Script executed:

#!/bin/bash
fd -t f 'useDelayState' --exec cat {}

Length of output: 1508



@afc163 这个问题确认有效,来龙去脉如下:

根本原因

useDelayState 中,setRafOpen(false, true)immediately=true 时)会同步调用 doUpdate()setState(false),但这只是调度 React 状态更新,rafOpen 变量在当前渲染闭包里仍然是 true,要等到下一次 re-render 才会变成 false

// useDelayState
const updateValue = (next, immediately?) => {
  nextValueRef.current = next;
  if (next || immediately) {
    doUpdate();          // ← 同步调用,但 setState() 是异步生效的
  } else {
    rafRef.current = raf(doUpdate);
  }
};

时序问题

onSelectorClear()
  ├─ triggerOpen(false, { force: true })   // rafOpen 仍为 true(re-render 还没发生)
  └─ selectorRef.current.focus()
       └─ onSelectorFocus()(同步触发)
            └─ triggerOpen(true, { inherit: true })
                 // setOpen 内:!inherit || rafOpen = false || true = true → 允许执行
                 // setRafOpen(true) 再次将 nextValueRef.current 置为 true
                 // React 最终 re-render 时取的是 true → 弹层重新打开

因此在 useOpen.setOpen 里,inherit: true 的保护逻辑失效了,因为 rafOpen 读到的还是旧值。

建议修复

使用一个 ref 旗标在 onSelectorClear 里标记"下一次 focus 跳过 open":

+  const skipOpenOnNextFocusRef = React.useRef(false);

   const onSelectorClear = () => {
     triggerSubmitChange(null);
     triggerOpen(false, { force: true });
+    skipOpenOnNextFocusRef.current = true;
     selectorRef.current.focus();
   };

   const onSelectorFocus: SelectorProps['onFocus'] = (event) => {
     lastOperation('input');
+    if (skipOpenOnNextFocusRef.current) {
+      skipOpenOnNextFocusRef.current = false;
+      onSharedFocus(event);
+      return;
+    }
     triggerOpen(true, {
       inherit: true,
     });
     onSharedFocus(event);
   };

这样可以在清除后正确聚焦输入框,同时不会导致弹层重新打开。


🧬 Code Graph Analysis Results

Relevant snippets (for understanding src/PickerInput/SinglePicker.tsx behavior)

src/interface.tsx

  • Lines 288-305SharedHTMLAttrs
    export type SharedHTMLAttrs = Omit<
      React.InputHTMLAttributes<HTMLDivElement>,
      | 'value'
      | 'defaultValue'
      | 'onChange'
      | 'placeholder'
      | 'id'
      | 'onInvalid'
      | 'disabled'
      | 'onFocus'
      | 'onBlur'
      | 'onSelect'
      | 'min'
      | 'max'
      | 'onKeyDown'
      | 'size'
      | 'prefix'
    >;
  • Lines 494-546SelectorProps<DateType>
    export interface SelectorProps<DateType = any> extends SharedHTMLAttrs {
      picker: PickerMode;
    
      prefix?: React.ReactNode;
      clearIcon?: React.ReactNode;
      suffixIcon?: React.ReactNode;
      className?: string;
      style?: React.CSSProperties;
      /** Add `-placeholder` className as a help info */
      activeHelp?: boolean;
      focused: boolean;
      onFocus: (event: React.FocusEvent<HTMLInputElement>, index?: number) => void;
      onBlur: (event: React.FocusEvent<HTMLInputElement>, index?: number) => void;
      /** Trigger by `enter` key */
      onSubmit: VoidFunction;
      /** `preventDefault` is deprecated which will remove from future version. */
      onKeyDown?: LegacyOnKeyDown;
      locale: Locale;
      generateConfig: GenerateConfig<DateType>;
    
      direction?: 'ltr' | 'rtl';
    
      onClick: React.MouseEventHandler<HTMLDivElement>;
    
      onClear: VoidFunction;
    
      classNames: string;
      styles?: React.CSSProperties;
    
      format: FormatType<DateType>[];
      maskFormat?: string;
      onInputChange: VoidFunction;
      onInvalid: (valid: boolean, index?: number) => void;
    
      preserveInvalidOnBlur?: boolean;
    
      open: boolean;
      onOpenChange: OnOpenChange;
    
      inputReadOnly?: boolean;
    }
  • Lines 327-465SharedPickerProps<DateType> (many props consumed by useFilledProps in SinglePicker)
  • Lines 468-472PickerRef
    export interface PickerRef {
      nativeElement: HTMLDivElement;
      focus: (options?: FocusOptions) => void;
      blur: VoidFunction;
    }
  • Lines 105-107BaseInfo
    export interface BaseInfo {
      range?: 'start' | 'end';
    }
  • Lines 86-86PanelMode
    export type PanelMode = 'time' | 'date' | 'week' | 'month' | 'quarter' | 'year' | 'decade';
  • Lines 88-88InternalMode
    export type InternalMode = PanelMode | 'datetime';

src/PickerInput/hooks/useFilledProps.ts

  • Lines 72-220useFilledProps(...)
    /**
     * Parameters: props (picker config), optional updater callback.
     * Returns: [filledProps, internalPicker, complexPicker, formatList, maskFormat, isInvalidateDate]
     *
     * - Derives `internalPicker` (adds 'datetime' when `picker==='date' && showTime`)
     * - Computes merged `locale` / `showTime` config
     * - Builds `formatList` and `maskFormat`
     * - Computes `disabledDate` boundary and `isInvalidateDate(date, ...)`
     * - Exposes filled props including:
     *   - picker/value/defaultValue/pickerValue/defaultPickerValue (normalized to arrays)
     *   - components.input, clearIcon, showTime
     * - No explicit error handling; uses memoization for derived values.
     */
    export default function useFilledProps(...) { ... }

src/PickerInput/hooks/useRangeValue.ts

  • Lines 164-332 — default export useRangeValue(...)
    /**
     * Parameters:
     * - info: { generateConfig, locale, allowEmpty, order, picker, ...callbacks }
     * - mergedValue, setInnerValue, getCalendarValue
     * - triggerCalendarChange(nextValue), disabled list, formatList
     * - focused, open
     * - isInvalidateDate(date, { from?, activeIndex })
     *
     * Returns tuple: [flushSubmit(index, needTriggerChange), triggerSubmitChange(value)]
     *
     * Key behavior:
     * - `triggerSubmitChange` validates:
     *   - allowEmpty rules
     *   - order rules (optionally sorts via orderDates)
     *   - invalidation via `isInvalidateDate` for start/end
     * - If valid and `onChange` exists, calls `onChange(cloneOrNull, dateStringsOrNull)`
     * - Updates `calendarValue` via `triggerCalendarChange(clone)`
     * - Uses an effect (via `useLockEffect`) to auto-submit when interaction finishes.
     * No explicit error handling is present.
     */
    export default function useRangeValue(...) { ... }
  • Lines 155-159triggerOk (used for onOk in some pickers)
    triggerOk = () => {
      if (onOk) {
        onOk(calendarValue());
      }
    }

src/PickerInput/hooks/useRangeActive.ts

  • Lines 14-96 — default export useRangeActive(disabled, empty?, mergedOpen?)
    /**
     * Parameters: disabled:boolean[], optional empty:boolean[], optional mergedOpen:boolean.
     * Returns:
     * - focused:boolean
     * - triggerFocus(next:boolean) -> void
     * - lastOperation(type?) -> OperationType
     * - activeIndex:number (initial 0)
     * - setActiveIndex(index:number)
     * - nextActiveIndex(nextValue) -> number|null
     * - activeList:number[] (ref snapshot)
     * - updateSubmitIndex(index|null)
     * - hasActiveSubmitValue(index:number) -> boolean
     *
     * Key behavior:
     * - Tracks lastOperation in a ref
     * - When `focused || mergedOpen` lock-effect runs, clears activeList if losing focus
     * - Pushes activeIndex into activeList when focused becomes true
     */
    export default function useRangeActive(...) { ... }
  • Lines 5-5OperationType:
    export type OperationType = 'input' | 'panel' | 'preset-click';
  • Lines 49-54lastOperation(type?)
    lastOperation = (type?: OperationType) => {
      if (type) {
        lastOperationRef.current = type;
      }
      return lastOperationRef.current;
    }

src/PickerInput/hooks/useRangePickerValue.ts

  • Lines 37-236 — default export useRangePickerValue(...)
    /**
     * Parameters:
     * - generateConfig, locale
     * - calendarValue (array for picker fields)
     * - modes: PanelMode[]
     * - open:boolean, activeIndex:number
     * - pickerMode: InternalMode, multiplePanel:boolean
     * - defaultPickerValue, pickerValue arrays
     * - timeDefaultValue (legacy)
     * - onPickerValueChange? (RangePicker onPickerValueChange)
     * - minDate?, maxDate?
     *
     * Returns: [currentPickerValue (DateType), setCurrentIndexPickerValue(value)]
     *
     * Key behavior:
     * - Controlled per-field start/end via `useControlledState`
     * - Computes current picker value based on activeIndex and time picker merging
     * - On open/activeIndex switch, may reset picker value from defaultPickerValue,
     *   calendarValue, or "now", applying min/max clamping (with offset when multiplePanel)
     * - Calls `onPickerValueChange(clone, { source, range:'start'|'end', mode })` when changed
     * No explicit error handling is present.
     */
    export default function useRangePickerValue(...) { ... }
  • Lines 102-123setCurrentPickerValue(...) change notification logic
    setCurrentPickerValue = (nextPickerValue, source='panel') => { ... }
  • Lines 132-158getEndDatePickerValue(...) end-offset logic
    const getEndDatePickerValue = (startDate, endDate) => { ... }

src/PickerInput/Popup/index.tsx

  • Lines 53-259Popup<DateType>(props)
    /**
     * Parameters: PopupProps<DateType>, including:
     * - panelRender?, internalMode, picker, showNow
     * - range/multiple/activeInfo, presets + hover/submit handlers
     * - focus handlers (onFocus/onBlur), direction
     * - value, onSelect, isInvalid, defaultOpenValue, onOk/onSubmit
     * - classNames/styles
     *
     * Returns: a ReactNode representing the popup panel layout.
     *
     * Key behavior:
     * - Computes panel offset/arrow position when `range` is true using ResizeObserver
     * - Builds valueList from `toArray(value)`, supports time-picker empty => defaultOpenValue
     * - Determines footer submit invalidation via `isInvalid`
     * - Renders PresetPanel + PopupPanel + Footer, and applies `panelRender` if provided
     * No explicit error handling is present.
     */
    export default function Popup<DateType extends object = any>(props: PopupProps<DateType>) { ... }
  • Lines 22-51PopupProps<DateType>
  • Lines 175-183onFooterSubmit() (time-picker extra onSelect then onOk() + onSubmit())
    onFooterSubmit = () => {
      if (isTimePickerEmptyValue) {
        onSelect(defaultOpenValue);
      }
      onOk();
      onSubmit();
    }

src/PickerTrigger/index.tsx

  • Lines 63-112PickerTrigger(...) wrapper around rc-trigger
    /**
     * Parameters: PickerTriggerProps (popupElement, visible, onClose, alignment/motion/layout options).
     * Returns: Trigger-wrapped children with popup behavior.
     *
     * Key behavior:
     * - Uses `getRealPlacement(placement, direction==='rtl')`
     * - Passes `popupVisible={visible}` and calls `onClose()` when popup becomes hidden
     * - Configures popup motion via `transitionName`, stretches minWidth, and adds class for range/rtl
     * No explicit error handling is present.
     */
    function PickerTrigger(...) { ... }
  • Lines 43-61PickerTriggerProps

src/hooks/useSemantic.ts

  • Lines 19-36useSemantic(classNames, styles)
    /**
     * Parameters: optional classNames and styles from SharedPickerProps.
     * Returns: [mergedClassNames, mergedStyles], each ensuring `.popup` exists.
     * Memoized by React.useMemo; no explicit error handling.
     */
    export default function useSemantic(...) { ... }

src/PickerInput/hooks/useToggleDates.ts

  • Lines 10-29 — default export useToggleDates(generateConfig, locale, panelMode)
    /**
     * Parameters: generateConfig, locale, panelMode.
     * Returns: function toggleDates(list: DateType[], target: DateType) => DateType[]
     *
     * Key behavior:
     * - Finds target in list using `isSame(generateConfig, locale, date, target, panelMode)`
     * - If present: removes it; else: appends it
     * No explicit error handling is present.
     */
    export default function useToggleDates(...) { ... }

src/PickerInput/hooks/useFieldsInvalidate.ts

  • Lines 8-54 — default export useFieldsInvalidate(calendarValue, isInvalidateDate, allowEmpty?)
    /**
     * Parameters:
     * - calendarValue: [DateType, DateType] array
     * - isInvalidateDate(date, { activeIndex, from? })
     * - allowEmpty?: boolean[]
     *
     * Returns: [submitInvalidates, onSelectorInvalid]
     * - submitInvalidates: boolean[] per field used by selector aria-disabled / invalid state
     * - onSelectorInvalid(invalid, index): sets internal invalid flags for typing invalids
     *
     * Key behavior:
     * - If typing produced invalid => true
     * - If field has value and isInvalidateDate returns true => true
     * - If empty and allowEmpty[index] is false => true
     * No explicit error handling is present.
     */
    export default function useFieldsInvalidate(...) { ... }
  • Lines 18-20onSelectorInvalid updater

src/PickerInput/RangePicker.tsx (not SinglePicker, but shares logic patterns)

  • Lines 237-242triggerOpen(nextOpen, config?)
    /**
     * Parameters: nextOpen:boolean, optional config
     * Behavior: if all fields are disabled or nextOpen is false, updates merged open.
     * Returns void.
     */
    triggerOpen: OnOpenChange = (nextOpen, config?: OpenConfig) => { ... }
  • Lines 500-507onPresetSubmit(...) (preset click behavior)
    onPresetSubmit = (nextValues) => {
      const passed = triggerSubmitChange(nextValues);
      if (passed) { lastOperation('preset-click'); triggerOpen(false,{force:true}); }
    }
  • Lines 652-685onSelectorFocus(...) (focus/confirm logic; useful for comparison with SinglePicker focus/confirm flow)

If you want, tell me what specific behavior in SinglePicker.tsx you’re trying to verify (open/confirm/hover/selector typing/pickerValue sync), and I can narrow this list to only the directly involved hooks/components.

};

// ======================== Hover =========================
Expand Down
7 changes: 4 additions & 3 deletions tests/__snapshots__/picker.spec.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ exports[`Picker.Basic icon 1`] = `
class="suffix-icon"
/>
</span>
<span
<button
aria-label="Clear"
class="rc-picker-clear"
role="button"
type="button"
>
<span
class="suffix-icon"
/>
</span>
</button>
</div>
`;

Expand Down
7 changes: 4 additions & 3 deletions tests/__snapshots__/range.spec.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ exports[`Picker.Range icon 1`] = `
class="suffix-icon"
/>
</span>
<span
<button
aria-label="清除"
class="rc-picker-clear"
role="button"
type="button"
>
<span
class="suffix-icon"
/>
</span>
</button>
</div>
</div>
`;
Expand Down
Loading