diff --git a/assets/index.less b/assets/index.less index e8ba28bac..982871c93 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,4 +1,5 @@ @select-prefix: ~'rc-select'; +@import url('./patch.less'); * { box-sizing: border-box; diff --git a/assets/patch.less b/assets/patch.less new file mode 100644 index 000000000..671892af6 --- /dev/null +++ b/assets/patch.less @@ -0,0 +1,83 @@ +// This is used for semantic refactoring +@import (reference) url('./index.less'); + +.@{select-prefix}.@{select-prefix} { + display: inline-flex; + align-items: center; + user-select: none; + border: 1px solid blue; + position: relative; + + // Content 部分自动占据剩余宽度 + .@{select-prefix}-content { + flex: auto; + display: flex; + align-items: center; + /* Prevent content from wrapping */ + min-width: 0; /* allow flex item to shrink */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + } + + .@{select-prefix}-input { + border: none; + background: transparent; + } + + .@{select-prefix}-placeholder { + opacity: 0.5; + + &::after { + content: '\00a0'; // nbsp placeholder + width: 0; + overflow: hidden; + } + } + + .@{select-prefix}-content, + .@{select-prefix}-input, + .@{select-prefix}-placeholder { + padding: 0; + margin: 0; + line-height: 1.5; + font-size: 14px; + font-weight: normal; + } + + // 其他部分禁止自动宽度,使用内容宽度 + .@{select-prefix}-prefix, + .@{select-prefix}-suffix, + .@{select-prefix}-clear { + flex: none; + } + + .@{select-prefix}-clear { + position: absolute; + top: 0; + right: 0; + } + + // ============================= Single ============================= + &-single { + .@{select-prefix}-input { + position: absolute; + inset: 0; + } + } + + // ============================ Multiple ============================ + &-multiple { + .@{select-prefix}-selection-item { + background: rgba(0, 0, 0, 0.1); + border-radius: 8px; + margin-right: 4px; + } + + .@{select-prefix}-input { + width: calc(var(--select-input-width, 10) * 1px); + min-width: 4px; + } + } +} diff --git a/docs/examples/multiple-with-maxCount.tsx b/docs/examples/multiple-with-maxCount.tsx index a84ddd5ae..2e968c1b9 100644 --- a/docs/examples/multiple-with-maxCount.tsx +++ b/docs/examples/multiple-with-maxCount.tsx @@ -14,6 +14,7 @@ const Test: React.FC = () => { <>

Multiple with maxCount

{ + setInputChanged(true); + inputProps.onChange?.(e); + }} + /> + + ); + }, +); + +export default SingleContent; diff --git a/src/SelectInput/Content/index.tsx b/src/SelectInput/Content/index.tsx new file mode 100644 index 000000000..fc56d19a5 --- /dev/null +++ b/src/SelectInput/Content/index.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import pickAttrs from '@rc-component/util/lib/pickAttrs'; +import SingleContent from './SingleContent'; +import MultipleContent from './MultipleContent'; +import { useSelectInputContext } from '../context'; +import useBaseProps from '../../hooks/useBaseProps'; + +export interface SharedContentProps { + inputProps: React.InputHTMLAttributes; +} + +const SelectContent = React.forwardRef(function SelectContent(_, ref) { + const { multiple, onInputKeyDown, tabIndex } = useSelectInputContext(); + const baseProps = useBaseProps(); + const { showSearch } = baseProps; + + const ariaProps = pickAttrs(baseProps, { aria: true }); + + const sharedInputProps: SharedContentProps['inputProps'] = { + ...ariaProps, + onKeyDown: onInputKeyDown, + readOnly: !showSearch, + tabIndex, + }; + + if (multiple) { + return ; + } + + return ; +}); + +export default SelectContent; diff --git a/src/SelectInput/Input.tsx b/src/SelectInput/Input.tsx new file mode 100644 index 000000000..338a09b67 --- /dev/null +++ b/src/SelectInput/Input.tsx @@ -0,0 +1,219 @@ +import * as React from 'react'; +import { clsx } from 'clsx'; +import { useSelectInputContext } from './context'; +import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; +import useBaseProps from '../hooks/useBaseProps'; +import { composeRef } from '@rc-component/util/lib/ref'; + +export interface InputProps { + id?: string; + readOnly?: boolean; + value?: string; + onChange?: React.ChangeEventHandler; + onKeyDown?: React.KeyboardEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + placeholder?: string; + className?: string; + style?: React.CSSProperties; + maxLength?: number; + /** width always match content width */ + syncWidth?: boolean; + /** autoComplete for input */ + autoComplete?: string; +} + +const Input = React.forwardRef((props, ref) => { + const { + onChange, + onKeyDown, + onBlur, + style, + syncWidth, + value, + className, + autoComplete, + ...restProps + } = props; + const { + prefixCls, + mode, + onSearch, + onSearchSubmit, + onInputBlur, + autoFocus, + tokenWithEnter, + components: { input: InputComponent = 'input' }, + } = useSelectInputContext(); + const { id, classNames, styles, open, activeDescendantId, role, disabled } = useBaseProps() || {}; + + const inputCls = clsx(`${prefixCls}-input`, classNames?.input, className); + + // Used to handle input method composition status + const compositionStatusRef = React.useRef(false); + + // Used to handle paste content, similar to original Selector implementation + const pastedTextRef = React.useRef(null); + + // ============================== Refs ============================== + const inputRef = React.useRef(null); + + React.useImperativeHandle(ref, () => inputRef.current); + + // ============================== Data ============================== + // Handle input changes + const handleChange: React.ChangeEventHandler = (event) => { + let { value: nextVal } = event.target; + + // Handle pasted text with tokenWithEnter, similar to original Selector implementation + if (tokenWithEnter && pastedTextRef.current && /[\r\n]/.test(pastedTextRef.current)) { + // CRLF will be treated as a single space for input element + const replacedText = pastedTextRef.current + .replace(/[\r\n]+$/, '') + .replace(/\r\n/g, ' ') + .replace(/[\r\n]/g, ' '); + nextVal = nextVal.replace(replacedText, pastedTextRef.current); + } + + // Reset pasted text reference + pastedTextRef.current = null; + + // Call onSearch callback + if (onSearch) { + onSearch(nextVal, true, compositionStatusRef.current); + } + + // Call original onChange callback + onChange?.(event); + }; + + // ============================ Keyboard ============================ + // Handle keyboard events + const handleKeyDown: React.KeyboardEventHandler = (event) => { + const { key } = event; + const { value: nextVal } = event.currentTarget; + + // Handle Enter key submission - referencing Selector implementation + if (key === 'Enter' && mode === 'tags' && !compositionStatusRef.current && onSearchSubmit) { + onSearchSubmit(nextVal); + } + + // Call original onKeyDown callback + onKeyDown?.(event); + }; + + // Handle blur events + const handleBlur: React.FocusEventHandler = (event) => { + // Call onInputBlur callback + onInputBlur?.(); + + // Call original onBlur callback + onBlur?.(event); + }; + + // Handle input method composition start + const handleCompositionStart = () => { + compositionStatusRef.current = true; + }; + + // Handle input method composition end + const handleCompositionEnd: React.CompositionEventHandler = (event) => { + compositionStatusRef.current = false; + + // Trigger search when input method composition ends, similar to original Selector + if (mode !== 'combobox') { + const { value: nextVal } = event.currentTarget; + onSearch?.(nextVal, true, false); + } + }; + + // Handle paste events to track pasted content + const handlePaste: React.ClipboardEventHandler = (event) => { + const { clipboardData } = event; + const pastedValue = clipboardData?.getData('text'); + pastedTextRef.current = pastedValue || ''; + }; + + // ============================= Width ============================== + const [widthCssVar, setWidthCssVar] = React.useState(undefined); + + // When syncWidth is enabled, adjust input width based on content + useLayoutEffect(() => { + const input = inputRef.current; + + if (syncWidth && input) { + input.style.width = '0px'; + const scrollWidth = input.scrollWidth; + setWidthCssVar(scrollWidth); + + // Reset input style + input.style.width = ''; + } + }, [syncWidth, value]); + + // ============================= Render ============================= + // Extract shared input props + const sharedInputProps = { + id, + type: mode === 'combobox' ? 'text' : 'search', + ...restProps, + ref: inputRef as React.Ref, + style: { + ...styles?.input, + ...style, + '--select-input-width': widthCssVar, + } as React.CSSProperties, + autoFocus, + autoComplete: autoComplete || 'off', + className: inputCls, + disabled, + value: value || '', + onChange: handleChange, + onKeyDown: handleKeyDown, + onBlur: handleBlur, + onPaste: handlePaste, + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + // Accessibility attributes + role: role || 'combobox', + 'aria-expanded': open || false, + 'aria-haspopup': 'listbox' as const, + 'aria-owns': `${id}_list`, + 'aria-autocomplete': 'list' as const, + 'aria-controls': `${id}_list`, + 'aria-activedescendant': open ? activeDescendantId : undefined, + }; + + // Handle different InputComponent types + if (React.isValidElement(InputComponent)) { + // If InputComponent is a ReactElement, use cloneElement with merged props + const existingProps: any = InputComponent.props || {}; + + // Start with shared props as base + const mergedProps = { ...sharedInputProps, ...existingProps }; + + // Batch update function calls + Object.keys(existingProps).forEach((key) => { + const existingValue = (existingProps as any)[key]; + + if (typeof existingValue === 'function') { + // Merge event handlers + (mergedProps as any)[key] = (...args: any[]) => { + existingValue(...args); + (sharedInputProps as any)[key]?.(...args); + }; + } + }); + + // Update ref + mergedProps.ref = composeRef((InputComponent as any).ref, sharedInputProps.ref); + + return React.cloneElement(InputComponent, mergedProps); + } + + // If InputComponent is a component type, render normally + const Component = InputComponent as React.ComponentType; + return ; +}); + +export default Input; diff --git a/src/SelectInput/context.ts b/src/SelectInput/context.ts new file mode 100644 index 000000000..e439521f5 --- /dev/null +++ b/src/SelectInput/context.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import type { SelectInputProps } from '.'; + +export type ContentContextProps = SelectInputProps; + +const SelectInputContext = React.createContext(null!); + +export function useSelectInputContext() { + return React.useContext(SelectInputContext); +} + +export default SelectInputContext; diff --git a/src/SelectInput/index.tsx b/src/SelectInput/index.tsx new file mode 100644 index 000000000..d73221ea9 --- /dev/null +++ b/src/SelectInput/index.tsx @@ -0,0 +1,277 @@ +import * as React from 'react'; +import Affix from './Affix'; +import SelectContent from './Content'; +import SelectInputContext from './context'; +import type { DisplayValueType, Mode, RenderNode } from '../interface'; +import useBaseProps from '../hooks/useBaseProps'; +import { omit, useEvent } from '@rc-component/util'; +import KeyCode from '@rc-component/util/lib/KeyCode'; +import { isValidateOpenKey } from '../utils/keyUtil'; +import { clsx } from 'clsx'; +import type { ComponentsConfig } from '../hooks/useComponents'; +import { getDOM } from '@rc-component/util/lib/Dom/findDOMNode'; +import { composeRef } from '@rc-component/util/lib/ref'; + +export interface SelectInputRef { + focus: (options?: FocusOptions) => void; + blur: () => void; + nativeElement: HTMLDivElement; +} + +export interface SelectInputProps extends Omit, 'prefix'> { + prefixCls: string; + prefix?: React.ReactNode; + suffix?: React.ReactNode; + clearIcon?: React.ReactNode; + removeIcon?: RenderNode; + multiple?: boolean; + displayValues: DisplayValueType[]; + placeholder?: React.ReactNode; + searchValue?: string; + activeValue?: string; + mode?: Mode; + autoClearSearchValue?: boolean; + onSearch?: (searchText: string, fromTyping: boolean, isCompositing: boolean) => void; + onSearchSubmit?: (searchText: string) => void; + onInputBlur?: () => void; + onClearMouseDown?: React.MouseEventHandler; + onInputKeyDown?: React.KeyboardEventHandler; + onSelectorRemove?: (value: DisplayValueType) => void; + maxLength?: number; + autoFocus?: boolean; + /** Check if `tokenSeparators` contains `\n` or `\r\n` */ + tokenWithEnter?: boolean; + // Add other props that need to be passed through + className?: string; + style?: React.CSSProperties; + focused?: boolean; + components: ComponentsConfig; + children?: React.ReactElement; +} + +const DEFAULT_OMIT_PROPS = [ + 'value', + 'onChange', + 'removeIcon', + 'placeholder', + 'maxTagCount', + 'maxTagTextLength', + 'maxTagPlaceholder', + 'choiceTransitionName', + 'onInputKeyDown', + 'onPopupScroll', + 'tabIndex', + 'activeValue', + 'onSelectorRemove', + 'focused', +] as const; + +export default React.forwardRef(function SelectInput( + props: SelectInputProps, + ref: React.ForwardedRef, +) { + const { + // Style + prefixCls, + className, + style, + + // UI + prefix, + suffix, + clearIcon, + children, + + // Data + multiple, + displayValues, + placeholder, + mode, + + // Search + searchValue, + onSearch, + onSearchSubmit, + onInputBlur, + + // Input + maxLength, + autoFocus, + + // Events + onMouseDown, + onBlur, + onClearMouseDown, + onInputKeyDown, + onSelectorRemove, + + // Token handling + tokenWithEnter, + + // Components + components, + + ...restProps + } = props; + + const { triggerOpen, toggleOpen, showSearch, disabled, loading, classNames, styles } = + useBaseProps(); + + const rootRef = React.useRef(null); + const inputRef = React.useRef(null); + + // Handle keyboard events similar to original Selector + const onInternalInputKeyDown = useEvent( + (event: React.KeyboardEvent) => { + const { which } = event; + + // Compatible with multiple lines in TextArea + const isTextAreaElement = inputRef.current instanceof HTMLTextAreaElement; + + // Prevent default behavior for up/down arrows when dropdown is open + if (!isTextAreaElement && triggerOpen && (which === KeyCode.UP || which === KeyCode.DOWN)) { + event.preventDefault(); + } + + // Call the original onInputKeyDown callback + if (onInputKeyDown) { + onInputKeyDown(event); + } + + // Move within the text box for TextArea + if ( + isTextAreaElement && + !triggerOpen && + ~[KeyCode.UP, KeyCode.DOWN, KeyCode.LEFT, KeyCode.RIGHT].indexOf(which) + ) { + return; + } + + // Open dropdown when a valid open key is pressed + if (isValidateOpenKey(which)) { + toggleOpen(true); + } + }, + ); + + // ====================== Refs ====================== + React.useImperativeHandle(ref, () => { + return { + focus: (options?: FocusOptions) => { + // Focus the inner input if available, otherwise fall back to root div. + (inputRef.current || rootRef.current).focus?.(options); + }, + blur: () => { + (inputRef.current || rootRef.current).blur?.(); + }, + nativeElement: rootRef.current, + }; + }); + + // ====================== Open ====================== + const onInternalMouseDown: SelectInputProps['onMouseDown'] = useEvent((event) => { + if (!disabled) { + if (event.target !== getDOM(inputRef.current)) { + event.preventDefault(); + } + + // Check if we should prevent closing when clicking on selector + // Don't close if: open && not multiple && (combobox mode || showSearch) + const shouldPreventClose = triggerOpen && !multiple && (mode === 'combobox' || showSearch); + + if (!(event.nativeEvent as any)._select_lazy) { + inputRef.current?.focus(); + + // Only toggle open if we should not prevent close + if (!shouldPreventClose) { + toggleOpen(); + } + } else if (triggerOpen) { + // Lazy should also close when click clear icon + toggleOpen(false); + } + } + + onMouseDown?.(event); + }); + + const onInternalBlur: SelectInputProps['onBlur'] = (event) => { + toggleOpen(false); + onBlur?.(event); + }; + + // =================== Components =================== + const { root: RootComponent } = components; + + // ===================== Render ===================== + const domProps = omit(restProps, DEFAULT_OMIT_PROPS as any); + + // Create context value with wrapped callbacks + const contextValue = { + ...props, + onInputKeyDown: onInternalInputKeyDown, + }; + + if (RootComponent) { + if (React.isValidElement(RootComponent)) { + return React.cloneElement(RootComponent, { + ...domProps, + ref: composeRef((RootComponent as any).ref, rootRef), + }); + } + + return ; + } + + return ( + +
+ {/* Prefix */} + + {prefix} + + + {/* Content */} + + + {/* Suffix */} + + {suffix} + + {/* Clear Icon */} + {clearIcon && ( + { + // Mark to tell not trigger open or focus + (e.nativeEvent as any)._select_lazy = true; + onClearMouseDown?.(e); + }} + > + {clearIcon} + + )} + {children} +
+
+ ); +}); diff --git a/src/SelectTrigger.tsx b/src/SelectTrigger.tsx index f5c4a90e6..a44df91aa 100644 --- a/src/SelectTrigger.tsx +++ b/src/SelectTrigger.tsx @@ -76,6 +76,7 @@ export interface SelectTriggerProps { onPopupVisibleChange?: (visible: boolean) => void; onPopupMouseEnter: () => void; + onPopupMouseDown: React.MouseEventHandler; } const SelectTrigger: React.ForwardRefRenderFunction = ( @@ -102,6 +103,7 @@ const SelectTrigger: React.ForwardRefRenderFunction{popupNode}} + popup={ +
+ {popupNode} +
+ } ref={triggerPopupRef} stretch={stretch} popupAlign={popupAlign} diff --git a/src/Selector/Input.tsx b/src/Selector/Input.tsx deleted file mode 100644 index e03e22499..000000000 --- a/src/Selector/Input.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from 'react'; -import { clsx } from 'clsx'; -import { composeRef } from '@rc-component/util/lib/ref'; -import { warning } from '@rc-component/util/lib/warning'; -import composeProps from '@rc-component/util/lib/composeProps'; -import useBaseProps from '../hooks/useBaseProps'; - -type InputRef = HTMLInputElement | HTMLTextAreaElement; - -interface InputProps { - prefixCls: string; - id: string; - inputElement: React.ReactElement; - disabled: boolean; - autoFocus: boolean; - autoComplete: string; - editable: boolean; - activeDescendantId?: string; - value: string; - maxLength?: number; - open: boolean; - tabIndex: number; - /** Pass accessibility props to input */ - attrs: Record; - - onKeyDown: React.KeyboardEventHandler; - onMouseDown: React.MouseEventHandler; - onChange: React.ChangeEventHandler; - onPaste: React.ClipboardEventHandler; - onBlur: React.FocusEventHandler; - onCompositionStart: React.CompositionEventHandler< - HTMLInputElement | HTMLTextAreaElement | HTMLElement - >; - onCompositionEnd: React.CompositionEventHandler< - HTMLInputElement | HTMLTextAreaElement | HTMLElement - >; -} - -const Input: React.ForwardRefRenderFunction = (props, ref) => { - const { - prefixCls, - id, - inputElement, - autoComplete, - editable, - activeDescendantId, - value, - open, - attrs, - ...restProps - } = props; - - const { classNames: contextClassNames, styles: contextStyles } = useBaseProps() || {}; - - let inputNode: React.ComponentElement = inputElement || ; - - const { ref: originRef, props: originProps } = inputNode; - - warning( - !('maxLength' in inputNode.props), - `Passing 'maxLength' to input element directly may not work because input in BaseSelect is controlled.`, - ); - - inputNode = React.cloneElement(inputNode, { - type: 'search', - ...composeProps(restProps, originProps, true), - - // Override over origin props - id, - ref: composeRef(ref, originRef as any), - autoComplete: autoComplete || 'off', - - className: clsx( - `${prefixCls}-selection-search-input`, - originProps.className, - contextClassNames?.input, - ), - - role: 'combobox', - 'aria-expanded': open || false, - 'aria-haspopup': 'listbox', - 'aria-owns': `${id}_list`, - 'aria-autocomplete': 'list', - 'aria-controls': `${id}_list`, - 'aria-activedescendant': open ? activeDescendantId : undefined, - ...attrs, - value: editable ? value : '', - readOnly: !editable, - unselectable: !editable ? 'on' : null, - - style: { - ...originProps.style, - opacity: editable ? null : 0, - ...contextStyles?.input, - }, - }); - - return inputNode; -}; - -const RefInput = React.forwardRef(Input); - -if (process.env.NODE_ENV !== 'production') { - RefInput.displayName = 'Input'; -} - -export default RefInput; diff --git a/src/Selector/MultipleSelector.tsx b/src/Selector/MultipleSelector.tsx deleted file mode 100644 index a17b3ae8c..000000000 --- a/src/Selector/MultipleSelector.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import * as React from 'react'; -import { useState } from 'react'; -import { clsx } from 'clsx'; -import pickAttrs from '@rc-component/util/lib/pickAttrs'; -import Overflow from 'rc-overflow'; -import TransBtn from '../TransBtn'; -import type { InnerSelectorProps } from '.'; -import Input from './Input'; -import useLayoutEffect from '../hooks/useLayoutEffect'; -import type { DisplayValueType, RenderNode, CustomTagProps, RawValueType } from '../BaseSelect'; -import { getTitle } from '../utils/commonUtil'; - -function itemKey(value: DisplayValueType) { - return value.key ?? value.value; -} - -interface SelectorProps extends InnerSelectorProps { - // Icon - removeIcon?: RenderNode; - - // Tags - maxTagCount?: number | 'responsive'; - maxTagTextLength?: number; - maxTagPlaceholder?: React.ReactNode | ((omittedValues: DisplayValueType[]) => React.ReactNode); - tokenSeparators?: string[]; - tagRender?: (props: CustomTagProps) => React.ReactElement; - onToggleOpen: (open?: boolean) => void; - - // Motion - choiceTransitionName?: string; - - // Event - onRemove: (value: DisplayValueType) => void; -} - -const onPreventMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); -}; - -const SelectSelector: React.FC = (props) => { - const { - id, - prefixCls, - - values, - open, - searchValue, - autoClearSearchValue, - inputRef, - placeholder, - disabled, - mode, - showSearch, - autoFocus, - autoComplete, - activeDescendantId, - tabIndex, - - removeIcon, - - maxTagCount, - maxTagTextLength, - maxTagPlaceholder = (omittedValues: DisplayValueType[]) => `+ ${omittedValues.length} ...`, - tagRender, - onToggleOpen, - - onRemove, - onInputChange, - onInputPaste, - onInputKeyDown, - onInputMouseDown, - onInputCompositionStart, - onInputCompositionEnd, - onInputBlur, - } = props; - - const measureRef = React.useRef(null); - const [inputWidth, setInputWidth] = useState(0); - const [focused, setFocused] = useState(false); - - const selectionPrefixCls = `${prefixCls}-selection`; - - // ===================== Search ====================== - const inputValue = - open || (mode === 'multiple' && autoClearSearchValue === false) || mode === 'tags' - ? searchValue - : ''; - const inputEditable: boolean = - mode === 'tags' || - (mode === 'multiple' && autoClearSearchValue === false) || - (showSearch && (open || focused)); - - // We measure width and set to the input immediately - useLayoutEffect(() => { - setInputWidth(measureRef.current.scrollWidth); - }, [inputValue]); - - // ===================== Render ====================== - // >>> Render Selector Node. Includes Item & Rest - const defaultRenderSelector = ( - item: DisplayValueType, - content: React.ReactNode, - itemDisabled: boolean, - closable?: boolean, - onClose?: React.MouseEventHandler, - ) => ( - - {content} - {closable && ( - - × - - )} - - ); - - const customizeRenderSelector = ( - value: RawValueType, - content: React.ReactNode, - itemDisabled: boolean, - closable?: boolean, - onClose?: React.MouseEventHandler, - isMaxTag?: boolean, - info?: { index: number }, - ) => { - const onMouseDown = (e: React.MouseEvent) => { - onPreventMouseDown(e); - onToggleOpen(!open); - }; - return ( - - {tagRender({ - label: content, - value, - index: info?.index, - disabled: itemDisabled, - closable, - onClose, - isMaxTag: !!isMaxTag, - })} - - ); - }; - - const renderItem = (valueItem: DisplayValueType, info: { index: number }) => { - const { disabled: itemDisabled, label, value } = valueItem; - const closable = !disabled && !itemDisabled; - - let displayLabel: React.ReactNode = label; - - if (typeof maxTagTextLength === 'number') { - if (typeof label === 'string' || typeof label === 'number') { - const strLabel = String(displayLabel); - if (strLabel.length > maxTagTextLength) { - displayLabel = `${strLabel.slice(0, maxTagTextLength)}...`; - } - } - } - - const onClose = (event?: React.MouseEvent) => { - if (event) { - event.stopPropagation(); - } - onRemove(valueItem); - }; - - return typeof tagRender === 'function' - ? customizeRenderSelector( - value, - displayLabel, - itemDisabled, - closable, - onClose, - undefined, - info, - ) - : defaultRenderSelector(valueItem, displayLabel, itemDisabled, closable, onClose); - }; - - const renderRest = (omittedValues: DisplayValueType[]) => { - // https://github.com/ant-design/ant-design/issues/48930 - if (!values.length) { - return null; - } - const content = - typeof maxTagPlaceholder === 'function' - ? maxTagPlaceholder(omittedValues) - : maxTagPlaceholder; - return typeof tagRender === 'function' - ? customizeRenderSelector(undefined, content, false, false, undefined, true) - : defaultRenderSelector({ title: content }, content, false); - }; - - // >>> Input Node - const inputNode = ( -
{ - setFocused(true); - }} - onBlur={() => { - setFocused(false); - }} - > - - - {/* Measure Node */} - - {inputValue}  - -
- ); - - // >>> Selections - const selectionNode = ( - - ); - - return ( - - {selectionNode} - {!values.length && !inputValue && ( - {placeholder} - )} - - ); -}; - -export default SelectSelector; diff --git a/src/Selector/SingleSelector.tsx b/src/Selector/SingleSelector.tsx deleted file mode 100644 index 87053cdf7..000000000 --- a/src/Selector/SingleSelector.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import * as React from 'react'; -import pickAttrs from '@rc-component/util/lib/pickAttrs'; -import Input from './Input'; -import type { InnerSelectorProps } from '.'; -import { getTitle } from '../utils/commonUtil'; - -interface SelectorProps extends InnerSelectorProps { - inputElement: React.ReactElement; - activeValue: string; -} - -const SingleSelector: React.FC = (props) => { - const { - inputElement, - prefixCls, - id, - inputRef, - disabled, - autoFocus, - autoComplete, - activeDescendantId, - mode, - open, - values, - placeholder, - tabIndex, - - showSearch, - searchValue, - activeValue, - maxLength, - - onInputKeyDown, - onInputMouseDown, - onInputChange, - onInputPaste, - onInputCompositionStart, - onInputCompositionEnd, - onInputBlur, - title, - } = props; - - const [inputChanged, setInputChanged] = React.useState(false); - - const combobox = mode === 'combobox'; - const inputEditable = combobox || showSearch; - const item = values[0]; - - let inputValue: string = searchValue || ''; - if (combobox && activeValue && !inputChanged) { - inputValue = activeValue; - } - - React.useEffect(() => { - if (combobox) { - setInputChanged(false); - } - }, [combobox, activeValue]); - - // Not show text when closed expect combobox mode - const hasTextInput = mode !== 'combobox' && !open && !showSearch ? false : !!inputValue; - - // Get title of selection item - const selectionTitle = title === undefined ? getTitle(item) : title; - - const placeholderNode = React.useMemo(() => { - if (item) { - return null; - } - return ( - - {placeholder} - - ); - }, [item, hasTextInput, placeholder, prefixCls]); - - return ( - - - { - setInputChanged(true); - onInputChange(e as any); - }} - onPaste={onInputPaste} - onCompositionStart={onInputCompositionStart} - onCompositionEnd={onInputCompositionEnd} - onBlur={onInputBlur} - tabIndex={tabIndex} - attrs={pickAttrs(props, true)} - maxLength={combobox ? maxLength : undefined} - /> - - - {/* Display value */} - {!combobox && item ? ( - - {item.label} - - ) : null} - - {/* Display placeholder */} - {placeholderNode} - - ); -}; - -export default SingleSelector; diff --git a/src/Selector/index.tsx b/src/Selector/index.tsx deleted file mode 100644 index 20543a149..000000000 --- a/src/Selector/index.tsx +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Cursor rule: - * 1. Only `showSearch` enabled - * 2. Only `open` is `true` - * 3. When typing, set `open` to `true` which hit rule of 2 - * - * Accessibility: - * - https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html - */ - -import KeyCode from '@rc-component/util/lib/KeyCode'; -import type { ScrollTo } from 'rc-virtual-list/lib/List'; -import * as React from 'react'; -import { useRef } from 'react'; -import type { CustomTagProps, DisplayValueType, Mode, RenderNode } from '../BaseSelect'; -import useLock from '../hooks/useLock'; -import { isValidateOpenKey } from '../utils/keyUtil'; -import MultipleSelector from './MultipleSelector'; -import SingleSelector from './SingleSelector'; -import { clsx } from 'clsx'; - -export interface InnerSelectorProps { - prefixCls: string; - id: string; - mode: Mode; - title?: string; - - inputRef: React.Ref; - placeholder?: React.ReactNode; - disabled?: boolean; - autoFocus?: boolean; - autoComplete?: string; - values: DisplayValueType[]; - showSearch?: boolean; - searchValue: string; - autoClearSearchValue?: boolean; - activeDescendantId?: string; - open: boolean; - tabIndex?: number; - maxLength?: number; - - onInputKeyDown: React.KeyboardEventHandler; - onInputMouseDown: React.MouseEventHandler; - onInputChange: React.ChangeEventHandler; - onInputPaste: React.ClipboardEventHandler; - onInputCompositionStart: React.CompositionEventHandler; - onInputCompositionEnd: React.CompositionEventHandler; - onInputBlur: React.FocusEventHandler; -} - -export interface RefSelectorProps { - focus: (options?: FocusOptions) => void; - blur: () => void; - scrollTo?: ScrollTo; - nativeElement: HTMLDivElement; -} - -export interface SelectorProps { - prefixClassName: string; - prefixStyle: React.CSSProperties; - id: string; - prefixCls: string; - showSearch?: boolean; - open: boolean; - /** Display in the Selector value, it's not same as `value` prop */ - values: DisplayValueType[]; - mode: Mode; - searchValue: string; - activeValue: string; - autoClearSearchValue: boolean; - inputElement: JSX.Element; - maxLength?: number; - - autoFocus?: boolean; - activeDescendantId?: string; - tabIndex?: number; - disabled?: boolean; - placeholder?: React.ReactNode; - removeIcon?: RenderNode; - prefix?: React.ReactNode; - - // Tags - maxTagCount?: number | 'responsive'; - maxTagTextLength?: number; - maxTagPlaceholder?: React.ReactNode | ((omittedValues: DisplayValueType[]) => React.ReactNode); - tagRender?: (props: CustomTagProps) => React.ReactElement; - - /** Check if `tokenSeparators` contains `\n` or `\r\n` */ - tokenWithEnter?: boolean; - - // Motion - choiceTransitionName?: string; - - onToggleOpen: (open?: boolean) => void; - /** `onSearch` returns go next step boolean to check if need do toggle open */ - onSearch: (searchText: string, fromTyping: boolean, isCompositing: boolean) => boolean; - onSearchSubmit?: (searchText: string) => void; - onRemove: (value: DisplayValueType) => void; - onInputKeyDown?: React.KeyboardEventHandler; - // on inner input blur - onInputBlur?: () => void; -} - -const Selector: React.ForwardRefRenderFunction = (props, ref) => { - const inputRef = useRef(null); - const compositionStatusRef = useRef(false); - - const { - prefixClassName, - prefixStyle, - prefixCls, - open, - mode, - showSearch, - tokenWithEnter, - disabled, - prefix, - - autoClearSearchValue, - - onSearch, - onSearchSubmit, - onToggleOpen, - onInputKeyDown, - onInputBlur, - } = props; - - // ======================= Ref ======================= - const containerRef = React.useRef(null); - - React.useImperativeHandle(ref, () => ({ - focus: (options) => { - inputRef.current.focus(options); - }, - blur: () => { - inputRef.current.blur(); - }, - nativeElement: containerRef.current, - })); - - // ====================== Input ====================== - const [getInputMouseDown, setInputMouseDown] = useLock(0); - - const onInternalInputKeyDown: React.KeyboardEventHandler< - HTMLInputElement | HTMLTextAreaElement - > = (event) => { - const { which } = event; - - // Compatible with multiple lines in TextArea - const isTextAreaElement = inputRef.current instanceof HTMLTextAreaElement; - if (!isTextAreaElement && open && (which === KeyCode.UP || which === KeyCode.DOWN)) { - event.preventDefault(); - } - - if (onInputKeyDown) { - onInputKeyDown(event); - } - - if (which === KeyCode.ENTER && mode === 'tags' && !compositionStatusRef.current && !open) { - // When menu isn't open, OptionList won't trigger a value change - // So when enter is pressed, the tag's input value should be emitted here to let selector know - onSearchSubmit?.((event.target as HTMLInputElement).value); - } - // Move within the text box - if ( - isTextAreaElement && - !open && - ~[KeyCode.UP, KeyCode.DOWN, KeyCode.LEFT, KeyCode.RIGHT].indexOf(which) - ) { - return; - } - if (isValidateOpenKey(which)) { - onToggleOpen(true); - } - }; - - /** - * We can not use `findDOMNode` sine it will get warning, - * have to use timer to check if is input element. - */ - const onInternalInputMouseDown: React.MouseEventHandler = () => { - setInputMouseDown(true); - }; - - // When paste come, ignore next onChange - const pastedTextRef = useRef(null); - - const triggerOnSearch = (value: string) => { - if (onSearch(value, true, compositionStatusRef.current) !== false) { - onToggleOpen(true); - } - }; - - const onInputCompositionStart = () => { - compositionStatusRef.current = true; - }; - - const onInputCompositionEnd: React.CompositionEventHandler = (e) => { - compositionStatusRef.current = false; - - // Trigger search again to support `tokenSeparators` with typewriting - if (mode !== 'combobox') { - triggerOnSearch((e.target as HTMLInputElement).value); - } - }; - - const onInputChange: React.ChangeEventHandler = (event) => { - let { - target: { value }, - } = event; - - // Pasted text should replace back to origin content - if (tokenWithEnter && pastedTextRef.current && /[\r\n]/.test(pastedTextRef.current)) { - // CRLF will be treated as a single space for input element - const replacedText = pastedTextRef.current - .replace(/[\r\n]+$/, '') - .replace(/\r\n/g, ' ') - .replace(/[\r\n]/g, ' '); - value = value.replace(replacedText, pastedTextRef.current); - } - - pastedTextRef.current = null; - - triggerOnSearch(value); - }; - - const onInputPaste: React.ClipboardEventHandler = (e) => { - const { clipboardData } = e; - const value = clipboardData?.getData('text'); - pastedTextRef.current = value || ''; - }; - - const onClick = ({ target }) => { - if (target !== inputRef.current) { - // Should focus input if click the selector - const isIE = (document.body.style as any).msTouchAction !== undefined; - if (isIE) { - setTimeout(() => { - inputRef.current.focus(); - }); - } else { - inputRef.current.focus(); - } - } - }; - - const onMouseDown: React.MouseEventHandler = (event) => { - const inputMouseDown = getInputMouseDown(); - - // when mode is combobox and it is disabled, don't prevent default behavior - // https://github.com/ant-design/ant-design/issues/37320 - // https://github.com/ant-design/ant-design/issues/48281 - if ( - event.target !== inputRef.current && - !inputMouseDown && - !(mode === 'combobox' && disabled) - ) { - event.preventDefault(); - } - - if ((mode !== 'combobox' && (!showSearch || !inputMouseDown)) || !open) { - if (open && autoClearSearchValue !== false) { - onSearch('', true, false); - } - onToggleOpen(); - } - }; - - // ================= Inner Selector ================== - const sharedProps = { - inputRef, - onInputKeyDown: onInternalInputKeyDown, - onInputMouseDown: onInternalInputMouseDown, - onInputChange, - onInputPaste, - onInputCompositionStart, - onInputCompositionEnd, - onInputBlur, - }; - - const selectNode = - mode === 'multiple' || mode === 'tags' ? ( - - ) : ( - - ); - - return ( -
- {prefix && ( -
- {prefix} -
- )} - {selectNode} -
- ); -}; - -const ForwardSelector = React.forwardRef(Selector); - -if (process.env.NODE_ENV !== 'production') { - ForwardSelector.displayName = 'Selector'; -} - -export default ForwardSelector; diff --git a/src/TransBtn.tsx b/src/TransBtn.tsx index e9240c365..31fcaf7f0 100644 --- a/src/TransBtn.tsx +++ b/src/TransBtn.tsx @@ -12,6 +12,16 @@ export interface TransBtnProps { children?: React.ReactNode; } +/** + * Small wrapper for Select icons (clear/arrow/etc.). + * Prevents default mousedown to avoid blurring or caret moves, and + * renders a custom icon or a fallback icon span. + * + * DOM structure: + * + * { icon || {children} } + * + */ const TransBtn: React.FC = (props) => { const { className, style, customizeIcon, customizeIconProps, children, onMouseDown, onClick } = props; diff --git a/src/hooks/useAllowClear.tsx b/src/hooks/useAllowClear.tsx index 7631521dd..2e8c46b4b 100644 --- a/src/hooks/useAllowClear.tsx +++ b/src/hooks/useAllowClear.tsx @@ -1,48 +1,42 @@ -import TransBtn from '../TransBtn'; -import type { DisplayValueType, Mode, RenderNode } from '../interface'; -import React from 'react'; +import type { DisplayValueType, Mode } from '../interface'; +import type React from 'react'; +import { useMemo } from 'react'; + +export interface AllowClearConfig { + allowClear: boolean; + clearIcon: React.ReactNode; +} export const useAllowClear = ( prefixCls: string, - onClearMouseDown: React.MouseEventHandler, displayValues: DisplayValueType[], - allowClear?: boolean | { clearIcon?: RenderNode }, - clearIcon?: RenderNode, + allowClear?: boolean | { clearIcon?: React.ReactNode }, + clearIcon?: React.ReactNode, disabled: boolean = false, mergedSearchValue?: string, mode?: Mode, -) => { - const mergedClearIcon = React.useMemo(() => { - if (typeof allowClear === 'object') { - return allowClear.clearIcon; +): AllowClearConfig => { + // Convert boolean to object first + const allowClearConfig = useMemo>(() => { + if (typeof allowClear === 'boolean') { + return { allowClear }; } - if (clearIcon) { - return clearIcon; + if (allowClear && typeof allowClear === 'object') { + return allowClear; } - }, [allowClear, clearIcon]); + return { allowClear: false }; + }, [allowClear]); - const mergedAllowClear = React.useMemo(() => { - if ( + return useMemo(() => { + const mergedAllowClear = !disabled && - !!allowClear && + allowClearConfig.allowClear !== false && (displayValues.length || mergedSearchValue) && - !(mode === 'combobox' && mergedSearchValue === '') - ) { - return true; - } - return false; - }, [allowClear, disabled, displayValues.length, mergedSearchValue, mode]); + !(mode === 'combobox' && mergedSearchValue === ''); - return { - allowClear: mergedAllowClear, - clearIcon: ( - - × - - ), - }; + return { + allowClear: mergedAllowClear, + clearIcon: mergedAllowClear ? allowClearConfig.clearIcon || clearIcon || '×' : null, + }; + }, [allowClearConfig, clearIcon, disabled, displayValues.length, mergedSearchValue, mode]); }; diff --git a/src/hooks/useBaseProps.ts b/src/hooks/useBaseProps.ts index 827157440..70bbe6097 100644 --- a/src/hooks/useBaseProps.ts +++ b/src/hooks/useBaseProps.ts @@ -10,6 +10,7 @@ export interface BaseSelectContextProps extends BaseSelectProps { triggerOpen: boolean; multiple: boolean; toggleOpen: (open?: boolean) => void; + role?: React.AriaRole; } export const BaseSelectContext = React.createContext(null); diff --git a/src/hooks/useComponents.ts b/src/hooks/useComponents.ts new file mode 100644 index 000000000..e7b29ea07 --- /dev/null +++ b/src/hooks/useComponents.ts @@ -0,0 +1,42 @@ +import * as React from 'react'; +import type { SelectInputRef, SelectInputProps } from '../SelectInput'; +import type { BaseSelectProps } from '../BaseSelect'; + +export interface ComponentsConfig { + root?: React.ComponentType | string | React.ReactElement; + input?: React.ComponentType | string | React.ReactElement; +} + +export interface FilledComponentsConfig { + root: React.ForwardRefExoticComponent>; + input: React.ForwardRefExoticComponent< + | React.TextareaHTMLAttributes + | (React.InputHTMLAttributes & + React.RefAttributes) + >; +} + +export default function useComponents( + components?: ComponentsConfig, + getInputElement?: BaseSelectProps['getInputElement'], + getRawInputElement?: BaseSelectProps['getRawInputElement'], +): ComponentsConfig { + return React.useMemo(() => { + let { root, input } = components || {}; + + // root: getRawInputElement + if (getRawInputElement) { + root = getRawInputElement(); + } + + // input: getInputElement + if (getInputElement) { + input = getInputElement(); + } + + return { + root, + input, + }; + }, [components, getInputElement, getRawInputElement]); +} diff --git a/src/hooks/useDelayReset.ts b/src/hooks/useDelayReset.ts deleted file mode 100644 index a8b400337..000000000 --- a/src/hooks/useDelayReset.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; - -/** - * Similar with `useLock`, but this hook will always execute last value. - * When set to `true`, it will keep `true` for a short time even if `false` is set. - */ -export default function useDelayReset( - timeout: number = 10, -): [boolean, (val: boolean, callback?: () => void) => void, () => void] { - const [bool, setBool] = React.useState(false); - const delayRef = React.useRef(null); - - const cancelLatest = () => { - window.clearTimeout(delayRef.current); - }; - - React.useEffect(() => { - return () => { - cancelLatest(); - }; - }, []); - - const delaySetBool = (value: boolean, callback?: () => void) => { - cancelLatest(); - - if (value === true) { - // true 值立即设置 - setBool(true); - callback?.(); - } else { - // false 值延迟设置 - delayRef.current = window.setTimeout(() => { - setBool(false); - callback?.(); - }, timeout); - } - }; - - return [bool, delaySetBool, cancelLatest]; -} diff --git a/src/hooks/useLayoutEffect.ts b/src/hooks/useLayoutEffect.ts deleted file mode 100644 index fab070cda..000000000 --- a/src/hooks/useLayoutEffect.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -import * as React from 'react'; -import { isBrowserClient } from '../utils/commonUtil'; - -/** - * Wrap `React.useLayoutEffect` which will not throw warning message in test env - */ -export default function useLayoutEffect(effect: React.EffectCallback, deps?: React.DependencyList) { - // Never happen in test env - if (isBrowserClient) { - /* istanbul ignore next */ - React.useLayoutEffect(effect, deps); - } else { - React.useEffect(effect, deps); - } -} -/* eslint-enable */ diff --git a/src/hooks/useOpen.ts b/src/hooks/useOpen.ts new file mode 100644 index 000000000..8b4e60049 --- /dev/null +++ b/src/hooks/useOpen.ts @@ -0,0 +1,93 @@ +import { useControlledState, useEvent } from '@rc-component/util'; +import { useRef, useState, useEffect } from 'react'; + +const internalMacroTask = (fn: VoidFunction) => { + const channel = new MessageChannel(); + channel.port1.onmessage = fn; + channel.port2.postMessage(null); +}; + +const macroTask = (fn: VoidFunction, times = 1) => { + if (times <= 0) { + fn(); + return; + } + + internalMacroTask(() => { + macroTask(fn, times - 1); + }); +}; + +/** + * Trigger by latest open call, if nextOpen is undefined, means toggle. + * ignoreNext will skip next call in the macro task queue. + */ +export type TriggerOpenType = (nextOpen?: boolean, ignoreNext?: boolean) => void; + +/** + * When `open` is controlled, follow the controlled value; + * Otherwise use uncontrolled logic. + * Setting `open` takes effect immediately, + * but setting it to `false` is delayed via MessageChannel. + * + * SSR handling: During SSR, `open` is always false to avoid Portal issues. + * On client-side hydration, it syncs with the actual open state. + */ +export default function useOpen( + propOpen: boolean, + onOpen: (nextOpen: boolean) => void, + postOpen: (nextOpen: boolean) => boolean, +): [boolean, TriggerOpenType] { + // SSR not support Portal which means we need delay `open` for the first time render + const [rendered, setRendered] = useState(false); + + useEffect(() => { + setRendered(true); + }, []); + + const [stateOpen, internalSetOpen] = useControlledState(false, propOpen); + + // During SSR, always return false for open state + const ssrSafeOpen = rendered ? stateOpen : false; + const mergedOpen = postOpen(ssrSafeOpen); + + const taskIdRef = useRef(0); + const taskLockRef = useRef(false); + + const triggerEvent = useEvent((nextOpen: boolean) => { + if (onOpen && mergedOpen !== nextOpen) { + onOpen(nextOpen); + } + internalSetOpen(nextOpen); + }); + + const toggleOpen = useEvent((nextOpen?: boolean, ignoreNext = false) => { + taskIdRef.current += 1; + const id = taskIdRef.current; + + const nextOpenVal = typeof nextOpen === 'boolean' ? nextOpen : !mergedOpen; + + // Since `mergedOpen` is post-processed, we need to check if the value really changed + if (nextOpenVal) { + triggerEvent(true); + + // Lock if needed + if (ignoreNext) { + taskLockRef.current = ignoreNext; + + macroTask(() => { + taskLockRef.current = false; + }, 2); + } + return; + } + + macroTask(() => { + if (id === taskIdRef.current && !taskLockRef.current) { + triggerEvent(false); + } + }); + }); + + return [mergedOpen, toggleOpen] as const; +} diff --git a/src/hooks/useSearchConfig.ts b/src/hooks/useSearchConfig.ts index f062dc250..2bcf789ea 100644 --- a/src/hooks/useSearchConfig.ts +++ b/src/hooks/useSearchConfig.ts @@ -1,10 +1,11 @@ -import type { SearchConfig, DefaultOptionType } from '@/Select'; +import type { SearchConfig, DefaultOptionType, SelectProps } from '../Select'; import * as React from 'react'; // Convert `showSearch` to unique config export default function useSearchConfig( showSearch: boolean | SearchConfig | undefined, props: SearchConfig, + mode: SelectProps['mode'], ) { const { filterOption, @@ -26,8 +27,17 @@ export default function useSearchConfig( ...(isObject ? showSearch : {}), }; - return [isObject ? true : showSearch, searchConfig]; + return [ + isObject || + mode === 'combobox' || + mode === 'tags' || + (mode === 'multiple' && showSearch === undefined) + ? true + : showSearch, + searchConfig, + ]; }, [ + mode, showSearch, filterOption, searchValue, diff --git a/src/hooks/useSelectTriggerControl.ts b/src/hooks/useSelectTriggerControl.ts index 5ae29c606..79ced5d06 100644 --- a/src/hooks/useSelectTriggerControl.ts +++ b/src/hooks/useSelectTriggerControl.ts @@ -1,43 +1,37 @@ import * as React from 'react'; +import { useEvent } from '@rc-component/util'; export default function useSelectTriggerControl( - elements: () => (HTMLElement | undefined)[], + elements: () => (HTMLElement | SVGElement | undefined)[], open: boolean, triggerOpen: (open: boolean) => void, customizedTrigger: boolean, ) { - const propsRef = React.useRef(null); - propsRef.current = { - open, - triggerOpen, - customizedTrigger, - }; - - React.useEffect(() => { - function onGlobalMouseDown(event: MouseEvent) { - // If trigger is customized, Trigger will take control of popupVisible - if (propsRef.current?.customizedTrigger) { - return; - } + const onGlobalMouseDown = useEvent((event: MouseEvent) => { + // If trigger is customized, Trigger will take control of popupVisible + if (customizedTrigger) { + return; + } - let target = event.target as HTMLElement; + let target = event.target as HTMLElement; - if (target.shadowRoot && event.composed) { - target = (event.composedPath()[0] || target) as HTMLElement; - } + if (target.shadowRoot && event.composed) { + target = (event.composedPath()[0] || target) as HTMLElement; + } - if ( - propsRef.current.open && - elements() - .filter((element) => element) - .every((element) => !element.contains(target) && element !== target) - ) { - // Should trigger close - propsRef.current.triggerOpen(false); - } + if ( + open && + elements() + .filter((element) => element) + .every((element) => !element.contains(target) && element !== target) + ) { + // Should trigger close + triggerOpen(false); } + }); + React.useEffect(() => { window.addEventListener('mousedown', onGlobalMouseDown); return () => window.removeEventListener('mousedown', onGlobalMouseDown); - }, []); + }, [onGlobalMouseDown]); } diff --git a/src/utils/keyUtil.ts b/src/utils/keyUtil.ts index bdfae6c2a..6d1e68b59 100644 --- a/src/utils/keyUtil.ts +++ b/src/utils/keyUtil.ts @@ -22,6 +22,11 @@ export function isValidateOpenKey(currentKeyCode: number): boolean { KeyCode.EQUALS, KeyCode.CAPS_LOCK, KeyCode.CONTEXT_MENU, + // Arrow keys - should not trigger open when navigating in input + KeyCode.UP, + // KeyCode.DOWN, + KeyCode.LEFT, + KeyCode.RIGHT, // F1-F12 KeyCode.F1, KeyCode.F2, diff --git a/tests/Accessibility.test.tsx b/tests/Accessibility.test.tsx index 1e4df9e90..8b48812ac 100644 --- a/tests/Accessibility.test.tsx +++ b/tests/Accessibility.test.tsx @@ -62,6 +62,10 @@ describe('Select.Accessibility', () => { expect(onActive).toHaveBeenCalledTimes(1); keyDown(container.querySelector('input')!, KeyCode.ENTER); + await act(async () => { + jest.runAllTimers(); + await Promise.resolve(); + }); expectOpen(container, false); // Next Match diff --git a/tests/BaseSelect.test.tsx b/tests/BaseSelect.test.tsx index 6a1fb514b..444d2872a 100644 --- a/tests/BaseSelect.test.tsx +++ b/tests/BaseSelect.test.tsx @@ -2,13 +2,22 @@ import type { OptionListProps, RefOptionListProps } from '@/OptionList'; import { fireEvent, render } from '@testing-library/react'; import { forwardRef, act } from 'react'; import BaseSelect from '../src/BaseSelect'; +import { waitFakeTimer } from './utils/common'; const OptionList = forwardRef(() => (
Popup
)); describe('BaseSelect', () => { - it('customized inputElement should trigger popup properly', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('customized inputElement should trigger popup properly', async () => { const { container } = render( { />, ); expect(container.querySelector('div.popup')).toBeFalsy(); + fireEvent.click(container.querySelector('a.trigger')); + await waitFakeTimer(); expect(container.querySelector('div.popup')).toBeTruthy(); + fireEvent.mouseDown(container.querySelector('a.trigger')); + await waitFakeTimer(); expect(container.querySelector('div.rc-select-dropdown-hidden')).toBeFalsy(); + fireEvent.click(container.querySelector('a.trigger')); + await waitFakeTimer(); expect(container.querySelector('div.rc-select-dropdown-hidden')).toBeTruthy(); }); @@ -123,4 +138,56 @@ describe('BaseSelect', () => { expect(container.querySelector('.rc-select-dropdown-placement-fallback')).toBeTruthy(); }); + + it('should use RootComponent when provided in components prop', () => { + const CustomRoot = forwardRef((props, ref) => ( +
+ )); + + const { container } = render( + {}} + searchValue="" + onSearch={() => {}} + OptionList={OptionList} + emptyOptions + components={{ + root: CustomRoot, + }} + />, + ); + + expect(container.querySelector('.custom-root')).toBeTruthy(); + expect(container.querySelector('.rc-select')).toBeFalsy(); + }); + + it('should use React element as RootComponent when provided in components prop', () => { + const CustomRoot = forwardRef((props, ref) => ( +
+ )); + + const customElement = ; + + const { container } = render( + {}} + searchValue="" + onSearch={() => {}} + OptionList={OptionList} + emptyOptions + components={{ + root: customElement, + }} + />, + ); + + expect(container.querySelector('.custom-root-element')).toBeTruthy(); + expect(container.querySelector('.rc-select')).toBeFalsy(); + }); }); diff --git a/tests/Combobox.test.tsx b/tests/Combobox.test.tsx index 17b0fc102..623837ab3 100644 --- a/tests/Combobox.test.tsx +++ b/tests/Combobox.test.tsx @@ -74,11 +74,9 @@ describe('Select.Combobox', () => { ); expect(container.querySelector('input').value).toBe(''); - expect(container.querySelector('.rc-select-selection-placeholder')!.textContent).toEqual( - 'placeholder', - ); + expect(container.querySelector('.rc-select-placeholder')!.textContent).toEqual('placeholder'); fireEvent.change(container.querySelector('input')!, { target: { value: '1' } }); - expect(container.querySelector('.rc-select-selection-placeholder')).toBeFalsy(); + expect(container.querySelector('.rc-select-placeholder')).toBeFalsy(); expect(container.querySelector('input')!.value).toBe('1'); }); @@ -114,7 +112,7 @@ describe('Select.Combobox', () => { toggleOpen(container); selectItem(container); - expect(container.querySelector('input').value).toEqual('1'); + expect(container.querySelector('input')).toHaveValue('1'); }); describe('input value', () => { @@ -321,7 +319,7 @@ describe('Select.Combobox', () => { onChange.mockReset(); keyDown(inputEle, KeyCode.DOWN); - expect(inputEle.value).toEqual('light@gmail.com'); + expect(inputEle).toHaveValue('light@gmail.com'); expect(onChange).not.toHaveBeenCalled(); keyDown(inputEle, KeyCode.ENTER); @@ -337,7 +335,7 @@ describe('Select.Combobox', () => { , ); - expect(container.querySelector('.rc-select-clear-icon')).toBeFalsy(); + expect(container.querySelector('.rc-select-clear')).toBeFalsy(); }); it("should show clear icon when inputValue is not ''", () => { @@ -348,7 +346,7 @@ describe('Select.Combobox', () => { , ); - expect(container.querySelector('.rc-select-clear-icon')).toBeTruthy(); + expect(container.querySelector('.rc-select-clear')).toBeTruthy(); }); it("should hide clear icon when inputValue is ''", () => { @@ -360,9 +358,9 @@ describe('Select.Combobox', () => { ); fireEvent.change(container.querySelector('input')!, { target: { value: '1' } }); - expect(container.querySelector('.rc-select-clear-icon')).toBeTruthy(); + expect(container.querySelector('.rc-select-clear')).toBeTruthy(); fireEvent.change(container.querySelector('input')!, { target: { value: '' } }); - expect(container.querySelector('.rc-select-clear-icon')).toBeFalsy(); + expect(container.querySelector('.rc-select-clear')).toBeFalsy(); }); it('autocomplete - option update when input change', () => { @@ -469,6 +467,7 @@ describe('Select.Combobox', () => { }); it('should reset value by control', () => { + jest.useFakeTimers(); const onChange = jest.fn(); const { container } = render( , ); - const selectorEle = container.querySelector('.rc-select-selector'); + const selectorEle = container.querySelector('.rc-select'); const mouseDownEvent = createEvent.mouseDown(selectorEle); mouseDownEvent.preventDefault = preventDefault; @@ -633,7 +634,7 @@ describe('Select.Combobox', () => { , ); - const selectorEle = container.querySelector('.rc-select-selector'); + const selectorEle = container.querySelector('.rc-select'); const mouseDownEvent = createEvent.mouseDown(selectorEle); mouseDownEvent.preventDefault = preventDefault; diff --git a/tests/Custom.test.tsx b/tests/Custom.test.tsx index eb781adaf..387bc81af 100644 --- a/tests/Custom.test.tsx +++ b/tests/Custom.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import Select from '../src'; -import { injectRunAllTimers } from './utils/common'; +import { injectRunAllTimers, waitFakeTimer } from './utils/common'; import { fireEvent, render } from '@testing-library/react'; describe('Select.Custom', () => { @@ -14,7 +14,7 @@ describe('Select.Custom', () => { jest.useRealTimers(); }); - it('getRawInputElement', () => { + it('getRawInputElement', async () => { const onPopupVisibleChange = jest.fn(); const { container } = render( + const renderDemo = (suffix?: React.ReactNode) => ( + ); const { container, rerender } = render(renderDemo()); - expect(container.querySelector('.rc-select-arrow')).toBeFalsy(); + expect(container.querySelector('.rc-select-suffix')).toBeFalsy(); rerender(renderDemo(
arrow
)); - expect(container.querySelector('.rc-select-arrow')).toBeTruthy(); + expect(container.querySelector('.rc-select-suffix')).toBeTruthy(); }); it('show static prefix', () => { @@ -402,6 +410,7 @@ describe('Select.Multiple', () => { keyDown(container.querySelector('input'), KeyCode.L); fireEvent.change(container.querySelector('input'), { target: { value: 'l' } }); + console.log('clear'); // Backspace keyDown(container.querySelector('input'), KeyCode.BACKSPACE); fireEvent.change(container.querySelector('input'), { target: { value: '' } }); @@ -411,7 +420,10 @@ describe('Select.Multiple', () => { keyDown(container.querySelector('input'), KeyCode.BACKSPACE); expect(onChange).not.toHaveBeenCalled(); - jest.runAllTimers(); + console.log('after 200ms'); + act(() => { + jest.runAllTimers(); + }); keyDown(container.querySelector('input'), KeyCode.BACKSPACE); expect(onChange).toHaveBeenCalledWith([], expect.anything()); @@ -422,9 +434,13 @@ describe('Select.Multiple', () => { const { container } = render( , ); - expect(container.querySelector('.rc-select').getAttribute('tabindex')).toBeFalsy(); + expect(container.querySelector('.rc-select')).not.toHaveAttribute('tabindex'); - expect( - container.querySelector('input.rc-select-selection-search-input').getAttribute('tabindex'), - ).toBe('0'); + expect(container.querySelector('input')).toHaveAttribute('tabindex', '0'); }); - it('should render title defaultly', () => { + it('should render title by default', () => { const { container } = render( label
}]} value={['1']} />, ); @@ -533,7 +545,7 @@ describe('Select.Multiple', () => { , ); - expect(container.querySelector('.rc-select-selection-item-remove')).toBeFalsy(); + expect(container.querySelector('.rc-select-item-remove')).toBeFalsy(); }); it('do not crash if value not in options when removing option', () => { @@ -573,8 +585,8 @@ describe('Select.Multiple', () => { , ); - expect(wrapper1.container.querySelector('.rc-select-selection-item')).toBeFalsy(); - expect(wrapper2.container.querySelector('.rc-select-selection-item')).toBeFalsy(); + expect(wrapper1.container.querySelector('.rc-select-item')).toBeFalsy(); + expect(wrapper2.container.querySelector('.rc-select-item')).toBeFalsy(); }); describe('optionLabelProp', () => { @@ -666,7 +678,7 @@ describe('Select.Multiple', () => { const { container } = render( + + + , + ); + + // Click on the Select should trigger close event + fireEvent.mouseDown(container.querySelector('.rc-select-clear')); + await waitFakeTimer(); + expect(onPopupVisibleChange).toHaveBeenCalledWith(false); + }); + describe('render', () => { function genSelect(props?: Partial) { return ( @@ -262,7 +286,7 @@ describe('Select.Basic', () => { , ); - expect(container1.querySelector('.rc-select-clear-icon')).toBeTruthy(); + expect(container1.querySelector('.rc-select-clear')).toBeTruthy(); const { container: container2 } = render( , ); - expect(container2.querySelector('.rc-select-clear-icon')).toBeFalsy(); + expect(container2.querySelector('.rc-select-clear')).toBeFalsy(); const { container: container3 } = render( ( +
+ {origin} +
+ )} + > + + + , + ); + + toggleOpen(container); + + fireEvent.mouseDown(container.querySelector('.little')); + fireEvent.blur(container.querySelector('input')); + fireEvent.focus(container.querySelector('.little')); + + await waitFakeTimer(); + + expectOpen(container, true); + }); + it('filter options by "value" prop by default', () => { const { container } = render( , ); - expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelector('input')).toHaveAttribute('readonly'); }); it('should contain falsy children', () => { @@ -672,12 +722,10 @@ describe('Select.Basic', () => { const focusSpy = jest.spyOn(container.querySelector('input'), 'focus'); - fireEvent.mouseDown(container.querySelector('.rc-select-selector')); - fireEvent.click(container.querySelector('.rc-select-selector')); + fireEvent.mouseDown(container.querySelector('.rc-select')); + fireEvent.click(container.querySelector('.rc-select')); expect(focusSpy).toHaveBeenCalled(); - // We should mock trigger focus event since it not work in jsdom - fireEvent.focus(container.querySelector('input')); jest.runAllTimers(); }); @@ -687,7 +735,7 @@ describe('Select.Basic', () => { it('fires focus event', () => { expect(handleFocus).toHaveBeenCalled(); - expect(handleFocus.mock.calls.length).toBe(1); + expect(handleFocus.mock.calls).toHaveLength(1); }); it('set className', () => { @@ -702,8 +750,8 @@ describe('Select.Basic', () => { , ); const inputSpy = jest.spyOn(container1.querySelector('input'), 'focus'); - fireEvent.mouseDown(container1.querySelector('.rc-select-selection-placeholder')); - fireEvent.click(container1.querySelector('.rc-select-selection-placeholder')); + fireEvent.mouseDown(container1.querySelector('.rc-select-placeholder')); + fireEvent.click(container1.querySelector('.rc-select-placeholder')); expect(inputSpy).toHaveBeenCalled(); }); }); @@ -786,7 +834,7 @@ describe('Select.Basic', () => { }); [KeyCode.ENTER, KeyCode.DOWN].forEach((keyCode) => { - it('open on key press', () => { + it(`open on key press: ${keyCode}`, () => { const { container } = render( + , ); - expect(container.querySelector('.rc-select-arrow-loading')).toBeTruthy(); + expect(container.querySelector('.rc-select-suffix-loading')).toBeTruthy(); }); it('if loading and multiple which has not arrow, but have loading icon', () => { const renderDemo = (loading?: boolean) => ( - ); const { container, rerender } = render(renderDemo()); - expect(container.querySelector('.rc-select-arrow-icon')).toBeFalsy(); - expect(container.querySelector('.rc-select-arrow-loading')).toBeFalsy(); + expect(container.querySelector('.rc-select-suffix-icon')).toBeFalsy(); + expect(container.querySelector('.rc-select-suffix-loading')).toBeFalsy(); rerender(renderDemo(true)); - expect(container.querySelector('.rc-select-arrow-loading')).toBeTruthy(); + expect(container.querySelector('.rc-select-suffix-loading')).toBeTruthy(); }); it('should keep trigger onSelect by select', () => { @@ -1799,6 +1857,8 @@ describe('Select.Basic', () => { }); it('click outside to close select', () => { + jest.useFakeTimers(); + const { container } = render( ); - expect(container.querySelector('.rc-select-selection-item').textContent).toEqual('light'); + expect(container.querySelector('.rc-select-content-value').textContent).toEqual('light'); rerender( @@ -1865,9 +1933,23 @@ describe('Select.Basic', () => { const { container, rerender } = render(renderDemo()); toggleOpen(container); + act(() => { + jest.runAllTimers(); + }); rerender(renderDemo(true)); + act(() => { + jest.runAllTimers(); + }); + rerender(renderDemo(false)); + + act(() => { + jest.runAllTimers(); + }); + expectOpen(container, false); + + jest.useRealTimers(); }); }); @@ -1928,7 +2010,7 @@ describe('Select.Basic', () => { toggleOpen(container); selectItem(container, index); expect(onChange).toHaveBeenCalledWith(value, expect.anything()); - expect(container.querySelector('.rc-select-selection-item').textContent).toEqual(showValue); + expect(container.querySelector('.rc-select-content-value').textContent).toEqual(showValue); }); expect(errorSpy).toHaveBeenCalledWith(warningMessage); @@ -2105,9 +2187,7 @@ describe('Select.Basic', () => { const { container } = render(); - fireEvent.click(container.querySelector('.rc-select-selector')); + fireEvent.click(container.querySelector('.rc-select')); expect(onClick).toHaveBeenCalled(); }); @@ -2196,8 +2276,7 @@ describe('Select.Basic', () => { , ); - expect(container.querySelector('.rc-select-clear-icon')).toBeTruthy(); + expect(container.querySelector('.rc-select-clear')).toBeTruthy(); - const mouseDownEvent = createEvent.mouseDown(container.querySelector('.rc-select-clear-icon')); + const mouseDownEvent = createEvent.mouseDown(container.querySelector('.rc-select-clear')); mouseDownEvent.preventDefault = mouseDownPreventDefault; - fireEvent(container.querySelector('.rc-select-clear-icon'), mouseDownEvent); + fireEvent(container.querySelector('.rc-select-clear'), mouseDownEvent); jest.runAllTimers(); expect(container.querySelector('.rc-select').className).toContain('-focused'); @@ -2253,17 +2332,15 @@ describe('Select.Basic', () => { it('should support title', () => { const { container: container1 } = render(); expect(container2.querySelector('.rc-select').getAttribute('title')).toBeFalsy(); - expect(container2.querySelector('.rc-select-selection-item').getAttribute('title')).toBe(''); + expect(container2.querySelector('.rc-select-content-value').getAttribute('title')).toBe(''); const { container: container3 } = render( ; @@ -2452,7 +2528,7 @@ describe('Select.Basic', () => { open classNames={customClassNames} styles={customStyle} - suffixIcon={
arrow
} + suffix={
arrow
} prefix="Foobar" value={['bamboo']} mode="multiple" @@ -2464,10 +2540,10 @@ describe('Select.Basic', () => { ); const prefix = container.querySelector('.rc-select-prefix'); - const suffix = container.querySelector('.rc-select-arrow'); + const suffix = container.querySelector('.rc-select-suffix'); const item = container.querySelector('.rc-select-item-option'); const list = container.querySelector('.rc-virtual-list'); - const input = container.querySelector('.rc-select-selection-search-input'); + const input = container.querySelector('input'); expect(prefix).toHaveClass(customClassNames.prefix); expect(prefix).toHaveStyle(customStyle.prefix); expect(suffix).toHaveClass(customClassNames.suffix); @@ -2502,7 +2578,7 @@ describe('Select.Basic', () => { open classNames={customClassNames} styles={customStyle} - suffixIcon={
arrow
} + suffix={
arrow
} prefix="Foobar" onDisplayValuesChange={() => {}} searchValue="" @@ -2512,8 +2588,8 @@ describe('Select.Basic', () => { />, ); const prefix = container.querySelector('.rc-select-prefix'); - const suffix = container.querySelector('.rc-select-arrow'); - const input = container.querySelector('.rc-select-selection-search-input'); + const suffix = container.querySelector('.rc-select-suffix'); + const input = container.querySelector('input'); expect(prefix).toHaveClass(customClassNames.prefix); expect(prefix).toHaveStyle(customStyle.prefix); expect(suffix).toHaveClass(customClassNames.suffix); diff --git a/tests/Tags.test.tsx b/tests/Tags.test.tsx index d045146e5..00c47322b 100644 --- a/tests/Tags.test.tsx +++ b/tests/Tags.test.tsx @@ -62,11 +62,10 @@ describe('Select.Tags', () => { it('tokenize input', () => { const handleChange = jest.fn(); const handleSelect = jest.fn(); - const option2 = ; const { container } = render( , ); @@ -79,6 +78,7 @@ describe('Select.Tags', () => { expect(findSelection(container, 1).textContent).toEqual('3'); expect(findSelection(container, 2).textContent).toEqual('4'); expect(container.querySelector('input').value).toBe(''); + expectOpen(container, false); }); @@ -513,9 +513,7 @@ describe('Select.Tags', () => { const { container } = render( - - - Search - - + Search +
+ `; @@ -42,34 +35,27 @@ exports[`Select.Combobox renders correctly 1`] = ` class="rc-select rc-select-single rc-select-show-search" >
- - - - - - Search - - + Search +
+ `; diff --git a/tests/__snapshots__/Multiple.test.tsx.snap b/tests/__snapshots__/Multiple.test.tsx.snap index 24596ec3b..c266dcd51 100644 --- a/tests/__snapshots__/Multiple.test.tsx.snap +++ b/tests/__snapshots__/Multiple.test.tsx.snap @@ -1,403 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Select.Multiple max tag render not display maxTagPlaceholder if maxTagCount not reach 1`] = ` - -`; - -exports[`Select.Multiple max tag render truncates tags by maxTagCount and show maxTagPlaceholder 1`] = ` - -`; - -exports[`Select.Multiple max tag render truncates tags by maxTagCount and show maxTagPlaceholder function 1`] = ` - -`; - exports[`Select.Multiple max tag render truncates values by maxTagTextLength 1`] = ` - +[ + "On...", + "Tw...", +] `; diff --git a/tests/__snapshots__/Select.test.tsx.snap b/tests/__snapshots__/Select.test.tsx.snap index 37e74e55a..76ed88b0a 100644 --- a/tests/__snapshots__/Select.test.tsx.snap +++ b/tests/__snapshots__/Select.test.tsx.snap @@ -106,47 +106,6 @@ exports[`Select.Basic filterOption could be true as described in default value 1 `; -exports[`Select.Basic no search 1`] = ` -
-
- - - - - - 1 - - -
-
-`; - exports[`Select.Basic render renders aria-attributes correctly 1`] = ` - + × + `; @@ -206,48 +151,34 @@ exports[`Select.Basic render renders correctly 1`] = ` class="antd select-test antd-single antd-allow-clear antd-show-search" >
- - - - - - 2 - - + 2 +
+ - + × + `; @@ -258,86 +189,65 @@ exports[`Select.Basic render renders data-attributes correctly 1`] = ` data-test="test-id" >
- - - - - - 2 - - + 2 +
+ - + × + `; exports[`Select.Basic render renders disabled select correctly 1`] = ` `; @@ -350,48 +260,34 @@ exports[`Select.Basic render renders role prop correctly 1`] = ` role="button" >
- - - - - - 2 - - + 2 +
+ - + × + `; diff --git a/tests/__snapshots__/Tags.test.tsx.snap b/tests/__snapshots__/Tags.test.tsx.snap index 49b7ee55f..6c0fa1db4 100644 --- a/tests/__snapshots__/Tags.test.tsx.snap +++ b/tests/__snapshots__/Tags.test.tsx.snap @@ -6,219 +6,193 @@ exports[`Select.Tags OptGroup renders correctly 1`] = ` class="rc-select rc-select-multiple rc-select-open rc-select-show-search" >
- -
-
- - - Jack - - - -
-
+
-
+ +
+
+ + - -
-
-
+ foo + + + +
+
+ +
-
-
+
+
+
+
+