diff --git a/components/vc-cascader2/Cascader.tsx b/components/vc-cascader2/Cascader.tsx new file mode 100644 index 000000000..401f0c67b --- /dev/null +++ b/components/vc-cascader2/Cascader.tsx @@ -0,0 +1,500 @@ +export interface ShowSearchType { + filter?: (inputValue: string, options: OptionType[], fieldNames: FieldNames) => boolean; + render?: (arg?: { + inputValue: string; + path: OptionType[]; + prefixCls: string; + fieldNames: FieldNames; + }) => any; + sort?: (a: OptionType[], b: OptionType[], inputValue: string, fieldNames: FieldNames) => number; + matchInputWidth?: boolean; + limit?: number | false; +} + +export interface FieldNames { + label?: string; + value?: string; + children?: string; +} + +export interface InternalFieldNames extends Required { + key: string; +} + +export type SingleValueType = (string | number)[]; + +export type ValueType = SingleValueType | SingleValueType[]; + +export interface BaseOptionType { + disabled?: boolean; + [name: string]: any; +} +export interface DefaultOptionType extends BaseOptionType { + label: React.ReactNode; + value?: string | number | null; + children?: DefaultOptionType[]; +} + +interface BaseCascaderProps + extends Omit< + BaseSelectPropsWithoutPrivate, + 'tokenSeparators' | 'labelInValue' | 'mode' | 'showSearch' + > { + // MISC + id?: string; + prefixCls?: string; + fieldNames?: FieldNames; + children?: React.ReactElement; + + // Value + value?: ValueType; + defaultValue?: ValueType; + changeOnSelect?: boolean; + onChange?: (value: ValueType, selectedOptions?: OptionType[] | OptionType[][]) => void; + displayRender?: (label: string[], selectedOptions?: OptionType[]) => React.ReactNode; + checkable?: boolean | React.ReactNode; + + // Search + showSearch?: boolean | ShowSearchType; + searchValue?: string; + onSearch?: (value: string) => void; + + // Trigger + expandTrigger?: 'hover' | 'click'; + + // Options + options?: OptionType[]; + /** @private Internal usage. Do not use in your production. */ + dropdownPrefixCls?: string; + loadData?: (selectOptions: OptionType[]) => void; + + // Open + /** @deprecated Use `open` instead */ + popupVisible?: boolean; + + /** @deprecated Use `dropdownClassName` instead */ + popupClassName?: string; + dropdownClassName?: string; + dropdownMenuColumnStyle?: React.CSSProperties; + + /** @deprecated Use `placement` instead */ + popupPlacement?: Placement; + placement?: Placement; + + /** @deprecated Use `onDropdownVisibleChange` instead */ + onPopupVisibleChange?: (open: boolean) => void; + onDropdownVisibleChange?: (open: boolean) => void; + + // Icon + expandIcon?: React.ReactNode; + loadingIcon?: React.ReactNode; +} + +type OnSingleChange = (value: SingleValueType, selectOptions: OptionType[]) => void; +type OnMultipleChange = ( + value: SingleValueType[], + selectOptions: OptionType[][], +) => void; + +export interface SingleCascaderProps + extends BaseCascaderProps { + checkable?: false; + + onChange?: OnSingleChange; +} + +export interface MultipleCascaderProps + extends BaseCascaderProps { + checkable: true | React.ReactNode; + + onChange?: OnMultipleChange; +} + +export type CascaderProps = + | SingleCascaderProps + | MultipleCascaderProps; + +type InternalCascaderProps = Omit< + SingleCascaderProps | MultipleCascaderProps, + 'onChange' +> & { + onChange?: ( + value: SingleValueType | SingleValueType[], + selectOptions: OptionType[] | OptionType[][], + ) => void; +}; + +export type CascaderRef = Omit; + +function isMultipleValue(value: ValueType): value is SingleValueType[] { + return Array.isArray(value) && Array.isArray(value[0]); +} + +function toRawValues(value: ValueType): SingleValueType[] { + if (!value) { + return []; + } + + if (isMultipleValue(value)) { + return value; + } + + return value.length === 0 ? [] : [value]; +} + +const Cascader = React.forwardRef((props, ref) => { + const { + // MISC + id, + prefixCls = 'rc-cascader', + fieldNames, + + // Value + defaultValue, + value, + changeOnSelect, + onChange, + displayRender, + checkable, + + // Search + searchValue, + onSearch, + showSearch, + + // Trigger + expandTrigger, + + // Options + options, + dropdownPrefixCls, + loadData, + + // Open + popupVisible, + open, + + popupClassName, + dropdownClassName, + dropdownMenuColumnStyle, + + popupPlacement, + placement, + + onDropdownVisibleChange, + onPopupVisibleChange, + + // Icon + expandIcon = '>', + loadingIcon, + + // Children + children, + + ...restProps + } = props; + + const mergedId = useId(id); + const multiple = !!checkable; + + // =========================== Values =========================== + const [rawValues, setRawValues] = useMergedState(defaultValue, { + value, + postState: toRawValues, + }); + + // ========================= FieldNames ========================= + const mergedFieldNames = React.useMemo( + () => fillFieldNames(fieldNames), + /* eslint-disable react-hooks/exhaustive-deps */ + [JSON.stringify(fieldNames)], + /* eslint-enable react-hooks/exhaustive-deps */ + ); + + // =========================== Option =========================== + const mergedOptions = React.useMemo(() => options || [], [options]); + + // Only used in multiple mode, this fn will not call in single mode + const getPathKeyEntities = useEntities(mergedOptions, mergedFieldNames); + + /** Convert path key back to value format */ + const getValueByKeyPath = React.useCallback( + (pathKeys: React.Key[]): SingleValueType[] => { + const ketPathEntities = getPathKeyEntities(); + + return pathKeys.map(pathKey => { + const { nodes } = ketPathEntities[pathKey]; + + return nodes.map(node => node[mergedFieldNames.value]); + }); + }, + [getPathKeyEntities, mergedFieldNames], + ); + + // =========================== Search =========================== + const [mergedSearchValue, setSearchValue] = useMergedState('', { + value: searchValue, + postState: search => search || '', + }); + + const onInternalSearch: BaseSelectProps['onSearch'] = (searchText, info) => { + setSearchValue(searchText); + + if (info.source !== 'blur' && onSearch) { + onSearch(searchText); + } + }; + + const [mergedShowSearch, searchConfig] = useSearchConfig(showSearch); + + const searchOptions = useSearchOptions( + mergedSearchValue, + mergedOptions, + mergedFieldNames, + dropdownPrefixCls || prefixCls, + searchConfig, + changeOnSelect, + ); + + // =========================== Values =========================== + const getMissingValues = useMissingValues(mergedOptions, mergedFieldNames); + + // Fill `rawValues` with checked conduction values + const [checkedValues, halfCheckedValues, missingCheckedValues] = React.useMemo(() => { + const [existValues, missingValues] = getMissingValues(rawValues); + + if (!multiple || !rawValues.length) { + return [existValues, [], missingValues]; + } + + const keyPathValues = toPathKeys(existValues); + const ketPathEntities = getPathKeyEntities(); + + const { checkedKeys, halfCheckedKeys } = conductCheck(keyPathValues, true, ketPathEntities); + + // Convert key back to value cells + return [getValueByKeyPath(checkedKeys), getValueByKeyPath(halfCheckedKeys), missingValues]; + }, [multiple, rawValues, getPathKeyEntities, getValueByKeyPath, getMissingValues]); + + const deDuplicatedValues = React.useMemo(() => { + const checkedKeys = toPathKeys(checkedValues); + const deduplicateKeys = formatStrategyValues(checkedKeys, getPathKeyEntities); + + return [...missingCheckedValues, ...getValueByKeyPath(deduplicateKeys)]; + }, [checkedValues, getPathKeyEntities, getValueByKeyPath, missingCheckedValues]); + + const displayValues = useDisplayValues( + deDuplicatedValues, + mergedOptions, + mergedFieldNames, + multiple, + displayRender, + ); + + // =========================== Change =========================== + const triggerChange = useRefFunc((nextValues: ValueType) => { + setRawValues(nextValues); + + // Save perf if no need trigger event + if (onChange) { + const nextRawValues = toRawValues(nextValues); + + const valueOptions = nextRawValues.map(valueCells => + toPathOptions(valueCells, mergedOptions, mergedFieldNames).map(valueOpt => valueOpt.option), + ); + + const triggerValues = multiple ? nextRawValues : nextRawValues[0]; + const triggerOptions = multiple ? valueOptions : valueOptions[0]; + + onChange(triggerValues, triggerOptions); + } + }); + + // =========================== Select =========================== + const onInternalSelect = useRefFunc((valuePath: SingleValueType) => { + if (!multiple) { + triggerChange(valuePath); + } else { + // Prepare conduct required info + const pathKey = toPathKey(valuePath); + const checkedPathKeys = toPathKeys(checkedValues); + const halfCheckedPathKeys = toPathKeys(halfCheckedValues); + + const existInChecked = checkedPathKeys.includes(pathKey); + const existInMissing = missingCheckedValues.some( + valueCells => toPathKey(valueCells) === pathKey, + ); + + // Do update + let nextCheckedValues = checkedValues; + let nextMissingValues = missingCheckedValues; + + if (existInMissing && !existInChecked) { + // Missing value only do filter + nextMissingValues = missingCheckedValues.filter( + valueCells => toPathKey(valueCells) !== pathKey, + ); + } else { + // Update checked key first + const nextRawCheckedKeys = existInChecked + ? checkedPathKeys.filter(key => key !== pathKey) + : [...checkedPathKeys, pathKey]; + + const pathKeyEntities = getPathKeyEntities(); + + // Conduction by selected or not + let checkedKeys: React.Key[]; + if (existInChecked) { + ({ checkedKeys } = conductCheck( + nextRawCheckedKeys, + { checked: false, halfCheckedKeys: halfCheckedPathKeys }, + pathKeyEntities, + )); + } else { + ({ checkedKeys } = conductCheck(nextRawCheckedKeys, true, pathKeyEntities)); + } + + // Roll up to parent level keys + const deDuplicatedKeys = formatStrategyValues(checkedKeys, getPathKeyEntities); + nextCheckedValues = getValueByKeyPath(deDuplicatedKeys); + } + + triggerChange([...nextMissingValues, ...nextCheckedValues]); + } + }); + + // Display Value change logic + const onDisplayValuesChange: BaseSelectProps['onDisplayValuesChange'] = (_, info) => { + if (info.type === 'clear') { + triggerChange([]); + return; + } + + // Cascader do not support `add` type. Only support `remove` + const { valueCells } = info.values[0] as DisplayValueType & { valueCells: SingleValueType }; + onInternalSelect(valueCells); + }; + + // ============================ Open ============================ + if (process.env.NODE_ENV !== 'production') { + warning( + !onPopupVisibleChange, + '`onPopupVisibleChange` is deprecated. Please use `onDropdownVisibleChange` instead.', + ); + warning(popupVisible === undefined, '`popupVisible` is deprecated. Please use `open` instead.'); + warning( + popupClassName === undefined, + '`popupClassName` is deprecated. Please use `dropdownClassName` instead.', + ); + warning( + popupPlacement === undefined, + '`popupPlacement` is deprecated. Please use `placement` instead.', + ); + } + + const mergedOpen = open !== undefined ? open : popupVisible; + + const mergedDropdownClassName = dropdownClassName || popupClassName; + + const mergedPlacement = placement || popupPlacement; + + const onInternalDropdownVisibleChange = (nextVisible: boolean) => { + onDropdownVisibleChange?.(nextVisible); + onPopupVisibleChange?.(nextVisible); + }; + + // ========================== Context =========================== + const cascaderContext = React.useMemo( + () => ({ + options: mergedOptions, + fieldNames: mergedFieldNames, + values: checkedValues, + halfValues: halfCheckedValues, + changeOnSelect, + onSelect: onInternalSelect, + checkable, + searchOptions, + dropdownPrefixCls, + loadData, + expandTrigger, + expandIcon, + loadingIcon, + dropdownMenuColumnStyle, + }), + [ + mergedOptions, + mergedFieldNames, + checkedValues, + halfCheckedValues, + changeOnSelect, + onInternalSelect, + checkable, + searchOptions, + dropdownPrefixCls, + loadData, + expandTrigger, + expandIcon, + loadingIcon, + dropdownMenuColumnStyle, + ], + ); + + // ============================================================== + // == Render == + // ============================================================== + const emptyOptions = !(mergedSearchValue ? searchOptions : mergedOptions).length; + + const dropdownStyle: React.CSSProperties = + // Search to match width + (mergedSearchValue && searchConfig.matchInputWidth) || + // Empty keep the width + emptyOptions + ? {} + : { + minWidth: 'auto', + }; + + return ( + + children} + /> + + ); +}) as (( + props: React.PropsWithChildren> & { + ref?: React.Ref; + }, +) => React.ReactElement) & { + displayName?: string; +}; + +if (process.env.NODE_ENV !== 'production') { + Cascader.displayName = 'Cascader'; +} + +export default Cascader; diff --git a/components/vc-cascader2/OptionList/Checkbox.tsx b/components/vc-cascader2/OptionList/Checkbox.tsx new file mode 100644 index 000000000..5cf15853b --- /dev/null +++ b/components/vc-cascader2/OptionList/Checkbox.tsx @@ -0,0 +1,44 @@ +import type { MouseEventHandler } from '../../_util/EventInterface'; +import { useInjectCascader } from '../context'; + +export interface CheckboxProps { + prefixCls: string; + checked?: boolean; + halfChecked?: boolean; + disabled?: boolean; + onClick?: MouseEventHandler; +} + +export default function Checkbox({ + prefixCls, + checked, + halfChecked, + disabled, + onClick, +}: CheckboxProps) { + const { slotsContext, checkable } = useInjectCascader(); + + const mergedCheckable = checkable.value === undefined ? slotsContext.value.checkable : checkable; + const customCheckbox = + typeof mergedCheckable === 'function' + ? mergedCheckable() + : typeof mergedCheckable === 'boolean' + ? null + : mergedCheckable; + return ( + + {customCheckbox} + + ); +} +Checkbox.props = ['prefixCls', 'checked', 'halfChecked', 'disabled', 'onClick']; +Checkbox.displayName = 'Checkbox'; +Checkbox.inheritAttrs = false; diff --git a/components/vc-cascader2/OptionList/Column.tsx b/components/vc-cascader2/OptionList/Column.tsx new file mode 100644 index 000000000..9cfdb5cf4 --- /dev/null +++ b/components/vc-cascader2/OptionList/Column.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { isLeaf, toPathKey } from '../utils/commonUtil'; +import CascaderContext from '../context'; +import Checkbox from './Checkbox'; +import type { DefaultOptionType, SingleValueType } from '../Cascader'; +import { SEARCH_MARK } from '../hooks/useSearchOptions'; + +export interface ColumnProps { + prefixCls: string; + multiple?: boolean; + options: DefaultOptionType[]; + /** Current Column opened item key */ + activeValue?: React.Key; + /** The value path before current column */ + prevValuePath: React.Key[]; + onToggleOpen: (open: boolean) => void; + onSelect: (valuePath: SingleValueType, leaf: boolean) => void; + onActive: (valuePath: SingleValueType) => void; + checkedSet: Set; + halfCheckedSet: Set; + loadingKeys: React.Key[]; + isSelectable: (option: DefaultOptionType) => boolean; +} + +export default function Column({ + prefixCls, + multiple, + options, + activeValue, + prevValuePath, + onToggleOpen, + onSelect, + onActive, + checkedSet, + halfCheckedSet, + loadingKeys, + isSelectable, +}: ColumnProps) { + const menuPrefixCls = `${prefixCls}-menu`; + const menuItemPrefixCls = `${prefixCls}-menu-item`; + + const { + fieldNames, + changeOnSelect, + expandTrigger, + expandIcon, + loadingIcon, + dropdownMenuColumnStyle, + } = React.useContext(CascaderContext); + + const hoverOpen = expandTrigger === 'hover'; + + // ============================ Render ============================ + return ( +
    + {options.map(option => { + const { disabled } = option; + const searchOptions = option[SEARCH_MARK]; + const label = option[fieldNames.label]; + const value = option[fieldNames.value]; + + const isMergedLeaf = isLeaf(option, fieldNames); + + // Get real value of option. Search option is different way. + const fullPath = searchOptions + ? searchOptions.map(opt => opt[fieldNames.value]) + : [...prevValuePath, value]; + const fullPathKey = toPathKey(fullPath); + + const isLoading = loadingKeys.includes(fullPathKey); + + // >>>>> checked + const checked = checkedSet.has(fullPathKey); + + // >>>>> halfChecked + const halfChecked = halfCheckedSet.has(fullPathKey); + + // >>>>> Open + const triggerOpenPath = () => { + if (!disabled && (!hoverOpen || !isMergedLeaf)) { + onActive(fullPath); + } + }; + + // >>>>> Selection + const triggerSelect = () => { + if (isSelectable(option)) { + onSelect(fullPath, isMergedLeaf); + } + }; + + // >>>>> Title + let title: string; + if (typeof option.title === 'string') { + title = option.title; + } else if (typeof label === 'string') { + title = label; + } + + // >>>>> Render + return ( +
  • { + triggerOpenPath(); + if (!multiple || isMergedLeaf) { + triggerSelect(); + } + }} + onDoubleClick={() => { + if (changeOnSelect) { + onToggleOpen(false); + } + }} + onMouseEnter={() => { + if (hoverOpen) { + triggerOpenPath(); + } + }} + > + {multiple && ( + ) => { + e.stopPropagation(); + triggerSelect(); + }} + /> + )} +
    {option[fieldNames.label]}
    + {!isLoading && expandIcon && !isMergedLeaf && ( +
    {expandIcon}
    + )} + {isLoading && loadingIcon && ( +
    {loadingIcon}
    + )} +
  • + ); + })} +
+ ); +} diff --git a/components/vc-cascader2/OptionList/index.tsx b/components/vc-cascader2/OptionList/index.tsx new file mode 100644 index 000000000..200b43eb6 --- /dev/null +++ b/components/vc-cascader2/OptionList/index.tsx @@ -0,0 +1,213 @@ +/* eslint-disable default-case */ +import * as React from 'react'; +import classNames from 'classnames'; +import { useBaseProps } from 'rc-select'; +import type { RefOptionListProps } from 'rc-select/lib/OptionList'; +import Column from './Column'; +import CascaderContext from '../context'; +import type { DefaultOptionType, SingleValueType } from '../Cascader'; +import { isLeaf, toPathKey, toPathKeys, toPathValueStr } from '../utils/commonUtil'; +import useActive from './useActive'; +import useKeyboard from './useKeyboard'; +import { toPathOptions } from '../utils/treeUtil'; + +const RefOptionList = React.forwardRef((props, ref) => { + const { prefixCls, multiple, searchValue, toggleOpen, notFoundContent, direction } = + useBaseProps(); + + const containerRef = React.useRef(); + const rtl = direction === 'rtl'; + + const { + options, + values, + halfValues, + fieldNames, + changeOnSelect, + onSelect, + searchOptions, + dropdownPrefixCls, + loadData, + expandTrigger, + } = React.useContext(CascaderContext); + + const mergedPrefixCls = dropdownPrefixCls || prefixCls; + + // ========================= loadData ========================= + const [loadingKeys, setLoadingKeys] = React.useState([]); + + const internalLoadData = (valueCells: React.Key[]) => { + // Do not load when search + if (!loadData || searchValue) { + return; + } + + const optionList = toPathOptions(valueCells, options, fieldNames); + const rawOptions = optionList.map(({ option }) => option); + const lastOption = rawOptions[rawOptions.length - 1]; + + if (lastOption && !isLeaf(lastOption, fieldNames)) { + const pathKey = toPathKey(valueCells); + + setLoadingKeys(keys => [...keys, pathKey]); + + loadData(rawOptions); + } + }; + + // zombieJ: This is bad. We should make this same as `rc-tree` to use Promise instead. + React.useEffect(() => { + if (loadingKeys.length) { + loadingKeys.forEach(loadingKey => { + const valueStrCells = toPathValueStr(loadingKey); + const optionList = toPathOptions(valueStrCells, options, fieldNames, true).map( + ({ option }) => option, + ); + const lastOption = optionList[optionList.length - 1]; + + if (!lastOption || lastOption[fieldNames.children] || isLeaf(lastOption, fieldNames)) { + setLoadingKeys(keys => keys.filter(key => key !== loadingKey)); + } + }); + } + }, [options, loadingKeys, fieldNames]); + + // ========================== Values ========================== + const checkedSet = React.useMemo(() => new Set(toPathKeys(values)), [values]); + const halfCheckedSet = React.useMemo(() => new Set(toPathKeys(halfValues)), [halfValues]); + + // ====================== Accessibility ======================= + const [activeValueCells, setActiveValueCells] = useActive(); + + // =========================== Path =========================== + const onPathOpen = (nextValueCells: React.Key[]) => { + setActiveValueCells(nextValueCells); + + // Trigger loadData + internalLoadData(nextValueCells); + }; + + const isSelectable = (option: DefaultOptionType) => { + const { disabled } = option; + + const isMergedLeaf = isLeaf(option, fieldNames); + return !disabled && (isMergedLeaf || changeOnSelect || multiple); + }; + + const onPathSelect = (valuePath: SingleValueType, leaf: boolean, fromKeyboard = false) => { + onSelect(valuePath); + + if (!multiple && (leaf || (changeOnSelect && (expandTrigger === 'hover' || fromKeyboard)))) { + toggleOpen(false); + } + }; + + // ========================== Option ========================== + const mergedOptions = React.useMemo(() => { + if (searchValue) { + return searchOptions; + } + + return options; + }, [searchValue, searchOptions, options]); + + // ========================== Column ========================== + const optionColumns = React.useMemo(() => { + const optionList = [{ options: mergedOptions }]; + let currentList = mergedOptions; + + for (let i = 0; i < activeValueCells.length; i += 1) { + const activeValueCell = activeValueCells[i]; + const currentOption = currentList.find( + option => option[fieldNames.value] === activeValueCell, + ); + + const subOptions = currentOption?.[fieldNames.children]; + if (!subOptions?.length) { + break; + } + + currentList = subOptions; + optionList.push({ options: subOptions }); + } + + return optionList; + }, [mergedOptions, activeValueCells, fieldNames]); + + // ========================= Keyboard ========================= + const onKeyboardSelect = (selectValueCells: SingleValueType, option: DefaultOptionType) => { + if (isSelectable(option)) { + onPathSelect(selectValueCells, isLeaf(option, fieldNames), true); + } + }; + + useKeyboard( + ref, + mergedOptions, + fieldNames, + activeValueCells, + onPathOpen, + containerRef, + onKeyboardSelect, + ); + + // ========================== Render ========================== + // >>>>> Empty + const isEmpty = !optionColumns[0]?.options?.length; + + const emptyList: DefaultOptionType[] = [ + { + [fieldNames.label as 'label']: notFoundContent, + [fieldNames.value as 'value']: '__EMPTY__', + disabled: true, + }, + ]; + + const columnProps = { + ...props, + multiple: !isEmpty && multiple, + onSelect: onPathSelect, + onActive: onPathOpen, + onToggleOpen: toggleOpen, + checkedSet, + halfCheckedSet, + loadingKeys, + isSelectable, + }; + + // >>>>> Columns + const mergedOptionColumns = isEmpty ? [{ options: emptyList }] : optionColumns; + + const columnNodes: React.ReactElement[] = mergedOptionColumns.map((col, index) => { + const prevValuePath = activeValueCells.slice(0, index); + const activeValue = activeValueCells[index]; + + return ( + + ); + }); + + // >>>>> Render + return ( + <> +
+ {columnNodes} +
+ + ); +}); + +export default RefOptionList; diff --git a/components/vc-cascader2/OptionList/useActive.ts b/components/vc-cascader2/OptionList/useActive.ts new file mode 100644 index 000000000..19134cd24 --- /dev/null +++ b/components/vc-cascader2/OptionList/useActive.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; +import CascaderContext from '../context'; +import { useBaseProps } from 'rc-select'; + +/** + * Control the active open options path. + */ +export default (): [React.Key[], (activeValueCells: React.Key[]) => void] => { + const { multiple, open } = useBaseProps(); + const { values } = React.useContext(CascaderContext); + + // Record current dropdown active options + // This also control the open status + const [activeValueCells, setActiveValueCells] = React.useState([]); + + React.useEffect( + () => { + if (open && !multiple) { + const firstValueCells = values[0]; + setActiveValueCells(firstValueCells || []); + } + }, + /* eslint-disable react-hooks/exhaustive-deps */ + [open], + /* eslint-enable react-hooks/exhaustive-deps */ + ); + + return [activeValueCells, setActiveValueCells]; +}; diff --git a/components/vc-cascader2/OptionList/useKeyboard.ts b/components/vc-cascader2/OptionList/useKeyboard.ts new file mode 100644 index 000000000..c1a2e012f --- /dev/null +++ b/components/vc-cascader2/OptionList/useKeyboard.ts @@ -0,0 +1,176 @@ +import * as React from 'react'; +import type { RefOptionListProps } from 'rc-select/lib/OptionList'; +import KeyCode from 'rc-util/lib/KeyCode'; +import type { DefaultOptionType, InternalFieldNames, SingleValueType } from '../Cascader'; +import { toPathKey } from '../utils/commonUtil'; +import { useBaseProps } from 'rc-select'; + +export default ( + ref: React.Ref, + options: DefaultOptionType[], + fieldNames: InternalFieldNames, + activeValueCells: React.Key[], + setActiveValueCells: (activeValueCells: React.Key[]) => void, + containerRef: React.RefObject, + onKeyBoardSelect: (valueCells: SingleValueType, option: DefaultOptionType) => void, +) => { + const { direction, searchValue, toggleOpen, open } = useBaseProps(); + const rtl = direction === 'rtl'; + + const [validActiveValueCells, lastActiveIndex, lastActiveOptions] = React.useMemo(() => { + let activeIndex = -1; + let currentOptions = options; + + const mergedActiveIndexes: number[] = []; + const mergedActiveValueCells: React.Key[] = []; + + const len = activeValueCells.length; + + // Fill validate active value cells and index + for (let i = 0; i < len; i += 1) { + // Mark the active index for current options + const nextActiveIndex = currentOptions.findIndex( + option => option[fieldNames.value] === activeValueCells[i], + ); + + if (nextActiveIndex === -1) { + break; + } + + activeIndex = nextActiveIndex; + mergedActiveIndexes.push(activeIndex); + mergedActiveValueCells.push(activeValueCells[i]); + + currentOptions = currentOptions[activeIndex][fieldNames.children]; + } + + // Fill last active options + let activeOptions = options; + for (let i = 0; i < mergedActiveIndexes.length - 1; i += 1) { + activeOptions = activeOptions[mergedActiveIndexes[i]][fieldNames.children]; + } + + return [mergedActiveValueCells, activeIndex, activeOptions]; + }, [activeValueCells, fieldNames, options]); + + // Update active value cells and scroll to target element + const internalSetActiveValueCells = (next: React.Key[]) => { + setActiveValueCells(next); + + const ele = containerRef.current?.querySelector(`li[data-path-key="${toPathKey(next)}"]`); + ele?.scrollIntoView?.({ block: 'nearest' }); + }; + + // Same options offset + const offsetActiveOption = (offset: number) => { + const len = lastActiveOptions.length; + + let currentIndex = lastActiveIndex; + if (currentIndex === -1 && offset < 0) { + currentIndex = len; + } + + for (let i = 0; i < len; i += 1) { + currentIndex = (currentIndex + offset + len) % len; + const option = lastActiveOptions[currentIndex]; + + if (option && !option.disabled) { + const value = option[fieldNames.value]; + const nextActiveCells = validActiveValueCells.slice(0, -1).concat(value); + internalSetActiveValueCells(nextActiveCells); + return; + } + } + }; + + // Different options offset + const prevColumn = () => { + if (validActiveValueCells.length > 1) { + const nextActiveCells = validActiveValueCells.slice(0, -1); + internalSetActiveValueCells(nextActiveCells); + } else { + toggleOpen(false); + } + }; + + const nextColumn = () => { + const nextOptions: DefaultOptionType[] = + lastActiveOptions[lastActiveIndex]?.[fieldNames.children] || []; + + const nextOption = nextOptions.find(option => !option.disabled); + + if (nextOption) { + const nextActiveCells = [...validActiveValueCells, nextOption[fieldNames.value]]; + internalSetActiveValueCells(nextActiveCells); + } + }; + + React.useImperativeHandle(ref, () => ({ + // scrollTo: treeRef.current?.scrollTo, + onKeyDown: event => { + const { which } = event; + + switch (which) { + // >>> Arrow keys + case KeyCode.UP: + case KeyCode.DOWN: { + let offset = 0; + if (which === KeyCode.UP) { + offset = -1; + } else if (which === KeyCode.DOWN) { + offset = 1; + } + + if (offset !== 0) { + offsetActiveOption(offset); + } + + break; + } + + case KeyCode.LEFT: { + if (rtl) { + nextColumn(); + } else { + prevColumn(); + } + break; + } + + case KeyCode.RIGHT: { + if (rtl) { + prevColumn(); + } else { + nextColumn(); + } + break; + } + + case KeyCode.BACKSPACE: { + if (!searchValue) { + prevColumn(); + } + break; + } + + // >>> Select + case KeyCode.ENTER: { + if (validActiveValueCells.length) { + onKeyBoardSelect(validActiveValueCells, lastActiveOptions[lastActiveIndex]); + } + break; + } + + // >>> Close + case KeyCode.ESC: { + toggleOpen(false); + + if (open) { + event.stopPropagation(); + } + } + } + }, + onKeyUp: () => {}, + })); +}; diff --git a/components/vc-cascader2/context.ts b/components/vc-cascader2/context.ts new file mode 100644 index 000000000..0df4927f9 --- /dev/null +++ b/components/vc-cascader2/context.ts @@ -0,0 +1,36 @@ +import type { CSSProperties, InjectionKey, Ref } from 'vue'; +import { inject, provide } from 'vue'; +import type { VueNode } from '../_util/type'; +import type { + CascaderProps, + InternalFieldNames, + DefaultOptionType, + SingleValueType, +} from './Cascader'; + +export interface CascaderContextProps { + options: Ref; + fieldNames: Ref; + values: Ref; + halfValues: Ref; + changeOnSelect: Ref; + onSelect: (valuePath: SingleValueType) => void; + checkable: Ref; + searchOptions: Ref; + dropdownPrefixCls?: Ref; + loadData: Ref<(selectOptions: DefaultOptionType[]) => void>; + expandTrigger: Ref<'hover' | 'click'>; + expandIcon: Ref; + loadingIcon: Ref; + dropdownMenuColumnStyle: Ref; + slotsContext: Ref>; +} + +const CascaderContextKey: InjectionKey = Symbol('CascaderContextKey'); +export const useProvideCascader = (props: CascaderContextProps) => { + provide(CascaderContextKey, props); +}; + +export const useInjectCascader = () => { + return inject(CascaderContextKey); +}; diff --git a/components/vc-cascader2/hooks/useDisplayValues.ts b/components/vc-cascader2/hooks/useDisplayValues.ts new file mode 100644 index 000000000..64ed1ead9 --- /dev/null +++ b/components/vc-cascader2/hooks/useDisplayValues.ts @@ -0,0 +1,59 @@ +import { toPathOptions } from '../utils/treeUtil'; +import * as React from 'react'; +import type { + DefaultOptionType, + SingleValueType, + CascaderProps, + InternalFieldNames, +} from '../Cascader'; +import { toPathKey } from '../utils/commonUtil'; + +export default ( + rawValues: SingleValueType[], + options: DefaultOptionType[], + fieldNames: InternalFieldNames, + multiple: boolean, + displayRender: CascaderProps['displayRender'], +) => { + return React.useMemo(() => { + const mergedDisplayRender = + displayRender || + // Default displayRender + (labels => { + const mergedLabels = multiple ? labels.slice(-1) : labels; + const SPLIT = ' / '; + + if (mergedLabels.every(label => ['string', 'number'].includes(typeof label))) { + return mergedLabels.join(SPLIT); + } + + // If exist non-string value, use ReactNode instead + return mergedLabels.reduce((list, label, index) => { + const keyedLabel = React.isValidElement(label) + ? React.cloneElement(label, { key: index }) + : label; + + if (index === 0) { + return [keyedLabel]; + } + + return [...list, SPLIT, keyedLabel]; + }, []); + }); + + return rawValues.map(valueCells => { + const valueOptions = toPathOptions(valueCells, options, fieldNames); + + const label = mergedDisplayRender( + valueOptions.map(({ option, value }) => option?.[fieldNames.label] ?? value), + valueOptions.map(({ option }) => option), + ); + + return { + label, + value: toPathKey(valueCells), + valueCells, + }; + }); + }, [rawValues, options, fieldNames, displayRender, multiple]); +}; diff --git a/components/vc-cascader2/hooks/useEntities.ts b/components/vc-cascader2/hooks/useEntities.ts new file mode 100644 index 000000000..abd08a92a --- /dev/null +++ b/components/vc-cascader2/hooks/useEntities.ts @@ -0,0 +1,38 @@ +import { convertDataToEntities } from '../../vc-tree/utils/treeUtil'; +import type { DataEntity } from '../../vc-tree/interface'; +import type { DefaultOptionType, InternalFieldNames } from '../Cascader'; +import { VALUE_SPLIT } from '../utils/commonUtil'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; + +export interface OptionsInfo { + keyEntities: Record; + pathKeyEntities: Record; +} + +export type GetEntities = () => OptionsInfo['pathKeyEntities']; + +/** Lazy parse options data into conduct-able info to avoid perf issue in single mode */ +export default (options: Ref, fieldNames: Ref) => { + const entities = computed(() => { + return ( + convertDataToEntities(options as any, { + fieldNames: fieldNames.value, + initWrapper: wrapper => ({ + ...wrapper, + pathKeyEntities: {}, + }), + processEntity: (entity, wrapper: any) => { + const pathKey = entity.nodes.map(node => node[fieldNames.value.value]).join(VALUE_SPLIT); + + wrapper.pathKeyEntities[pathKey] = entity; + + // Overwrite origin key. + // this is very hack but we need let conduct logic work with connect path + entity.key = pathKey; + }, + }) as any + ).pathKeyEntities; + }); + return entities; +}; diff --git a/components/vc-cascader2/hooks/useMissingValues.ts b/components/vc-cascader2/hooks/useMissingValues.ts new file mode 100644 index 000000000..e704bf622 --- /dev/null +++ b/components/vc-cascader2/hooks/useMissingValues.ts @@ -0,0 +1,26 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { SingleValueType, DefaultOptionType, InternalFieldNames } from '../Cascader'; +import { toPathOptions } from '../utils/treeUtil'; + +export default ( + options: Ref, + fieldNames: Ref, + rawValues: Ref, +) => { + return computed(() => { + const missingValues: SingleValueType[] = []; + const existsValues: SingleValueType[] = []; + + rawValues.value.forEach(valueCell => { + const pathOptions = toPathOptions(valueCell, options.value, fieldNames.value); + if (pathOptions.every(opt => opt.option)) { + existsValues.push(valueCell); + } else { + missingValues.push(valueCell); + } + }); + + return [existsValues, missingValues]; + }); +}; diff --git a/components/vc-cascader2/hooks/useSearchConfig.ts b/components/vc-cascader2/hooks/useSearchConfig.ts new file mode 100644 index 000000000..a49720410 --- /dev/null +++ b/components/vc-cascader2/hooks/useSearchConfig.ts @@ -0,0 +1,35 @@ +import type { CascaderProps, ShowSearchType } from '../Cascader'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import { warning } from '../../vc-util/warning'; + +// Convert `showSearch` to unique config +export default function useSearchConfig(showSearch?: Ref) { + return computed(() => { + if (!showSearch.value) { + return [false, {}]; + } + + let searchConfig: ShowSearchType = { + matchInputWidth: true, + limit: 50, + }; + + if (showSearch.value && typeof showSearch.value === 'object') { + searchConfig = { + ...searchConfig, + ...showSearch.value, + }; + } + + if (searchConfig.limit <= 0) { + delete searchConfig.limit; + + if (process.env.NODE_ENV !== 'production') { + warning(false, "'limit' of showSearch should be positive number or false."); + } + } + + return [true, searchConfig]; + }); +} diff --git a/components/vc-cascader2/hooks/useSearchOptions.ts b/components/vc-cascader2/hooks/useSearchOptions.ts new file mode 100644 index 000000000..6e0c11e87 --- /dev/null +++ b/components/vc-cascader2/hooks/useSearchOptions.ts @@ -0,0 +1,76 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { DefaultOptionType, ShowSearchType, InternalFieldNames } from '../Cascader'; + +export const SEARCH_MARK = '__rc_cascader_search_mark__'; + +const defaultFilter: ShowSearchType['filter'] = (search, options, { label }) => + options.some(opt => String(opt[label]).toLowerCase().includes(search.toLowerCase())); + +const defaultRender: ShowSearchType['render'] = ({ path, fieldNames }) => + path.map(opt => opt[fieldNames.label]).join(' / '); + +export default ( + search: Ref, + options: Ref, + fieldNames: Ref, + prefixCls: Ref, + config: Ref, + changeOnSelect: Ref, +) => { + return computed(() => { + const { filter = defaultFilter, render = defaultRender, limit = 50, sort } = config.value; + const filteredOptions: DefaultOptionType[] = []; + if (!search.value) { + return []; + } + + function dig(list: DefaultOptionType[], pathOptions: DefaultOptionType[]) { + list.forEach(option => { + // Perf saving when `sort` is disabled and `limit` is provided + if (!sort && limit > 0 && filteredOptions.length >= limit) { + return; + } + + const connectedPathOptions = [...pathOptions, option]; + const children = option[fieldNames.value.children]; + + // If current option is filterable + if ( + // If is leaf option + !children || + // If is changeOnSelect + changeOnSelect.value + ) { + if (filter(search.value, connectedPathOptions, { label: fieldNames.value.label })) { + filteredOptions.push({ + ...option, + [fieldNames.value.label as 'label']: render({ + inputValue: search.value, + path: connectedPathOptions, + prefixCls: prefixCls.value, + fieldNames: fieldNames.value, + }), + [SEARCH_MARK]: connectedPathOptions, + }); + } + } + + if (children) { + dig(option[fieldNames.value.children] as DefaultOptionType[], connectedPathOptions); + } + }); + } + + dig(options.value, []); + + // Do sort + if (sort) { + filteredOptions.sort((a, b) => { + return sort(a[SEARCH_MARK], b[SEARCH_MARK], search.value, fieldNames.value); + }); + } + + return limit > 0 ? filteredOptions.slice(0, limit as number) : filteredOptions; + }); +}; diff --git a/components/vc-cascader2/utils/commonUtil.ts b/components/vc-cascader2/utils/commonUtil.ts new file mode 100644 index 000000000..df470a6d2 --- /dev/null +++ b/components/vc-cascader2/utils/commonUtil.ts @@ -0,0 +1,35 @@ +import type { + DefaultOptionType, + FieldNames, + InternalFieldNames, + SingleValueType, +} from '../Cascader'; + +export const VALUE_SPLIT = '__RC_CASCADER_SPLIT__'; + +export function toPathKey(value: SingleValueType) { + return value.join(VALUE_SPLIT); +} + +export function toPathKeys(value: SingleValueType[]) { + return value.map(toPathKey); +} + +export function toPathValueStr(pathKey: string) { + return pathKey.split(VALUE_SPLIT); +} + +export function fillFieldNames(fieldNames?: FieldNames): InternalFieldNames { + const { label, value, children } = fieldNames || {}; + const val = value || 'value'; + return { + label: label || 'label', + value: val, + key: val, + children: children || 'children', + }; +} + +export function isLeaf(option: DefaultOptionType, fieldNames: FieldNames) { + return option.isLeaf ?? !option[fieldNames.children]?.length; +} diff --git a/components/vc-cascader2/utils/treeUtil.ts b/components/vc-cascader2/utils/treeUtil.ts new file mode 100644 index 000000000..d1d6837cf --- /dev/null +++ b/components/vc-cascader2/utils/treeUtil.ts @@ -0,0 +1,52 @@ +import type { Key } from '../../_util/type'; +import type { SingleValueType, DefaultOptionType, InternalFieldNames } from '../Cascader'; +import type { GetEntities } from '../hooks/useEntities'; + +export function formatStrategyValues(pathKeys: Key[], getKeyPathEntities: GetEntities) { + const valueSet = new Set(pathKeys); + const keyPathEntities = getKeyPathEntities(); + + return pathKeys.filter(key => { + const entity = keyPathEntities[key]; + const parent = entity ? entity.parent : null; + + if (parent && !parent.node.disabled && valueSet.has(parent.key)) { + return false; + } + return true; + }); +} + +export function toPathOptions( + valueCells: SingleValueType, + options: DefaultOptionType[], + fieldNames: InternalFieldNames, + // Used for loadingKeys which saved loaded keys as string + stringMode = false, +) { + let currentList = options; + const valueOptions: { + value: SingleValueType[number]; + index: number; + option: DefaultOptionType; + }[] = []; + + for (let i = 0; i < valueCells.length; i += 1) { + const valueCell = valueCells[i]; + const foundIndex = currentList?.findIndex(option => { + const val = option[fieldNames.value]; + return stringMode ? String(val) === String(valueCell) : val === valueCell; + }); + const foundOption = foundIndex !== -1 ? currentList?.[foundIndex] : null; + + valueOptions.push({ + value: foundOption?.[fieldNames.value] ?? valueCell, + index: foundIndex, + option: foundOption, + }); + + currentList = foundOption?.[fieldNames.children]; + } + + return valueOptions; +} diff --git a/components/vc-select copy/BaseSelect.tsx b/components/vc-select copy/BaseSelect.tsx new file mode 100644 index 000000000..1d262b476 --- /dev/null +++ b/components/vc-select copy/BaseSelect.tsx @@ -0,0 +1,863 @@ +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 type { BaseSelectContextProps } from './hooks/useBaseProps'; +import { useProvideBaseSelectProps } from './hooks/useBaseProps'; +import type { Key, VueNode } from '../_util/type'; +import type { + FocusEventHandler, + KeyboardEventHandler, + MouseEventHandler, +} from '../_util/EventInterface'; +import type { ScrollTo } from '../vc-virtual-list/List'; +import { + computed, + defineComponent, + getCurrentInstance, + onBeforeUnmount, + onMounted, + ref, + toRefs, + watch, + watchEffect, +} from 'vue'; +import type { CSSProperties, ExtractPropTypes, PropType, VNode } from 'vue'; +import PropTypes from '../_util/vue-types'; +import { initDefaultProps } from '../_util/props-util'; +import isMobile from '../vc-util/isMobile'; +import KeyCode from '../_util/KeyCode'; +import { toReactive } from '../_util/toReactive'; +import classNames from '../_util/classNames'; +import OptionList from './OptionList'; +import createRef from '../_util/createRef'; + +const DEFAULT_OMIT_PROPS = [ + 'value', + 'onChange', + 'removeIcon', + 'placeholder', + 'autofocus', + 'maxTagCount', + 'maxTagTextLength', + 'maxTagPlaceholder', + 'choiceTransitionName', + 'onInputKeyDown', + 'onPopupScroll', + 'tabindex', +] as const; + +export type RenderNode = VueNode | ((props: any) => VueNode); + +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: KeyboardEventHandler; + onKeyup: KeyboardEventHandler; + scrollTo?: (index: number) => void; +} + +export type CustomTagProps = { + label: any; + value: any; + disabled: boolean; + onClose: (event?: MouseEvent) => void; + closable: boolean; +}; + +export interface DisplayValueType { + key?: Key; + value?: RawValueType; + label?: any; + disabled: boolean; +} + +export interface BaseSelectRef { + focus: () => void; + blur: () => void; + scrollTo: ScrollTo; +} + +const baseSelectPrivateProps = () => { + return { + prefixCls: String, + id: String, + omitDomProps: Array as PropType, + + // >>> Value + displayValues: Array as PropType, + onDisplayValuesChange: Function as PropType< + ( + values: DisplayValueType[], + info: { + type: 'add' | 'remove' | 'clear'; + values: DisplayValueType[]; + }, + ) => void + >, + + // >>> Active + /** Current dropdown list active item string value */ + activeValue: String, + /** Link search input with target element */ + activeDescendantId: String, + onActiveValueChange: Function as PropType<(value: string | null) => void>, + + // >>> Search + searchValue: String, + /** Trigger onSearch, return false to prevent trigger open event */ + onSearch: Function as PropType< + ( + 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: Function as PropType<(words: string[]) => void>, + maxLength: Number, + + OptionList: PropTypes.any, + + /** Tell if provided `options` is empty */ + emptyOptions: Boolean, + }; +}; + +export type DropdownObject = { + menuNode?: VueNode; + props?: Record; +}; + +export type DropdownRender = (opt?: DropdownObject) => VueNode; +export const baseSelectPropsWithoutPrivate = () => { + return { + showSearch: { type: Boolean, default: undefined }, + tagRender: { type: Function as PropType<(props: CustomTagProps) => any> }, + direction: { type: String as PropType<'ltr' | 'rtl'> }, + + // MISC + tabindex: Number, + autofocus: Boolean, + notFoundContent: PropTypes.any, + placeholder: PropTypes.any, + onClear: Function as PropType<() => void>, + + choiceTransitionName: String, + + // >>> Mode + mode: String as PropType, + + // >>> Status + disabled: { type: Boolean, default: undefined }, + loading: { type: Boolean, default: undefined }, + + // >>> Open + open: { type: Boolean, default: undefined }, + defaultOpen: { type: Boolean, default: undefined }, + onDropdownVisibleChange: { type: Function as PropType<(open: boolean) => void> }, + + // >>> Customize Input + /** @private Internal usage. Do not use in your production. */ + getInputElement: { type: Function as PropType<() => any> }, + /** @private Internal usage. Do not use in your production. */ + getRawInputElement: { type: Function as PropType<() => any> }, + + // >>> Selector + maxTagTextLength: Number, + maxTagCount: { type: [String, Number] as PropType }, + maxTagPlaceholder: PropTypes.any, + + // >>> Search + tokenSeparators: { type: Array as PropType }, + + // >>> Icons + allowClear: { type: Boolean, default: undefined }, + showArrow: { type: Boolean, default: undefined }, + inputIcon: PropTypes.any, + /** Clear all icon */ + clearIcon: PropTypes.any, + /** Selector remove icon */ + removeIcon: PropTypes.any, + + // >>> Dropdown + animation: String, + transitionName: String, + dropdownStyle: { type: Object as PropType }, + dropdownClassName: String, + dropdownMatchSelectWidth: { + type: [Boolean, Number] as PropType, + default: undefined, + }, + dropdownRender: { type: Function as PropType }, + dropdownAlign: PropTypes.any, + placement: { + type: String as PropType, + }, + getPopupContainer: { type: Function as PropType }, + + // >>> Focus + showAction: { type: Array as PropType<('focus' | 'click')[]> }, + onBlur: { type: Function as PropType<(e: FocusEvent) => void> }, + onFocus: { type: Function as PropType<(e: FocusEvent) => void> }, + + // >>> Rest Events + onKeyup: Function as PropType<(e: KeyboardEvent) => void>, + onKeydown: Function as PropType<(e: KeyboardEvent) => void>, + onMousedown: Function as PropType<(e: MouseEvent) => void>, + onPopupScroll: Function as PropType<(e: UIEvent) => void>, + onInputKeyDown: Function as PropType<(e: KeyboardEvent) => void>, + onMouseenter: Function as PropType<(e: MouseEvent) => void>, + onMouseleave: Function as PropType<(e: MouseEvent) => void>, + onClick: Function as PropType<(e: MouseEvent) => void>, + }; +}; +const baseSelectProps = () => { + return { + ...baseSelectPrivateProps(), + ...baseSelectPropsWithoutPrivate(), + }; +}; + +export type BaseSelectPrivateProps = Partial< + ExtractPropTypes> +>; + +export type BaseSelectProps = Partial>>; + +export type BaseSelectPropsWithoutPrivate = Omit; + +export function isMultiple(mode: Mode) { + return mode === 'tags' || mode === 'multiple'; +} + +export default defineComponent({ + name: 'BaseSelect', + inheritAttrs: false, + props: initDefaultProps(baseSelectProps(), { showAction: [], notFoundContent: 'Not Found' }), + setup(props, { attrs, expose }) { + const multiple = computed(() => isMultiple(props.mode)); + + const mergedShowSearch = computed(() => + props.showSearch !== undefined + ? props.showSearch + : multiple.value || props.mode === 'combobox', + ); + const mobile = ref(false); + onMounted(() => { + mobile.value = isMobile(); + }); + + // ============================== Refs ============================== + const containerRef = ref(null); + const selectorDomRef = createRef(); + const triggerRef = ref(null); + const selectorRef = ref(null); + const listRef = ref(null); + + /** Used for component focused management */ + const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); + + expose({ + focus, + blur, + scrollTo: arg => listRef.value?.scrollTo(arg), + }); + + const mergedSearchValue = computed(() => { + if (props.mode !== 'combobox') { + return props.searchValue; + } + + const val = props.displayValues[0]?.value; + + return typeof val === 'string' || typeof val === 'number' ? String(val) : ''; + }); + + // ============================== Open ============================== + const initOpen = props.open !== undefined ? props.open : props.defaultOpen; + const innerOpen = ref(initOpen); + const mergedOpen = ref(initOpen); + const setInnerOpen = (val: boolean) => { + innerOpen.value = props.open !== undefined ? props.open : val; + mergedOpen.value = innerOpen.value; + }; + watch( + () => props.open, + () => { + setInnerOpen(props.open); + }, + ); + + // Not trigger `open` in `combobox` when `notFoundContent` is empty + const emptyListContent = computed(() => !props.notFoundContent && props.emptyOptions); + + watchEffect(() => { + mergedOpen.value = innerOpen.value; + if ( + props.disabled || + (emptyListContent.value && mergedOpen.value && props.mode === 'combobox') + ) { + mergedOpen.value = false; + } + }); + + const triggerOpen = computed(() => (emptyListContent.value ? false : mergedOpen.value)); + + const onToggleOpen = (newOpen?: boolean) => { + const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen.value; + + if (innerOpen.value !== nextOpen && !props.disabled) { + setInnerOpen(nextOpen); + if (props.onDropdownVisibleChange) { + props.onDropdownVisibleChange(nextOpen); + } + } + }; + + const tokenWithEnter = computed(() => + (props.tokenSeparators || []).some(tokenSeparator => ['\n', '\r\n'].includes(tokenSeparator)), + ); + + const onInternalSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { + let ret = true; + let newSearchText = searchText; + props.onActiveValueChange?.(null); + + // Check if match the `tokenSeparators` + const patchLabels: string[] = isCompositing + ? null + : getSeparatedContent(searchText, props.tokenSeparators); + + // Ignore combobox since it's not split-able + if (props.mode !== 'combobox' && patchLabels) { + newSearchText = ''; + + props.onSearchSplit?.(patchLabels); + + // Should close when paste finish + onToggleOpen(false); + + // Tell Selector that break next actions + ret = false; + } + + if (props.onSearch && mergedSearchValue.value !== newSearchText) { + props.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; + } + props.onSearch?.(searchText, { source: 'submit' }); + }; + + // Close will clean up single mode search text + watch( + mergedOpen, + () => { + if (!mergedOpen.value && !multiple.value && props.mode !== 'combobox') { + onInternalSearch('', false, false); + } + }, + { immediate: true }, + ); + + // ============================ Disabled ============================ + // Close dropdown & remove focus state when disabled change + watch( + () => props.disabled, + () => { + if (innerOpen.value && !!props.disabled) { + setInnerOpen(false); + } + }, + { immediate: true }, + ); + + // ============================ 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: KeyboardEventHandler = (event, ...rest) => { + const clearLock = getClearLock(); + const { which } = event; + + if (which === KeyCode.ENTER) { + // Do not submit form when type in the input + if (props.mode !== 'combobox') { + event.preventDefault(); + } + + // We only manage open state here, close logic should handle by list component + if (!mergedOpen.value) { + onToggleOpen(true); + } + } + + setClearLock(!!mergedSearchValue.value); + + // Remove value by `backspace` + if ( + which === KeyCode.BACKSPACE && + !clearLock && + multiple.value && + !mergedSearchValue.value && + props.displayValues.length + ) { + const cloneDisplayValues = [...props.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) { + props.onDisplayValuesChange(cloneDisplayValues, { + type: 'remove', + values: [removedDisplayValue], + }); + } + } + + if (mergedOpen.value && listRef.value) { + listRef.value.onKeydown(event, ...rest); + } + + props.onKeydown?.(event, ...rest); + }; + + // KeyUp + const onInternalKeyUp: KeyboardEventHandler = (event: KeyboardEvent, ...rest) => { + if (mergedOpen.value && listRef.value) { + listRef.value.onKeyup(event, ...rest); + } + + if (props.onKeyup) { + props.onKeyup(event, ...rest); + } + }; + + // ============================ Selector ============================ + const onSelectorRemove = (val: DisplayValueType) => { + const newValues = props.displayValues.filter(i => i !== val); + + props.onDisplayValuesChange(newValues, { + type: 'remove', + values: [val], + }); + }; + + // ========================== Focus / Blur ========================== + /** Record real focus status */ + const focusRef = ref(false); + + const onContainerFocus: FocusEventHandler = (...args) => { + setMockFocused(true); + + if (!props.disabled) { + if (props.onFocus && !focusRef.value) { + props.onFocus(...args); + } + + // `showAction` should handle `focus` if set + if (props.showAction && props.showAction.includes('focus')) { + onToggleOpen(true); + } + } + + focusRef.value = true; + }; + + const onContainerBlur: FocusEventHandler = (...args) => { + setMockFocused(false, () => { + focusRef.value = false; + onToggleOpen(false); + }); + + if (props.disabled) { + return; + } + const searchVal = mergedSearchValue.value; + if (searchVal) { + // `tags` mode should move `searchValue` into values + if (props.mode === 'tags') { + props.onSearch(searchVal, { source: 'submit' }); + } else if (props.mode === 'multiple') { + // `multiple` mode only clean the search value but not trigger event + props.onSearch('', { + source: 'blur', + }); + } + } + + if (props.onBlur) { + props.onBlur(...args); + } + }; + + const activeTimeoutIds: any[] = []; + + onMounted(() => { + activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + onBeforeUnmount(() => { + activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + + const onInternalMouseDown: MouseEventHandler = (event, ...restArgs) => { + const { target } = event; + const popupElement: HTMLDivElement = triggerRef.value?.getPopupElement(); + + // We should give focus back to selector if clicked item is not focusable + if (popupElement && popupElement.contains(target as HTMLElement)) { + const timeoutId: any = setTimeout(() => { + const index = activeTimeoutIds.indexOf(timeoutId); + if (index !== -1) { + activeTimeoutIds.splice(index, 1); + } + + cancelSetMockFocused(); + + if (!mobile.value && !popupElement.contains(document.activeElement)) { + selectorRef.value?.focus(); + } + }); + + activeTimeoutIds.push(timeoutId); + } + + props.onMousedown?.(event, ...restArgs); + }; + + // ============================= Dropdown ============================== + const containerWidth = ref(null); + const instance = getCurrentInstance(); + const onPopupMouseEnter = () => { + // We need force update here since popup dom is render async + instance.update(); + }; + onMounted(() => { + watch( + triggerOpen, + () => { + if (triggerOpen.value) { + const newWidth = Math.ceil(containerRef.value.offsetWidth); + if (containerWidth.value !== newWidth && !Number.isNaN(newWidth)) { + containerWidth.value = newWidth; + } + } + }, + { immediate: true }, + ); + }); + + // Close when click on non-select element + useSelectTriggerControl([containerRef, triggerRef], triggerOpen, onToggleOpen); + useProvideBaseSelectProps( + toReactive({ + ...toRefs(props), + open: mergedOpen, + triggerOpen, + showSearch: mergedShowSearch, + multiple, + toggleOpen: onToggleOpen, + } as unknown as BaseSelectContextProps), + ); + return () => { + const { + prefixCls, + id, + + open, + defaultOpen, + + mode, + + // Search related + showSearch, + searchValue, + onSearch, + + // Icons + allowClear, + clearIcon, + showArrow, + inputIcon, + + // Others + disabled, + loading, + getInputElement, + getPopupContainer, + placement, + + // Dropdown + animation, + transitionName, + dropdownStyle, + dropdownClassName, + dropdownMatchSelectWidth, + dropdownRender, + dropdownAlign, + showAction, + direction, + + // Tags + tokenSeparators, + tagRender, + + // Events + onPopupScroll, + onDropdownVisibleChange, + onFocus, + onBlur, + onKeyup, + onKeydown, + onMousedown, + + onClear, + omitDomProps, + getRawInputElement, + displayValues, + onDisplayValuesChange, + emptyOptions, + activeDescendantId, + activeValue, + + ...restProps + } = { ...props, ...attrs } as BaseSelectProps; + // ============================= Input ============================== + // Only works in `combobox` + const customizeInputElement: any = + (mode === 'combobox' && getInputElement && getInputElement()) || null; + + // Used for customize replacement for `vc-cascader` + const customizeRawInputElement: any = + typeof getRawInputElement === 'function' && getRawInputElement(); + const domProps = { + ...restProps, + } as Omit; + + // Used for raw custom input trigger + let onTriggerVisibleChange: null | ((newOpen: boolean) => void); + if (customizeRawInputElement) { + onTriggerVisibleChange = (newOpen: boolean) => { + onToggleOpen(newOpen); + }; + } + + DEFAULT_OMIT_PROPS.forEach(propName => { + delete domProps[propName]; + }); + + omitDomProps?.forEach(propName => { + delete domProps[propName]; + }); + + // ============================= Arrow ============================== + const mergedShowArrow = + showArrow !== undefined ? showArrow : loading || (!multiple.value && mode !== 'combobox'); + let arrowNode: VNode | JSX.Element; + + if (mergedShowArrow) { + arrowNode = ( + + ); + } + + // ============================= Clear ============================== + let clearNode: VNode | JSX.Element; + const onClearMouseDown: MouseEventHandler = () => { + onClear?.(); + + onDisplayValuesChange([], { + type: 'clear', + values: displayValues, + }); + onInternalSearch('', false, false); + }; + + if (!disabled && allowClear && (displayValues.length || mergedSearchValue)) { + clearNode = ( + + × + + ); + } + + // =========================== OptionList =========================== + const optionList = ; + + // ============================= Select ============================= + const mergedClassName = classNames(prefixCls, attrs.class, { + [`${prefixCls}-focused`]: mockFocused.value, + [`${prefixCls}-multiple`]: multiple.value, + [`${prefixCls}-single`]: !multiple.value, + [`${prefixCls}-allow-clear`]: allowClear, + [`${prefixCls}-show-arrow`]: mergedShowArrow, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-loading`]: loading, + [`${prefixCls}-open`]: mergedOpen.value, + [`${prefixCls}-customize-input`]: customizeInputElement, + [`${prefixCls}-show-search`]: mergedShowSearch.value, + }); + + // >>> Selector + const selectorNode = ( + selectorDomRef.current} + onPopupVisibleChange={onTriggerVisibleChange} + onPopupMouseEnter={onPopupMouseEnter} + v-slots={{ + default: () => { + return customizeRawInputElement ? ( + customizeRawInputElement + ) : ( + + ); + }, + }} + > + ); + // >>> Render + let renderNode: VNode | JSX.Element; + + // Render raw + if (customizeRawInputElement) { + renderNode = selectorNode; + } else { + renderNode = ( +
+ {mockFocused && !mergedOpen.value && ( + + {/* 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; + }; + }, +}); diff --git a/components/vc-select copy/OptGroup.tsx b/components/vc-select copy/OptGroup.tsx new file mode 100644 index 000000000..00a60e353 --- /dev/null +++ b/components/vc-select copy/OptGroup.tsx @@ -0,0 +1,15 @@ +import type { FunctionalComponent } from 'vue'; + +import type { DefaultOptionType } from './Select'; + +export type OptGroupProps = Omit; + +export interface OptionGroupFC extends FunctionalComponent { + /** Legacy for check if is a Option Group */ + isSelectOptGroup: boolean; +} + +const OptGroup: OptionGroupFC = () => null; +OptGroup.isSelectOptGroup = true; +OptGroup.displayName = 'ASelectOptGroup'; +export default OptGroup; diff --git a/components/vc-select copy/Option.tsx b/components/vc-select copy/Option.tsx new file mode 100644 index 000000000..6c98f3287 --- /dev/null +++ b/components/vc-select copy/Option.tsx @@ -0,0 +1,18 @@ +import type { FunctionalComponent } from 'vue'; + +import type { DefaultOptionType } from './Select'; + +export interface OptionProps extends Omit { + /** Save for customize data */ + [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +export interface OptionFC extends FunctionalComponent { + /** Legacy for check if is a Option Group */ + isSelectOption: boolean; +} + +const Option: OptionFC = () => null; +Option.isSelectOption = true; +Option.displayName = 'ASelectOption'; +export default Option; diff --git a/components/vc-select copy/OptionList.tsx b/components/vc-select copy/OptionList.tsx new file mode 100644 index 000000000..61b51bcf5 --- /dev/null +++ b/components/vc-select copy/OptionList.tsx @@ -0,0 +1,372 @@ +import TransBtn from './TransBtn'; + +import KeyCode from '../_util/KeyCode'; +import classNames from '../_util/classNames'; +import pickAttrs from '../_util/pickAttrs'; +import { isValidElement } from '../_util/props-util'; +import createRef from '../_util/createRef'; +import { computed, defineComponent, nextTick, reactive, watch } from 'vue'; +import List from '../vc-virtual-list'; +import useMemo from '../_util/hooks/useMemo'; +import { isPlatformMac } from './utils/platformUtil'; + +export interface RefOptionListProps { + onKeydown: (e?: KeyboardEvent) => void; + onKeyup: (e?: KeyboardEvent) => void; + scrollTo?: (index: number) => void; +} + +import type { EventHandler } from '../_util/EventInterface'; +import omit from '../_util/omit'; +import useBaseProps from './hooks/useBaseProps'; +import type { RawValueType } from './Select'; +import useSelectProps from './SelectContext'; +// export interface OptionListProps { +export type OptionListProps = Record; + +/** + * Using virtual list of option display. + * Will fallback to dom if use customize render. + */ +const OptionList = defineComponent({ + name: 'OptionList', + inheritAttrs: false, + slots: ['option'], + setup(_, { expose, slots }) { + const baseProps = useBaseProps(); + const props = useSelectProps(); + const itemPrefixCls = computed(() => `${baseProps.prefixCls}-item`); + + const memoFlattenOptions = useMemo( + () => props.flattenOptions, + [() => baseProps.open, () => props.flattenOptions], + next => next[0], + ); + + // =========================== List =========================== + const listRef = createRef(); + + const onListMouseDown: EventHandler = event => { + event.preventDefault(); + }; + + const scrollIntoView = (index: number) => { + if (listRef.current) { + listRef.current.scrollTo({ index }); + } + }; + + // ========================== Active ========================== + const getEnabledActiveIndex = (index: number, offset = 1) => { + const len = memoFlattenOptions.value.length; + + for (let i = 0; i < len; i += 1) { + const current = (index + i * offset + len) % len; + + const { group, data } = memoFlattenOptions.value[current]; + if (!group && !data.disabled) { + return current; + } + } + + return -1; + }; + const state = reactive({ + activeIndex: getEnabledActiveIndex(0), + }); + + const setActive = (index: number, fromKeyboard = false) => { + state.activeIndex = index; + const info = { source: fromKeyboard ? ('keyboard' as const) : ('mouse' as const) }; + + // Trigger active event + const flattenItem = memoFlattenOptions.value[index]; + if (!flattenItem) { + props.onActiveValue(null, -1, info); + return; + } + + props.onActiveValue(flattenItem.data.value, index, info); + }; + + // Auto active first item when list length or searchValue changed + + watch( + [() => memoFlattenOptions.value.length, () => baseProps.searchValue], + () => { + setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1); + }, + { immediate: true }, + ); + // Auto scroll to item position in single mode + + watch( + [() => baseProps.open, () => baseProps.searchValue], + () => { + if (!baseProps.multiple && baseProps.open && props.rawValues.size === 1) { + const value = Array.from(props.rawValues)[0]; + const index = memoFlattenOptions.value.findIndex(({ data }) => data.value === value); + if (index !== -1) { + setActive(index); + nextTick(() => { + scrollIntoView(index); + }); + } + } + // Force trigger scrollbar visible when open + if (baseProps.open) { + nextTick(() => { + listRef.current?.scrollTo(undefined); + }); + } + }, + { immediate: true, flush: 'post' }, + ); + + // ========================== Values ========================== + const onSelectValue = (value?: RawValueType) => { + if (value !== undefined) { + props.onSelect(value, { selected: !props.rawValues.has(value) }); + } + + // Single mode should always close by select + if (!baseProps.multiple) { + baseProps.toggleOpen(false); + } + }; + const getLabel = (item: Record) => item.label; + function renderItem(index: number) { + const item = memoFlattenOptions.value[index]; + if (!item) return null; + + const itemData = item.data || {}; + const { value } = itemData; + const { group } = item; + const attrs = pickAttrs(itemData, true); + const mergedLabel = getLabel(item); + return item ? ( +
+ {value} +
+ ) : null; + } + const onKeydown = (event: KeyboardEvent) => { + const { which, ctrlKey } = event; + switch (which) { + // >>> Arrow keys & ctrl + n/p on Mac + case KeyCode.N: + case KeyCode.P: + case KeyCode.UP: + case KeyCode.DOWN: { + let offset = 0; + if (which === KeyCode.UP) { + offset = -1; + } else if (which === KeyCode.DOWN) { + offset = 1; + } else if (isPlatformMac() && ctrlKey) { + if (which === KeyCode.N) { + offset = 1; + } else if (which === KeyCode.P) { + offset = -1; + } + } + + if (offset !== 0) { + const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset); + scrollIntoView(nextActiveIndex); + setActive(nextActiveIndex, true); + } + + break; + } + + // >>> Select + case KeyCode.ENTER: { + // value + const item = memoFlattenOptions.value[state.activeIndex]; + if (item && !item.data.disabled) { + onSelectValue(item.data.value); + } else { + onSelectValue(undefined); + } + + if (baseProps.open) { + event.preventDefault(); + } + + break; + } + + // >>> Close + case KeyCode.ESC: { + baseProps.toggleOpen(false); + if (baseProps.open) { + event.stopPropagation(); + } + } + } + }; + const onKeyup = () => {}; + + const scrollTo = (index: number) => { + scrollIntoView(index); + }; + expose({ + onKeydown, + onKeyup, + scrollTo, + }); + return () => { + // const { + // renderItem, + // listRef, + // onListMouseDown, + // itemPrefixCls, + // setActive, + // onSelectValue, + // memoFlattenOptions, + // $slots, + // } = this as any; + const { id, notFoundContent, onPopupScroll } = baseProps; + const { menuItemSelectedIcon, rawValues, fieldNames, virtual, listHeight, listItemHeight } = + props; + + const renderOption = slots.option; + const { activeIndex } = state; + const omitFieldNameList = Object.keys(fieldNames).map(key => fieldNames[key]); + // ========================== Render ========================== + if (memoFlattenOptions.value.length === 0) { + return ( +
+ {notFoundContent} +
+ ); + } + return ( + <> +
+ {renderItem(activeIndex - 1)} + {renderItem(activeIndex)} + {renderItem(activeIndex + 1)} +
+ { + const { group, groupOption, data, label, value } = item; + const { key } = data; + // Group + if (group) { + return ( +
+ {renderOption ? renderOption(data) : label !== undefined ? label : key} +
+ ); + } + + const { + disabled, + title, + children, + style, + class: cls, + className, + ...otherProps + } = data; + const passedProps = omit(otherProps, omitFieldNameList); + // Option + const selected = rawValues.has(value); + + const optionPrefixCls = `${itemPrefixCls.value}-option`; + const optionClassName = classNames(itemPrefixCls, optionPrefixCls, cls, className, { + [`${optionPrefixCls}-grouped`]: groupOption, + [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled, + [`${optionPrefixCls}-disabled`]: disabled, + [`${optionPrefixCls}-selected`]: selected, + }); + + const mergedLabel = getLabel(item); + + const iconVisible = + !menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected; + + const content = mergedLabel || value; + // https://github.com/ant-design/ant-design/issues/26717 + let optionTitle = + typeof content === 'string' || typeof content === 'number' + ? content.toString() + : undefined; + if (title !== undefined) { + optionTitle = title; + } + + return ( +
{ + if (otherProps.onMousemove) { + otherProps.onMousemove(e); + } + if (activeIndex === itemIndex || disabled) { + return; + } + setActive(itemIndex); + }} + onClick={e => { + if (!disabled) { + onSelectValue(value); + } + if (otherProps.onClick) { + otherProps.onClick(e); + } + }} + style={style} + > +
+ {renderOption ? renderOption(data) : content} +
+ {isValidElement(menuItemSelectedIcon) || selected} + {iconVisible && ( + + {selected ? '✓' : null} + + )} +
+ ); + }, + }} + >
+ + ); + }; + }, +}); + +export default OptionList; diff --git a/components/vc-select copy/Select.tsx b/components/vc-select copy/Select.tsx new file mode 100644 index 000000000..5b713010a --- /dev/null +++ b/components/vc-select copy/Select.tsx @@ -0,0 +1,162 @@ +/** + * To match accessibility requirement, we always provide an input in the component. + * Other element will not set `tabIndex` to avoid `onBlur` sequence problem. + * For focused select, we set `aria-live="polite"` to update the accessibility content. + * + * ref: + * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions + * + * New api: + * - listHeight + * - listItemHeight + * - component + * + * Remove deprecated api: + * - multiple + * - tags + * - combobox + * - firstActiveValue + * - dropdownMenuStyle + * - openClassName (Not list in api) + * + * Update: + * - `backfill` only support `combobox` mode + * - `combobox` mode not support `labelInValue` since it's meaningless + * - `getInputElement` only support `combobox` mode + * - `onChange` return OptionData instead of ReactNode + * - `filterOption` `onChange` `onSelect` accept OptionData instead of ReactNode + * - `combobox` mode trigger `onChange` will get `undefined` if no `value` match in Option + * - `combobox` mode not support `optionLabelProp` + */ + +import BaseSelect, { baseSelectPropsWithoutPrivate, isMultiple } from './BaseSelect'; +import type { + DisplayValueType, + RenderNode, + BaseSelectRef, + BaseSelectPropsWithoutPrivate, + BaseSelectProps, +} from './BaseSelect'; +import OptionList from './OptionList'; +import Option from './Option'; +import OptGroup from './OptGroup'; +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'; +import type { Key } from '../_util/type'; +import { defineComponent } from 'vue'; +import type { ExtractPropTypes, PropType } from 'vue'; +import PropTypes from '../_util/vue-types'; + +const OMIT_DOM_PROPS = ['inputValue']; + +export type OnActiveValue = ( + active: RawValueType, + index: number, + info?: { source?: 'keyboard' | 'mouse' }, +) => void; + +export type OnInternalSelect = (value: RawValueType, info: { selected: boolean }) => void; + +export type RawValueType = string | number; +export interface LabelInValueType { + label: any; + value: RawValueType; + /** @deprecated `key` is useless since it should always same as `value` */ + key?: 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: any; + value?: string | number | null; + children?: Omit[]; +} + +export type SelectHandler = + | ((value: RawValueType | LabelInValueType, option: OptionType) => void) + | ((value: ValueType, option: OptionType) => void); + +export function selectProps< + ValueType = any, + OptionType extends BaseOptionType = DefaultOptionType, +>() { + return { + ...baseSelectPropsWithoutPrivate(), + prefixCls: String, + id: String, + + backfill: { type: Boolean, default: undefined }, + + // >>> Field Names + fieldNames: Object as PropType, + + // >>> Search + /** @deprecated Use `searchValue` instead */ + inputValue: String, + searchValue: String, + onSearch: Function as PropType<(value: string) => void>, + autoClearSearchValue: { type: Boolean, default: undefined }, + + // >>> Select + onSelect: Function as PropType>, + onDeselect: Function as PropType>, + + // >>> Options + /** + * In Select, `false` means do nothing. + * In TreeSelect, `false` will highlight match item. + * It's by design. + */ + filterOption: { + type: [Boolean, Function] as PropType>, + default: undefined, + }, + filterSort: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>, + optionFilterProp: String, + optionLabelProp: String, + children: PropTypes.any, + options: Array as PropType, + defaultActiveFirstOption: { type: Boolean, default: undefined }, + virtual: { type: Boolean, default: undefined }, + listHeight: Number, + listItemHeight: Number, + + // >>> Icon + menuItemSelectedIcon: PropTypes.any, + + mode: String as PropType<'combobox' | 'multiple' | 'tags'>, + labelInValue: { type: Boolean, default: undefined }, + value: PropTypes.any, + defaultValue: PropTypes.any, + onChange: Function as PropType<(value: ValueType, option: OptionType | OptionType[]) => void>, + }; +} + +export type SelectProps = Partial>>; + +export default defineComponent({}); diff --git a/components/vc-select copy/SelectContext.ts b/components/vc-select copy/SelectContext.ts new file mode 100644 index 000000000..88d2760ce --- /dev/null +++ b/components/vc-select copy/SelectContext.ts @@ -0,0 +1,36 @@ +/** + * BaseSelect provide some parsed data into context. + * You can use this hooks to get them. + */ + +import type { InjectionKey } from 'vue'; +import { inject, provide } from 'vue'; +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; +} + +const SelectContextKey: InjectionKey = Symbol('SelectContextKey'); + +export function useProvideSelectProps(props: SelectContextProps) { + return provide(SelectContextKey, props); +} + +export default function useSelectProps() { + return inject(SelectContextKey, {} as SelectContextProps); +} diff --git a/components/vc-select copy/SelectTrigger.tsx b/components/vc-select copy/SelectTrigger.tsx new file mode 100644 index 000000000..ed4ed7550 --- /dev/null +++ b/components/vc-select copy/SelectTrigger.tsx @@ -0,0 +1,193 @@ +import Trigger from '../vc-trigger'; +import PropTypes from '../_util/vue-types'; +import classNames from '../_util/classNames'; +import type { CSSProperties } from 'vue'; +import { computed, ref, defineComponent } from 'vue'; +import type { VueNode } from '../_util/type'; +import type { DropdownRender, Placement, RenderDOMFunc } from './BaseSelect'; + +const getBuiltInPlacements = (adjustX: number) => { + return { + bottomLeft: { + points: ['tl', 'bl'], + offset: [0, 4], + overflow: { + adjustX, + adjustY: 1, + }, + }, + bottomRight: { + points: ['tr', 'br'], + offset: [0, 4], + overflow: { + adjustX, + adjustY: 1, + }, + }, + topLeft: { + points: ['bl', 'tl'], + offset: [0, -4], + overflow: { + adjustX, + adjustY: 1, + }, + }, + topRight: { + points: ['br', 'tr'], + offset: [0, -4], + overflow: { + adjustX, + adjustY: 1, + }, + }, + }; +}; + +const getAdjustX = ( + adjustXDependencies: Pick, +) => { + const { autoAdjustOverflow, dropdownMatchSelectWidth } = adjustXDependencies; + if (!!autoAdjustOverflow) return 1; + // Enable horizontal overflow auto-adjustment when a custom dropdown width is provided + return typeof dropdownMatchSelectWidth !== 'number' ? 0 : 1; +}; +export interface RefTriggerProps { + getPopupElement: () => HTMLDivElement; +} + +export interface SelectTriggerProps { + prefixCls: string; + disabled: boolean; + visible: boolean; + popupElement: VueNode; + animation?: string; + transitionName?: string; + containerWidth: number; + placement?: Placement; + dropdownStyle: CSSProperties; + dropdownClassName: string; + direction: string; + dropdownMatchSelectWidth?: boolean | number; + dropdownRender?: DropdownRender; + getPopupContainer?: RenderDOMFunc; + dropdownAlign: object; + empty: boolean; + autoAdjustOverflow?: boolean; + getTriggerDOMNode: () => any; + onPopupVisibleChange?: (visible: boolean) => void; + + onPopupMouseEnter: () => void; +} + +const SelectTrigger = defineComponent({ + name: 'SelectTrigger', + inheritAttrs: false, + props: { + dropdownAlign: PropTypes.object, + visible: PropTypes.looseBool, + disabled: PropTypes.looseBool, + dropdownClassName: PropTypes.string, + dropdownStyle: PropTypes.object, + placement: PropTypes.string, + empty: PropTypes.looseBool, + autoAdjustOverflow: PropTypes.looseBool, + prefixCls: PropTypes.string, + popupClassName: PropTypes.string, + animation: PropTypes.string, + transitionName: PropTypes.string, + getPopupContainer: PropTypes.func, + dropdownRender: PropTypes.func, + containerWidth: PropTypes.number, + dropdownMatchSelectWidth: PropTypes.oneOfType([Number, Boolean]).def(true), + popupElement: PropTypes.any, + direction: PropTypes.string, + getTriggerDOMNode: PropTypes.func, + onPopupVisibleChange: PropTypes.func, + onPopupMouseEnter: PropTypes.func, + } as any, + setup(props, { slots, attrs, expose }) { + const builtInPlacements = computed(() => { + const { autoAdjustOverflow, dropdownMatchSelectWidth } = props; + return getBuiltInPlacements( + getAdjustX({ + autoAdjustOverflow, + dropdownMatchSelectWidth, + }), + ); + }); + const popupRef = ref(); + expose({ + getPopupElement: () => { + return popupRef.value; + }, + }); + return () => { + const { empty = false, ...restProps } = { ...props, ...attrs }; + const { + visible, + dropdownAlign, + prefixCls, + popupElement, + dropdownClassName, + dropdownStyle, + direction = 'ltr', + placement, + dropdownMatchSelectWidth, + containerWidth, + dropdownRender, + animation, + transitionName, + getPopupContainer, + getTriggerDOMNode, + onPopupVisibleChange, + onPopupMouseEnter, + } = restProps as SelectTriggerProps; + const dropdownPrefixCls = `${prefixCls}-dropdown`; + + let popupNode = popupElement; + if (dropdownRender) { + popupNode = dropdownRender({ menuNode: popupElement, props }); + } + + const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; + + const popupStyle = { minWidth: `${containerWidth}px`, ...dropdownStyle }; + + if (typeof dropdownMatchSelectWidth === 'number') { + popupStyle.width = `${dropdownMatchSelectWidth}px`; + } else if (dropdownMatchSelectWidth) { + popupStyle.width = `${containerWidth}px`; + } + return ( + ( +
+ {popupNode} +
+ ), + }} + >
+ ); + }; + }, +}); + +export default SelectTrigger; diff --git a/components/vc-select copy/Selector/Input.tsx b/components/vc-select copy/Selector/Input.tsx new file mode 100644 index 000000000..9013055cd --- /dev/null +++ b/components/vc-select copy/Selector/Input.tsx @@ -0,0 +1,218 @@ +import { cloneElement } from '../../_util/vnode'; +import type { VNode } from 'vue'; +import { defineComponent, getCurrentInstance, inject, onMounted, withDirectives } from 'vue'; +import PropTypes from '../../_util/vue-types'; +import type { RefObject } from '../../_util/createRef'; +import antInput from '../../_util/antInputDirective'; +import classNames from '../../_util/classNames'; +import type { EventHandler } from '../../_util/EventInterface'; +import type { VueNode } from '../../_util/type'; + +interface InputProps { + prefixCls: string; + id: string; + inputElement: VueNode; + disabled: boolean; + autofocus: boolean; + autocomplete: string; + editable: boolean; + activeDescendantId?: string; + value: string; + open: boolean; + tabindex: number | string; + /** Pass accessibility props to input */ + attrs: object; + inputRef: RefObject; + onKeydown: EventHandler; + onMousedown: EventHandler; + onChange: EventHandler; + onPaste: EventHandler; + onCompositionstart: EventHandler; + onCompositionend: EventHandler; + onFocus: EventHandler; + onBlur: EventHandler; +} + +const Input = defineComponent({ + name: 'Input', + inheritAttrs: false, + props: { + inputRef: PropTypes.any, + prefixCls: PropTypes.string, + id: PropTypes.string, + inputElement: PropTypes.any, + disabled: PropTypes.looseBool, + autofocus: PropTypes.looseBool, + autocomplete: PropTypes.string, + editable: PropTypes.looseBool, + activeDescendantId: PropTypes.string, + value: PropTypes.string, + open: PropTypes.looseBool, + tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** Pass accessibility props to input */ + attrs: PropTypes.object, + onKeydown: PropTypes.func, + onMousedown: PropTypes.func, + onChange: PropTypes.func, + onPaste: PropTypes.func, + onCompositionstart: PropTypes.func, + onCompositionend: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + }, + setup(props) { + if (process.env.NODE_ENV === 'test') { + onMounted(() => { + const ins = getCurrentInstance(); + if (props.autofocus) { + if (ins.vnode && ins.vnode.el) { + ins.vnode.el.focus(); + } + } + }); + } + return { + blurTimeout: null, + VCSelectContainerEvent: inject('VCSelectContainerEvent') as any, + }; + }, + render() { + const { + prefixCls, + id, + inputElement, + disabled, + tabindex, + autofocus, + autocomplete, + editable, + activeDescendantId, + value, + onKeydown, + onMousedown, + onChange, + onPaste, + onCompositionstart, + onCompositionend, + onFocus, + onBlur, + open, + inputRef, + attrs, + } = this.$props as InputProps; + let inputNode: any = inputElement || withDirectives(() as VNode, [[antInput]]); + + const inputProps = inputNode.props || {}; + const { + onKeydown: onOriginKeyDown, + onInput: onOriginInput, + onFocus: onOriginFocus, + onBlur: onOriginBlur, + onMousedown: onOriginMouseDown, + onCompositionstart: onOriginCompositionStart, + onCompositionend: onOriginCompositionEnd, + style, + } = inputProps; + inputNode = cloneElement( + inputNode, + Object.assign( + { + id, + ref: inputRef, + disabled, + tabindex, + autocomplete: autocomplete || 'off', + autofocus, + class: classNames(`${prefixCls}-selection-search-input`, inputNode?.props?.className), + style: { ...style, opacity: editable ? null : 0 }, + role: 'combobox', + 'aria-expanded': open, + 'aria-haspopup': 'listbox', + 'aria-owns': `${id}_list`, + 'aria-autocomplete': 'list', + 'aria-controls': `${id}_list`, + 'aria-activedescendant': activeDescendantId, + ...attrs, + value: editable ? value : '', + readonly: !editable, + unselectable: !editable ? 'on' : null, + onKeydown: (event: KeyboardEvent) => { + onKeydown(event); + if (onOriginKeyDown) { + onOriginKeyDown(event); + } + }, + onMousedown: (event: MouseEvent) => { + onMousedown(event); + if (onOriginMouseDown) { + onOriginMouseDown(event); + } + }, + onInput: (event: Event) => { + onChange(event); + if (onOriginInput) { + onOriginInput(event); + } + }, + onCompositionstart(event: CompositionEvent) { + onCompositionstart(event); + if (onOriginCompositionStart) { + onOriginCompositionStart(event); + } + }, + onCompositionend(event: CompositionEvent) { + onCompositionend(event); + if (onOriginCompositionEnd) { + onOriginCompositionEnd(event); + } + }, + onPaste, + onFocus: (...args: any[]) => { + clearTimeout(this.blurTimeout); + onOriginFocus && onOriginFocus(args[0]); + onFocus && onFocus(args[0]); + this.VCSelectContainerEvent?.focus(args[0]); + }, + onBlur: (...args: any[]) => { + this.blurTimeout = setTimeout(() => { + onOriginBlur && onOriginBlur(args[0]); + onBlur && onBlur(args[0]); + this.VCSelectContainerEvent?.blur(args[0]); + }, 200); + }, + }, + inputNode.type === 'textarea' ? {} : { type: 'search' }, + ), + true, + true, + ) as VNode; + return inputNode; + }, +}); + +// Input.props = { +// inputRef: PropTypes.any, +// prefixCls: PropTypes.string, +// id: PropTypes.string, +// inputElement: PropTypes.any, +// disabled: PropTypes.looseBool, +// autofocus: PropTypes.looseBool, +// autocomplete: PropTypes.string, +// editable: PropTypes.looseBool, +// accessibilityIndex: PropTypes.number, +// value: PropTypes.string, +// open: PropTypes.looseBool, +// tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), +// /** Pass accessibility props to input */ +// attrs: PropTypes.object, +// onKeydown: PropTypes.func, +// onMousedown: PropTypes.func, +// onChange: PropTypes.func, +// onPaste: PropTypes.func, +// onCompositionstart: PropTypes.func, +// onCompositionend: PropTypes.func, +// onFocus: PropTypes.func, +// onBlur: PropTypes.func, +// }; + +export default Input; diff --git a/components/vc-select copy/Selector/MultipleSelector.tsx b/components/vc-select copy/Selector/MultipleSelector.tsx new file mode 100644 index 000000000..40408a2fa --- /dev/null +++ b/components/vc-select copy/Selector/MultipleSelector.tsx @@ -0,0 +1,281 @@ +import TransBtn from '../TransBtn'; +import type { InnerSelectorProps } from './interface'; +import Input from './Input'; +import type { Ref, PropType } from 'vue'; +import { computed, defineComponent, onMounted, ref, watch } from 'vue'; +import classNames from '../../_util/classNames'; +import pickAttrs from '../../_util/pickAttrs'; +import PropTypes from '../../_util/vue-types'; +import type { VueNode } from '../../_util/type'; +import Overflow from '../../vc-overflow'; +import type { DisplayValueType, RenderNode, CustomTagProps, RawValueType } from '../BaseSelect'; + +type SelectorProps = InnerSelectorProps & { + // Icon + removeIcon?: RenderNode; + + // Tags + maxTagCount?: number | 'responsive'; + maxTagTextLength?: number; + maxTagPlaceholder?: VueNode | ((omittedValues: DisplayValueType[]) => VueNode); + tokenSeparators?: string[]; + tagRender?: (props: CustomTagProps) => VueNode; + onToggleOpen: any; + + // Motion + choiceTransitionName?: string; + + // Event + onRemove: (value: DisplayValueType) => void; +}; + +const props = { + id: PropTypes.string, + prefixCls: PropTypes.string, + values: PropTypes.array, + open: PropTypes.looseBool, + searchValue: PropTypes.string, + inputRef: PropTypes.any, + placeholder: PropTypes.any, + disabled: PropTypes.looseBool, + mode: PropTypes.string, + showSearch: PropTypes.looseBool, + autofocus: PropTypes.looseBool, + autocomplete: PropTypes.string, + activeDescendantId: PropTypes.string, + tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + removeIcon: PropTypes.any, + choiceTransitionName: PropTypes.string, + + maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + maxTagTextLength: PropTypes.number, + maxTagPlaceholder: PropTypes.any.def( + () => (omittedValues: DisplayValueType[]) => `+ ${omittedValues.length} ...`, + ), + tagRender: PropTypes.func, + + onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, + onRemove: PropTypes.func, + onInputChange: PropTypes.func, + onInputPaste: PropTypes.func, + onInputKeyDown: PropTypes.func, + onInputMouseDown: PropTypes.func, + onInputCompositionStart: PropTypes.func, + onInputCompositionEnd: PropTypes.func, +}; + +const onPreventMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + +const SelectSelector = defineComponent({ + name: 'MultipleSelectSelector', + inheritAttrs: false, + props: props as any, + setup(props) { + const measureRef = ref(); + const inputWidth = ref(0); + const focused = ref(false); + + const selectionPrefixCls = computed(() => `${props.prefixCls}-selection`); + + // ===================== Search ====================== + const inputValue = computed(() => + props.open || props.mode === 'tags' ? props.searchValue : '', + ); + const inputEditable: Ref = computed( + () => + props.mode === 'tags' || ((props.showSearch && (props.open || focused.value)) as boolean), + ); + + // We measure width and set to the input immediately + onMounted(() => { + watch( + inputValue, + () => { + inputWidth.value = measureRef.value.scrollWidth; + }, + { flush: 'post', immediate: true }, + ); + }); + + // ===================== Render ====================== + // >>> Render Selector Node. Includes Item & Rest + function defaultRenderSelector( + title: VueNode, + content: VueNode, + itemDisabled: boolean, + closable?: boolean, + onClose?: (e: MouseEvent) => void, + ) { + return ( + + {content} + {closable && ( + + × + + )} + + ); + } + + function customizeRenderSelector( + value: RawValueType, + content: VueNode, + itemDisabled: boolean, + closable: boolean, + onClose: (e: MouseEvent) => void, + ) { + const onMouseDown = (e: MouseEvent) => { + onPreventMouseDown(e); + props.onToggleOpen(!open); + }; + + return ( + + {props.tagRender({ + label: content, + value, + disabled: itemDisabled, + closable, + onClose, + })} + + ); + } + + function renderItem(valueItem: DisplayValueType) { + const { disabled: itemDisabled, label, value } = valueItem; + const closable = !props.disabled && !itemDisabled; + + let displayLabel = label; + + if (typeof props.maxTagTextLength === 'number') { + if (typeof label === 'string' || typeof label === 'number') { + const strLabel = String(displayLabel); + + if (strLabel.length > props.maxTagTextLength) { + displayLabel = `${strLabel.slice(0, props.maxTagTextLength)}...`; + } + } + } + const onClose = (event?: MouseEvent) => { + if (event) event.stopPropagation(); + props.onRemove?.(valueItem); + }; + + return typeof props.tagRender === 'function' + ? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose) + : defaultRenderSelector(label, displayLabel, itemDisabled, closable, onClose); + } + + function renderRest(omittedValues: DisplayValueType[]) { + const { maxTagPlaceholder = omittedValues => `+ ${omittedValues.length} ...` } = props; + const content = + typeof maxTagPlaceholder === 'function' + ? maxTagPlaceholder(omittedValues) + : maxTagPlaceholder; + + return defaultRenderSelector(content, content, false); + } + + return () => { + const { + id, + prefixCls, + values, + open, + inputRef, + placeholder, + disabled, + autofocus, + autocomplete, + activeDescendantId, + tabindex, + onInputChange, + onInputPaste, + onInputKeyDown, + onInputMouseDown, + onInputCompositionStart, + onInputCompositionEnd, + } = props; + + // >>> Input Node + const inputNode = ( +
+ (focused.value = true)} + onBlur={() => (focused.value = false)} + /> + + {/* Measure Node */} + + {inputValue.value}  + +
+ ); + + // >>> Selections + const selectionNode = ( + + ); + return ( + <> + {selectionNode} + {!values.length && !inputValue.value && ( + {placeholder} + )} + + ); + }; + }, +}); + +export default SelectSelector; diff --git a/components/vc-select copy/Selector/SingleSelector.tsx b/components/vc-select copy/Selector/SingleSelector.tsx new file mode 100644 index 000000000..a6ecde102 --- /dev/null +++ b/components/vc-select copy/Selector/SingleSelector.tsx @@ -0,0 +1,172 @@ +import pickAttrs from '../../_util/pickAttrs'; +import Input from './Input'; +import type { InnerSelectorProps } from './interface'; +import { Fragment, computed, defineComponent, ref, watch } from 'vue'; +import PropTypes from '../../_util/vue-types'; +import { useInjectTreeSelectContext } from '../../vc-tree-select/Context'; +import type { VueNode } from '../../_util/type'; + +interface SelectorProps extends InnerSelectorProps { + inputElement: VueNode; + activeValue: string; +} +const props = { + inputElement: PropTypes.any, + id: PropTypes.string, + prefixCls: PropTypes.string, + values: PropTypes.array, + open: PropTypes.looseBool, + searchValue: PropTypes.string, + inputRef: PropTypes.any, + placeholder: PropTypes.any, + disabled: PropTypes.looseBool, + mode: PropTypes.string, + showSearch: PropTypes.looseBool, + autofocus: PropTypes.looseBool, + autocomplete: PropTypes.string, + activeDescendantId: PropTypes.string, + tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + activeValue: PropTypes.string, + backfill: PropTypes.looseBool, + onInputChange: PropTypes.func, + onInputPaste: PropTypes.func, + onInputKeyDown: PropTypes.func, + onInputMouseDown: PropTypes.func, + onInputCompositionStart: PropTypes.func, + onInputCompositionEnd: PropTypes.func, +}; +const SingleSelector = defineComponent({ + name: 'SingleSelector', + setup(props) { + const inputChanged = ref(false); + + const combobox = computed(() => props.mode === 'combobox'); + const inputEditable = computed(() => combobox.value || props.showSearch); + + const inputValue = computed(() => { + let inputValue: string = props.searchValue || ''; + if (combobox.value && props.activeValue && !inputChanged.value) { + inputValue = props.activeValue; + } + return inputValue; + }); + const treeSelectContext = useInjectTreeSelectContext(); + watch( + [combobox, () => props.activeValue], + () => { + if (combobox.value) { + inputChanged.value = false; + } + }, + { immediate: true }, + ); + + // Not show text when closed expect combobox mode + const hasTextInput = computed(() => + props.mode !== 'combobox' && !props.open && !props.showSearch ? false : !!inputValue.value, + ); + + const title = computed(() => { + const item = props.values[0]; + return item && (typeof item.label === 'string' || typeof item.label === 'number') + ? item.label.toString() + : undefined; + }); + + const renderPlaceholder = () => { + if (props.values[0]) { + return null; + } + const hiddenStyle = hasTextInput.value ? { visibility: 'hidden' as const } : undefined; + return ( + + {props.placeholder} + + ); + }; + + return () => { + const { + inputElement, + prefixCls, + id, + values, + inputRef, + disabled, + autofocus, + autocomplete, + activeDescendantId, + open, + tabindex, + onInputKeyDown, + onInputMouseDown, + onInputChange, + onInputPaste, + onInputCompositionStart, + onInputCompositionEnd, + } = props; + const item = values[0]; + let titleNode = null; + // custom tree-select title by slot + if (item && treeSelectContext.value.slots) { + titleNode = + treeSelectContext.value.slots[item?.option?.data?.slots?.title] || + treeSelectContext.value.slots.title || + item.label; + if (typeof titleNode === 'function') { + titleNode = titleNode(item.option?.data || {}); + } + // else if (treeSelectContext.value.slots.titleRender) { + // // 因历史 title 是覆盖逻辑,新增 titleRender,所有的 title 都走一遍 titleRender + // titleNode = treeSelectContext.value.slots.titleRender(item.option?.data || {}); + // } + } else { + titleNode = item?.label; + } + return ( + <> + + { + inputChanged.value = true; + onInputChange(e as any); + }} + onPaste={onInputPaste} + onCompositionstart={onInputCompositionStart} + onCompositionend={onInputCompositionEnd} + tabindex={tabindex} + attrs={pickAttrs(props, true)} + /> + + + {/* Display value */} + {!combobox.value && item && !hasTextInput.value && ( + + {titleNode} + + )} + + {/* Display placeholder */} + {renderPlaceholder()} + + ); + }; + }, +}); +SingleSelector.props = props; +SingleSelector.inheritAttrs = false; + +export default SingleSelector; diff --git a/components/vc-select copy/Selector/index.tsx b/components/vc-select copy/Selector/index.tsx new file mode 100644 index 000000000..6fc30878e --- /dev/null +++ b/components/vc-select copy/Selector/index.tsx @@ -0,0 +1,275 @@ +/** + * Cursor rule: + * 1. Only `showSearch` enabled + * 2. Only `open` is `true` + * 3. When typing, set `open` to `true` which hit rule of 2 + * + * Accessibility: + * - https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html + */ + +import KeyCode from '../../_util/KeyCode'; +import MultipleSelector from './MultipleSelector'; +import SingleSelector from './SingleSelector'; +import type { CustomTagProps, DisplayValueType, Mode, RenderNode } from '../BaseSelect'; +import { isValidateOpenKey } from '../utils/keyUtil'; +import useLock from '../hooks/useLock'; +import type { PropType } from 'vue'; +import { defineComponent } from 'vue'; +import createRef from '../../_util/createRef'; +import PropTypes from '../../_util/vue-types'; +import type { VueNode } from '../../_util/type'; +import type { EventHandler } from '../../_util/EventInterface'; +import type { ScrollTo } from '../../vc-virtual-list/List'; + +export interface SelectorProps { + id: string; + prefixCls: string; + showSearch?: boolean; + open: boolean; + values: DisplayValueType[]; + multiple?: boolean; + mode: Mode; + searchValue: string; + activeValue: string; + inputElement: VueNode; + + autofocus?: boolean; + activeDescendantId?: string; + tabindex?: number | string; + disabled?: boolean; + placeholder?: VueNode; + removeIcon?: RenderNode; + + // Tags + maxTagCount?: number | 'responsive'; + maxTagTextLength?: number; + maxTagPlaceholder?: VueNode | ((omittedValues: DisplayValueType[]) => VueNode); + tagRender?: (props: CustomTagProps) => VueNode; + + /** Check if `tokenSeparators` contains `\n` or `\r\n` */ + tokenWithEnter?: boolean; + + // Motion + choiceTransitionName?: string; + + onToggleOpen: (open?: boolean) => void | any; + /** `onSearch` returns go next step boolean to check if need do toggle open */ + onSearch: (searchText: string, fromTyping: boolean, isCompositing: boolean) => boolean; + onSearchSubmit: (searchText: string) => void; + onRemove: (value: DisplayValueType) => void; + onInputKeyDown?: (e: KeyboardEvent) => void; + + /** + * @private get real dom for trigger align. + * This may be removed after React provides replacement of `findDOMNode` + */ + domRef: () => HTMLDivElement; +} +export interface RefSelectorProps { + focus: () => void; + blur: () => void; + scrollTo?: ScrollTo; +} + +const Selector = defineComponent({ + name: 'Selector', + inheritAttrs: false, + props: { + id: PropTypes.string, + prefixCls: PropTypes.string, + showSearch: PropTypes.looseBool, + open: PropTypes.looseBool, + /** Display in the Selector value, it's not same as `value` prop */ + values: PropTypes.array, + multiple: PropTypes.looseBool, + mode: PropTypes.string, + searchValue: PropTypes.string, + activeValue: PropTypes.string, + inputElement: PropTypes.any, + + autofocus: PropTypes.looseBool, + activeDescendantId: PropTypes.string, + tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + disabled: PropTypes.looseBool, + placeholder: PropTypes.any, + removeIcon: PropTypes.any, + + // Tags + maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + maxTagTextLength: PropTypes.number, + maxTagPlaceholder: PropTypes.any, + tagRender: PropTypes.func, + + /** Check if `tokenSeparators` contains `\n` or `\r\n` */ + tokenWithEnter: PropTypes.looseBool, + + // Motion + choiceTransitionName: PropTypes.string, + + onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, + /** `onSearch` returns go next step boolean to check if need do toggle open */ + onSearch: PropTypes.func, + onSearchSubmit: PropTypes.func, + onRemove: PropTypes.func, + onInputKeyDown: { type: Function as PropType }, + + /** + * @private get real dom for trigger align. + * This may be removed after React provides replacement of `findDOMNode` + */ + domRef: PropTypes.func, + } as any, + setup(props, { expose }) { + const inputRef = createRef(); + let compositionStatus = false; + + // ====================== Input ====================== + const [getInputMouseDown, setInputMouseDown] = useLock(0); + + const onInternalInputKeyDown = (event: KeyboardEvent) => { + const { which } = event; + + if (which === KeyCode.UP || which === KeyCode.DOWN) { + event.preventDefault(); + } + + if (props.onInputKeyDown) { + props.onInputKeyDown(event); + } + + if (which === KeyCode.ENTER && props.mode === 'tags' && !compositionStatus && !props.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 + props.onSearchSubmit((event.target as HTMLInputElement).value); + } + + if (isValidateOpenKey(which)) { + props.onToggleOpen(true); + } + }; + + /** + * We can not use `findDOMNode` sine it will get warning, + * have to use timer to check if is input element. + */ + const onInternalInputMouseDown = () => { + setInputMouseDown(true); + }; + + // When paste come, ignore next onChange + let pastedText = null; + + const triggerOnSearch = (value: string) => { + if (props.onSearch(value, true, compositionStatus) !== false) { + props.onToggleOpen(true); + } + }; + + const onInputCompositionStart = () => { + compositionStatus = true; + }; + + const onInputCompositionEnd = (e: InputEvent) => { + compositionStatus = false; + // Trigger search again to support `tokenSeparators` with typewriting + if (props.mode !== 'combobox') { + triggerOnSearch((e.target as HTMLInputElement).value); + } + }; + + const onInputChange = (event: { target: { value: any } }) => { + let { + target: { value }, + } = event; + + // Pasted text should replace back to origin content + if (props.tokenWithEnter && pastedText && /[\r\n]/.test(pastedText)) { + // CRLF will be treated as a single space for input element + const replacedText = pastedText + .replace(/[\r\n]+$/, '') + .replace(/\r\n/g, ' ') + .replace(/[\r\n]/g, ' '); + value = value.replace(replacedText, pastedText); + } + + pastedText = null; + + triggerOnSearch(value); + }; + + const onInputPaste = (e: ClipboardEvent) => { + const { clipboardData } = e; + const value = clipboardData.getData('text'); + + pastedText = value; + }; + + const onClick = ({ target }) => { + if (target !== inputRef.current) { + // Should focus input if click the selector + const isIE = (document.body.style as any).msTouchAction !== undefined; + if (isIE) { + setTimeout(() => { + inputRef.current.focus(); + }); + } else { + inputRef.current.focus(); + } + } + }; + + const onMousedown = (event: MouseEvent) => { + const inputMouseDown = getInputMouseDown(); + if (event.target !== inputRef.current && !inputMouseDown) { + event.preventDefault(); + } + + if ((props.mode !== 'combobox' && (!props.showSearch || !inputMouseDown)) || !props.open) { + if (props.open) { + props.onSearch('', true, false); + } + props.onToggleOpen(); + } + }; + expose({ + focus: () => { + inputRef.current.focus(); + }, + blur: () => { + inputRef.current.blur(); + }, + }); + + return () => { + const { prefixCls, domRef, mode } = props as SelectorProps; + const sharedProps = { + inputRef, + onInputKeyDown: onInternalInputKeyDown, + onInputMouseDown: onInternalInputMouseDown, + onInputChange, + onInputPaste, + onInputCompositionStart, + onInputCompositionEnd, + }; + const selectNode = + mode === 'multiple' || mode === 'tags' ? ( + + ) : ( + + ); + return ( +
+ {selectNode} +
+ ); + }; + }, +}); + +export default Selector; diff --git a/components/vc-select copy/Selector/interface.ts b/components/vc-select copy/Selector/interface.ts new file mode 100644 index 000000000..18dfd6709 --- /dev/null +++ b/components/vc-select copy/Selector/interface.ts @@ -0,0 +1,28 @@ +import type { RefObject } from '../../_util/createRef'; + +import type { EventHandler } from '../../_util/EventInterface'; +import type { VueNode } from '../../_util/type'; +import type { Mode, DisplayValueType } from '../BaseSelect'; + +export interface InnerSelectorProps { + prefixCls: string; + id: string; + mode: Mode; + inputRef: RefObject; + placeholder?: VueNode; + disabled?: boolean; + autofocus?: boolean; + autocomplete?: string; + values: DisplayValueType[]; + showSearch?: boolean; + searchValue: string; + activeDescendantId: string; + open: boolean; + tabindex?: number | string; + onInputKeyDown: EventHandler; + onInputMouseDown: EventHandler; + onInputChange: EventHandler; + onInputPaste: EventHandler; + onInputCompositionStart: EventHandler; + onInputCompositionEnd: EventHandler; +} diff --git a/components/vc-select copy/TransBtn.tsx b/components/vc-select copy/TransBtn.tsx new file mode 100644 index 000000000..6c95ce1d9 --- /dev/null +++ b/components/vc-select copy/TransBtn.tsx @@ -0,0 +1,66 @@ +import type { FunctionalComponent } from 'vue'; +import type { VueNode } from '../_util/type'; +import PropTypes from '../_util/vue-types'; +import type { RenderNode } from './BaseSelect'; + +export interface TransBtnProps { + class: string; + customizeIcon: RenderNode; + customizeIconProps?: any; + onMousedown?: (payload: MouseEvent) => void; + onClick?: (payload: MouseEvent) => void; +} + +export interface TransBtnType extends FunctionalComponent { + displayName: string; +} + +const TransBtn: TransBtnType = (props, { slots }) => { + const { class: className, customizeIcon, customizeIconProps, onMousedown, onClick } = props; + let icon: VueNode; + + if (typeof customizeIcon === 'function') { + icon = customizeIcon(customizeIconProps); + } else { + icon = customizeIcon; + } + + return ( + { + event.preventDefault(); + if (onMousedown) { + onMousedown(event); + } + }} + style={{ + userSelect: 'none', + WebkitUserSelect: 'none', + }} + unselectable="on" + onClick={onClick} + aria-hidden + > + {icon !== undefined ? ( + icon + ) : ( + `${cls}-icon`)}> + {slots.default?.()} + + )} + + ); +}; + +TransBtn.inheritAttrs = false; +TransBtn.displayName = 'TransBtn'; +TransBtn.props = { + class: PropTypes.string, + customizeIcon: PropTypes.any, + customizeIconProps: PropTypes.any, + onMousedown: PropTypes.func, + onClick: PropTypes.func, +}; + +export default TransBtn; diff --git a/components/vc-select copy/generate.tsx b/components/vc-select copy/generate.tsx new file mode 100644 index 000000000..e29ad9ab9 --- /dev/null +++ b/components/vc-select copy/generate.tsx @@ -0,0 +1,1246 @@ +/** + * To match accessibility requirement, we always provide an input in the component. + * Other element will not set `tabindex` to avoid `onBlur` sequence problem. + * For focused select, we set `aria-live="polite"` to update the accessibility content. + * + * ref: + * - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions + */ + +import KeyCode from '../_util/KeyCode'; +import classNames from '../_util/classNames'; +import Selector from './Selector'; +import SelectTrigger from './SelectTrigger'; +import type { Mode, RenderDOMFunc, OnActiveValue, FieldNames } from './interface'; +import type { + GetLabeledValue, + FilterOptions, + FilterFunc, + DefaultValueType, + RawValueType, + Key, + DisplayLabelValueType, + FlattenOptionsType, + SingleType, + OnClear, + SelectSource, + CustomTagProps, +} from './interface/generator'; +import { INTERNAL_PROPS_MARK } from './interface/generator'; +import type { OptionListProps } from './OptionList'; +import { toInnerValue, toOuterValues, removeLastEnabledValue, getUUID } from './utils/commonUtil'; +import TransBtn from './TransBtn'; +import useLock from './hooks/useLock'; +import useDelayReset from './hooks/useDelayReset'; +import { getSeparatedContent } from './utils/valueUtil'; +import useSelectTriggerControl from './hooks/useSelectTriggerControl'; +import useCacheDisplayValue from './hooks/useCacheDisplayValue'; +import useCacheOptions from './hooks/useCacheOptions'; +import type { CSSProperties, PropType, VNode } from 'vue'; +import { + getCurrentInstance, + computed, + defineComponent, + onBeforeUnmount, + onMounted, + provide, + ref, + shallowRef, + watch, + watchEffect, +} from 'vue'; +import createRef from '../_util/createRef'; +import PropTypes from '../_util/vue-types'; +import warning from '../_util/warning'; +import isMobile from '../vc-util/isMobile'; +import { getTextFromElement } from '../_util/props-util'; +import type { VueNode } from '../_util/type'; + +const DEFAULT_OMIT_PROPS = [ + 'children', + 'removeIcon', + 'placeholder', + 'autofocus', + 'maxTagCount', + 'maxTagTextLength', + 'maxTagPlaceholder', + 'choiceTransitionName', + 'onInputKeyDown', + 'tabindex', +]; + +export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + +export function selectBaseProps() { + return { + prefixCls: String, + id: String, + + // Options + options: { type: Array as PropType }, + mode: { type: String as PropType }, + + // Value + value: { + type: [String, Number, Object, Array] as PropType, + default: undefined as ValueType, + }, + defaultValue: { + type: [String, Number, Object, Array] as PropType, + default: undefined as ValueType, + }, + labelInValue: { type: Boolean, default: undefined }, + + fieldNames: { type: Object as PropType }, + // 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: { + type: [Boolean, Function] as PropType>, + default: undefined, + }, + filterSort: { + type: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>, + }, + showSearch: { type: Boolean, default: undefined }, + autoClearSearchValue: { type: Boolean, default: undefined }, + onSearch: { type: Function as PropType<(value: string) => void> }, + onClear: { type: Function as PropType }, + + // Icons + allowClear: { type: Boolean, default: undefined }, + clearIcon: PropTypes.any, + showArrow: { type: Boolean, default: undefined }, + inputIcon: PropTypes.any, + removeIcon: PropTypes.any, + menuItemSelectedIcon: PropTypes.any, + + // Dropdown + open: { type: Boolean, default: undefined }, + defaultOpen: { type: Boolean, default: undefined }, + listHeight: Number, + listItemHeight: Number, + dropdownStyle: { type: Object as PropType }, + dropdownClassName: String, + dropdownMatchSelectWidth: { + type: [Boolean, Number] as PropType, + default: undefined, + }, + placement: { + type: String as PropType, + }, + virtual: { type: Boolean, default: undefined }, + dropdownRender: { type: Function as PropType<(menu: VNode) => any> }, + dropdownAlign: PropTypes.any, + animation: String, + transitionName: String, + getPopupContainer: { type: Function as PropType }, + direction: { type: String as PropType<'ltr' | 'rtl'> }, + + // Others + disabled: { type: Boolean, default: undefined }, + loading: { type: Boolean, default: undefined }, + autofocus: { type: Boolean, default: undefined }, + defaultActiveFirstOption: { type: Boolean, default: undefined }, + notFoundContent: PropTypes.any, + placeholder: PropTypes.any, + backfill: { type: Boolean, default: undefined }, + /** @private Internal usage. Do not use in your production. */ + getInputElement: { type: Function as PropType<() => any> }, + optionLabelProp: String, + maxTagTextLength: Number, + maxTagCount: { type: [String, Number] as PropType }, + maxTagPlaceholder: PropTypes.any, + tokenSeparators: { type: Array as PropType }, + tagRender: { type: Function as PropType<(props: CustomTagProps) => any> }, + showAction: { type: Array as PropType<('focus' | 'click')[]> }, + tabindex: { type: [Number, String] }, + + // Events + onKeyup: { type: Function as PropType<(e: KeyboardEvent) => void> }, + onKeydown: { type: Function as PropType<(e: KeyboardEvent) => void> }, + onPopupScroll: { type: Function as PropType<(e: UIEvent) => void> }, + onDropdownVisibleChange: { type: Function as PropType<(open: boolean) => void> }, + onSelect: { + type: Function as PropType<(value: SingleType, option: OptionType) => void>, + }, + onDeselect: { + type: Function as PropType<(value: SingleType, option: OptionType) => void>, + }, + onInputKeyDown: { type: Function as PropType<(e: KeyboardEvent) => void> }, + onClick: { type: Function as PropType<(e: MouseEvent) => void> }, + onChange: { + type: Function as PropType<(value: ValueType, option: OptionType | OptionType[]) => void>, + }, + onBlur: { type: Function as PropType<(e: FocusEvent) => void> }, + onFocus: { type: Function as PropType<(e: FocusEvent) => void> }, + onMousedown: { type: Function as PropType<(e: MouseEvent) => void> }, + onMouseenter: { type: Function as PropType<(e: MouseEvent) => void> }, + onMouseleave: { type: Function as PropType<(e: MouseEvent) => void> }, + + // Motion + choiceTransitionName: String, + + // Internal props + /** + * Only used in current version for internal event process. + * Do not use in production environment. + */ + internalProps: { + type: Object as PropType<{ + mark?: string; + onClear?: OnClear; + skipTriggerChange?: boolean; + skipTriggerSelect?: boolean; + onRawSelect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; + onRawDeselect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; + }>, + default: undefined as { + mark?: string; + onClear?: OnClear; + skipTriggerChange?: boolean; + skipTriggerSelect?: boolean; + onRawSelect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; + onRawDeselect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; + }, + }, + children: { type: Array as PropType }, + }; +} + +class Helper { + SelectBaseProps = selectBaseProps(); +} +type FuncReturnType = Helper['SelectBaseProps']; + +export type SelectProps = FuncReturnType; + +export interface GenerateConfig { + prefixCls: string; + components: { + // TODO + optionList: ( + props: Omit, 'options'> & { options?: OptionType[] }, + ) => JSX.Element; + // optionList: DefineComponent< + // Omit, 'options'> & { options?: OptionType[] } + // >; + }; + /** Convert jsx tree into `OptionType[]` */ + convertChildrenToData: (children: VueNode) => OptionType[]; + /** Flatten nest options into raw option list */ + flattenOptions: (options: OptionType[], 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) => OptionType[]) + // New API add prevValueOptions support + | (( + values: RawValueType[], + options: FlattenOptionsType, + info?: { prevValueOptions?: OptionType[][]; props?: any }, + ) => OptionType[]); + /** Check if a value is disabled */ + isValueDisabled: (value: RawValueType, options: FlattenOptionsType) => boolean; + warningProps?: (props: any) => void; + fillOptionsWithMissingValue?: ( + options: OptionType[], + value: DefaultValueType, + optionLabelProp: string, + labelInValue: boolean, + ) => OptionType[]; + omitDOMProps?: (props: object) => object; +} + +type ValueType = DefaultValueType; +/** + * This function is in internal usage. + * Do not use it in your prod env since we may refactor this. + */ +export default function generateSelector< + OptionType extends { + value?: RawValueType; + label?: any; + key?: Key; + disabled?: boolean; + }, +>(config: GenerateConfig) { + const { + prefixCls: defaultPrefixCls, + components: { optionList: OptionList }, + convertChildrenToData, + flattenOptions, + getLabeledValue, + filterOptions, + isValueDisabled, + findValueOption, + warningProps, + fillOptionsWithMissingValue, + omitDOMProps, + } = config as any; + const Select = defineComponent({ + name: 'Select', + slots: ['option'], + inheritAttrs: false, + props: selectBaseProps(), + setup(props, { expose, attrs, slots }) { + const useInternalProps = computed( + () => props.internalProps && props.internalProps.mark === INTERNAL_PROPS_MARK, + ); + warning( + props.optionFilterProp !== 'children', + 'Select', + 'optionFilterProp not support children, please use label instead', + ); + const containerRef = ref(); + const triggerRef = ref(); + const selectorRef = ref(); + const listRef = ref(); + const tokenWithEnter = computed(() => + (props.tokenSeparators || []).some(tokenSeparator => + ['\n', '\r\n'].includes(tokenSeparator), + ), + ); + + /** Used for component focused management */ + const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); + + const mergedId = computed(() => props.id || `rc_select_${getUUID()}`); + + // optionLabelProp + const mergedOptionLabelProp = computed(() => { + let mergedOptionLabelProp = props.optionLabelProp; + if (mergedOptionLabelProp === undefined) { + mergedOptionLabelProp = props.options ? 'label' : 'children'; + } + return mergedOptionLabelProp; + }); + + // labelInValue + const mergedLabelInValue = computed(() => + props.mode === 'combobox' ? false : props.labelInValue, + ); + + const isMultiple = computed(() => props.mode === 'tags' || props.mode === 'multiple'); + + const mergedShowSearch = computed(() => + props.showSearch !== undefined + ? props.showSearch + : isMultiple.value || props.mode === 'combobox', + ); + + const mobile = ref(false); + onMounted(() => { + mobile.value = isMobile(); + }); + + // ============================== Ref =============================== + const selectorDomRef = createRef(); + + const innerSearchValue = ref(''); + const setInnerSearchValue = (val: string) => { + innerSearchValue.value = val; + }; + + const mergedValue = ref(props.value !== undefined ? props.value : props.defaultValue); + watch( + () => props.value, + () => { + mergedValue.value = props.value; + innerSearchValue.value = ''; + }, + ); + // ============================= Value ============================== + + /** Unique raw values */ + const mergedRawValueArr = computed(() => + toInnerValue(mergedValue.value, { + labelInValue: mergedLabelInValue.value, + combobox: props.mode === 'combobox', + }), + ); + const mergedRawValue = computed(() => mergedRawValueArr.value[0]); + const mergedValueMap = computed(() => mergedRawValueArr.value[1]); + /** We cache a set of raw values to speed up check */ + const rawValues = computed(() => new Set(mergedRawValue.value)); + + // ============================= Option ============================= + // Set by option list active, it will merge into search input when mode is `combobox` + const activeValue = ref(); + const setActiveValue = (val: string) => { + activeValue.value = val; + }; + + const mergedSearchValue = computed(() => { + let mergedSearchValue = innerSearchValue.value; + if (props.mode === 'combobox' && mergedValue.value !== undefined) { + mergedSearchValue = mergedValue.value as string; + } else if (props.searchValue !== undefined) { + mergedSearchValue = props.searchValue; + } else if (props.inputValue) { + mergedSearchValue = props.inputValue; + } + return mergedSearchValue; + }); + + const mergedOptions = computed((): OptionType[] => { + let newOptions = props.options; + if (newOptions === undefined) { + newOptions = convertChildrenToData(props.children as VueNode); + } + + /** + * `tags` should fill un-list item. + * This is not cool here since TreeSelect do not need this + */ + if (props.mode === 'tags' && fillOptionsWithMissingValue) { + newOptions = fillOptionsWithMissingValue( + newOptions, + mergedValue.value, + mergedOptionLabelProp.value, + props.labelInValue, + ); + } + + return newOptions || ([] as OptionType[]); + }); + + const mergedFlattenOptions = computed(() => flattenOptions(mergedOptions.value, props)); + + const getValueOption = useCacheOptions(mergedFlattenOptions); + + // Display options for OptionList + const displayOptions = computed(() => { + if (!mergedSearchValue.value || !mergedShowSearch.value) { + return [...mergedOptions.value] as OptionType[]; + } + const { optionFilterProp = 'value', mode, filterOption } = props; + const filteredOptions: OptionType[] = filterOptions( + mergedSearchValue.value, + mergedOptions.value, + { + optionFilterProp, + filterOption: + mode === 'combobox' && filterOption === undefined ? () => true : filterOption, + }, + ); + if ( + mode === 'tags' && + filteredOptions.every(opt => opt[optionFilterProp] !== mergedSearchValue.value) + ) { + filteredOptions.unshift({ + value: mergedSearchValue.value, + label: mergedSearchValue.value, + key: '__RC_SELECT_TAG_PLACEHOLDER__', + } as OptionType); + } + if (props.filterSort && Array.isArray(filteredOptions)) { + return ([...filteredOptions] as OptionType[]).sort(props.filterSort); + } + + return filteredOptions; + }); + + const displayFlattenOptions = computed(() => flattenOptions(displayOptions.value, props)); + onMounted(() => { + watch( + mergedSearchValue, + () => { + if (listRef.value && listRef.value.scrollTo) { + listRef.value.scrollTo(0); + } + }, + { flush: 'post', immediate: true }, + ); + }); + + // ============================ Selector ============================ + let displayValues = computed(() => { + const tmpValues = mergedRawValue.value.map((val: RawValueType) => { + const valueOptions = getValueOption([val]); + const displayValue = getLabeledValue(val, { + options: valueOptions, + prevValueMap: mergedValueMap.value, + labelInValue: mergedLabelInValue.value, + optionLabelProp: mergedOptionLabelProp.value, + }); + return { + ...displayValue, + disabled: isValueDisabled(val, valueOptions), + option: valueOptions[0], + }; + }); + + if ( + !props.mode && + tmpValues.length === 1 && + tmpValues[0].value === null && + tmpValues[0].label === null + ) { + return []; + } + + return tmpValues; + }); + + // 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]; + const { internalProps = {} } = props; + if (!internalProps.skipTriggerSelect) { + // Skip trigger `onSelect` or `onDeselect` if configured + const selectValue = ( + mergedLabelInValue.value + ? getLabeledValue(newValue, { + options: newValueOption, + prevValueMap: mergedValueMap.value, + labelInValue: mergedLabelInValue.value, + optionLabelProp: mergedOptionLabelProp.value, + }) + : newValue + ) as SingleType; + + if (isSelect && props.onSelect) { + props.onSelect(selectValue, outOption); + } else if (!isSelect && props.onDeselect) { + props.onDeselect(selectValue, outOption); + } + } + + // Trigger internal event + if (useInternalProps.value) { + 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 = shallowRef([]); + const setPrevValueOptions = (val: any[]) => { + prevValueOptions.value = val; + }; + const triggerChange = (newRawValues: RawValueType[]) => { + if ( + useInternalProps.value && + props.internalProps && + props.internalProps.skipTriggerChange + ) { + return; + } + const newRawValuesOptions = getValueOption(newRawValues); + const outValues = toOuterValues>(Array.from(newRawValues), { + labelInValue: mergedLabelInValue.value, + options: newRawValuesOptions as any, + getLabeledValue, + prevValueMap: mergedValueMap.value, + optionLabelProp: mergedOptionLabelProp.value, + }); + + const outValue: ValueType = (isMultiple.value ? outValues : outValues[0]) as ValueType; + // Skip trigger if prev & current value is both empty + if ( + props.onChange && + (mergedRawValue.value.length !== 0 || (outValues as []).length !== 0) + ) { + const outOptions = findValueOption(newRawValues, newRawValuesOptions, { + prevValueOptions: prevValueOptions.value, + 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; + }), + ); + + props.onChange(outValue, isMultiple.value ? outOptions : outOptions[0]); + } + + if (props.value === undefined) { + mergedValue.value = outValue; + } + }; + + const onInternalSelect = ( + newValue: RawValueType, + { selected, source }: { selected: boolean; source: 'option' | 'selection' }, + ) => { + const { autoClearSearchValue = true } = props; + if (props.disabled) { + return; + } + + let newRawValue: Set; + + if (isMultiple.value) { + newRawValue = new Set(mergedRawValue.value); + 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.value || + (!isMultiple.value && Array.from(mergedRawValue.value)[0] !== newValue) + ) { + triggerChange(Array.from(newRawValue)); + } + + // Trigger `onSelect`. Single mode always trigger select + triggerSelect(newValue, !isMultiple.value || selected, source); + + // Clean search value if single or configured + if (props.mode === 'combobox') { + setInnerSearchValue(String(newValue)); + setActiveValue(''); + } else if (!isMultiple.value || 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' }); + }; + + // ============================== Open ============================== + const initOpen = props.open !== undefined ? props.open : props.defaultOpen; + const innerOpen = ref(initOpen); + const mergedOpen = ref(initOpen); + const setInnerOpen = (val: boolean) => { + innerOpen.value = props.open !== undefined ? props.open : val; + mergedOpen.value = innerOpen.value; + }; + watch( + () => props.open, + () => { + setInnerOpen(props.open); + }, + ); + + // Not trigger `open` in `combobox` when `notFoundContent` is empty + const emptyListContent = computed( + () => !props.notFoundContent && !displayOptions.value.length, + ); + + watchEffect(() => { + mergedOpen.value = innerOpen.value; + if ( + props.disabled || + (emptyListContent.value && mergedOpen.value && props.mode === 'combobox') + ) { + mergedOpen.value = false; + } + }); + + const triggerOpen = computed(() => (emptyListContent.value ? false : mergedOpen.value)); + + const onToggleOpen = (newOpen?: boolean) => { + const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen.value; + + if (innerOpen.value !== nextOpen && !props.disabled) { + setInnerOpen(nextOpen); + if (props.onDropdownVisibleChange) { + props.onDropdownVisibleChange(nextOpen); + } + } + }; + + useSelectTriggerControl([containerRef, triggerRef], triggerOpen, onToggleOpen); + + // ============================= Search ============================= + const triggerSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { + let ret = true; + let newSearchText = searchText; + const preSearchValue = mergedSearchValue.value; + setActiveValue(null); + + // Check if match the `tokenSeparators` + const patchLabels: string[] = isCompositing + ? null + : getSeparatedContent(searchText, props.tokenSeparators as string[]); + let patchRawValues: RawValueType[] = patchLabels; + + if (props.mode === 'combobox') { + // Only typing will trigger onChange + if (fromTyping) { + triggerChange([newSearchText]); + } + } else if (patchLabels) { + newSearchText = ''; + + if (props.mode !== 'tags') { + patchRawValues = patchLabels + .map(label => { + const item = mergedFlattenOptions.value.find( + ({ data }) => getTextFromElement(data[mergedOptionLabelProp.value]) === label, + ); + return item ? item.data.value : null; + }) + .filter((val: RawValueType) => val !== null); + } + + const newRawValues = Array.from( + new Set([...mergedRawValue.value, ...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 (props.onSearch && preSearchValue !== newSearchText) { + props.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.value, searchText]), + ); + triggerChange(newRawValues); + newRawValues.forEach(newRawValue => { + triggerSelect(newRawValue, true, 'input'); + }); + setInnerSearchValue(''); + }; + + // Close dropdown when disabled change + + watch( + () => props.disabled, + () => { + if (innerOpen.value && !!props.disabled) { + setInnerOpen(false); + } + }, + { immediate: true }, + ); + + // Close will clean up single mode search text + watch( + mergedOpen, + () => { + if (!mergedOpen.value && !isMultiple.value && props.mode !== 'combobox') { + triggerSearch('', false, false); + } + }, + { immediate: true }, + ); + // ============================ 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 = (event: KeyboardEvent) => { + const clearLock = getClearLock(); + const { which } = event; + + if (which === KeyCode.ENTER) { + // Do not submit form when type in the input + if (props.mode !== 'combobox') { + event.preventDefault(); + } + + // We only manage open state here, close logic should handle by list component + if (!mergedOpen.value) { + onToggleOpen(true); + } + } + + setClearLock(!!mergedSearchValue.value); + + // Remove value by `backspace` + if ( + which === KeyCode.BACKSPACE && + !clearLock && + isMultiple.value && + !mergedSearchValue.value && + mergedRawValue.value.length + ) { + const removeInfo = removeLastEnabledValue(displayValues.value, mergedRawValue.value); + + if (removeInfo.removedValue !== null) { + triggerChange(removeInfo.values); + triggerSelect(removeInfo.removedValue, false, 'input'); + } + } + + if (mergedOpen.value && listRef.value) { + listRef.value.onKeydown(event); + } + + if (props.onKeydown) { + props.onKeydown(event); + } + }; + + // KeyUp + const onInternalKeyUp = (event: KeyboardEvent) => { + if (mergedOpen.value && listRef.value) { + listRef.value.onKeyup(event); + } + + if (props.onKeyup) { + props.onKeyup(event); + } + }; + + // ========================== Focus / Blur ========================== + /** Record real focus status */ + const focusRef = ref(false); + + const onContainerFocus = (...args: any[]) => { + setMockFocused(true); + + if (!props.disabled) { + if (props.onFocus && !focusRef.value) { + props.onFocus(args[0]); + } + + // `showAction` should handle `focus` if set + if (props.showAction && props.showAction.includes('focus')) { + onToggleOpen(true); + } + } + + focusRef.value = true; + }; + + const onContainerBlur = (...args: any[]) => { + setMockFocused(false, () => { + focusRef.value = false; + onToggleOpen(false); + }); + + if (props.disabled) { + return; + } + const searchVal = mergedSearchValue.value; + if (searchVal) { + // `tags` mode should move `searchValue` into values + if (props.mode === 'tags') { + triggerSearch('', false, false); + triggerChange(Array.from(new Set([...mergedRawValue.value, searchVal]))); + } else if (props.mode === 'multiple') { + // `multiple` mode only clean the search value but not trigger event + setInnerSearchValue(''); + } + } + + if (props.onBlur) { + props.onBlur(args[0]); + } + }; + provide('VCSelectContainerEvent', { + focus: onContainerFocus, + blur: onContainerBlur, + }); + const activeTimeoutIds: number[] = []; + + onMounted(() => { + activeTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + onBeforeUnmount(() => { + activeTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + + const onInternalMouseDown = (event: MouseEvent) => { + const { target } = event; + const popupElement: HTMLDivElement = triggerRef.value && triggerRef.value.getPopupElement(); + // We should give focus back to selector if clicked item is not focusable + if (popupElement && popupElement.contains(target as HTMLElement)) { + const timeoutId = window.setTimeout(() => { + const index = activeTimeoutIds.indexOf(timeoutId); + if (index !== -1) { + activeTimeoutIds.splice(index, 1); + } + + cancelSetMockFocused(); + + if (!mobile.value && !popupElement.contains(document.activeElement)) { + selectorRef.value.focus(); + } + }); + + activeTimeoutIds.push(timeoutId); + } + + if (props.onMousedown) { + props.onMousedown(event); + } + }; + + // ========================= Accessibility ========================== + const accessibilityIndex = ref(0); + const mergedDefaultActiveFirstOption = computed(() => + props.defaultActiveFirstOption !== undefined + ? props.defaultActiveFirstOption + : props.mode !== 'combobox', + ); + + const onActiveValue: OnActiveValue = (active, index, { source = 'keyboard' } = {}) => { + accessibilityIndex.value = index; + + if ( + props.backfill && + props.mode === 'combobox' && + active !== null && + source === 'keyboard' + ) { + setActiveValue(String(active)); + } + }; + + // ============================= Popup ============================== + const containerWidth = ref(null); + onMounted(() => { + watch( + triggerOpen, + () => { + if (triggerOpen.value) { + const newWidth = Math.ceil(containerRef.value.offsetWidth); + if (containerWidth.value !== newWidth) { + containerWidth.value = newWidth; + } + } + }, + { immediate: true }, + ); + }); + + const focus = () => { + selectorRef.value.focus(); + }; + const blur = () => { + selectorRef.value.blur(); + }; + expose({ + focus, + blur, + scrollTo: (...args: any[]) => listRef.value?.scrollTo(...args), + }); + const instance = getCurrentInstance(); + const onPopupMouseEnter = () => { + // We need force update here since popup dom is render async + instance.update(); + }; + return () => { + const { + prefixCls = defaultPrefixCls, + id, + + open, + defaultOpen, + options, + children, + + mode, + value, + defaultValue, + labelInValue, + + // Search related + showSearch, + inputValue, + searchValue, + filterOption, + optionFilterProp, + autoClearSearchValue, + onSearch, + + // Icons + allowClear, + clearIcon, + showArrow, + inputIcon, + menuItemSelectedIcon, + + // Others + disabled, + loading, + defaultActiveFirstOption, + notFoundContent = 'Not Found', + optionLabelProp, + backfill, + getInputElement, + getPopupContainer, + placement, + + // Dropdown + listHeight = 200, + listItemHeight = 20, + animation, + transitionName, + virtual, + dropdownStyle, + dropdownClassName, + dropdownMatchSelectWidth, + dropdownRender, + dropdownAlign, + showAction, + direction, + fieldNames, + + // Tags + tokenSeparators, + tagRender, + + // Events + onPopupScroll, + onDropdownVisibleChange, + onFocus, + onBlur, + onKeyup, + onKeydown, + onMousedown, + + onChange, + onSelect, + onDeselect, + onClear, + + internalProps = {}, + + ...restProps + } = { ...props, ...attrs }; //as SelectProps; + // ============================= Input ============================== + // Only works in `combobox` + const customizeInputElement: VueNode = + (mode === 'combobox' && getInputElement && getInputElement()) || null; + + const domProps = omitDOMProps ? omitDOMProps(restProps) : restProps; + DEFAULT_OMIT_PROPS.forEach(prop => { + delete domProps[prop]; + }); + const popupNode = ( + + ); + + // ============================= Clear ============================== + let clearNode: VNode | JSX.Element; + const onClearMouseDown = () => { + // Trigger internal `onClear` event + if (useInternalProps.value && internalProps.onClear) { + internalProps.onClear(); + } + + if (onClear) { + onClear(); + } + + triggerChange([]); + triggerSearch('', false, false); + }; + + if (!disabled && allowClear && (mergedRawValue.value.length || mergedSearchValue.value)) { + clearNode = ( + + × + + ); + } + + // ============================= Arrow ============================== + const mergedShowArrow = + showArrow !== undefined + ? showArrow + : loading || (!isMultiple.value && mode !== 'combobox'); + let arrowNode: VNode | JSX.Element; + + if (mergedShowArrow) { + arrowNode = ( + + ); + } + + // ============================ Warning ============================= + if (process.env.NODE_ENV !== 'production' && warningProps) { + warningProps(props); + } + + // ============================= Render ============================= + const mergedClassName = classNames(prefixCls, attrs.class, { + [`${prefixCls}-focused`]: mockFocused.value, + [`${prefixCls}-multiple`]: isMultiple.value, + [`${prefixCls}-single`]: !isMultiple.value, + [`${prefixCls}-allow-clear`]: allowClear, + [`${prefixCls}-show-arrow`]: mergedShowArrow, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-loading`]: loading, + [`${prefixCls}-open`]: mergedOpen.value, + [`${prefixCls}-customize-input`]: customizeInputElement, + [`${prefixCls}-show-search`]: mergedShowSearch.value, + }); + return ( +
+ {mockFocused.value && !mergedOpen.value && ( + + {/* Merge into one string to make screen reader work as expect */} + {`${mergedRawValue.value.join(', ')}`} + + )} + selectorDomRef.current} + > + + + + {arrowNode} + {clearNode} +
+ ); + }; + }, + }); + return Select; +} diff --git a/components/vc-select copy/hooks/useBaseProps.ts b/components/vc-select copy/hooks/useBaseProps.ts new file mode 100644 index 000000000..8c3dfa529 --- /dev/null +++ b/components/vc-select copy/hooks/useBaseProps.ts @@ -0,0 +1,24 @@ +/** + * BaseSelect provide some parsed data into context. + * You can use this hooks to get them. + */ + +import type { InjectionKey } from 'vue'; +import { inject, provide } from 'vue'; +import type { BaseSelectProps } from '../BaseSelect'; + +export interface BaseSelectContextProps extends BaseSelectProps { + triggerOpen: boolean; + multiple: boolean; + toggleOpen: (open?: boolean) => void; +} + +const BaseSelectContextKey: InjectionKey = Symbol('BaseSelectContextKey'); + +export function useProvideBaseSelectProps(props: BaseSelectContextProps) { + return provide(BaseSelectContextKey, props); +} + +export default function useBaseProps() { + return inject(BaseSelectContextKey, {} as BaseSelectContextProps); +} diff --git a/components/vc-select copy/hooks/useCache.ts b/components/vc-select copy/hooks/useCache.ts new file mode 100644 index 000000000..0c55d6e2a --- /dev/null +++ b/components/vc-select copy/hooks/useCache.ts @@ -0,0 +1,55 @@ +import type { Ref } from 'vue'; +import { shallowRef, computed } from 'vue'; +import type { RawValueType } from '../BaseSelect'; +import type { DefaultOptionType, LabelInValueType } from '../Select'; + +/** + * Cache `value` related LabeledValue & options. + */ +export default ( + labeledValues: Ref, + valueOptions: Ref>, +): [Ref, (val: RawValueType) => DefaultOptionType] => { + const cacheRef = shallowRef({ + values: new Map(), + options: new Map(), + }); + + const filledLabeledValues = computed(() => { + const { values: prevValueCache, options: prevOptionCache } = cacheRef.value; + + // Fill label by cache + const patchedValues = labeledValues.value.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.value.get(item.value) || prevOptionCache.get(item.value), + ); + }); + + cacheRef.value.values = valueCache; + cacheRef.value.options = optionCache; + + return patchedValues; + }); + + const getOption = (val: RawValueType) => + valueOptions.value.get(val) || cacheRef.value.options.get(val); + + return [filledLabeledValues, getOption]; +}; diff --git a/components/vc-select copy/hooks/useDelayReset.ts b/components/vc-select copy/hooks/useDelayReset.ts new file mode 100644 index 000000000..ff1e0fcbc --- /dev/null +++ b/components/vc-select copy/hooks/useDelayReset.ts @@ -0,0 +1,32 @@ +import type { Ref } from 'vue'; +import { onMounted, ref } from 'vue'; + +/** + * Similar with `useLock`, but this hook will always execute last value. + * When set to `true`, it will keep `true` for a short time even if `false` is set. + */ +export default function useDelayReset( + timeout = 10, +): [Ref, (val: boolean, callback?: () => void) => void, () => void] { + const bool = ref(false); + let delay: any; + + const cancelLatest = () => { + clearTimeout(delay); + }; + + onMounted(() => { + cancelLatest(); + }); + const delaySetBool = (value: boolean, callback: () => void) => { + cancelLatest(); + delay = setTimeout(() => { + bool.value = value; + if (callback) { + callback(); + } + }, timeout); + }; + + return [bool, delaySetBool, cancelLatest]; +} diff --git a/components/vc-select copy/hooks/useFilterOptions.ts b/components/vc-select copy/hooks/useFilterOptions.ts new file mode 100644 index 000000000..97720e348 --- /dev/null +++ b/components/vc-select copy/hooks/useFilterOptions.ts @@ -0,0 +1,85 @@ +import { toArray } from '../utils/commonUtil'; +import type { + FieldNames, + DefaultOptionType, + SelectProps, + FilterFunc, + BaseOptionType, +} from '../Select'; +import { injectPropsWithOption } from '../utils/valueUtil'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; + +function includes(test: any, search: string) { + return toArray(test).join('').toUpperCase().includes(search); +} + +export default ( + options: Ref, + fieldNames: Ref, + searchValue?: Ref, + filterOption?: Ref, + optionFilterProp?: Ref, +) => + computed(() => { + if (!searchValue.value || filterOption.value === false) { + return options.value; + } + + const { options: fieldOptions, label: fieldLabel, value: fieldValue } = fieldNames.value; + const filteredOptions: DefaultOptionType[] = []; + + const customizeFilter = typeof filterOption.value === 'function'; + + const upperSearch = searchValue.value.toUpperCase(); + const filterFunc = customizeFilter + ? (filterOption.value as FilterFunc) + : (_: string, option: DefaultOptionType) => { + // Use provided `optionFilterProp` + if (optionFilterProp.value) { + return includes(option[optionFilterProp.value], 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.value.forEach(item => { + // Group should check child options + if (item[fieldOptions]) { + // Check group first + const matchGroup = filterFunc(searchValue.value, wrapOption(item)); + if (matchGroup) { + filteredOptions.push(item); + } else { + // Check option + const subOptions = item[fieldOptions].filter((subItem: DefaultOptionType) => + filterFunc(searchValue.value, wrapOption(subItem)), + ); + if (subOptions.length) { + filteredOptions.push({ + ...item, + [fieldOptions]: subOptions, + }); + } + } + + return; + } + + if (filterFunc(searchValue.value, wrapOption(item))) { + filteredOptions.push(item); + } + }); + + return filteredOptions; + }); diff --git a/components/vc-select copy/hooks/useId.ts b/components/vc-select copy/hooks/useId.ts new file mode 100644 index 000000000..5bfb8b8a2 --- /dev/null +++ b/components/vc-select copy/hooks/useId.ts @@ -0,0 +1,30 @@ +import { ref } from 'vue'; +import canUseDom from '../../_util/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 = ref('')) { + // Inner id for accessibility usage. Only work in client side + const innerId = `rc_select_${getUUID()}`; + + return id.value || innerId; +} diff --git a/components/vc-select copy/hooks/useLock.ts b/components/vc-select copy/hooks/useLock.ts new file mode 100644 index 000000000..5a7266570 --- /dev/null +++ b/components/vc-select copy/hooks/useLock.ts @@ -0,0 +1,29 @@ +import { onBeforeUpdate } from 'vue'; + +/** + * Locker return cached mark. + * If set to `true`, will return `true` in a short time even if set `false`. + * If set to `false` and then set to `true`, will change to `true`. + * And after time duration, it will back to `null` automatically. + */ +export default function useLock(duration = 250): [() => boolean | null, (lock: boolean) => void] { + let lock: boolean | null = null; + let timeout: any; + + onBeforeUpdate(() => { + clearTimeout(timeout); + }); + + function doLock(locked: boolean) { + if (locked || lock === null) { + lock = locked; + } + + clearTimeout(timeout); + timeout = setTimeout(() => { + lock = null; + }, duration); + } + + return [() => lock, doLock]; +} diff --git a/components/vc-select copy/hooks/useOptions.ts b/components/vc-select copy/hooks/useOptions.ts new file mode 100644 index 000000000..fc7b628fe --- /dev/null +++ b/components/vc-select copy/hooks/useOptions.ts @@ -0,0 +1,46 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +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: Ref, + children: Ref, + fieldNames: Ref, +) { + return computed(() => { + let mergedOptions = options.value; + const childrenAsData = !options.value; + + if (childrenAsData) { + mergedOptions = convertChildrenToData(children.value); + } + + 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.value.options] || isChildren) { + valueOptions.set(option[fieldNames.value.value], option); + labelOptions.set(option[fieldNames.value.label], option); + } else { + dig(option[fieldNames.value.options], true); + } + } + } + dig(mergedOptions); + + return { + options: mergedOptions, + valueOptions, + labelOptions, + }; + }); +} diff --git a/components/vc-select copy/hooks/useSelectTriggerControl.ts b/components/vc-select copy/hooks/useSelectTriggerControl.ts new file mode 100644 index 000000000..460d9b9a8 --- /dev/null +++ b/components/vc-select copy/hooks/useSelectTriggerControl.ts @@ -0,0 +1,32 @@ +import type { Ref } from 'vue'; +import { onBeforeUnmount, onMounted } from 'vue'; + +export default function useSelectTriggerControl( + refs: Ref[], + open: Ref, + triggerOpen: (open: boolean) => void, +) { + function onGlobalMouseDown(event: MouseEvent) { + let target = event.target as HTMLElement; + + if (target.shadowRoot && event.composed) { + target = (event.composedPath()[0] || target) as HTMLElement; + } + const elements = [refs[0]?.value, refs[1]?.value?.getPopupElement()]; + if ( + open.value && + elements.every(element => element && !element.contains(target) && element !== target) + ) { + // Should trigger close + triggerOpen(false); + } + } + + onMounted(() => { + window.addEventListener('mousedown', onGlobalMouseDown); + }); + + onBeforeUnmount(() => { + window.removeEventListener('mousedown', onGlobalMouseDown); + }); +} diff --git a/components/vc-select copy/index.ts b/components/vc-select copy/index.ts new file mode 100644 index 000000000..685603662 --- /dev/null +++ b/components/vc-select copy/index.ts @@ -0,0 +1,12 @@ +import type { SelectProps } from './Select'; +import Select, { selectProps } from './Select'; +import Option from './Option'; +import OptGroup from './OptGroup'; +import BaseSelect from './BaseSelect'; +import type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate } from './BaseSelect'; +import useBaseProps from './hooks/useBaseProps'; + +export { Option, OptGroup, selectProps, BaseSelect, useBaseProps }; +export type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate, SelectProps }; + +export default Select; diff --git a/components/vc-select copy/interface.ts b/components/vc-select copy/interface.ts new file mode 100644 index 000000000..a2811a3ba --- /dev/null +++ b/components/vc-select copy/interface.ts @@ -0,0 +1,11 @@ +import type { Key } from '../_util/type'; +import type { RawValueType } from './BaseSelect'; + +export interface FlattenOptionData { + label?: any; + data: OptionType; + key: Key; + value?: RawValueType; + groupOption?: boolean; + group?: boolean; +} diff --git a/components/vc-select copy/interface1/generator.ts b/components/vc-select copy/interface1/generator.ts new file mode 100644 index 000000000..aa3c6702b --- /dev/null +++ b/components/vc-select copy/interface1/generator.ts @@ -0,0 +1,73 @@ +import type { VueNode } from '../../_util/type'; + +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 | null; + +export interface LabelValueType extends Record { + key?: Key; + value?: RawValueType; + label?: any; + 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 = () => any; + +export type CustomTagProps = { + label: any; + value: DefaultValueType; + disabled: boolean; + onClose: (event?: 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 type FlattenOptionsType = { + key: Key; + data: OptionType; + label?: any; + value?: RawValueType; + /** Used for customize data */ + [name: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any +}[]; + +export type DropdownObject = { + menuNode?: VueNode; + props?: Record; +}; + +export type DropdownRender = (opt?: DropdownObject) => VueNode; diff --git a/components/vc-select copy/interface1/index.ts b/components/vc-select copy/interface1/index.ts new file mode 100644 index 000000000..c71cee0a3 --- /dev/null +++ b/components/vc-select copy/interface1/index.ts @@ -0,0 +1,62 @@ +import type { VueNode } from '../../_util/type'; +import type { VNode, CSSProperties } from 'vue'; +import type { Key, RawValueType } from './generator'; + +export type RenderDOMFunc = (props: any) => HTMLElement; + +export type RenderNode = VueNode | ((props: any) => VueNode); + +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; + class?: string; + style?: CSSProperties; + label?: VueNode; + /** @deprecated Only works when use `children` as option data */ + children?: VNode[] | JSX.Element[]; +} + +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?: VueNode; + options: OptionData[]; + class?: string; + style?: 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?: any; + value?: RawValueType; +} diff --git a/components/vc-select copy/utils/commonUtil.ts b/components/vc-select copy/utils/commonUtil.ts new file mode 100644 index 000000000..6e61f053d --- /dev/null +++ b/components/vc-select copy/utils/commonUtil.ts @@ -0,0 +1,12 @@ +export function toArray(value: T | T[]): T[] { + if (Array.isArray(value)) { + return value; + } + return value !== undefined ? [value] : []; +} + +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; diff --git a/components/vc-select copy/utils/keyUtil.ts b/components/vc-select copy/utils/keyUtil.ts new file mode 100644 index 000000000..307f4552a --- /dev/null +++ b/components/vc-select copy/utils/keyUtil.ts @@ -0,0 +1,34 @@ +import KeyCode from '../../_util/KeyCode'; + +/** keyCode Judgment function */ +export function isValidateOpenKey(currentKeyCode: number): boolean { + return ![ + // System function button + KeyCode.ESC, + KeyCode.SHIFT, + KeyCode.BACKSPACE, + KeyCode.TAB, + KeyCode.WIN_KEY, + KeyCode.ALT, + KeyCode.META, + KeyCode.WIN_KEY_RIGHT, + KeyCode.CTRL, + KeyCode.SEMICOLON, + KeyCode.EQUALS, + KeyCode.CAPS_LOCK, + KeyCode.CONTEXT_MENU, + // F1-F12 + KeyCode.F1, + KeyCode.F2, + KeyCode.F3, + KeyCode.F4, + KeyCode.F5, + KeyCode.F6, + KeyCode.F7, + KeyCode.F8, + KeyCode.F9, + KeyCode.F10, + KeyCode.F11, + KeyCode.F12, + ].includes(currentKeyCode); +} diff --git a/components/vc-select copy/utils/legacyUtil.ts b/components/vc-select copy/utils/legacyUtil.ts new file mode 100644 index 000000000..ea875f5e6 --- /dev/null +++ b/components/vc-select copy/utils/legacyUtil.ts @@ -0,0 +1,61 @@ +import { flattenChildren, isValidElement } from '../../_util/props-util'; +import type { VNode } from 'vue'; +import type { BaseOptionType, DefaultOptionType } from '../Select'; +import type { VueNode } from '../../_util/type'; + +function convertNodeToOption( + node: VNode, +): OptionType { + const { + key, + children, + props: { value, disabled, ...restProps }, + } = node as Omit & { + children: { default?: () => any }; + key: string | number; + }; + const child = children && children.default ? children.default() : undefined; + return { + key, + value: value !== undefined ? value : key, + children: child, + disabled: disabled || disabled === '', // support + ...(restProps as any), + }; +} + +export function convertChildrenToData( + nodes: VueNode, + optionOnly = false, +): OptionType[] { + const dd = flattenChildren(nodes as []) + .map((node: VNode, index: number): OptionType | null => { + if (!isValidElement(node) || !node.type) { + return null; + } + + const { + type: { isSelectOptGroup }, + key, + children, + props, + } = node as VNode & { + type: { isSelectOptGroup?: boolean }; + children: { default?: () => any; label?: () => any }; + }; + + if (optionOnly || !isSelectOptGroup) { + return convertNodeToOption(node); + } + const child = children && children.default ? children.default() : undefined; + const label = props?.label || children.label?.() || key; + return { + key: `__RC_SELECT_GRP__${key === null ? index : String(key)}__`, + ...props, + label, + options: convertChildrenToData(child || []), + } as any; + }) + .filter(data => data); + return dd; +} diff --git a/components/vc-select copy/utils/platformUtil.ts b/components/vc-select copy/utils/platformUtil.ts new file mode 100644 index 000000000..f6bdcc68b --- /dev/null +++ b/components/vc-select copy/utils/platformUtil.ts @@ -0,0 +1,4 @@ +/* istanbul ignore file */ +export function isPlatformMac(): boolean { + return /(mac\sos|macintosh)/i.test(navigator.appVersion); +} diff --git a/components/vc-select copy/utils/valueUtil.ts b/components/vc-select copy/utils/valueUtil.ts new file mode 100644 index 000000000..ca21cff9e --- /dev/null +++ b/components/vc-select copy/utils/valueUtil.ts @@ -0,0 +1,128 @@ +import type { BaseOptionType, DefaultOptionType, RawValueType, FieldNames } from '../Select'; +import { warning } from '../../vc-util/warning'; +import type { FlattenOptionData } from '../interface'; + +function getKey(data: BaseOptionType, index: number) { + const { key } = data; + let value: RawValueType; + + if ('value' in data) { + ({ value } = data); + } + + if (key !== null && key !== undefined) { + return key; + } + if (value !== undefined) { + return value; + } + return `rc-index-key-${index}`; +} + +export function fillFieldNames(fieldNames: FieldNames | undefined, childrenAsData: boolean) { + const { label, value, options } = fieldNames || {}; + + return { + label: label || (childrenAsData ? 'children' : 'label'), + value: value || 'value', + options: options || 'options', + }; +} + +/** + * Flat options into flatten list. + * 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: OptionType[], + { fieldNames, childrenAsData }: { fieldNames?: FieldNames; childrenAsData?: boolean } = {}, +): FlattenOptionData[] { + const flattenList: FlattenOptionData[] = []; + + const { + label: fieldLabel, + value: fieldValue, + options: fieldOptions, + } = fillFieldNames(fieldNames, false); + + 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, + }); + } else { + let grpLabel = label; + if (grpLabel === undefined && childrenAsData) { + grpLabel = data.label; + } + // Option Group + flattenList.push({ + key: getKey(data, flattenList.length), + group: true, + data, + label: grpLabel, + }); + + dig(data[fieldOptions], true); + } + }); + } + + dig(options, false); + + return flattenList; +} + +/** + * Inject `props` into `option` for legacy usage + */ +export function injectPropsWithOption(option: T): T { + const newOption = { ...option }; + if (!('props' in newOption)) { + Object.defineProperty(newOption, 'props', { + get() { + warning( + false, + 'Return type is option instead of Option instance. Please read value directly instead of reading from `props`.', + ); + return newOption; + }, + }); + } + + return newOption; +} + +export function getSeparatedContent(text: string, tokens: string[]): string[] { + if (!tokens || !tokens.length) { + return null; + } + + let match = false; + + function separate(str: string, [token, ...restTokens]: string[]) { + if (!token) { + return [str]; + } + + const list = str.split(token); + match = match || list.length > 1; + + return list + .reduce((prevList, unitStr) => [...prevList, ...separate(unitStr, restTokens)], []) + .filter(unit => unit); + } + + const list = separate(text, tokens); + return match ? list : null; +} diff --git a/components/vc-select copy/utils/warningPropsUtil.ts b/components/vc-select copy/utils/warningPropsUtil.ts new file mode 100644 index 000000000..aa7ce292d --- /dev/null +++ b/components/vc-select copy/utils/warningPropsUtil.ts @@ -0,0 +1,135 @@ +import warning, { noteOnce } from '../../vc-util/warning'; +import { convertChildrenToData } from './legacyUtil'; +import { toArray } from './commonUtil'; +import { isValidElement } from '../../_util/props-util'; +import type { VNode } from 'vue'; +import type { RawValueType, LabelInValueType, SelectProps } from '../Select'; +import { isMultiple } from '../BaseSelect'; + +function warningProps(props: SelectProps) { + const { + mode, + options, + children, + backfill, + allowClear, + placeholder, + getInputElement, + showSearch, + onSearch, + defaultOpen, + autofocus, + labelInValue, + value, + inputValue, + optionLabelProp, + } = props; + + 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 }) => !opt.disabled), + 'Please avoid setting option to disabled in tags mode since user can always type text as tag.', + ); + + // `combobox` should not use `optionLabelProp` + warning( + mode !== 'combobox' || !optionLabelProp, + '`combobox` mode not support `optionLabelProp`. Please set `value` on Option directly.', + ); + + // Only `combobox` support `backfill` + warning(mode === 'combobox' || !backfill, '`backfill` only works with `combobox` mode.'); + + // Only `combobox` support `getInputElement` + warning( + mode === 'combobox' || !getInputElement, + '`getInputElement` only work with `combobox` mode.', + ); + + // Customize `getInputElement` should not use `allowClear` & `placeholder` + noteOnce( + mode !== 'combobox' || !getInputElement || !allowClear || !placeholder, + 'Customize `getInputElement` should customize clear and placeholder logic instead of configuring `allowClear` and `placeholder`.', + ); + + // `onSearch` should use in `combobox` or `showSearch` + if (onSearch && !mergedShowSearch && mode !== 'combobox' && mode !== 'tags') { + warning(false, '`onSearch` should work with `showSearch` instead of use alone.'); + } + + noteOnce( + !defaultOpen || autofocus, + '`defaultOpen` makes Select open without focus which means it will not close by click outside. You can set `autofocus` if needed.', + ); + + if (value !== undefined && value !== null) { + const values = toArray(value); + warning( + !labelInValue || + values.every(val => typeof val === 'object' && ('key' in val || 'value' in val)), + '`value` should in shape of `{ value: string | number, label?: any }` when you set `labelInValue` to `true`', + ); + + warning( + !multiple || Array.isArray(value), + '`value` should be array when `mode` is `multiple` or `tags`', + ); + } + + // Syntactic sugar should use correct children type + if (children) { + let invalidateChildType = null; + children.some((node: any) => { + if (!isValidElement(node) || !node.type) { + return false; + } + + const { type } = node as { type: { isSelectOption?: boolean; isSelectOptGroup?: boolean } }; + + if (type.isSelectOption) { + return false; + } + if (type.isSelectOptGroup) { + const childs = node.children?.default() || []; + const allChildrenValid = childs.every((subNode: VNode) => { + if ( + !isValidElement(subNode) || + !node.type || + (subNode.type as { isSelectOption?: boolean }).isSelectOption + ) { + return true; + } + invalidateChildType = subNode.type; + return false; + }); + + if (allChildrenValid) { + return false; + } + return true; + } + invalidateChildType = type; + return true; + }); + + if (invalidateChildType) { + warning( + false, + `\`children\` should be \`Select.Option\` or \`Select.OptGroup\` instead of \`${ + invalidateChildType.displayName || invalidateChildType.name || invalidateChildType + }\`.`, + ); + } + + warning( + inputValue === undefined, + '`inputValue` is deprecated, please use `searchValue` instead.', + ); + } +} + +export default warningProps; diff --git a/components/vc-select/examples/combobox.jsx b/components/vc-select/examples/combobox.jsx deleted file mode 100644 index 9e9de6485..000000000 --- a/components/vc-select/examples/combobox.jsx +++ /dev/null @@ -1,136 +0,0 @@ -import createRef from '../../_util/createRef'; -/* eslint-disable no-console */ -import Select, { Option } from '..'; -import '../assets/index.less'; -import { nextTick } from 'vue'; - -const Combobox = { - data() { - this.textareaRef = createRef(); - - this.timeoutId; - return { - disabled: false, - value: '', - options: [], - }; - }, - - mounted() { - nextTick(() => { - console.log('Ref:', this.textareaRef.current); - }); - }, - methods: { - onChange(value, option) { - console.log('onChange', value, option); - - this.value = value; - }, - - onKeyDown(e) { - const { value } = this; - if (e.keyCode === 13) { - console.log('onEnter', value); - } - }, - - onSelect(v, option) { - console.log('onSelect', v, option); - }, - - onSearch(text) { - console.log('onSearch:', text); - }, - - onAsyncChange(value) { - window.clearTimeout(this.timeoutId); - console.log(value); - this.options = []; - //const value = String(Math.random()); - this.timeoutId = window.setTimeout(() => { - this.options = [{ value }, { value: `${value}-${value}` }]; - }, 1000); - }, - - toggleDisabled() { - const { disabled } = this; - - this.disabled = !disabled; - }, - }, - - render() { - const { value, disabled } = this; - return ( -
-

combobox

-

- - -

-
- - -

Customize Input Element

-