diff --git a/package.json b/package.json index 7a6f743a1..b0373ab2c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "rc-motion": "^2.0.1", "rc-overflow": "^1.0.0", "rc-trigger": "^5.0.4", - "rc-util": "^5.9.8", + "rc-util": "^5.16.1", "rc-virtual-list": "^3.2.0" }, "devDependencies": { diff --git a/src/BaseSelect.tsx b/src/BaseSelect.tsx new file mode 100644 index 000000000..3f1dd1542 --- /dev/null +++ b/src/BaseSelect.tsx @@ -0,0 +1,809 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import KeyCode from 'rc-util/lib/KeyCode'; +import isMobile from 'rc-util/lib/isMobile'; +import { useComposeRef } from 'rc-util/lib/ref'; +import type { ScrollTo } from 'rc-virtual-list/lib/List'; +import pickAttrs from 'rc-util/lib/pickAttrs'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; +import { getSeparatedContent } from './utils/valueUtil'; +import type { RefTriggerProps } from './SelectTrigger'; +import SelectTrigger from './SelectTrigger'; +import type { RefSelectorProps } from './Selector'; +import Selector from './Selector'; +import useSelectTriggerControl from './hooks/useSelectTriggerControl'; +import useDelayReset from './hooks/useDelayReset'; +import TransBtn from './TransBtn'; +import useLock from './hooks/useLock'; +import { BaseSelectContext } from './hooks/useBaseProps'; + +const DEFAULT_OMIT_PROPS = [ + 'value', + 'onChange', + 'onSelect', + 'placeholder', + 'autoFocus', + 'onInputKeyDown', + 'tabIndex', +]; + +export type RenderNode = React.ReactNode | ((props: any) => React.ReactNode); + +export type RenderDOMFunc = (props: any) => HTMLElement; + +export type Mode = 'multiple' | 'tags' | 'combobox'; + +export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + +export type RawValueType = string | number; + +export interface RefOptionListProps { + onKeyDown: React.KeyboardEventHandler; + onKeyUp: React.KeyboardEventHandler; + scrollTo?: (index: number) => void; +} + +export type CustomTagProps = { + label: React.ReactNode; + value: any; + disabled: boolean; + onClose: (event?: React.MouseEvent) => void; + closable: boolean; +}; + +export interface DisplayValueType { + key?: React.Key; + value?: RawValueType; + label?: React.ReactNode; + disabled?: boolean; +} + +export interface BaseSelectRef { + focus: () => void; + blur: () => void; + scrollTo: ScrollTo; +} + +export interface BaseSelectPrivateProps { + // >>> MISC + id: string; + prefixCls: string; + + // >>> Value + displayValues: DisplayValueType[]; + onDisplayValuesChange: ( + values: DisplayValueType[], + info: { + type: 'add' | 'remove'; + values: DisplayValueType[]; + }, + ) => void; + + // >>> Active + /** Current dropdown list active item string value */ + activeValue?: string; + /** Link search input with target element */ + activeDescendantId?: string; + onActiveValueChange?: (value: string | null) => void; + + // >>> Search + searchValue: string; + /** Trigger onSearch, return false to prevent trigger open event */ + onSearch: ( + searchValue: string, + info: { + source: + | 'typing' //User typing + | 'effect' // Code logic trigger + | 'submit' // tag mode only + | 'blur'; // Not trigger event + }, + ) => void; + /** Trigger when search text match the `tokenSeparators`. Will provide split content */ + onSearchSplit: (words: string[]) => void; + maxLength?: number; + + // >>> Dropdown + OptionList: React.ForwardRefExoticComponent< + React.PropsWithoutRef & React.RefAttributes + >; + /** Tell if provided `options` is empty */ + emptyOptions: boolean; +} + +export type BaseSelectPropsWithoutPrivate = Omit; + +export interface BaseSelectProps extends BaseSelectPrivateProps, React.AriaAttributes { + className?: string; + style?: React.CSSProperties; + showSearch?: boolean; + tagRender?: (props: CustomTagProps) => React.ReactElement; + direction?: 'ltr' | 'rtl'; + + // MISC + tabIndex?: number; + autoFocus?: boolean; + notFoundContent?: React.ReactNode; + placeholder?: React.ReactNode; + onClear?: () => void; + + // >>> Mode + mode?: Mode; + + // >>> Status + disabled?: boolean; + loading?: boolean; + + // >>> Open + open?: boolean; + defaultOpen?: boolean; + onDropdownVisibleChange?: (open: boolean) => void; + + // >>> Customize Input + /** @private Internal usage. Do not use in your production. */ + getInputElement?: () => JSX.Element; + /** @private Internal usage. Do not use in your production. */ + getRawInputElement?: () => JSX.Element; + + // >>> Selector + maxTagTextLength?: number; + maxTagCount?: number | 'responsive'; + maxTagPlaceholder?: React.ReactNode | ((omittedValues: DisplayValueType[]) => React.ReactNode); + + // >>> Search + tokenSeparators?: string[]; + + // >>> Icons + allowClear?: boolean; + showArrow?: boolean; + inputIcon?: RenderNode; + clearIcon?: RenderNode; + + // >>> Dropdown + animation?: string; + transitionName?: string; + dropdownStyle?: React.CSSProperties; + dropdownClassName?: string; + dropdownMatchSelectWidth?: boolean | number; + dropdownRender?: (menu: React.ReactElement) => React.ReactElement; + dropdownAlign?: any; + placement?: Placement; + getPopupContainer?: RenderDOMFunc; + + // >>> Focus + showAction?: ('focus' | 'click')[]; + onBlur?: React.FocusEventHandler; + onFocus?: React.FocusEventHandler; + + // >>> Rest Events + onKeyUp?: React.KeyboardEventHandler; + onKeyDown?: React.KeyboardEventHandler; + onMouseDown?: React.MouseEventHandler; + onPopupScroll?: React.UIEventHandler; +} + +export function isMultiple(mode: Mode) { + return mode === 'tags' || mode === 'multiple'; +} + +const BaseSelect = React.forwardRef((props: BaseSelectProps, ref: React.Ref) => { + const { + id, + prefixCls, + className, + showSearch, + tagRender, + direction, + + // Value + displayValues, + onDisplayValuesChange, + emptyOptions, + notFoundContent = 'Not Found', + onClear, + + // Mode + mode, + + // Status + disabled, + loading, + + // Customize Input + getInputElement, + getRawInputElement, + + // Open + open, + defaultOpen, + onDropdownVisibleChange, + + // Active + activeValue, + onActiveValueChange, + activeDescendantId, + + // Search + searchValue, + onSearch, + onSearchSplit, + tokenSeparators, + + // Icons + allowClear, + showArrow, + inputIcon, + clearIcon, + + // Dropdown + OptionList, + animation, + transitionName, + dropdownStyle, + dropdownClassName, + dropdownMatchSelectWidth, + dropdownRender, + dropdownAlign, + placement, + getPopupContainer, + + // Focus + showAction = [], + onFocus, + onBlur, + + // Rest Events + onKeyUp, + onKeyDown, + onMouseDown, + + // Rest Props + ...restProps + } = props; + + // ============================== MISC ============================== + const multiple = isMultiple(mode); + const mergedShowSearch = + (showSearch !== undefined ? showSearch : multiple) || mode === 'combobox'; + + const domProps = pickAttrs(restProps, { + aria: true, + attr: true, + data: true, + }); + DEFAULT_OMIT_PROPS.forEach((prop) => { + delete domProps[prop]; + }); + + // ============================= Mobile ============================= + const [mobile, setMobile] = React.useState(false); + React.useEffect(() => { + // Only update on the client side + setMobile(isMobile()); + }, []); + + // ============================== Refs ============================== + const containerRef = React.useRef(null); + const selectorDomRef = React.useRef(null); + const triggerRef = React.useRef(null); + const selectorRef = React.useRef(null); + const listRef = React.useRef(null); + + /** Used for component focused management */ + const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); + + // =========================== Imperative =========================== + React.useImperativeHandle(ref, () => ({ + focus: selectorRef.current?.focus, + blur: selectorRef.current?.blur, + scrollTo: (arg) => listRef.current?.scrollTo(arg as any), + })); + + // ========================== Search Value ========================== + const mergedSearchValue = React.useMemo(() => { + if (mode !== 'combobox') { + return searchValue; + } + + const val = displayValues[0]?.value; + + return typeof val === 'string' || typeof val === 'number' ? String(val) : ''; + }, [searchValue, mode, displayValues]); + + // ========================== Custom Input ========================== + // Only works in `combobox` + const customizeInputElement: React.ReactElement = + (mode === 'combobox' && typeof getInputElement === 'function' && getInputElement()) || null; + + // Used for customize replacement for `rc-cascader` + const customizeRawInputElement: React.ReactElement = + typeof getRawInputElement === 'function' && getRawInputElement(); + + const customizeRawInputRef = useComposeRef( + selectorDomRef, + customizeRawInputElement?.props?.ref, + ); + + // ============================== Open ============================== + const [innerOpen, setInnerOpen] = useMergedState(undefined, { + defaultValue: defaultOpen, + value: open, + }); + + let mergedOpen = innerOpen; + + // Not trigger `open` in `combobox` when `notFoundContent` is empty + const emptyListContent = !notFoundContent && emptyOptions; + if (disabled || (emptyListContent && mergedOpen && mode === 'combobox')) { + mergedOpen = false; + } + const triggerOpen = emptyListContent ? false : mergedOpen; + + const onToggleOpen = React.useCallback( + (newOpen?: boolean) => { + const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen; + + if (mergedOpen !== nextOpen && !disabled) { + setInnerOpen(nextOpen); + onDropdownVisibleChange?.(nextOpen); + } + }, + [disabled, mergedOpen, setInnerOpen, onDropdownVisibleChange], + ); + + // ============================= Search ============================= + const tokenWithEnter = React.useMemo( + () => (tokenSeparators || []).some((tokenSeparator) => ['\n', '\r\n'].includes(tokenSeparator)), + [tokenSeparators], + ); + + const onInternalSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { + let ret = true; + let newSearchText = searchText; + onActiveValueChange?.(null); + + // Check if match the `tokenSeparators` + const patchLabels: string[] = isCompositing + ? null + : getSeparatedContent(searchText, tokenSeparators); + + // Ignore combobox since it's not split-able + if (mode !== 'combobox' && patchLabels) { + newSearchText = ''; + + onSearchSplit(patchLabels); + + // Should close when paste finish + onToggleOpen(false); + + // Tell Selector that break next actions + ret = false; + } + + if (onSearch && mergedSearchValue !== newSearchText) { + onSearch(newSearchText, { + source: fromTyping ? 'typing' : 'effect', + }); + } + + return ret; + }; + + // Only triggered when menu is closed & mode is tags + // If menu is open, OptionList will take charge + // If mode isn't tags, press enter is not meaningful when you can't see any option + const onInternalSearchSubmit = (searchText: string) => { + // prevent empty tags from appearing when you click the Enter button + if (!searchText || !searchText.trim()) { + return; + } + onSearch(searchText, { source: 'submit' }); + }; + + // Close will clean up single mode search text + React.useEffect(() => { + if (!mergedOpen && !multiple && mode !== 'combobox') { + onInternalSearch('', false, false); + } + }, [mergedOpen]); + + // ============================ Disabled ============================ + // Close dropdown & remove focus state when disabled change + React.useEffect(() => { + if (innerOpen && disabled) { + setInnerOpen(false); + } + + if (disabled) { + setMockFocused(false); + } + }, [disabled]); + + // ============================ Keyboard ============================ + /** + * We record input value here to check if can press to clean up by backspace + * - null: Key is not down, this is reset by key up + * - true: Search text is empty when first time backspace down + * - false: Search text is not empty when first time backspace down + */ + const [getClearLock, setClearLock] = useLock(); + + // KeyDown + const onInternalKeyDown: React.KeyboardEventHandler = (event, ...rest) => { + const clearLock = getClearLock(); + const { which } = event; + + if (which === KeyCode.ENTER) { + // Do not submit form when type in the input + if (mode !== 'combobox') { + event.preventDefault(); + } + + // We only manage open state here, close logic should handle by list component + if (!mergedOpen) { + onToggleOpen(true); + } + } + + setClearLock(!!mergedSearchValue); + + // Remove value by `backspace` + if ( + which === KeyCode.BACKSPACE && + !clearLock && + multiple && + !mergedSearchValue && + displayValues.length + ) { + const cloneDisplayValues = [...displayValues]; + let removedDisplayValue = null; + + for (let i = cloneDisplayValues.length - 1; i >= 0; i -= 1) { + const current = cloneDisplayValues[i]; + + if (!current.disabled) { + cloneDisplayValues.splice(i, 1); + removedDisplayValue = current; + break; + } + } + + if (removedDisplayValue) { + onDisplayValuesChange(cloneDisplayValues, { + type: 'remove', + values: [removedDisplayValue], + }); + } + } + + if (mergedOpen && listRef.current) { + listRef.current.onKeyDown(event, ...rest); + } + + onKeyDown?.(event, ...rest); + }; + + // KeyUp + const onInternalKeyUp: React.KeyboardEventHandler = (event, ...rest) => { + if (mergedOpen && listRef.current) { + listRef.current.onKeyUp(event, ...rest); + } + + onKeyUp?.(event, ...rest); + }; + + // ============================ Selector ============================ + const onSelectorRemove = (val: DisplayValueType) => { + const newValues = displayValues.filter((i) => i !== val); + + onDisplayValuesChange(newValues, { + type: 'remove', + values: [val], + }); + }; + + // ========================== Focus / Blur ========================== + /** Record real focus status */ + const focusRef = React.useRef(false); + + const onContainerFocus: React.FocusEventHandler = (...args) => { + setMockFocused(true); + + if (!disabled) { + if (onFocus && !focusRef.current) { + onFocus(...args); + } + + // `showAction` should handle `focus` if set + if (showAction.includes('focus')) { + onToggleOpen(true); + } + } + + focusRef.current = true; + }; + + const onContainerBlur: React.FocusEventHandler = (...args) => { + setMockFocused(false, () => { + focusRef.current = false; + onToggleOpen(false); + }); + + if (disabled) { + return; + } + + if (mergedSearchValue) { + // `tags` mode should move `searchValue` into values + if (mode === 'tags') { + onSearch(mergedSearchValue, { source: 'submit' }); + } else if (mode === 'multiple') { + // `multiple` mode only clean the search value but not trigger event + onSearch('', { + source: 'blur', + }); + } + } + + if (onBlur) { + onBlur(...args); + } + }; + + // Give focus back of Select + const activeTimeoutIds: any[] = []; + React.useEffect( + () => () => { + activeTimeoutIds.forEach((timeoutId) => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }, + [], + ); + + const onInternalMouseDown: React.MouseEventHandler = (event, ...restArgs) => { + const { target } = event; + const popupElement: HTMLDivElement = triggerRef.current?.getPopupElement(); + + // We should give focus back to selector if clicked item is not focusable + if (popupElement && popupElement.contains(target as HTMLElement)) { + const timeoutId = setTimeout(() => { + const index = activeTimeoutIds.indexOf(timeoutId); + if (index !== -1) { + activeTimeoutIds.splice(index, 1); + } + + cancelSetMockFocused(); + + if (!mobile && !popupElement.contains(document.activeElement)) { + selectorRef.current?.focus(); + } + }); + + activeTimeoutIds.push(timeoutId); + } + + onMouseDown?.(event, ...restArgs); + }; + + // ============================ Dropdown ============================ + const [containerWidth, setContainerWidth] = React.useState(null); + + const [, forceUpdate] = React.useState({}); + // We need force update here since popup dom is render async + function onPopupMouseEnter() { + forceUpdate({}); + } + + useLayoutEffect(() => { + if (triggerOpen) { + const newWidth = Math.ceil(containerRef.current?.offsetWidth); + if (containerWidth !== newWidth && !Number.isNaN(newWidth)) { + setContainerWidth(newWidth); + } + } + }, [triggerOpen]); + + // Used for raw custom input trigger + let onTriggerVisibleChange: null | ((newOpen: boolean) => void); + if (customizeRawInputElement) { + onTriggerVisibleChange = (newOpen: boolean) => { + onToggleOpen(newOpen); + }; + } + + // Close when click on non-select element + useSelectTriggerControl( + () => [containerRef.current, triggerRef.current?.getPopupElement()], + triggerOpen, + onToggleOpen, + ); + + // ============================ Context ============================= + const baseSelectContext = React.useMemo( + () => ({ + ...props, + notFoundContent, + open: mergedOpen, + triggerOpen, + id, + showSearch: mergedShowSearch, + multiple, + toggleOpen: onToggleOpen, + }), + [props, notFoundContent, triggerOpen, mergedOpen, id, mergedShowSearch, multiple, onToggleOpen], + ); + + // ================================================================== + // == Render == + // ================================================================== + + // ============================= Arrow ============================== + const mergedShowArrow = + showArrow !== undefined ? showArrow : loading || (!multiple && mode !== 'combobox'); + let arrowNode: React.ReactNode; + + if (mergedShowArrow) { + arrowNode = ( + + ); + } + + // ============================= Clear ============================== + let clearNode: React.ReactNode; + const onClearMouseDown: React.MouseEventHandler = () => { + onClear?.(); + + onDisplayValuesChange([], { + type: 'remove', + values: displayValues, + }); + onInternalSearch('', false, false); + }; + + if (!disabled && allowClear && (displayValues.length || mergedSearchValue)) { + clearNode = ( + + × + + ); + } + + // =========================== OptionList =========================== + const optionList = ; + + // ============================= Select ============================= + const mergedClassName = classNames(prefixCls, className, { + [`${prefixCls}-focused`]: mockFocused, + [`${prefixCls}-multiple`]: multiple, + [`${prefixCls}-single`]: !multiple, + [`${prefixCls}-allow-clear`]: allowClear, + [`${prefixCls}-show-arrow`]: mergedShowArrow, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-loading`]: loading, + [`${prefixCls}-open`]: mergedOpen, + [`${prefixCls}-customize-input`]: customizeInputElement, + [`${prefixCls}-show-search`]: mergedShowSearch, + }); + + // >>> Selector + const selectorNode = ( + selectorDomRef.current} + onPopupVisibleChange={onTriggerVisibleChange} + onPopupMouseEnter={onPopupMouseEnter} + > + {customizeRawInputElement ? ( + React.cloneElement(customizeRawInputElement, { + ref: customizeRawInputRef, + }) + ) : ( + + )} + + ); + + // >>> Render + let renderNode: React.ReactNode; + + // Render raw + if (customizeRawInputElement) { + renderNode = selectorNode; + } else { + renderNode = ( +
+ {mockFocused && !mergedOpen && ( + + {/* Merge into one string to make screen reader work as expect */} + {`${displayValues + .map(({ label, value }) => + ['number', 'string'].includes(typeof label) ? label : value, + ) + .join(', ')}`} + + )} + {selectorNode} + + {arrowNode} + {clearNode} +
+ ); + } + + return ( + {renderNode} + ); +}); + +export default BaseSelect; diff --git a/src/OptGroup.tsx b/src/OptGroup.tsx index 4b43ec55a..7f6998be7 100644 --- a/src/OptGroup.tsx +++ b/src/OptGroup.tsx @@ -1,8 +1,8 @@ /* istanbul ignore file */ import type * as React from 'react'; -import type { OptionGroupData } from './interface'; +import type { DefaultOptionType } from './Select'; -export interface OptGroupProps extends Omit { +export interface OptGroupProps extends Omit { children?: React.ReactNode; } diff --git a/src/Option.tsx b/src/Option.tsx index 383f6fd17..58a18c154 100644 --- a/src/Option.tsx +++ b/src/Option.tsx @@ -1,8 +1,8 @@ /* istanbul ignore file */ import type * as React from 'react'; -import type { OptionCoreData } from './interface'; +import type { DefaultOptionType } from './Select'; -export interface OptionProps extends Omit { +export interface OptionProps extends Omit { children: React.ReactNode; /** Save for customize data */ diff --git a/src/OptionList.tsx b/src/OptionList.tsx index 14aa26dcd..401b5c4cf 100644 --- a/src/OptionList.tsx +++ b/src/OptionList.tsx @@ -8,46 +8,14 @@ import classNames from 'classnames'; import type { ListRef } from 'rc-virtual-list'; import List from 'rc-virtual-list'; import TransBtn from './TransBtn'; -import type { - OptionsType as SelectOptionsType, - FlattenOptionData as SelectFlattenOptionData, - OptionData, - RenderNode, - OnActiveValue, - FieldNames, -} from './interface'; -import type { RawValueType, FlattenOptionsType } from './interface/generator'; -import { fillFieldNames } from './utils/valueUtil'; import { isPlatformMac } from './utils/platformUtil'; +import useBaseProps from './hooks/useBaseProps'; +import SelectContext from './SelectContext'; +import type { BaseOptionType, RawValueType } from './Select'; +import type { FlattenOptionData } from './interface'; -export interface OptionListProps { - prefixCls: string; - id: string; - options: OptionsType; - fieldNames?: FieldNames; - flattenOptions: FlattenOptionsType; - height: number; - itemHeight: number; - values: Set; - multiple: boolean; - open: boolean; - defaultActiveFirstOption?: boolean; - notFoundContent?: React.ReactNode; - menuItemSelectedIcon?: RenderNode; - childrenAsData: boolean; - searchValue: string; - virtual: boolean; - direction?: 'ltr' | 'rtl'; - - onSelect: (value: RawValueType, option: { selected: boolean }) => void; - onToggleOpen: (open?: boolean) => void; - /** Tell Select that some value is now active to make accessibility work */ - onActiveValue: OnActiveValue; - onScroll: React.UIEventHandler; - - /** Tell Select that mouse enter the popup to force re-render */ - onMouseEnter?: React.MouseEventHandler; -} +// export interface OptionListProps { +export type OptionListProps = Record; export interface RefOptionListProps { onKeyDown: React.KeyboardEventHandler; @@ -59,34 +27,26 @@ export interface RefOptionListProps { * Using virtual list of option display. * Will fallback to dom if use customize render. */ -const OptionList: React.ForwardRefRenderFunction< - RefOptionListProps, - OptionListProps -> = ( - { - prefixCls, - id, - fieldNames, +const OptionList: React.ForwardRefRenderFunction = ( + _, + ref, +) => { + const { prefixCls, id, open, multiple, searchValue, toggleOpen, notFoundContent, onPopupScroll } = + useBaseProps(); + const { flattenOptions, - childrenAsData, - values, - searchValue, - multiple, + onActiveValue, defaultActiveFirstOption, - height, - itemHeight, - notFoundContent, - open, + onSelect, menuItemSelectedIcon, + rawValues, + fieldNames, virtual, - onSelect, - onToggleOpen, - onActiveValue, - onScroll, - onMouseEnter, - }, - ref, -) => { + listHeight, + listItemHeight, + optionLabelProp, + } = React.useContext(SelectContext); + const itemPrefixCls = `${prefixCls}-item`; const memoFlattenOptions = useMemo( @@ -116,7 +76,7 @@ const OptionList: React.ForwardRefRenderFunction< const current = (index + i * offset + len) % len; const { group, data } = memoFlattenOptions[current]; - if (!group && !(data as OptionData).disabled) { + if (!group && !data.disabled) { return current; } } @@ -137,7 +97,7 @@ const OptionList: React.ForwardRefRenderFunction< return; } - onActiveValue((flattenItem.data as OptionData).value, index, info); + onActiveValue(flattenItem.data.value, index, info); }; // Auto active first item when list length or searchValue changed @@ -153,11 +113,9 @@ const OptionList: React.ForwardRefRenderFunction< * So we need to delay to let Input component trigger onChange first. */ const timeoutId = setTimeout(() => { - if (!multiple && open && values.size === 1) { - const value: RawValueType = Array.from(values)[0]; - const index = memoFlattenOptions.findIndex( - ({ data }) => (data as OptionData).value === value, - ); + if (!multiple && open && rawValues.size === 1) { + const value: RawValueType = Array.from(rawValues)[0]; + const index = memoFlattenOptions.findIndex(({ data }) => data.value === value); if (index !== -1) { setActive(index); @@ -177,12 +135,12 @@ const OptionList: React.ForwardRefRenderFunction< // ========================== Values ========================== const onSelectValue = (value: RawValueType) => { if (value !== undefined) { - onSelect(value, { selected: !values.has(value) }); + onSelect(value, { selected: !rawValues.has(value) }); } // Single mode should always close by select if (!multiple) { - onToggleOpen(false); + toggleOpen(false); } }; @@ -222,8 +180,8 @@ const OptionList: React.ForwardRefRenderFunction< case KeyCode.ENTER: { // value const item = memoFlattenOptions[activeIndex]; - if (item && !(item.data as OptionData).disabled) { - onSelectValue((item.data as OptionData).value); + if (item && !item.data.disabled) { + onSelectValue(item.data.value); } else { onSelectValue(undefined); } @@ -237,7 +195,7 @@ const OptionList: React.ForwardRefRenderFunction< // >>> Close case KeyCode.ESC: { - onToggleOpen(false); + toggleOpen(false); if (open) { event.stopPropagation(); } @@ -265,25 +223,33 @@ const OptionList: React.ForwardRefRenderFunction< ); } - const filledFieldNames = fillFieldNames(fieldNames); - const omitFieldNameList = Object.keys(filledFieldNames).map((key) => filledFieldNames[key]); + const omitFieldNameList = Object.keys(fieldNames).map((key) => fieldNames[key]); + + const getLabel = (item: Record) => { + if (optionLabelProp) { + return item.data[optionLabelProp]; + } + + return item.label; + }; const renderItem = (index: number) => { const item = memoFlattenOptions[index]; if (!item) return null; - const itemData = (item.data || {}) as OptionData; - const { value, label, children } = itemData; + const itemData = item.data || {}; + const { value } = itemData; + const { group } = item; const attrs = pickAttrs(itemData, true); - const mergedLabel = childrenAsData ? children : label; + const mergedLabel = getLabel(item); return item ? (
{value}
@@ -297,19 +263,19 @@ const OptionList: React.ForwardRefRenderFunction< {renderItem(activeIndex)} {renderItem(activeIndex + 1)} - + > itemKey="key" ref={listRef} data={memoFlattenOptions} - height={height} - itemHeight={itemHeight} + height={listHeight} + itemHeight={listItemHeight} fullHeight={false} onMouseDown={onListMouseDown} - onScroll={onScroll} + onScroll={onPopupScroll} virtual={virtual} - onMouseEnter={onMouseEnter} > - {({ group, groupOption, data, label, value }, itemIndex) => { + {(item, itemIndex) => { + const { group, groupOption, data, label, value } = item; const { key } = data; // Group @@ -321,11 +287,11 @@ const OptionList: React.ForwardRefRenderFunction< ); } - const { disabled, title, children, style, className, ...otherProps } = data as OptionData; + const { disabled, title, children, style, className, ...otherProps } = data; const passedProps = omit(otherProps, omitFieldNameList); // Option - const selected = values.has(value); + const selected = rawValues.has(value); const optionPrefixCls = `${itemPrefixCls}-option`; const optionClassName = classNames(itemPrefixCls, optionPrefixCls, className, { @@ -335,7 +301,7 @@ const OptionList: React.ForwardRefRenderFunction< [`${optionPrefixCls}-selected`]: selected, }); - const mergedLabel = childrenAsData ? children : label; + const mergedLabel = getLabel(item); const iconVisible = !menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected; @@ -388,9 +354,7 @@ const OptionList: React.ForwardRefRenderFunction< ); }; -const RefOptionList = React.forwardRef>( - OptionList, -); +const RefOptionList = React.forwardRef(OptionList); RefOptionList.displayName = 'OptionList'; export default RefOptionList; diff --git a/src/Select.tsx b/src/Select.tsx index 623322dce..df1ef146e 100644 --- a/src/Select.tsx +++ b/src/Select.tsx @@ -30,65 +30,625 @@ */ import * as React from 'react'; -import type { OptionsType as SelectOptionsType } from './interface'; -import SelectOptionList from './OptionList'; +import warning from 'rc-util/lib/warning'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import BaseSelect, { isMultiple } from './BaseSelect'; +import type { DisplayValueType, RenderNode } from './BaseSelect'; +import OptionList from './OptionList'; import Option from './Option'; import OptGroup from './OptGroup'; -import { convertChildrenToData as convertSelectChildrenToData } from './utils/legacyUtil'; -import { - getLabeledValue as getSelectLabeledValue, - filterOptions as selectDefaultFilterOptions, - isValueDisabled as isSelectValueDisabled, - findValueOption as findSelectValueOption, - flattenOptions, - fillOptionsWithMissingValue, -} from './utils/valueUtil'; -import type { SelectProps, RefSelectProps } from './generate'; -import generateSelector from './generate'; -import type { DefaultValueType } from './interface/generator'; +import type { BaseSelectRef, BaseSelectPropsWithoutPrivate, BaseSelectProps } from './BaseSelect'; +import useOptions from './hooks/useOptions'; +import SelectContext from './SelectContext'; +import useId from './hooks/useId'; +import useRefFunc from './hooks/useRefFunc'; +import { fillFieldNames, flattenOptions, injectPropsWithOption } from './utils/valueUtil'; import warningProps from './utils/warningPropsUtil'; +import { toArray } from './utils/commonUtil'; +import useFilterOptions from './hooks/useFilterOptions'; +import useCache from './hooks/useCache'; -const RefSelect = generateSelector({ - prefixCls: 'rc-select', - components: { - optionList: SelectOptionList, - }, - convertChildrenToData: convertSelectChildrenToData, - flattenOptions, - getLabeledValue: getSelectLabeledValue, - filterOptions: selectDefaultFilterOptions, - isValueDisabled: isSelectValueDisabled, - findValueOption: findSelectValueOption, - warningProps, - fillOptionsWithMissingValue, -}); - -export type ExportedSelectProps< - ValueType extends DefaultValueType = DefaultValueType -> = SelectProps; +export type OnActiveValue = ( + active: RawValueType, + index: number, + info?: { source?: 'keyboard' | 'mouse' }, +) => void; -/** - * Typescript not support generic with function component, - * we have to wrap an class component to handle this. - */ -class Select extends React.Component> { - static Option: typeof Option = Option; +export type OnInternalSelect = (value: RawValueType, info: { selected: boolean }) => void; + +export type RawValueType = string | number; +export interface LabelInValueType { + label: React.ReactNode; + value: RawValueType; + /** @deprecated `key` is useless since it should always same as `value` */ + key?: React.Key; +} + +export type DraftValueType = + | RawValueType + | LabelInValueType + | DisplayValueType + | (RawValueType | LabelInValueType | DisplayValueType)[]; + +export type FilterFunc = (inputValue: string, option?: OptionType) => boolean; + +export interface FieldNames { + value?: string; + label?: string; + options?: string; +} + +export interface BaseOptionType { + disabled?: boolean; + [name: string]: any; +} + +export interface DefaultOptionType extends BaseOptionType { + label: React.ReactNode; + value?: string | number | null; + children?: Omit[]; +} + +export interface SharedSelectProps + extends BaseSelectPropsWithoutPrivate { + prefixCls?: string; + id?: string; + + backfill?: boolean; + + // >>> Field Names + fieldNames?: FieldNames; + + // >>> Search + /** @deprecated Use `searchValue` instead */ + inputValue?: string; + searchValue?: string; + onSearch?: (value: string) => void; + autoClearSearchValue?: boolean; + + // >>> Select + onSelect?: (value: RawValueType | LabelInValueType, option: OptionType) => void; + onDeselect?: (value: RawValueType | LabelInValueType, option: OptionType) => void; - static OptGroup: typeof OptGroup = OptGroup; + // >>> Options + /** + * In Select, `false` means do nothing. + * In TreeSelect, `false` will highlight match item. + * It's by design. + */ + filterOption?: boolean | FilterFunc; + filterSort?: (optionA: OptionType, optionB: OptionType) => number; + optionFilterProp?: string; + optionLabelProp?: string; + children?: React.ReactNode; + options?: OptionType[]; + defaultActiveFirstOption?: boolean; + virtual?: boolean; + listHeight?: number; + listItemHeight?: number; - selectRef = React.createRef(); + // >>> Icon + menuItemSelectedIcon?: RenderNode; +} + +export interface SingleRawSelectProps + extends SharedSelectProps { + mode?: 'combobox'; + labelInValue?: false; + value?: RawValueType | null; + defaultValue?: RawValueType | null; + onChange?: (value: RawValueType, option: OptionType) => void; +} - focus = () => { - this.selectRef.current.focus(); - }; +export interface SingleLabeledSelectProps + extends SharedSelectProps { + mode?: 'combobox'; + labelInValue: true; + value?: LabelInValueType | null; + defaultValue?: LabelInValueType | null; + onChange?: (value: LabelInValueType, option: OptionType) => void; +} - blur = () => { - this.selectRef.current.blur(); - }; +export interface MultipleRawSelectProps + extends SharedSelectProps { + mode: 'multiple' | 'tags'; + labelInValue?: false; + value?: RawValueType[] | null; + defaultValue?: RawValueType[] | null; + onChange?: (value: RawValueType[], option: OptionType[]) => void; +} - render() { - return ; - } +export interface MultipleLabeledSelectProps + extends SharedSelectProps { + mode: 'multiple' | 'tags'; + labelInValue: true; + value?: LabelInValueType[] | null; + defaultValue?: LabelInValueType[] | null; + onChange?: (value: LabelInValueType[], option: OptionType[]) => void; } -export default Select; +// TODO: Types test +export type SelectProps = Omit< + | SingleRawSelectProps + | SingleLabeledSelectProps + | MultipleRawSelectProps + | MultipleLabeledSelectProps, + 'onChange' +> & { + onChange?: (value: DraftValueType, option: OptionType | OptionType[]) => void; +}; + +function isRawValue(value: DraftValueType): value is RawValueType { + return !value || typeof value !== 'object'; +} + +const Select = React.forwardRef( + (props: SelectProps, ref: React.Ref) => { + const { + id, + mode, + prefixCls = 'rc-select', + backfill, + fieldNames, + + // Search + inputValue, + searchValue, + onSearch, + autoClearSearchValue = true, + + // Select + onSelect, + onDeselect, + + // Options + filterOption, + filterSort, + optionFilterProp, + optionLabelProp, + options, + children, + defaultActiveFirstOption, + menuItemSelectedIcon, + virtual, + listHeight = 200, + listItemHeight = 20, + + // Value + value, + defaultValue, + labelInValue, + onChange, + } = props; + + const mergedId = useId(id); + const multiple = isMultiple(mode); + const childrenAsData = !!(!options && children); + + // ========================= FieldNames ========================= + const mergedFieldNames = React.useMemo( + () => fillFieldNames(fieldNames, childrenAsData), + /* eslint-disable react-hooks/exhaustive-deps */ + [ + // We stringify fieldNames to avoid unnecessary re-renders. + JSON.stringify(fieldNames), + childrenAsData, + ], + /* eslint-enable react-hooks/exhaustive-deps */ + ); + + // =========================== Search =========================== + const [mergedSearchValue, setSearchValue] = useMergedState('', { + value: searchValue !== undefined ? searchValue : inputValue, + postState: (search) => search || '', + }); + + // =========================== Option =========================== + const parsedOptions = useOptions(options, children, mergedFieldNames); + const { valueOptions, labelOptions, options: mergedOptions } = parsedOptions; + + // ========================= Wrap Value ========================= + const convert2LabelValues = React.useCallback( + (draftValues: DraftValueType) => { + // Convert to array + const valueList = toArray(draftValues); + + // Convert to labelInValue type + return valueList.map((val) => { + let rawValue: RawValueType; + let rawLabel: React.ReactNode; + let rawKey: React.Key; + + // Fill label & value + if (isRawValue(val)) { + rawValue = val; + } else { + rawKey = val.key; + rawLabel = val.label; + rawValue = val.value ?? rawKey; + } + + // If label is not provided, fill it + if (rawLabel === undefined || rawKey === undefined) { + const option = valueOptions.get(rawValue); + if (rawLabel === undefined) rawLabel = option?.[mergedFieldNames.label]; + if (rawKey === undefined) rawKey = option?.key ?? rawValue; + } + + // Warning if label not same as provided + if (process.env.NODE_ENV !== 'production' && !isRawValue(val)) { + const optionLabel = valueOptions.get(rawValue)?.[mergedFieldNames.label]; + if (optionLabel !== undefined && optionLabel !== rawLabel) { + warning(false, '`label` of `value` is not same as `label` in Select options.'); + } + } + + return { + label: rawLabel, + value: rawValue, + key: rawKey, + }; + }); + }, + [mergedFieldNames, valueOptions], + ); + + // =========================== Values =========================== + const [internalValue, setInternalValue] = useMergedState(defaultValue, { + value, + }); + + // Merged value with LabelValueType + const rawLabeledValues = React.useMemo(() => { + const values = convert2LabelValues(internalValue); + + // combobox no need save value when it's empty + if (mode === 'combobox' && !values[0]?.value) { + return []; + } + + return values; + }, [internalValue, convert2LabelValues, mode]); + + // Fill label with cache to avoid option remove + const [mergedValues, getMixedOption] = useCache(rawLabeledValues, valueOptions); + + const displayValues = React.useMemo(() => { + // `null` need show as placeholder instead + // https://github.com/ant-design/ant-design/issues/25057 + if (!mode && mergedValues.length === 1) { + const firstValue = mergedValues[0]; + if ( + firstValue.value === null && + (firstValue.label === null || firstValue.label === undefined) + ) { + return []; + } + } + + return mergedValues.map((item) => ({ + ...item, + label: item.label ?? item.value, + })); + }, [mode, mergedValues]); + + /** Convert `displayValues` to raw value type set */ + const rawValues = React.useMemo( + () => new Set(mergedValues.map((val) => val.value)), + [mergedValues], + ); + + React.useEffect(() => { + if (mode === 'combobox') { + const strValue = mergedValues[0]?.value; + + if (strValue !== undefined && strValue !== null) { + setSearchValue(String(strValue)); + } + } + }, [mergedValues]); + + // ======================= Display Option ======================= + // Create a placeholder item if not exist in `options` + const createTagOption = useRefFunc((val: RawValueType, label?: React.ReactNode) => { + const mergedLabel = label ?? val; + return { + [mergedFieldNames.value]: val, + [mergedFieldNames.label]: mergedLabel, + } as DefaultOptionType; + }); + + // Fill tag as option if mode is `tags` + const filledTagOptions = React.useMemo(() => { + if (mode !== 'tags') { + return mergedOptions; + } + + // >>> Tag mode + const cloneOptions = [...mergedOptions]; + + // Check if value exist in options (include new patch item) + const existOptions = (val: RawValueType) => valueOptions.has(val); + + // Fill current value as option + [...mergedValues] + .sort((a, b) => (a.value < b.value ? -1 : 1)) + .forEach((item) => { + const val = item.value; + + if (!existOptions(val)) { + cloneOptions.push(createTagOption(val, item.label)); + } + }); + + return cloneOptions; + }, [createTagOption, mergedOptions, valueOptions, mergedValues, mode]); + + const filteredOptions = useFilterOptions( + filledTagOptions, + mergedFieldNames, + mergedSearchValue, + filterOption, + optionFilterProp, + ); + + // Fill options with search value if needed + const filledSearchOptions = React.useMemo(() => { + if ( + mode !== 'tags' || + !mergedSearchValue || + filteredOptions.some((item) => item[optionFilterProp || 'value'] === mergedSearchValue) + ) { + return filteredOptions; + } + + // Fill search value as option + return [createTagOption(mergedSearchValue), ...filteredOptions]; + }, [createTagOption, optionFilterProp, mode, filteredOptions, mergedSearchValue]); + + const orderedFilteredOptions = React.useMemo(() => { + if (!filterSort) { + return filledSearchOptions; + } + + return [...filledSearchOptions].sort((a, b) => filterSort(a, b)); + }, [filledSearchOptions, filterSort]); + + const displayOptions = React.useMemo( + () => + flattenOptions(orderedFilteredOptions, { fieldNames: mergedFieldNames, childrenAsData }), + [orderedFilteredOptions, mergedFieldNames, childrenAsData], + ); + + // =========================== Change =========================== + const triggerChange = (values: DraftValueType) => { + const labeledValues = convert2LabelValues(values); + setInternalValue(labeledValues); + + if ( + onChange && + // Trigger event only when value changed + (labeledValues.length !== mergedValues.length || + labeledValues.some((newVal, index) => mergedValues[index]?.value !== newVal?.value)) + ) { + const returnValues = labelInValue ? labeledValues : labeledValues.map((v) => v.value); + const returnOptions = labeledValues.map((v) => + injectPropsWithOption(getMixedOption(v.value)), + ); + + onChange( + // Value + multiple ? returnValues : returnValues[0], + // Option + multiple ? returnOptions : returnOptions[0], + ); + } + }; + + // ======================= Accessibility ======================== + const [activeValue, setActiveValue] = React.useState(null); + const [accessibilityIndex, setAccessibilityIndex] = React.useState(0); + const mergedDefaultActiveFirstOption = + defaultActiveFirstOption !== undefined ? defaultActiveFirstOption : mode !== 'combobox'; + + const onActiveValue: OnActiveValue = React.useCallback( + (active, index, { source = 'keyboard' } = {}) => { + setAccessibilityIndex(index); + + if (backfill && mode === 'combobox' && active !== null && source === 'keyboard') { + setActiveValue(String(active)); + } + }, + [backfill, mode], + ); + + // ========================= OptionList ========================= + const triggerSelect = (val: RawValueType, selected: boolean) => { + const getSelectEnt = (): [RawValueType | LabelInValueType, DefaultOptionType] => { + const option = getMixedOption(val); + return [ + labelInValue + ? { + label: option?.[mergedFieldNames.label], + value: val, + key: option.key ?? val, + } + : val, + injectPropsWithOption(option), + ]; + }; + + if (selected && onSelect) { + const [wrappedValue, option] = getSelectEnt(); + onSelect(wrappedValue, option); + } else if (!selected && onDeselect) { + const [wrappedValue, option] = getSelectEnt(); + onDeselect(wrappedValue, option); + } + }; + + // Used for OptionList selection + const onInternalSelect = useRefFunc((val, info) => { + let cloneValues: (RawValueType | DisplayValueType)[]; + + // Single mode always trigger select only with option list + const mergedSelect = multiple ? info.selected : true; + + if (mergedSelect) { + cloneValues = multiple ? [...mergedValues, val] : [val]; + } else { + cloneValues = mergedValues.filter((v) => v.value !== val); + } + + triggerChange(cloneValues); + triggerSelect(val, mergedSelect); + + // Clean search value if single or configured + if (mode === 'combobox') { + // setSearchValue(String(val)); + setActiveValue(''); + } else if (!isMultiple || autoClearSearchValue) { + setSearchValue(''); + setActiveValue(''); + } + }); + + // ======================= Display Change ======================= + // BaseSelect display values change + const onDisplayValuesChange: BaseSelectProps['onDisplayValuesChange'] = (nextValues, info) => { + triggerChange(nextValues); + + if (info.type === 'remove') { + info.values.forEach((item) => { + triggerSelect(item.value, false); + }); + } + }; + + // =========================== Search =========================== + const onInternalSearch: BaseSelectProps['onSearch'] = (searchText, info) => { + setSearchValue(searchText); + setActiveValue(null); + + // [Submit] Tag mode should flush input + if (info.source === 'submit') { + const formatted = (searchText || '').trim(); + // prevent empty tags from appearing when you click the Enter button + if (formatted) { + const newRawValues = Array.from(new Set([...rawValues, formatted])); + triggerChange(newRawValues); + triggerSelect(formatted, true); + setSearchValue(''); + } + + return; + } + + if (info.source !== 'blur') { + if (mode === 'combobox') { + triggerChange(searchText); + } + + onSearch?.(searchText); + } + }; + + const onInternalSearchSplit: BaseSelectProps['onSearchSplit'] = (words) => { + let patchValues: RawValueType[] = words; + + if (mode !== 'tags') { + patchValues = words + .map((word) => { + const opt = labelOptions.get(word); + return opt?.value; + }) + .filter((val) => val !== undefined); + } + + const newRawValues = Array.from(new Set([...rawValues, ...patchValues])); + triggerChange(newRawValues); + newRawValues.forEach((newRawValue) => { + triggerSelect(newRawValue, true); + }); + }; + + // ========================== Context =========================== + const selectContext = React.useMemo( + () => ({ + ...parsedOptions, + flattenOptions: displayOptions, + onActiveValue, + defaultActiveFirstOption: mergedDefaultActiveFirstOption, + onSelect: onInternalSelect, + menuItemSelectedIcon, + rawValues, + fieldNames: mergedFieldNames, + virtual, + listHeight, + listItemHeight, + childrenAsData, + optionLabelProp, + }), + [ + parsedOptions, + displayOptions, + onActiveValue, + mergedDefaultActiveFirstOption, + onInternalSelect, + menuItemSelectedIcon, + rawValues, + mergedFieldNames, + virtual, + listHeight, + listItemHeight, + childrenAsData, + optionLabelProp, + ], + ); + + // ========================== Warning =========================== + if (process.env.NODE_ENV !== 'production') { + warningProps(props); + } + + // ============================================================== + // == Render == + // ============================================================== + return ( + + >> MISC + id={mergedId} + prefixCls={prefixCls} + ref={ref} + // >>> Values + displayValues={displayValues} + onDisplayValuesChange={onDisplayValuesChange} + // >>> Search + searchValue={mergedSearchValue} + onSearch={onInternalSearch} + onSearchSplit={onInternalSearchSplit} + // >>> OptionList + OptionList={OptionList} + emptyOptions={!displayOptions.length} + // >>> Accessibility + activeValue={activeValue} + activeDescendantId={`${mergedId}_list_${accessibilityIndex}`} + /> + + ); + }, +); + +type SelectType = typeof Select; + +const TypedSelect = Select as SelectType & { + Option: typeof Option; + OptGroup: typeof OptGroup; +}; + +TypedSelect.Option = Option; +TypedSelect.OptGroup = OptGroup; + +export default TypedSelect; diff --git a/src/SelectContext.ts b/src/SelectContext.ts new file mode 100644 index 000000000..488a63d7f --- /dev/null +++ b/src/SelectContext.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import type { RawValueType, RenderNode } from './BaseSelect'; +import type { FlattenOptionData } from './interface'; +import type { BaseOptionType, FieldNames, OnActiveValue, OnInternalSelect } from './Select'; + +// Use any here since we do not get the type during compilation +export interface SelectContextProps { + options: BaseOptionType[]; + flattenOptions: FlattenOptionData[]; + onActiveValue: OnActiveValue; + defaultActiveFirstOption?: boolean; + onSelect: OnInternalSelect; + menuItemSelectedIcon?: RenderNode; + rawValues: Set; + fieldNames?: FieldNames; + virtual?: boolean; + listHeight?: number; + listItemHeight?: number; + childrenAsData?: boolean; + optionLabelProp?: string; +} + +const SelectContext = React.createContext(null); + +export default SelectContext; diff --git a/src/SelectTrigger.tsx b/src/SelectTrigger.tsx index f418eee6b..54acd4a5b 100644 --- a/src/SelectTrigger.tsx +++ b/src/SelectTrigger.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import Trigger from 'rc-trigger'; import classNames from 'classnames'; -import type { RenderDOMFunc } from './interface'; -import type { Placement } from './generate'; +import type { Placement, RenderDOMFunc } from './BaseSelect'; const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { // Enable horizontal overflow auto-adjustment when a custom dropdown width is provided @@ -70,6 +69,8 @@ export interface SelectTriggerProps { getTriggerDOMNode: () => HTMLElement; onPopupVisibleChange?: (visible: boolean) => void; + + onPopupMouseEnter: () => void; } const SelectTrigger: React.RefForwardingComponent = ( @@ -96,6 +97,7 @@ const SelectTrigger: React.RefForwardingComponent{popupNode}} + popup={ +
+ {popupNode} +
+ } popupAlign={dropdownAlign} popupVisible={visible} getPopupContainer={getPopupContainer} diff --git a/src/Selector/Input.tsx b/src/Selector/Input.tsx index 67101f739..10d387bb2 100644 --- a/src/Selector/Input.tsx +++ b/src/Selector/Input.tsx @@ -12,7 +12,7 @@ interface InputProps { autoFocus: boolean; autoComplete: string; editable: boolean; - accessibilityIndex: number; + activeDescendantId?: string; value: string; maxLength?: number; open: boolean; @@ -42,7 +42,7 @@ const Input: React.RefForwardingComponent = ( autoFocus, autoComplete, editable, - accessibilityIndex, + activeDescendantId, value, maxLength, onKeyDown, @@ -86,7 +86,7 @@ const Input: React.RefForwardingComponent = ( 'aria-owns': `${id}_list`, 'aria-autocomplete': 'list', 'aria-controls': `${id}_list`, - 'aria-activedescendant': `${id}_list_${accessibilityIndex}`, + 'aria-activedescendant': activeDescendantId, ...attrs, value: editable ? value : '', maxLength, diff --git a/src/Selector/MultipleSelector.tsx b/src/Selector/MultipleSelector.tsx index bcfa08884..22d458fed 100644 --- a/src/Selector/MultipleSelector.tsx +++ b/src/Selector/MultipleSelector.tsx @@ -4,17 +4,10 @@ import classNames from 'classnames'; import pickAttrs from 'rc-util/lib/pickAttrs'; import Overflow from 'rc-overflow'; import TransBtn from '../TransBtn'; -import type { - LabelValueType, - DisplayLabelValueType, - RawValueType, - CustomTagProps, - DefaultValueType, -} from '../interface/generator'; -import type { RenderNode } from '../interface'; import type { InnerSelectorProps } from '.'; import Input from './Input'; import useLayoutEffect from '../hooks/useLayoutEffect'; +import type { DisplayValueType, RenderNode, CustomTagProps, RawValueType } from '../BaseSelect'; interface SelectorProps extends InnerSelectorProps { // Icon @@ -23,7 +16,7 @@ interface SelectorProps extends InnerSelectorProps { // Tags maxTagCount?: number | 'responsive'; maxTagTextLength?: number; - maxTagPlaceholder?: React.ReactNode | ((omittedValues: LabelValueType[]) => React.ReactNode); + maxTagPlaceholder?: React.ReactNode | ((omittedValues: DisplayValueType[]) => React.ReactNode); tokenSeparators?: string[]; tagRender?: (props: CustomTagProps) => React.ReactElement; onToggleOpen: (open?: boolean) => void; @@ -32,7 +25,7 @@ interface SelectorProps extends InnerSelectorProps { choiceTransitionName?: string; // Event - onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onRemove: (value: DisplayValueType) => void; } const onPreventMouseDown = (event: React.MouseEvent) => { @@ -54,18 +47,18 @@ const SelectSelector: React.FC = (props) => { showSearch, autoFocus, autoComplete, - accessibilityIndex, + activeDescendantId, tabIndex, removeIcon, maxTagCount, maxTagTextLength, - maxTagPlaceholder = (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, + maxTagPlaceholder = (omittedValues: DisplayValueType[]) => `+ ${omittedValues.length} ...`, tagRender, onToggleOpen, - onSelect, + onRemove, onInputChange, onInputPaste, onInputKeyDown, @@ -123,7 +116,7 @@ const SelectSelector: React.FC = (props) => { } function customizeRenderSelector( - value: DefaultValueType, + value: RawValueType, content: React.ReactNode, itemDisabled: boolean, closable: boolean, @@ -147,7 +140,8 @@ const SelectSelector: React.FC = (props) => { ); } - function renderItem({ disabled: itemDisabled, label, value }: DisplayLabelValueType) { + function renderItem(valueItem: DisplayValueType) { + const { disabled: itemDisabled, label, value } = valueItem; const closable = !disabled && !itemDisabled; let displayLabel: React.ReactNode = label; @@ -164,7 +158,7 @@ const SelectSelector: React.FC = (props) => { const onClose = (event?: React.MouseEvent) => { if (event) event.stopPropagation(); - onSelect(value, { selected: false }); + onRemove(valueItem); }; return typeof tagRender === 'function' @@ -172,7 +166,7 @@ const SelectSelector: React.FC = (props) => { : defaultRenderSelector(label, displayLabel, itemDisabled, closable, onClose); } - function renderRest(omittedValues: DisplayLabelValueType[]) { + function renderRest(omittedValues: DisplayValueType[]) { const content = typeof maxTagPlaceholder === 'function' ? maxTagPlaceholder(omittedValues) @@ -203,7 +197,7 @@ const SelectSelector: React.FC = (props) => { autoFocus={autoFocus} autoComplete={autoComplete} editable={inputEditable} - accessibilityIndex={accessibilityIndex} + activeDescendantId={activeDescendantId} value={inputValue} onKeyDown={onInputKeyDown} onMouseDown={onInputMouseDown} diff --git a/src/Selector/SingleSelector.tsx b/src/Selector/SingleSelector.tsx index 86768b2fd..ae1a788f1 100644 --- a/src/Selector/SingleSelector.tsx +++ b/src/Selector/SingleSelector.tsx @@ -6,7 +6,6 @@ import type { InnerSelectorProps } from '.'; interface SelectorProps extends InnerSelectorProps { inputElement: React.ReactElement; activeValue: string; - backfill?: boolean; } const SingleSelector: React.FC = (props) => { @@ -18,7 +17,7 @@ const SingleSelector: React.FC = (props) => { disabled, autoFocus, autoComplete, - accessibilityIndex, + activeDescendantId, mode, open, values, @@ -88,7 +87,7 @@ const SingleSelector: React.FC = (props) => { autoFocus={autoFocus} autoComplete={autoComplete} editable={inputEditable} - accessibilityIndex={accessibilityIndex} + activeDescendantId={activeDescendantId} value={inputValue} onKeyDown={onInputKeyDown} onMouseDown={onInputMouseDown} diff --git a/src/Selector/index.tsx b/src/Selector/index.tsx index d1fb1e2a3..46af99662 100644 --- a/src/Selector/index.tsx +++ b/src/Selector/index.tsx @@ -14,9 +14,8 @@ import KeyCode from 'rc-util/lib/KeyCode'; import type { ScrollTo } from 'rc-virtual-list/lib/List'; import MultipleSelector from './MultipleSelector'; import SingleSelector from './SingleSelector'; -import type { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator'; -import type { RenderNode, Mode } from '../interface'; import useLock from '../hooks/useLock'; +import type { CustomTagProps, DisplayValueType, Mode, RenderNode } from '../BaseSelect'; export interface InnerSelectorProps { prefixCls: string; @@ -28,10 +27,10 @@ export interface InnerSelectorProps { disabled?: boolean; autoFocus?: boolean; autoComplete?: string; - values: LabelValueType[]; + values: DisplayValueType[]; showSearch?: boolean; searchValue: string; - accessibilityIndex: number; + activeDescendantId?: string; open: boolean; tabIndex?: number; maxLength?: number; @@ -56,15 +55,14 @@ export interface SelectorProps { showSearch?: boolean; open: boolean; /** Display in the Selector value, it's not same as `value` prop */ - values: LabelValueType[]; - multiple: boolean; + values: DisplayValueType[]; mode: Mode; searchValue: string; activeValue: string; inputElement: JSX.Element; autoFocus?: boolean; - accessibilityIndex: number; + activeDescendantId?: string; tabIndex?: number; disabled?: boolean; placeholder?: React.ReactNode; @@ -73,7 +71,7 @@ export interface SelectorProps { // Tags maxTagCount?: number | 'responsive'; maxTagTextLength?: number; - maxTagPlaceholder?: React.ReactNode | ((omittedValues: LabelValueType[]) => React.ReactNode); + maxTagPlaceholder?: React.ReactNode | ((omittedValues: DisplayValueType[]) => React.ReactNode); tagRender?: (props: CustomTagProps) => React.ReactElement; /** Check if `tokenSeparators` contains `\n` or `\r\n` */ @@ -85,8 +83,8 @@ export interface SelectorProps { 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; - onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onSearchSubmit?: (searchText: string) => void; + onRemove: (value: DisplayValueType) => void; onInputKeyDown?: React.KeyboardEventHandler; /** @@ -102,7 +100,6 @@ const Selector: React.RefForwardingComponent = const { prefixCls, - multiple, open, mode, showSearch, @@ -143,7 +140,7 @@ const Selector: React.RefForwardingComponent = 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); + onSearchSubmit?.((event.target as HTMLInputElement).value); } if (![KeyCode.SHIFT, KeyCode.TAB, KeyCode.BACKSPACE, KeyCode.ESC].includes(which)) { @@ -247,11 +244,12 @@ const Selector: React.RefForwardingComponent = onInputCompositionEnd, }; - const selectNode = multiple ? ( - - ) : ( - - ); + const selectNode = + mode === 'multiple' || mode === 'tags' ? ( + + ) : ( + + ); return (
void; - blur: () => void; - scrollTo?: ScrollTo; -} - -export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; - -export interface SelectProps extends React.AriaAttributes { - prefixCls?: string; - id?: string; - className?: string; - style?: React.CSSProperties; - - // Options - options?: OptionsType; - children?: React.ReactNode; - mode?: Mode; - - // Value - value?: ValueType; - defaultValue?: ValueType; - labelInValue?: boolean; - /** Config max length of input. This is only work when `mode` is `combobox` */ - maxLength?: number; - - // Field - fieldNames?: FieldNames; - - // Search - inputValue?: string; - searchValue?: string; - optionFilterProp?: string; - /** - * In Select, `false` means do nothing. - * In TreeSelect, `false` will highlight match item. - * It's by design. - */ - filterOption?: boolean | FilterFunc; - filterSort?: (optionA: OptionsType[number], optionB: OptionsType[number]) => number; - showSearch?: boolean; - autoClearSearchValue?: boolean; - onSearch?: (value: string) => void; - onClear?: OnClear; - - // Icons - allowClear?: boolean; - clearIcon?: React.ReactNode; - showArrow?: boolean; - inputIcon?: RenderNode; - removeIcon?: React.ReactNode; - menuItemSelectedIcon?: RenderNode; - - // Dropdown - open?: boolean; - defaultOpen?: boolean; - listHeight?: number; - listItemHeight?: number; - dropdownStyle?: React.CSSProperties; - dropdownClassName?: string; - dropdownMatchSelectWidth?: boolean | number; - placement?: Placement; - virtual?: boolean; - dropdownRender?: (menu: React.ReactElement) => React.ReactElement; - dropdownAlign?: any; - animation?: string; - transitionName?: string; - getPopupContainer?: RenderDOMFunc; - direction?: 'ltr' | 'rtl'; - - // Others - disabled?: boolean; - loading?: boolean; - autoFocus?: boolean; - defaultActiveFirstOption?: boolean; - notFoundContent?: React.ReactNode; - placeholder?: React.ReactNode; - backfill?: boolean; - /** @private Internal usage. Do not use in your production. */ - getInputElement?: () => JSX.Element; - /** @private Internal usage. Do not use in your production. */ - getRawInputElement?: () => JSX.Element; - optionLabelProp?: string; - maxTagTextLength?: number; - maxTagCount?: number | 'responsive'; - maxTagPlaceholder?: React.ReactNode | ((omittedValues: LabelValueType[]) => React.ReactNode); - tokenSeparators?: string[]; - tagRender?: (props: CustomTagProps) => React.ReactElement; - showAction?: ('focus' | 'click')[]; - tabIndex?: number; - - // Events - onKeyUp?: React.KeyboardEventHandler; - onKeyDown?: React.KeyboardEventHandler; - onPopupScroll?: React.UIEventHandler; - onDropdownVisibleChange?: (open: boolean) => void; - onSelect?: (value: SingleType, option: OptionsType[number]) => void; - onDeselect?: (value: SingleType, option: OptionsType[number]) => void; - onInputKeyDown?: React.KeyboardEventHandler; - onClick?: React.MouseEventHandler; - onChange?: (value: ValueType, option: OptionsType[number] | OptionsType) => void; - onBlur?: React.FocusEventHandler; - onFocus?: React.FocusEventHandler; - onMouseDown?: React.MouseEventHandler; - onMouseEnter?: React.MouseEventHandler; - onMouseLeave?: React.MouseEventHandler; - - // Motion - choiceTransitionName?: string; - - // Internal props - /** - * Only used in current version for internal event process. - * Do not use in production environment. - */ - internalProps?: { - mark?: string; - onClear?: OnClear; - skipTriggerChange?: boolean; - skipTriggerSelect?: boolean; - onRawSelect?: (value: RawValueType, option: OptionsType[number], source: SelectSource) => void; - onRawDeselect?: ( - value: RawValueType, - option: OptionsType[number], - source: SelectSource, - ) => void; - }; -} - -export interface GenerateConfig { - prefixCls: string; - components: { - optionList: React.ForwardRefExoticComponent< - React.PropsWithoutRef< - Omit, 'options'> & { options: OptionsType } - > & - React.RefAttributes - >; - }; - /** Convert jsx tree into `OptionsType` */ - convertChildrenToData: (children: React.ReactNode) => OptionsType; - /** Flatten nest options into raw option list */ - flattenOptions: (options: OptionsType, props: any) => FlattenOptionsType; - /** Convert single raw value into { label, value } format. Will be called by each value */ - getLabeledValue: GetLabeledValue>; - filterOptions: FilterOptions; - findValueOption: // Need still support legacy ts api - | ((values: RawValueType[], options: FlattenOptionsType) => OptionsType) - // New API add prevValueOptions support - | (( - values: RawValueType[], - options: FlattenOptionsType, - info?: { prevValueOptions?: OptionsType[]; props?: any }, - ) => OptionsType); - /** Check if a value is disabled */ - isValueDisabled: (value: RawValueType, options: FlattenOptionsType) => boolean; - warningProps?: (props: any) => void; - fillOptionsWithMissingValue?: ( - options: OptionsType, - value: DefaultValueType, - optionLabelProp: string, - labelInValue: boolean, - ) => OptionsType; - omitDOMProps?: (props: object) => object; -} - -/** - * This function is in internal usage. - * Do not use it in your prod env since we may refactor this. - */ -export default function generateSelector< - OptionsType extends { - value?: RawValueType; - label?: React.ReactNode; - key?: Key; - disabled?: boolean; - }[], ->(config: GenerateConfig) { - const { - prefixCls: defaultPrefixCls, - components: { optionList: OptionList }, - convertChildrenToData, - flattenOptions, - getLabeledValue, - filterOptions, - isValueDisabled, - findValueOption, - warningProps, - fillOptionsWithMissingValue, - omitDOMProps, - } = config; - - // Use raw define since `React.FC` not support generic - function Select( - props: SelectProps, - ref: React.Ref, - ): React.ReactElement { - const { - prefixCls = defaultPrefixCls, - className, - id, - - open, - defaultOpen, - options, - children, - - mode, - value, - defaultValue, - labelInValue, - - // Search related - showSearch, - inputValue, - searchValue, - filterOption, - filterSort, - optionFilterProp = 'value', - autoClearSearchValue = true, - onSearch, - - fieldNames, - - // Icons - allowClear, - clearIcon, - showArrow, - inputIcon, - menuItemSelectedIcon, - - // Others - disabled, - loading, - defaultActiveFirstOption, - notFoundContent = 'Not Found', - optionLabelProp, - backfill, - tabIndex, - getInputElement, - getRawInputElement, - getPopupContainer, - - // Dropdown - placement, - listHeight = 200, - listItemHeight = 20, - animation, - transitionName, - virtual, - dropdownStyle, - dropdownClassName, - dropdownMatchSelectWidth, - dropdownRender, - dropdownAlign, - showAction = [], - direction, - - // Tags - tokenSeparators, - tagRender, - - // Events - onPopupScroll, - onDropdownVisibleChange, - onFocus, - onBlur, - onKeyUp, - onKeyDown, - onMouseDown, - - onChange, - onSelect, - onDeselect, - onClear, - - internalProps = {}, - - ...restProps - } = props; - - const useInternalProps = internalProps.mark === INTERNAL_PROPS_MARK; - - const domProps = omitDOMProps ? omitDOMProps(restProps) : restProps; - DEFAULT_OMIT_PROPS.forEach((prop) => { - delete domProps[prop]; - }); - - const containerRef = useRef(null); - const triggerRef = useRef(null); - const selectorRef = useRef(null); - const listRef = useRef(null); - - const tokenWithEnter = useMemo( - () => - (tokenSeparators || []).some((tokenSeparator) => ['\n', '\r\n'].includes(tokenSeparator)), - [tokenSeparators], - ); - - /** Used for component focused management */ - const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); - - // Inner id for accessibility usage. Only work in client side - const [innerId, setInnerId] = useState(); - useEffect(() => { - setInnerId(`rc_select_${getUUID()}`); - }, []); - const mergedId = id || innerId; - - // optionLabelProp - let mergedOptionLabelProp = optionLabelProp; - if (mergedOptionLabelProp === undefined) { - mergedOptionLabelProp = options ? 'label' : 'children'; - } - - // labelInValue - const mergedLabelInValue = mode === 'combobox' ? false : labelInValue; - - const isMultiple = mode === 'tags' || mode === 'multiple'; - - const mergedShowSearch = - showSearch !== undefined ? showSearch : isMultiple || mode === 'combobox'; - - // ======================== Mobile ======================== - const [mobile, setMobile] = useState(false); - useEffect(() => { - // Only update on the client side - setMobile(isMobile()); - }, []); - - // ============================== Ref =============================== - const selectorDomRef = useRef(null); - - React.useImperativeHandle(ref, () => ({ - focus: selectorRef.current?.focus, - blur: selectorRef.current?.blur, - scrollTo: listRef.current?.scrollTo as ScrollTo, - })); - - // ============================= Value ============================== - const [mergedValue, setMergedValue] = useMergedState(defaultValue, { - value, - }); - - /** Unique raw values */ - const [mergedRawValue, mergedValueMap] = useMemo< - [RawValueType[], Map] - >( - () => - toInnerValue(mergedValue, { - labelInValue: mergedLabelInValue, - combobox: mode === 'combobox', - }), - [mergedValue, mergedLabelInValue], - ); - /** We cache a set of raw values to speed up check */ - const rawValues = useMemo>(() => new Set(mergedRawValue), [mergedRawValue]); - - // ============================= Option ============================= - // Set by option list active, it will merge into search input when mode is `combobox` - const [activeValue, setActiveValue] = useState(null); - const [innerSearchValue, setInnerSearchValue] = useState(''); - let mergedSearchValue = innerSearchValue; - if (mode === 'combobox' && mergedValue !== undefined) { - mergedSearchValue = mergedValue as string; - } else if (searchValue !== undefined) { - mergedSearchValue = searchValue; - } else if (inputValue) { - mergedSearchValue = inputValue; - } - - const mergedOptions = useMemo((): OptionsType => { - let newOptions = options; - if (newOptions === undefined) { - newOptions = convertChildrenToData(children); - } - - /** - * `tags` should fill un-list item. - * This is not cool here since TreeSelect do not need this - */ - if (mode === 'tags' && fillOptionsWithMissingValue) { - newOptions = fillOptionsWithMissingValue( - newOptions, - mergedValue, - mergedOptionLabelProp, - labelInValue, - ); - } - - return newOptions || ([] as OptionsType); - }, [options, children, mode, mergedValue]); - - const mergedFlattenOptions: FlattenOptionsType = useMemo( - () => flattenOptions(mergedOptions, props), - [mergedOptions], - ); - - const getValueOption = useCacheOptions(mergedFlattenOptions); - - // Display options for OptionList - const displayOptions = useMemo(() => { - if (!mergedSearchValue || !mergedShowSearch) { - return [...mergedOptions] as OptionsType; - } - const filteredOptions: OptionsType = filterOptions(mergedSearchValue, mergedOptions, { - optionFilterProp, - filterOption: mode === 'combobox' && filterOption === undefined ? () => true : filterOption, - }); - if ( - mode === 'tags' && - filteredOptions.every((opt) => opt[optionFilterProp] !== mergedSearchValue) - ) { - filteredOptions.unshift({ - value: mergedSearchValue, - label: mergedSearchValue, - key: '__RC_SELECT_TAG_PLACEHOLDER__', - }); - } - if (filterSort && Array.isArray(filteredOptions)) { - return ([...filteredOptions] as OptionsType).sort(filterSort); - } - - return filteredOptions; - }, [mergedOptions, mergedSearchValue, mode, mergedShowSearch, filterSort]); - - const displayFlattenOptions: FlattenOptionsType = useMemo( - () => flattenOptions(displayOptions, props), - [displayOptions], - ); - - useEffect(() => { - if (listRef.current && listRef.current.scrollTo) { - listRef.current.scrollTo(0); - } - }, [mergedSearchValue]); - - // ============================ Selector ============================ - let displayValues = useMemo(() => { - const tmpValues = mergedRawValue.map((val: RawValueType) => { - const valueOptions = getValueOption([val]); - const displayValue = getLabeledValue(val, { - options: valueOptions, - prevValueMap: mergedValueMap, - labelInValue: mergedLabelInValue, - optionLabelProp: mergedOptionLabelProp, - }); - - return { - ...displayValue, - disabled: isValueDisabled(val, valueOptions), - }; - }); - - if ( - !mode && - tmpValues.length === 1 && - tmpValues[0].value === null && - tmpValues[0].label === null - ) { - return []; - } - - return tmpValues; - }, [mergedValue, mergedOptions, mode]); - - // Polyfill with cache label - displayValues = useCacheDisplayValue(displayValues); - - const triggerSelect = (newValue: RawValueType, isSelect: boolean, source: SelectSource) => { - const newValueOption = getValueOption([newValue]); - const outOption = findValueOption([newValue], newValueOption, { props })[0]; - - if (!internalProps.skipTriggerSelect) { - // Skip trigger `onSelect` or `onDeselect` if configured - const selectValue = ( - mergedLabelInValue - ? getLabeledValue(newValue, { - options: newValueOption, - prevValueMap: mergedValueMap, - labelInValue: mergedLabelInValue, - optionLabelProp: mergedOptionLabelProp, - }) - : newValue - ) as SingleType; - - if (isSelect && onSelect) { - onSelect(selectValue, outOption); - } else if (!isSelect && onDeselect) { - onDeselect(selectValue, outOption); - } - } - - // Trigger internal event - if (useInternalProps) { - if (isSelect && internalProps.onRawSelect) { - internalProps.onRawSelect(newValue, outOption, source); - } else if (!isSelect && internalProps.onRawDeselect) { - internalProps.onRawDeselect(newValue, outOption, source); - } - } - }; - - // We need cache options here in case user update the option list - const [prevValueOptions, setPrevValueOptions] = useState([]); - - const triggerChange = (newRawValues: RawValueType[]) => { - if (useInternalProps && internalProps.skipTriggerChange) { - return; - } - const newRawValuesOptions = getValueOption(newRawValues); - const outValues = toOuterValues>(Array.from(newRawValues), { - labelInValue: mergedLabelInValue, - options: newRawValuesOptions, - getLabeledValue, - prevValueMap: mergedValueMap, - optionLabelProp: mergedOptionLabelProp, - }); - - const outValue: ValueType = (isMultiple ? outValues : outValues[0]) as ValueType; - // Skip trigger if prev & current value is both empty - if (onChange && (mergedRawValue.length !== 0 || outValues.length !== 0)) { - const outOptions = findValueOption(newRawValues, newRawValuesOptions, { - prevValueOptions, - props, - }); - - // We will cache option in case it removed by ajax - setPrevValueOptions( - outOptions.map((option, index) => { - const clone = { ...option }; - Object.defineProperty(clone, '_INTERNAL_OPTION_VALUE_', { - get: () => newRawValues[index], - }); - return clone; - }), - ); - - onChange(outValue, isMultiple ? outOptions : outOptions[0]); - } - - setMergedValue(outValue); - }; - - const onInternalSelect = ( - newValue: RawValueType, - { selected, source }: { selected: boolean; source: 'option' | 'selection' }, - ) => { - if (disabled) { - return; - } - - let newRawValue: Set; - - if (isMultiple) { - newRawValue = new Set(mergedRawValue); - if (selected) { - newRawValue.add(newValue); - } else { - newRawValue.delete(newValue); - } - } else { - newRawValue = new Set(); - newRawValue.add(newValue); - } - - // Multiple always trigger change and single should change if value changed - if (isMultiple || (!isMultiple && Array.from(mergedRawValue)[0] !== newValue)) { - triggerChange(Array.from(newRawValue)); - } - - // Trigger `onSelect`. Single mode always trigger select - triggerSelect(newValue, !isMultiple || selected, source); - - // Clean search value if single or configured - if (mode === 'combobox') { - setInnerSearchValue(String(newValue)); - setActiveValue(''); - } else if (!isMultiple || autoClearSearchValue) { - setInnerSearchValue(''); - setActiveValue(''); - } - }; - - const onInternalOptionSelect = (newValue: RawValueType, info: { selected: boolean }) => { - onInternalSelect(newValue, { ...info, source: 'option' }); - }; - - const onInternalSelectionSelect = (newValue: RawValueType, info: { selected: boolean }) => { - onInternalSelect(newValue, { ...info, source: 'selection' }); - }; - - // ============================= Input ============================== - // Only works in `combobox` - const customizeInputElement: React.ReactElement = - (mode === 'combobox' && typeof getInputElement === 'function' && getInputElement()) || null; - - // Used for customize replacement for `rc-cascader` - const customizeRawInputElement: React.ReactElement = - typeof getRawInputElement === 'function' && getRawInputElement(); - - // ============================== Open ============================== - const [innerOpen, setInnerOpen] = useMergedState(undefined, { - defaultValue: defaultOpen, - value: open, - }); - - let mergedOpen = innerOpen; - - // Not trigger `open` in `combobox` when `notFoundContent` is empty - const emptyListContent = !notFoundContent && !displayOptions.length; - if (disabled || (emptyListContent && mergedOpen && mode === 'combobox')) { - mergedOpen = false; - } - const triggerOpen = emptyListContent ? false : mergedOpen; - - const onToggleOpen = (newOpen?: boolean) => { - const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen; - - if (innerOpen !== nextOpen && !disabled) { - setInnerOpen(nextOpen); - - if (onDropdownVisibleChange) { - onDropdownVisibleChange(nextOpen); - } - } - }; - - // Used for raw custom input trigger - let onTriggerVisibleChange: null | ((newOpen: boolean) => void); - if (customizeRawInputElement) { - onTriggerVisibleChange = (newOpen: boolean) => { - onToggleOpen(newOpen); - }; - } - - useSelectTriggerControl( - () => [containerRef.current, triggerRef.current?.getPopupElement()], - triggerOpen, - onToggleOpen, - ); - - // ============================= Search ============================= - const triggerSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { - let ret = true; - let newSearchText = searchText; - setActiveValue(null); - - // Check if match the `tokenSeparators` - const patchLabels: string[] = isCompositing - ? null - : getSeparatedContent(searchText, tokenSeparators); - let patchRawValues: RawValueType[] = patchLabels; - - if (mode === 'combobox') { - // Only typing will trigger onChange - if (fromTyping) { - triggerChange([newSearchText]); - } - } else if (patchLabels) { - newSearchText = ''; - - if (mode !== 'tags') { - patchRawValues = patchLabels - .map((label) => { - const item = mergedFlattenOptions.find( - ({ data }) => data[mergedOptionLabelProp] === label, - ); - return item ? item.data.value : null; - }) - .filter((val: RawValueType) => val !== null); - } - - const newRawValues = Array.from( - new Set([...mergedRawValue, ...patchRawValues]), - ); - triggerChange(newRawValues); - newRawValues.forEach((newRawValue) => { - triggerSelect(newRawValue, true, 'input'); - }); - - // Should close when paste finish - onToggleOpen(false); - - // Tell Selector that break next actions - ret = false; - } - - setInnerSearchValue(newSearchText); - - if (onSearch && mergedSearchValue !== newSearchText) { - onSearch(newSearchText); - } - - return ret; - }; - - // Only triggered when menu is closed & mode is tags - // If menu is open, OptionList will take charge - // If mode isn't tags, press enter is not meaningful when you can't see any option - const onSearchSubmit = (searchText: string) => { - // prevent empty tags from appearing when you click the Enter button - if (!searchText || !searchText.trim()) { - return; - } - const newRawValues = Array.from(new Set([...mergedRawValue, searchText])); - triggerChange(newRawValues); - newRawValues.forEach((newRawValue) => { - triggerSelect(newRawValue, true, 'input'); - }); - setInnerSearchValue(''); - }; - - // Close dropdown & remove focus state when disabled change - useEffect(() => { - if (innerOpen && disabled) { - setInnerOpen(false); - } - - if (disabled) { - setMockFocused(false); - } - }, [disabled]); - - // Close will clean up single mode search text - useEffect(() => { - if (!mergedOpen && !isMultiple && mode !== 'combobox') { - triggerSearch('', false, false); - } - }, [mergedOpen]); - - // ============================ Keyboard ============================ - /** - * We record input value here to check if can press to clean up by backspace - * - null: Key is not down, this is reset by key up - * - true: Search text is empty when first time backspace down - * - false: Search text is not empty when first time backspace down - */ - const [getClearLock, setClearLock] = useLock(); - - // KeyDown - const onInternalKeyDown: React.KeyboardEventHandler = (event, ...rest) => { - const clearLock = getClearLock(); - const { which } = event; - - if (which === KeyCode.ENTER) { - // Do not submit form when type in the input - if (mode !== 'combobox') { - event.preventDefault(); - } - - // We only manage open state here, close logic should handle by list component - if (!mergedOpen) { - onToggleOpen(true); - } - } - - setClearLock(!!mergedSearchValue); - - // Remove value by `backspace` - if ( - which === KeyCode.BACKSPACE && - !clearLock && - isMultiple && - !mergedSearchValue && - mergedRawValue.length - ) { - const removeInfo = removeLastEnabledValue(displayValues, mergedRawValue); - - if (removeInfo.removedValue !== null) { - triggerChange(removeInfo.values); - triggerSelect(removeInfo.removedValue, false, 'input'); - } - } - - if (mergedOpen && listRef.current) { - listRef.current.onKeyDown(event, ...rest); - } - - if (onKeyDown) { - onKeyDown(event, ...rest); - } - }; - - // KeyUp - const onInternalKeyUp: React.KeyboardEventHandler = (event, ...rest) => { - if (mergedOpen && listRef.current) { - listRef.current.onKeyUp(event, ...rest); - } - - if (onKeyUp) { - onKeyUp(event, ...rest); - } - }; - - // ========================== Focus / Blur ========================== - /** Record real focus status */ - const focusRef = useRef(false); - - const onContainerFocus: React.FocusEventHandler = (...args) => { - setMockFocused(true); - - if (!disabled) { - if (onFocus && !focusRef.current) { - onFocus(...args); - } - - // `showAction` should handle `focus` if set - if (showAction.includes('focus')) { - onToggleOpen(true); - } - } - - focusRef.current = true; - }; - - const onContainerBlur: React.FocusEventHandler = (...args) => { - setMockFocused(false, () => { - focusRef.current = false; - onToggleOpen(false); - }); - - if (disabled) { - return; - } - - if (mergedSearchValue) { - // `tags` mode should move `searchValue` into values - if (mode === 'tags') { - triggerSearch('', false, false); - triggerChange(Array.from(new Set([...mergedRawValue, mergedSearchValue]))); - } else if (mode === 'multiple') { - // `multiple` mode only clean the search value but not trigger event - setInnerSearchValue(''); - } - } - - if (onBlur) { - onBlur(...args); - } - }; - - const activeTimeoutIds: any[] = []; - useEffect( - () => () => { - activeTimeoutIds.forEach((timeoutId) => clearTimeout(timeoutId)); - activeTimeoutIds.splice(0, activeTimeoutIds.length); - }, - [], - ); - - const onInternalMouseDown: React.MouseEventHandler = (event, ...restArgs) => { - const { target } = event; - const popupElement: HTMLDivElement = triggerRef.current?.getPopupElement(); - - // We should give focus back to selector if clicked item is not focusable - if (popupElement && popupElement.contains(target as HTMLElement)) { - const timeoutId = setTimeout(() => { - const index = activeTimeoutIds.indexOf(timeoutId); - if (index !== -1) { - activeTimeoutIds.splice(index, 1); - } - - cancelSetMockFocused(); - - if (!mobile && !popupElement.contains(document.activeElement)) { - selectorRef.current?.focus(); - } - }); - - activeTimeoutIds.push(timeoutId); - } - - if (onMouseDown) { - onMouseDown(event, ...restArgs); - } - }; - - // ========================= Accessibility ========================== - const [accessibilityIndex, setAccessibilityIndex] = useState(0); - const mergedDefaultActiveFirstOption = - defaultActiveFirstOption !== undefined ? defaultActiveFirstOption : mode !== 'combobox'; - - const onActiveValue: OnActiveValue = (active, index, { source = 'keyboard' } = {}) => { - setAccessibilityIndex(index); - - if (backfill && mode === 'combobox' && active !== null && source === 'keyboard') { - setActiveValue(String(active)); - } - }; - - // ============================= Popup ============================== - const [containerWidth, setContainerWidth] = useState(null); - - const [, forceUpdate] = useState({}); - // We need force update here since popup dom is render async - function onPopupMouseEnter() { - forceUpdate({}); - } - - useLayoutEffect(() => { - if (triggerOpen) { - const newWidth = Math.ceil(containerRef.current?.offsetWidth); - if (containerWidth !== newWidth && !Number.isNaN(newWidth)) { - setContainerWidth(newWidth); - } - } - }, [triggerOpen]); - - const popupNode = ( - - ); - - // ============================= Clear ============================== - let clearNode: React.ReactNode; - const onClearMouseDown: React.MouseEventHandler = () => { - // Trigger internal `onClear` event - if (useInternalProps && internalProps.onClear) { - internalProps.onClear(); - } - - if (onClear) { - onClear(); - } - - triggerChange([]); - triggerSearch('', false, false); - }; - - if (!disabled && allowClear && (mergedRawValue.length || mergedSearchValue)) { - clearNode = ( - - × - - ); - } - - // ============================= Arrow ============================== - const mergedShowArrow = - showArrow !== undefined ? showArrow : loading || (!isMultiple && mode !== 'combobox'); - let arrowNode: React.ReactNode; - - if (mergedShowArrow) { - arrowNode = ( - - ); - } - - // ============================ Warning ============================= - if (process.env.NODE_ENV !== 'production' && warningProps) { - warningProps(props); - } - - // ============================= Render ============================= - const mergedClassName = classNames(prefixCls, className, { - [`${prefixCls}-focused`]: mockFocused, - [`${prefixCls}-multiple`]: isMultiple, - [`${prefixCls}-single`]: !isMultiple, - [`${prefixCls}-allow-clear`]: allowClear, - [`${prefixCls}-show-arrow`]: mergedShowArrow, - [`${prefixCls}-disabled`]: disabled, - [`${prefixCls}-loading`]: loading, - [`${prefixCls}-open`]: mergedOpen, - [`${prefixCls}-customize-input`]: customizeInputElement, - [`${prefixCls}-show-search`]: mergedShowSearch, - }); - - const selectorNode = ( - selectorDomRef.current} - onPopupVisibleChange={onTriggerVisibleChange} - > - {customizeRawInputElement ? ( - React.cloneElement(customizeRawInputElement, { - ref: composeRef(selectorDomRef, customizeRawInputElement.props.ref), - }) - ) : ( - - )} - - ); - - // Render raw - if (customizeRawInputElement) { - return selectorNode; - } - - return ( -
- {mockFocused && !mergedOpen && ( - - {/* Merge into one string to make screen reader work as expect */} - {`${mergedRawValue.join(', ')}`} - - )} - {selectorNode} - - {arrowNode} - {clearNode} -
- ); - } - - // Ref of Select - type RefSelectFuncType = typeof RefSelectFunc; - const RefSelect = (React.forwardRef as unknown as RefSelectFuncType)(Select); - - return RefSelect; -} diff --git a/src/hooks/useBaseProps.ts b/src/hooks/useBaseProps.ts new file mode 100644 index 000000000..827157440 --- /dev/null +++ b/src/hooks/useBaseProps.ts @@ -0,0 +1,19 @@ +/** + * BaseSelect provide some parsed data into context. + * You can use this hooks to get them. + */ + +import * as React from 'react'; +import type { BaseSelectProps } from '../BaseSelect'; + +export interface BaseSelectContextProps extends BaseSelectProps { + triggerOpen: boolean; + multiple: boolean; + toggleOpen: (open?: boolean) => void; +} + +export const BaseSelectContext = React.createContext(null); + +export default function useBaseProps() { + return React.useContext(BaseSelectContext); +} diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts new file mode 100644 index 000000000..732f864b9 --- /dev/null +++ b/src/hooks/useCache.ts @@ -0,0 +1,53 @@ +import * as React from 'react'; +import type { RawValueType } from '../BaseSelect'; +import type { DefaultOptionType, LabelInValueType } from '../Select'; + +/** + * Cache `value` related LabeledValue & options. + */ +export default ( + labeledValues: LabelInValueType[], + valueOptions: Map, +): [LabelInValueType[], (val: RawValueType) => DefaultOptionType] => { + const cacheRef = React.useRef({ + values: new Map(), + options: new Map(), + }); + + const filledLabeledValues = React.useMemo(() => { + const { values: prevValueCache, options: prevOptionCache } = cacheRef.current; + + // Fill label by cache + const patchedValues = labeledValues.map((item) => { + if (item.label === undefined) { + return { + ...item, + label: prevValueCache.get(item.value)?.label, + }; + } + + return item; + }); + + // Refresh cache + const valueCache = new Map(); + const optionCache = new Map(); + + patchedValues.forEach((item) => { + valueCache.set(item.value, item); + optionCache.set(item.value, valueOptions.get(item.value) || prevOptionCache.get(item.value)); + }); + + cacheRef.current.values = valueCache; + cacheRef.current.options = optionCache; + + return patchedValues; + }, [labeledValues, valueOptions]); + + const getOption = React.useCallback( + (val: RawValueType) => valueOptions.get(val) || cacheRef.current.options.get(val), + [valueOptions], + ); + + return [filledLabeledValues, getOption]; +}; diff --git a/src/hooks/useCacheDisplayValue.ts b/src/hooks/useCacheDisplayValue.ts deleted file mode 100644 index d4cdbae10..000000000 --- a/src/hooks/useCacheDisplayValue.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; -import type { DisplayLabelValueType } from '../interface/generator'; - -export default function useCacheDisplayValue( - values: DisplayLabelValueType[], -): DisplayLabelValueType[] { - const prevValuesRef = React.useRef(values); - - const mergedValues = React.useMemo(() => { - // Create value - label map - const valueLabels = new Map(); - prevValuesRef.current.forEach(({ value, label }) => { - if (value !== label) { - valueLabels.set(value, label); - } - }); - - const resultValues = values.map((item) => { - const cacheLabel = valueLabels.get(item.value); - if (item.isCacheable && cacheLabel) { - return { - ...item, - label: cacheLabel, - }; - } - - return item; - }); - - prevValuesRef.current = resultValues; - return resultValues; - }, [values]); - - return mergedValues; -} diff --git a/src/hooks/useCacheOptions.ts b/src/hooks/useCacheOptions.ts deleted file mode 100644 index 06aef44c8..000000000 --- a/src/hooks/useCacheOptions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import type { RawValueType, FlattenOptionsType, Key } from '../interface/generator'; - -export default function useCacheOptions< - OptionsType extends { - value?: RawValueType; - label?: React.ReactNode; - key?: Key; - disabled?: boolean; - }[] ->(options: FlattenOptionsType) { - const prevOptionMapRef = React.useRef[number]>>( - null, - ); - - const optionMap = React.useMemo(() => { - const map: Map[number]> = new Map(); - options.forEach((item) => { - const { value } = item; - map.set(value, item); - }); - return map; - }, [options]); - - prevOptionMapRef.current = optionMap; - - const getValueOption = (valueList: RawValueType[]): FlattenOptionsType => - valueList.map((value) => prevOptionMapRef.current.get(value)).filter(Boolean); - - return getValueOption; -} diff --git a/src/hooks/useFilterOptions.ts b/src/hooks/useFilterOptions.ts new file mode 100644 index 000000000..41884fd1f --- /dev/null +++ b/src/hooks/useFilterOptions.ts @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { toArray } from '../utils/commonUtil'; +import type { FieldNames, DefaultOptionType, SelectProps } from '../Select'; +import { injectPropsWithOption } from '../utils/valueUtil'; + +function includes(test: React.ReactNode, search: string) { + return toArray(test).join('').toUpperCase().includes(search); +} + +export default ( + options: DefaultOptionType[], + fieldNames: FieldNames, + searchValue?: string, + filterOption?: SelectProps['filterOption'], + optionFilterProp?: string, +) => + React.useMemo(() => { + if (!searchValue || filterOption === false) { + return options; + } + + const { options: fieldOptions, label: fieldLabel, value: fieldValue } = fieldNames; + const filteredOptions: DefaultOptionType[] = []; + + const customizeFilter = typeof filterOption === 'function'; + + const upperSearch = searchValue.toUpperCase(); + const filterFunc = customizeFilter + ? filterOption + : (_: string, option: DefaultOptionType) => { + // Use provided `optionFilterProp` + if (optionFilterProp) { + return includes(option[optionFilterProp], upperSearch); + } + + // Auto select `label` or `value` by option type + if (option[fieldOptions]) { + // hack `fieldLabel` since `OptionGroup` children is not `label` + return includes(option[fieldLabel !== 'children' ? fieldLabel : 'label'], upperSearch); + } + + return includes(option[fieldValue], upperSearch); + }; + + const wrapOption: (opt: DefaultOptionType) => DefaultOptionType = customizeFilter + ? (opt) => injectPropsWithOption(opt) + : (opt) => opt; + + options.forEach((item) => { + // Group should check child options + if (item[fieldOptions]) { + // Check group first + const matchGroup = filterFunc(searchValue, wrapOption(item)); + if (matchGroup) { + filteredOptions.push(item); + } else { + // Check option + const subOptions = item[fieldOptions].filter((subItem: DefaultOptionType) => + filterFunc(searchValue, wrapOption(subItem)), + ); + if (subOptions.length) { + filteredOptions.push({ + ...item, + [fieldOptions]: subOptions, + }); + } + } + + return; + } + + if (filterFunc(searchValue, wrapOption(item))) { + filteredOptions.push(item); + } + }); + + return filteredOptions; + }, [options, filterOption, optionFilterProp, searchValue, fieldNames]); diff --git a/src/hooks/useId.ts b/src/hooks/useId.ts new file mode 100644 index 000000000..6c88e87cd --- /dev/null +++ b/src/hooks/useId.ts @@ -0,0 +1,32 @@ +import * as React from 'react'; +import canUseDom from 'rc-util/lib/Dom/canUseDom'; + +let uuid = 0; + +/** Is client side and not jsdom */ +export const isBrowserClient = process.env.NODE_ENV !== 'test' && canUseDom(); + +/** Get unique id for accessibility usage */ +export function getUUID(): number | string { + let retId: string | number; + + // Test never reach + /* istanbul ignore if */ + if (isBrowserClient) { + retId = uuid; + uuid += 1; + } else { + retId = 'TEST_OR_SSR'; + } + + return retId; +} + +export default function useId(id?: string) { + // Inner id for accessibility usage. Only work in client side + const [innerId, setInnerId] = React.useState(); + React.useEffect(() => { + setInnerId(`rc_select_${getUUID()}`); + }, []); + return id || innerId; +} diff --git a/src/hooks/useOptions.ts b/src/hooks/useOptions.ts new file mode 100644 index 000000000..0a286e6d2 --- /dev/null +++ b/src/hooks/useOptions.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; +import type { FieldNames, RawValueType } from '../Select'; +import { convertChildrenToData } from '../utils/legacyUtil'; + +/** + * Parse `children` to `options` if `options` is not provided. + * Then flatten the `options`. + */ +export default function useOptions( + options: OptionType[], + children: React.ReactNode, + fieldNames: FieldNames, +) { + return React.useMemo(() => { + let mergedOptions = options; + const childrenAsData = !options; + + if (childrenAsData) { + mergedOptions = convertChildrenToData(children); + } + + const valueOptions = new Map(); + const labelOptions = new Map(); + + function dig(optionList: OptionType[], isChildren = false) { + // for loop to speed up collection speed + for (let i = 0; i < optionList.length; i += 1) { + const option = optionList[i]; + if (!option[fieldNames.options] || isChildren) { + valueOptions.set(option[fieldNames.value], option); + labelOptions.set(option[fieldNames.label], option); + } else { + dig(option[fieldNames.options], true); + } + } + } + dig(mergedOptions); + + return { + options: mergedOptions, + valueOptions, + labelOptions, + }; + }, [options, children, fieldNames]); +} diff --git a/src/hooks/useRefFunc.ts b/src/hooks/useRefFunc.ts new file mode 100644 index 000000000..720f972cd --- /dev/null +++ b/src/hooks/useRefFunc.ts @@ -0,0 +1,16 @@ +import * as React from 'react'; + +/** + * Same as `React.useCallback` but always return a memoized function + * but redirect to real function. + */ +export default function useRefFunc any>(callback: T): T { + const funcRef = React.useRef(); + funcRef.current = callback; + + const cacheFn = React.useCallback((...args: any[]) => { + return funcRef.current(...args); + }, []); + + return cacheFn as any; +} diff --git a/src/index.ts b/src/index.ts index 74bfc799f..31580ee4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ import Select from './Select'; import Option from './Option'; import OptGroup from './OptGroup'; -import type { ExportedSelectProps as SelectProps } from './Select'; -import type { RefSelectProps } from './generate'; +import type { SelectProps } from './Select'; +import BaseSelect from './BaseSelect'; +import type { BaseSelectProps } from './BaseSelect'; +import useBaseProps from './hooks/useBaseProps'; -export { Option, OptGroup }; -export type { SelectProps, RefSelectProps }; +export { Option, OptGroup, BaseSelect, useBaseProps }; +export type { SelectProps, BaseSelectProps }; export default Select; diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 000000000..a24aa372d --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,11 @@ +import type * as React from 'react'; +import type { RawValueType } from './BaseSelect'; + +export interface FlattenOptionData { + label?: React.ReactNode; + data: OptionType; + key: React.Key; + value?: RawValueType; + groupOption?: boolean; + group?: boolean; +} diff --git a/src/interface/generator.ts b/src/interface/generator.ts deleted file mode 100644 index 56c90f0f5..000000000 --- a/src/interface/generator.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { SelectProps, RefSelectProps } from '../generate'; - -export type SelectSource = 'option' | 'selection' | 'input'; - -export const INTERNAL_PROPS_MARK = 'RC_SELECT_INTERNAL_PROPS_MARK'; - -// =================================== Shared Type =================================== -export type Key = string | number; - -export type RawValueType = string | number; - -export interface LabelValueType { - key?: Key; - value?: RawValueType; - label?: React.ReactNode; - isCacheable?: boolean; -} -export type DefaultValueType = RawValueType | RawValueType[] | LabelValueType | LabelValueType[]; - -export interface DisplayLabelValueType extends LabelValueType { - disabled?: boolean; -} - -export type SingleType = MixType extends (infer Single)[] ? Single : MixType; - -export type OnClear = () => void; - -export type CustomTagProps = { - label: React.ReactNode; - value: DefaultValueType; - disabled: boolean; - onClose: (event?: React.MouseEvent) => void; - closable: boolean; -}; - -// ==================================== Generator ==================================== -export type GetLabeledValue = ( - value: RawValueType, - config: { - options: FOT; - prevValueMap: Map; - labelInValue: boolean; - optionLabelProp: string; - }, -) => LabelValueType; - -export type FilterOptions = ( - searchValue: string, - options: OptionsType, - /** Component props, since Select & TreeSelect use different prop name, use any here */ - config: { - optionFilterProp: string; - filterOption: boolean | FilterFunc; - }, -) => OptionsType; - -export type FilterFunc = (inputValue: string, option?: OptionType) => boolean; - -export declare function RefSelectFunc( - Component: React.RefForwardingComponent>, -): React.ForwardRefExoticComponent< - React.PropsWithoutRef> & React.RefAttributes ->; - -export type FlattenOptionsType = { - key: Key; - data: OptionsType[number]; - label?: React.ReactNode; - value?: RawValueType; - /** Used for customize data */ - [name: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any -}[]; diff --git a/src/interface/index.ts b/src/interface/index.ts deleted file mode 100644 index a84b4a7e5..000000000 --- a/src/interface/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type * as React from 'react'; -import type { Key, RawValueType } from './generator'; - -export type RenderDOMFunc = (props: any) => HTMLElement; - -export type RenderNode = React.ReactNode | ((props: any) => React.ReactNode); - -export type Mode = 'multiple' | 'tags' | 'combobox'; - -// ======================== Option ======================== -export interface FieldNames { - value?: string; - label?: string; - options?: string; -} - -export type OnActiveValue = ( - active: RawValueType, - index: number, - info?: { source?: 'keyboard' | 'mouse' }, -) => void; - -export interface OptionCoreData { - key?: Key; - disabled?: boolean; - value: Key; - title?: string; - className?: string; - style?: React.CSSProperties; - label?: React.ReactNode; - /** @deprecated Only works when use `children` as option data */ - children?: React.ReactNode; -} - -export interface OptionData extends OptionCoreData { - /** Save for customize data */ - [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any -} - -export interface OptionGroupData { - key?: Key; - label?: React.ReactNode; - options: OptionData[]; - className?: string; - style?: React.CSSProperties; - - /** Save for customize data */ - [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any -} - -export type OptionsType = (OptionData | OptionGroupData)[]; - -export interface FlattenOptionData { - group?: boolean; - groupOption?: boolean; - key: string | number; - data: OptionData | OptionGroupData; - label?: React.ReactNode; - value?: React.Key; -} diff --git a/src/utils/commonUtil.ts b/src/utils/commonUtil.ts index c8167deaf..6e61f053d 100644 --- a/src/utils/commonUtil.ts +++ b/src/utils/commonUtil.ts @@ -1,11 +1,3 @@ -import type { - RawValueType, - GetLabeledValue, - LabelValueType, - DefaultValueType, - FlattenOptionsType, -} from '../interface/generator'; - export function toArray(value: T | T[]): T[] { if (Array.isArray(value)) { return value; @@ -13,116 +5,8 @@ export function toArray(value: T | T[]): T[] { return value !== undefined ? [value] : []; } -/** - * Convert outer props value into internal value - */ -export function toInnerValue( - value: DefaultValueType, - { labelInValue, combobox }: { labelInValue: boolean; combobox: boolean }, -): [RawValueType[], Map] { - const valueMap = new Map(); - - if (value === undefined || (value === '' && combobox)) { - return [[], valueMap]; - } - - const values = Array.isArray(value) ? value : [value]; - let rawValues = values as RawValueType[]; - - if (labelInValue) { - rawValues = (values as LabelValueType[]) - .filter((item) => item !== null) - .map((itemValue: LabelValueType) => { - const { key, value: val } = itemValue; - const finalVal = val !== undefined ? val : key; - valueMap.set(finalVal, itemValue); - return finalVal; - }); - } - - return [rawValues, valueMap]; -} - -/** - * Convert internal value into out event value - */ -export function toOuterValues( - valueList: RawValueType[], - { - optionLabelProp, - labelInValue, - prevValueMap, - options, - getLabeledValue, - }: { - optionLabelProp: string; - labelInValue: boolean; - getLabeledValue: GetLabeledValue; - options: FOT; - prevValueMap: Map; - }, -): RawValueType[] | LabelValueType[] { - let values: DefaultValueType = valueList; - - if (labelInValue) { - values = values.map((val) => - getLabeledValue(val, { - options, - prevValueMap, - labelInValue, - optionLabelProp, - }), - ); - } - - return values; -} - -export function removeLastEnabledValue< - T extends { disabled?: boolean }, - P extends RawValueType | object ->(measureValues: T[], values: P[]): { values: P[]; removedValue: P } { - const newValues = [...values]; - - let removeIndex: number; - for (removeIndex = measureValues.length - 1; removeIndex >= 0; removeIndex -= 1) { - if (!measureValues[removeIndex].disabled) { - break; - } - } - - let removedValue = null; - - if (removeIndex !== -1) { - removedValue = newValues[removeIndex]; - newValues.splice(removeIndex, 1); - } - - return { - values: newValues, - removedValue, - }; -} - export const isClient = typeof window !== 'undefined' && window.document && window.document.documentElement; /** Is client side and not jsdom */ export const isBrowserClient = process.env.NODE_ENV !== 'test' && isClient; - -let uuid = 0; -/** Get unique id for accessibility usage */ -export function getUUID(): number | string { - let retId: string | number; - - // Test never reach - /* istanbul ignore if */ - if (isBrowserClient) { - retId = uuid; - uuid += 1; - } else { - retId = 'TEST_OR_SSR'; - } - - return retId; -} diff --git a/src/utils/legacyUtil.ts b/src/utils/legacyUtil.ts index 41f8f9f8a..aee08610f 100644 --- a/src/utils/legacyUtil.ts +++ b/src/utils/legacyUtil.ts @@ -1,8 +1,10 @@ import * as React from 'react'; import toArray from 'rc-util/lib/Children/toArray'; -import type { OptionData, OptionGroupData, OptionsType } from '../interface'; +import type { BaseOptionType, DefaultOptionType } from '../Select'; -function convertNodeToOption(node: React.ReactElement): OptionData { +function convertNodeToOption( + node: React.ReactElement, +): OptionType { const { key, props: { children, value, ...restProps }, @@ -11,12 +13,12 @@ function convertNodeToOption(node: React.ReactElement): OptionData { return { key, value: value !== undefined ? value : key, children, ...restProps }; } -export function convertChildrenToData( +export function convertChildrenToData( nodes: React.ReactNode, optionOnly: boolean = false, -): OptionsType { +): OptionType[] { return toArray(nodes) - .map((node: React.ReactElement, index: number): OptionData | OptionGroupData | null => { + .map((node: React.ReactElement, index: number): OptionType | null => { if (!React.isValidElement(node) || !node.type) { return null; } diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index fcb319813..a2be08c34 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -1,22 +1,9 @@ +import type { BaseOptionType, DefaultOptionType } from '../Select'; import warning from 'rc-util/lib/warning'; -import type { - OptionsType as SelectOptionsType, - OptionData, - OptionGroupData, - FlattenOptionData, - FieldNames, -} from '../interface'; -import type { - LabelValueType, - FilterFunc, - RawValueType, - GetLabeledValue, - DefaultValueType, -} from '../interface/generator'; +import type { RawValueType, FieldNames } from '../Select'; +import type { FlattenOptionData } from '../interface'; -import { toArray } from './commonUtil'; - -function getKey(data: OptionData | OptionGroupData, index: number) { +function getKey(data: BaseOptionType, index: number) { const { key } = data; let value: RawValueType; @@ -33,11 +20,11 @@ function getKey(data: OptionData | OptionGroupData, index: number) { return `rc-index-key-${index}`; } -export function fillFieldNames(fieldNames?: FieldNames) { +export function fillFieldNames(fieldNames: FieldNames | undefined, childrenAsData: boolean) { const { label, value, options } = fieldNames || {}; return { - label: label || 'label', + label: label || (childrenAsData ? 'children' : 'label'), value: value || 'value', options: options || 'options', }; @@ -48,38 +35,45 @@ export function fillFieldNames(fieldNames?: FieldNames) { * We use `optionOnly` here is aim to avoid user use nested option group. * Here is simply set `key` to the index if not provided. */ -export function flattenOptions( - options: SelectOptionsType, - { fieldNames }: { fieldNames?: FieldNames } = {}, -): FlattenOptionData[] { - const flattenList: FlattenOptionData[] = []; +export function flattenOptions( + options: OptionType[], + { fieldNames, childrenAsData }: { fieldNames?: FieldNames; childrenAsData?: boolean } = {}, +): FlattenOptionData[] { + const flattenList: FlattenOptionData[] = []; const { label: fieldLabel, value: fieldValue, options: fieldOptions, - } = fillFieldNames(fieldNames); + } = fillFieldNames(fieldNames, false); - function dig(list: SelectOptionsType, isGroupOption: boolean) { + function dig(list: OptionType[], isGroupOption: boolean) { list.forEach((data) => { const label = data[fieldLabel]; if (isGroupOption || !(fieldOptions in data)) { + const value = data[fieldValue]; + // Option flattenList.push({ key: getKey(data, flattenList.length), groupOption: isGroupOption, data, label, - value: data[fieldValue], + value, }); } else { + let grpLabel = label; + if (grpLabel === undefined && childrenAsData) { + grpLabel = data.label; + } + // Option Group flattenList.push({ key: getKey(data, flattenList.length), group: true, data, - label, + label: grpLabel, }); dig(data[fieldOptions], true); @@ -95,7 +89,7 @@ export function flattenOptions( /** * Inject `props` into `option` for legacy usage */ -function injectPropsWithOption(option: T): T { +export function injectPropsWithOption(option: T): T { const newOption = { ...option }; if (!('props' in newOption)) { Object.defineProperty(newOption, 'props', { @@ -112,141 +106,6 @@ function injectPropsWithOption(option: T): T { return newOption; } -export function findValueOption( - values: RawValueType[], - options: FlattenOptionData[], - { prevValueOptions = [] }: { prevValueOptions?: OptionData[] } = {}, -): OptionData[] { - const optionMap: Map = new Map(); - - options.forEach(({ data, group, value }) => { - if (!group) { - // Check if match - optionMap.set(value, data as OptionData); - } - }); - - return values.map((val) => { - let option = optionMap.get(val); - - // Fallback to try to find prev options - if (!option) { - option = { - // eslint-disable-next-line no-underscore-dangle - ...prevValueOptions.find((opt) => opt._INTERNAL_OPTION_VALUE_ === val), - }; - } - - return injectPropsWithOption(option); - }); -} - -export const getLabeledValue: GetLabeledValue = ( - value, - { options, prevValueMap, labelInValue, optionLabelProp }, -): LabelValueType => { - const item = findValueOption([value], options)[0]; - const result: LabelValueType = { - value, - }; - - const prevValItem: LabelValueType = labelInValue ? prevValueMap.get(value) : undefined; - - if (prevValItem && typeof prevValItem === 'object' && 'label' in prevValItem) { - result.label = prevValItem.label; - - if ( - item && - typeof prevValItem.label === 'string' && - typeof item[optionLabelProp] === 'string' && - prevValItem.label.trim() !== item[optionLabelProp].trim() - ) { - warning(false, '`label` of `value` is not same as `label` in Select options.'); - } - } else if (item && optionLabelProp in item) { - result.label = item[optionLabelProp]; - } else { - result.label = value; - result.isCacheable = true; - } - - // Used for motion control - result.key = result.value; - - return result; -}; - -function toRawString(content: React.ReactNode): string { - return toArray(content).join(''); -} - -/** Filter single option if match the search text */ -function getFilterFunction(optionFilterProp: string) { - return (searchValue: string, option: OptionData | OptionGroupData) => { - const lowerSearchText = searchValue.toLowerCase(); - - // Group label search - if ('options' in option) { - return toRawString(option.label).toLowerCase().includes(lowerSearchText); - } - - // Option value search - const rawValue = option[optionFilterProp]; - const value = toRawString(rawValue).toLowerCase(); - return value.includes(lowerSearchText); - }; -} - -/** Filter options and return a new options by the search text */ -export function filterOptions( - searchValue: string, - options: SelectOptionsType, - { - optionFilterProp, - filterOption, - }: { optionFilterProp: string; filterOption: boolean | FilterFunc }, -) { - const filteredOptions: SelectOptionsType = []; - let filterFunc: FilterFunc; - - if (filterOption === false) { - return [...options]; - } - if (typeof filterOption === 'function') { - filterFunc = filterOption; - } else { - filterFunc = getFilterFunction(optionFilterProp); - } - - options.forEach((item) => { - // Group should check child options - if ('options' in item) { - // Check group first - const matchGroup = filterFunc(searchValue, item); - if (matchGroup) { - filteredOptions.push(item); - } else { - // Check option - const subOptions = item.options.filter((subItem) => filterFunc(searchValue, subItem)); - if (subOptions.length) { - filteredOptions.push({ - ...item, - options: subOptions, - }); - } - } - - return; - } - - if (filterFunc(searchValue, injectPropsWithOption(item))) { - filteredOptions.push(item); - } - }); - - return filteredOptions; -} - export function getSeparatedContent(text: string, tokens: string[]): string[] { if (!tokens || !tokens.length) { return null; @@ -270,53 +129,3 @@ export function getSeparatedContent(text: string, tokens: string[]): string[] { const list = separate(text, tokens); return match ? list : null; } - -export function isValueDisabled(value: RawValueType, options: FlattenOptionData[]): boolean { - const option = findValueOption([value], options)[0]; - return option.disabled; -} - -/** - * `tags` mode should fill un-list item into the option list - */ -export function fillOptionsWithMissingValue( - options: SelectOptionsType, - value: DefaultValueType, - optionLabelProp: string, - labelInValue: boolean, -): SelectOptionsType { - const values = toArray(value).slice().sort(); - const cloneOptions = [...options]; - - // Convert options value to set - const optionValues = new Set(); - options.forEach((opt) => { - if (opt.options) { - opt.options.forEach((subOpt: OptionData) => { - optionValues.add(subOpt.value); - }); - } else { - optionValues.add((opt as OptionData).value); - } - }); - - // Fill missing value - values.forEach((item) => { - const val: RawValueType = labelInValue - ? (item as LabelValueType).value - : (item as RawValueType); - - if (!optionValues.has(val)) { - cloneOptions.push( - labelInValue - ? { - [optionLabelProp]: (item as LabelValueType).label, - value: val, - } - : { value: val }, - ); - } - }); - - return cloneOptions; -} diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts index 0e988accc..efeb09f16 100644 --- a/src/utils/warningPropsUtil.ts +++ b/src/utils/warningPropsUtil.ts @@ -3,9 +3,9 @@ import warning, { noteOnce } from 'rc-util/lib/warning'; import toNodeArray from 'rc-util/lib/Children/toArray'; import type { SelectProps } from '..'; import { convertChildrenToData } from './legacyUtil'; -import type { OptionData, OptionGroupData } from '../interface'; import { toArray } from './commonUtil'; -import type { RawValueType, LabelValueType } from '../interface/generator'; +import type { RawValueType, LabelInValueType, BaseOptionType } from '../Select'; +import { isMultiple } from '../BaseSelect'; function warningProps(props: SelectProps) { const { @@ -26,14 +26,13 @@ function warningProps(props: SelectProps) { optionLabelProp, } = props; - const multiple = mode === 'multiple' || mode === 'tags'; + const multiple = isMultiple(mode); const mergedShowSearch = showSearch !== undefined ? showSearch : multiple || mode === 'combobox'; const mergedOptions = options || convertChildrenToData(children); // `tags` should not set option as disabled warning( - mode !== 'tags' || - mergedOptions.every((opt: { disabled?: boolean } & OptionGroupData) => !opt.disabled), + mode !== 'tags' || mergedOptions.every((opt: { disabled?: boolean }) => !opt.disabled), 'Please avoid setting option to disabled in tags mode since user can always type text as tag.', ); @@ -42,7 +41,7 @@ function warningProps(props: SelectProps) { const hasNumberValue = mergedOptions.some((item) => { if (item.options) { return item.options.some( - (opt: OptionData) => typeof ('value' in opt ? opt.value : opt.key) === 'number', + (opt: BaseOptionType) => typeof ('value' in opt ? opt.value : opt.key) === 'number', ); } return typeof ('value' in item ? item.value : item.key) === 'number'; @@ -86,7 +85,7 @@ function warningProps(props: SelectProps) { ); if (value !== undefined && value !== null) { - const values = toArray(value); + const values = toArray(value); warning( !labelInValue || values.every((val) => typeof val === 'object' && ('key' in val || 'value' in val)), diff --git a/tests/Custom.test.tsx b/tests/Custom.test.tsx new file mode 100644 index 000000000..2c5d9df0d --- /dev/null +++ b/tests/Custom.test.tsx @@ -0,0 +1,29 @@ +import { mount } from 'enzyme'; +import * as React from 'react'; +import Select from '../src'; +import { injectRunAllTimers } from './utils/common'; + +describe('Select.Custom', () => { + injectRunAllTimers(jest); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('getRawInputElement', () => { + const onDropdownVisibleChange = jest.fn(); + const wrapper = mount( + + , ); - expect(wrapper.find('Trigger').props().popupPlacement).toBe('bottomRight'); + expect(wrapper.find('Trigger').prop('popupPlacement')).toBe('bottomRight'); - expect(wrapper.find('OptionList').props().direction).toEqual('rtl'); + expect(wrapper.find('.direction').last().text()).toEqual('rtl'); }); it('should not response click event when select is disabled', () => { @@ -466,7 +480,7 @@ describe('Select.Basic', () => { it('should also fires extra search event when user search and select', () => { jest.useFakeTimers(); - const handleSearch = jest.fn(); + const handleSearch = jest.fn(() => console.trace()); const wrapper = mount( , @@ -1383,22 +1398,8 @@ describe('Select.Basic', () => { expect(opt.props).toBeTruthy(); }; - // We also test if internal hooks work here. - // Can be remove if not need in `rc-tree-select` anymore. - const onRawSelect = jest.fn(); - const onRawDeselect = jest.fn(); - const wrapper = mount( - , @@ -1409,7 +1410,6 @@ describe('Select.Basic', () => { expect(errorSpy).toHaveBeenCalledWith( 'Warning: Return type is option instead of Option instance. Please read value directly instead of reading from `props`.', ); - expect(onRawSelect).toHaveBeenCalled(); errorSpy.mockReset(); resetWarned(); @@ -1417,7 +1417,6 @@ describe('Select.Basic', () => { expect(errorSpy).toHaveBeenCalledWith( 'Warning: Return type is option instead of Option instance. Please read value directly instead of reading from `props`.', ); - expect(onRawDeselect).toHaveBeenCalled(); errorSpy.mockRestore(); }); @@ -1445,25 +1444,6 @@ describe('Select.Basic', () => { errorSpy.mockRestore(); }); - - // This test case can be safe remove - it('skip onChange', () => { - const onChange = jest.fn(); - const wrapper = mount( - , - ); - - toggleOpen(wrapper); - selectItem(wrapper); - - expect(onChange).not.toHaveBeenCalled(); - }); }); it('not crash when options is null', () => { @@ -1476,8 +1456,8 @@ describe('Select.Basic', () => { it('not open when `notFoundCount` is empty & no data', () => { const wrapper = mount(); - wrapper.find('List').find('div').first().simulate('mouseenter'); + let renderTimes = 0; + const Wrapper = ({ children }: any) => { + renderTimes += 1; + return children; + }; + + const wrapper = mount( + ); + + // Not crash + ref.current.scrollTo(100); + + // Open to call again + wrapper.setProps({ + open: true, + }); + wrapper.update(); + ref.current.scrollTo(100); + }); }); diff --git a/tests/__snapshots__/OptionList.test.tsx.snap b/tests/__snapshots__/OptionList.test.tsx.snap index f9c0f1f97..6f45589df 100644 --- a/tests/__snapshots__/OptionList.test.tsx.snap +++ b/tests/__snapshots__/OptionList.test.tsx.snap @@ -10,7 +10,7 @@ exports[`OptionList renders correctly 1`] = `
@@ -501,7 +501,7 @@ exports[`Select.Basic render renders dropdown correctly 1`] = `
{ jest.useRealTimers(); }); + + it('IE focus', () => { + jest.clearAllTimers(); + jest.useFakeTimers(); + + jest.clearAllTimers(); + + (document.body.style as any).msTouchAction = true; + const wrapper = mount(