refactor: select

refactor-cascader
tangjinzhou 2022-01-09 22:29:13 +08:00
parent 54cdc3ff40
commit 1c508d61fb
53 changed files with 6286 additions and 555 deletions

View File

@ -0,0 +1,500 @@
export interface ShowSearchType<OptionType extends BaseOptionType = DefaultOptionType> {
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<FieldNames> {
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<OptionType extends BaseOptionType = DefaultOptionType>
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<OptionType>;
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<OptionType> = (value: SingleValueType, selectOptions: OptionType[]) => void;
type OnMultipleChange<OptionType> = (
value: SingleValueType[],
selectOptions: OptionType[][],
) => void;
export interface SingleCascaderProps<OptionType extends BaseOptionType = DefaultOptionType>
extends BaseCascaderProps<OptionType> {
checkable?: false;
onChange?: OnSingleChange<OptionType>;
}
export interface MultipleCascaderProps<OptionType extends BaseOptionType = DefaultOptionType>
extends BaseCascaderProps<OptionType> {
checkable: true | React.ReactNode;
onChange?: OnMultipleChange<OptionType>;
}
export type CascaderProps<OptionType extends BaseOptionType = DefaultOptionType> =
| SingleCascaderProps<OptionType>
| MultipleCascaderProps<OptionType>;
type InternalCascaderProps<OptionType extends BaseOptionType = DefaultOptionType> = Omit<
SingleCascaderProps<OptionType> | MultipleCascaderProps<OptionType>,
'onChange'
> & {
onChange?: (
value: SingleValueType | SingleValueType[],
selectOptions: OptionType[] | OptionType[][],
) => void;
};
export type CascaderRef = Omit<BaseSelectRef, 'scrollTo'>;
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<CascaderRef, InternalCascaderProps>((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<ValueType, SingleValueType[]>(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 (
<CascaderContext.Provider value={cascaderContext}>
<BaseSelect
{...restProps}
// MISC
ref={ref as any}
id={mergedId}
prefixCls={prefixCls}
dropdownMatchSelectWidth={false}
dropdownStyle={dropdownStyle}
// Value
displayValues={displayValues}
onDisplayValuesChange={onDisplayValuesChange}
mode={multiple ? 'multiple' : undefined}
// Search
searchValue={mergedSearchValue}
onSearch={onInternalSearch}
showSearch={mergedShowSearch}
// Options
OptionList={OptionList}
emptyOptions={emptyOptions}
// Open
open={mergedOpen}
dropdownClassName={mergedDropdownClassName}
placement={mergedPlacement}
onDropdownVisibleChange={onInternalDropdownVisibleChange}
// Children
getRawInputElement={() => children}
/>
</CascaderContext.Provider>
);
}) as (<OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType>(
props: React.PropsWithChildren<CascaderProps<OptionType>> & {
ref?: React.Ref<BaseSelectRef>;
},
) => React.ReactElement) & {
displayName?: string;
};
if (process.env.NODE_ENV !== 'production') {
Cascader.displayName = 'Cascader';
}
export default Cascader;

View File

@ -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 (
<span
class={{
[prefixCls]: true,
[`${prefixCls}-checked`]: checked,
[`${prefixCls}-indeterminate`]: !checked && halfChecked,
[`${prefixCls}-disabled`]: disabled,
}}
onClick={onClick}
>
{customCheckbox}
</span>
);
}
Checkbox.props = ['prefixCls', 'checked', 'halfChecked', 'disabled', 'onClick'];
Checkbox.displayName = 'Checkbox';
Checkbox.inheritAttrs = false;

View File

@ -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<React.Key>;
halfCheckedSet: Set<React.Key>;
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 (
<ul className={menuPrefixCls} role="menu">
{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 (
<li
key={fullPathKey}
className={classNames(menuItemPrefixCls, {
[`${menuItemPrefixCls}-expand`]: !isMergedLeaf,
[`${menuItemPrefixCls}-active`]: activeValue === value,
[`${menuItemPrefixCls}-disabled`]: disabled,
[`${menuItemPrefixCls}-loading`]: isLoading,
})}
style={dropdownMenuColumnStyle}
role="menuitemcheckbox"
title={title}
aria-checked={checked}
data-path-key={fullPathKey}
onClick={() => {
triggerOpenPath();
if (!multiple || isMergedLeaf) {
triggerSelect();
}
}}
onDoubleClick={() => {
if (changeOnSelect) {
onToggleOpen(false);
}
}}
onMouseEnter={() => {
if (hoverOpen) {
triggerOpenPath();
}
}}
>
{multiple && (
<Checkbox
prefixCls={`${prefixCls}-checkbox`}
checked={checked}
halfChecked={halfChecked}
disabled={disabled}
onClick={(e: React.MouseEvent<HTMLSpanElement>) => {
e.stopPropagation();
triggerSelect();
}}
/>
)}
<div className={`${menuItemPrefixCls}-content`}>{option[fieldNames.label]}</div>
{!isLoading && expandIcon && !isMergedLeaf && (
<div className={`${menuItemPrefixCls}-expand-icon`}>{expandIcon}</div>
)}
{isLoading && loadingIcon && (
<div className={`${menuItemPrefixCls}-loading-icon`}>{loadingIcon}</div>
)}
</li>
);
})}
</ul>
);
}

View File

@ -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<RefOptionListProps>((props, ref) => {
const { prefixCls, multiple, searchValue, toggleOpen, notFoundContent, direction } =
useBaseProps();
const containerRef = React.useRef<HTMLDivElement>();
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 (
<Column
key={index}
{...columnProps}
prefixCls={mergedPrefixCls}
options={col.options}
prevValuePath={prevValuePath}
activeValue={activeValue}
/>
);
});
// >>>>> Render
return (
<>
<div
className={classNames(`${mergedPrefixCls}-menus`, {
[`${mergedPrefixCls}-menu-empty`]: isEmpty,
[`${mergedPrefixCls}-rtl`]: rtl,
})}
ref={containerRef}
>
{columnNodes}
</div>
</>
);
});
export default RefOptionList;

View File

@ -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.Key[]>([]);
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];
};

View File

@ -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<RefOptionListProps>,
options: DefaultOptionType[],
fieldNames: InternalFieldNames,
activeValueCells: React.Key[],
setActiveValueCells: (activeValueCells: React.Key[]) => void,
containerRef: React.RefObject<HTMLElement>,
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: () => {},
}));
};

View File

@ -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<CascaderProps['options']>;
fieldNames: Ref<InternalFieldNames>;
values: Ref<SingleValueType[]>;
halfValues: Ref<SingleValueType[]>;
changeOnSelect: Ref<boolean>;
onSelect: (valuePath: SingleValueType) => void;
checkable: Ref<boolean | VueNode>;
searchOptions: Ref<DefaultOptionType[]>;
dropdownPrefixCls?: Ref<string>;
loadData: Ref<(selectOptions: DefaultOptionType[]) => void>;
expandTrigger: Ref<'hover' | 'click'>;
expandIcon: Ref<VueNode>;
loadingIcon: Ref<VueNode>;
dropdownMenuColumnStyle: Ref<CSSProperties>;
slotsContext: Ref<Record<string, Function>>;
}
const CascaderContextKey: InjectionKey<CascaderContextProps> = Symbol('CascaderContextKey');
export const useProvideCascader = (props: CascaderContextProps) => {
provide(CascaderContextKey, props);
};
export const useInjectCascader = () => {
return inject(CascaderContextKey);
};

View File

@ -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]);
};

View File

@ -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<string, DataEntity>;
pathKeyEntities: Record<string, DataEntity>;
}
export type GetEntities = () => OptionsInfo['pathKeyEntities'];
/** Lazy parse options data into conduct-able info to avoid perf issue in single mode */
export default (options: Ref<DefaultOptionType[]>, fieldNames: Ref<InternalFieldNames>) => {
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;
};

View File

@ -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<DefaultOptionType[]>,
fieldNames: Ref<InternalFieldNames>,
rawValues: Ref<SingleValueType[]>,
) => {
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];
});
};

View File

@ -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<CascaderProps['showSearch']>) {
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];
});
}

View File

@ -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<string>,
options: Ref<DefaultOptionType[]>,
fieldNames: Ref<InternalFieldNames>,
prefixCls: Ref<string>,
config: Ref<ShowSearchType>,
changeOnSelect: Ref<boolean>,
) => {
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;
});
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<string[]>,
// >>> Value
displayValues: Array as PropType<DisplayValueType[]>,
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<string, any>;
};
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<Mode>,
// >>> 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<number | 'responsive'> },
maxTagPlaceholder: PropTypes.any,
// >>> Search
tokenSeparators: { type: Array as PropType<string[]> },
// >>> 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<CSSProperties> },
dropdownClassName: String,
dropdownMatchSelectWidth: {
type: [Boolean, Number] as PropType<boolean | number>,
default: undefined,
},
dropdownRender: { type: Function as PropType<DropdownRender> },
dropdownAlign: PropTypes.any,
placement: {
type: String as PropType<Placement>,
},
getPopupContainer: { type: Function as PropType<RenderDOMFunc> },
// >>> 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<ReturnType<typeof baseSelectPrivateProps>>
>;
export type BaseSelectProps = Partial<ExtractPropTypes<ReturnType<typeof baseSelectProps>>>;
export type BaseSelectPropsWithoutPrivate = Omit<BaseSelectProps, keyof BaseSelectPrivateProps>;
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<HTMLDivElement>(null);
const selectorDomRef = createRef();
const triggerRef = ref<RefTriggerProps>(null);
const selectorRef = ref<RefSelectorProps>(null);
const listRef = ref<RefOptionListProps>(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<number>(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<keyof typeof restProps, typeof DEFAULT_OMIT_PROPS[number]>;
// 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 = (
<TransBtn
class={classNames(`${prefixCls}-arrow`, {
[`${prefixCls}-arrow-loading`]: loading,
})}
customizeIcon={inputIcon}
customizeIconProps={{
loading,
searchValue: mergedSearchValue.value,
open: mergedOpen.value,
focused: mockFocused.value,
showSearch: mergedShowSearch.value,
}}
/>
);
}
// ============================= 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 = (
<TransBtn
class={`${prefixCls}-clear`}
onMousedown={onClearMouseDown}
customizeIcon={clearIcon}
>
×
</TransBtn>
);
}
// =========================== OptionList ===========================
const optionList = <OptionList ref={listRef} />;
// ============================= 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 = (
<SelectTrigger
ref={triggerRef}
disabled={disabled}
prefixCls={prefixCls}
visible={triggerOpen.value}
popupElement={optionList}
containerWidth={containerWidth.value}
animation={animation}
transitionName={transitionName}
dropdownStyle={dropdownStyle}
dropdownClassName={dropdownClassName}
direction={direction}
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
dropdownRender={dropdownRender}
dropdownAlign={dropdownAlign}
placement={placement}
getPopupContainer={getPopupContainer}
empty={emptyOptions}
getTriggerDOMNode={() => selectorDomRef.current}
onPopupVisibleChange={onTriggerVisibleChange}
onPopupMouseEnter={onPopupMouseEnter}
v-slots={{
default: () => {
return customizeRawInputElement ? (
customizeRawInputElement
) : (
<Selector
{...props}
domRef={selectorDomRef}
prefixCls={prefixCls}
inputElement={customizeInputElement}
ref={selectorRef}
id={id}
showSearch={mergedShowSearch.value}
mode={mode}
activeDescendantId={activeDescendantId}
tagRender={tagRender}
values={displayValues}
open={mergedOpen.value}
onToggleOpen={onToggleOpen}
activeValue={activeValue}
searchValue={mergedSearchValue.value}
onSearch={onInternalSearch}
onSearchSubmit={onInternalSearchSubmit}
onRemove={onSelectorRemove}
tokenWithEnter={tokenWithEnter.value}
/>
);
},
}}
></SelectTrigger>
);
// >>> Render
let renderNode: VNode | JSX.Element;
// Render raw
if (customizeRawInputElement) {
renderNode = selectorNode;
} else {
renderNode = (
<div
class={mergedClassName}
{...domProps}
ref={containerRef}
onMousedown={onInternalMouseDown}
onKeydown={onInternalKeyDown}
onKeyup={onInternalKeyUp}
onFocus={onContainerFocus}
onBlur={onContainerBlur}
>
{mockFocused && !mergedOpen.value && (
<span
style={{
width: 0,
height: 0,
display: 'flex',
overflow: 'hidden',
opacity: 0,
}}
aria-live="polite"
>
{/* Merge into one string to make screen reader work as expect */}
{`${displayValues
.map(({ label, value }) =>
['number', 'string'].includes(typeof label) ? label : value,
)
.join(', ')}`}
</span>
)}
{selectorNode}
{arrowNode}
{clearNode}
</div>
);
}
return renderNode;
};
},
});

View File

@ -0,0 +1,15 @@
import type { FunctionalComponent } from 'vue';
import type { DefaultOptionType } from './Select';
export type OptGroupProps = Omit<DefaultOptionType, 'options'>;
export interface OptionGroupFC extends FunctionalComponent<OptGroupProps> {
/** Legacy for check if is a Option Group */
isSelectOptGroup: boolean;
}
const OptGroup: OptionGroupFC = () => null;
OptGroup.isSelectOptGroup = true;
OptGroup.displayName = 'ASelectOptGroup';
export default OptGroup;

View File

@ -0,0 +1,18 @@
import type { FunctionalComponent } from 'vue';
import type { DefaultOptionType } from './Select';
export interface OptionProps extends Omit<DefaultOptionType, 'label'> {
/** Save for customize data */
[prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface OptionFC extends FunctionalComponent<OptionProps> {
/** Legacy for check if is a Option Group */
isSelectOption: boolean;
}
const Option: OptionFC = () => null;
Option.isSelectOption = true;
Option.displayName = 'ASelectOption';
export default Option;

View File

@ -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<OptionsType extends object[]> {
export type OptionListProps = Record<string, never>;
/**
* 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<string, any>) => 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 ? (
<div
aria-label={typeof mergedLabel === 'string' && !group ? mergedLabel : null}
{...attrs}
key={index}
role={group ? 'presentation' : 'option'}
id={`${baseProps.id}_list_${index}`}
aria-selected={props.rawValues.has(value)}
>
{value}
</div>
) : 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 (
<div
role="listbox"
id={`${id}_list`}
class={`${itemPrefixCls.value}-empty`}
onMousedown={onListMouseDown}
>
{notFoundContent}
</div>
);
}
return (
<>
<div role="listbox" id={`${id}_list`} style={{ height: 0, width: 0, overflow: 'hidden' }}>
{renderItem(activeIndex - 1)}
{renderItem(activeIndex)}
{renderItem(activeIndex + 1)}
</div>
<List
itemKey="key"
ref={listRef}
data={memoFlattenOptions.value}
height={listHeight}
itemHeight={listItemHeight}
fullHeight={false}
onMousedown={onListMouseDown}
onScroll={onPopupScroll}
virtual={virtual}
v-slots={{
default: (item, itemIndex) => {
const { group, groupOption, data, label, value } = item;
const { key } = data;
// Group
if (group) {
return (
<div class={classNames(itemPrefixCls, `${itemPrefixCls.value}-group`)}>
{renderOption ? renderOption(data) : label !== undefined ? label : key}
</div>
);
}
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 (
<div
{...passedProps}
aria-selected={selected}
class={optionClassName}
title={optionTitle}
onMousemove={e => {
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}
>
<div class={`${optionPrefixCls}-content`}>
{renderOption ? renderOption(data) : content}
</div>
{isValidElement(menuItemSelectedIcon) || selected}
{iconVisible && (
<TransBtn
class={`${itemPrefixCls.value}-option-state`}
customizeIcon={menuItemSelectedIcon}
customizeIconProps={{ isSelected: selected }}
>
{selected ? '✓' : null}
</TransBtn>
)}
</div>
);
},
}}
></List>
</>
);
};
},
});
export default OptionList;

View File

@ -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<OptionType> = (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<DefaultOptionType, 'children'>[];
}
export type SelectHandler<ValueType = any, OptionType extends BaseOptionType = DefaultOptionType> =
| ((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<FieldNames>,
// >>> 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<SelectHandler<ValueType, OptionType>>,
onDeselect: Function as PropType<SelectHandler<ValueType, OptionType>>,
// >>> Options
/**
* In Select, `false` means do nothing.
* In TreeSelect, `false` will highlight match item.
* It's by design.
*/
filterOption: {
type: [Boolean, Function] as PropType<boolean | FilterFunc<OptionType>>,
default: undefined,
},
filterSort: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>,
optionFilterProp: String,
optionLabelProp: String,
children: PropTypes.any,
options: Array as PropType<OptionType[]>,
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<ExtractPropTypes<ReturnType<typeof selectProps>>>;
export default defineComponent({});

View File

@ -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<BaseOptionType>[];
onActiveValue: OnActiveValue;
defaultActiveFirstOption?: boolean;
onSelect: OnInternalSelect;
menuItemSelectedIcon?: RenderNode;
rawValues: Set<RawValueType>;
fieldNames?: FieldNames;
virtual?: boolean;
listHeight?: number;
listItemHeight?: number;
childrenAsData?: boolean;
}
const SelectContextKey: InjectionKey<SelectContextProps> = Symbol('SelectContextKey');
export function useProvideSelectProps(props: SelectContextProps) {
return provide(SelectContextKey, props);
}
export default function useSelectProps() {
return inject(SelectContextKey, {} as SelectContextProps);
}

View File

@ -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<SelectTriggerProps, 'autoAdjustOverflow' | 'dropdownMatchSelectWidth'>,
) => {
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<SelectTriggerProps, { popupRef: any }>({
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 (
<Trigger
{...props}
showAction={[]}
hideAction={[]}
popupPlacement={placement || (direction === 'rtl' ? 'bottomRight' : 'bottomLeft')}
builtinPlacements={builtInPlacements.value}
prefixCls={dropdownPrefixCls}
popupTransitionName={mergedTransitionName}
popupAlign={dropdownAlign}
popupVisible={visible}
getPopupContainer={getPopupContainer}
popupClassName={classNames(dropdownClassName, {
[`${dropdownPrefixCls}-empty`]: empty,
})}
popupStyle={popupStyle}
getTriggerDOMNode={getTriggerDOMNode}
onPopupVisibleChange={onPopupVisibleChange}
v-slots={{
default: slots.default,
popup: () => (
<div ref={popupRef} onMouseenter={onPopupMouseEnter}>
{popupNode}
</div>
),
}}
></Trigger>
);
};
},
});
export default SelectTrigger;

View File

@ -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((<input />) 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;

View File

@ -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<SelectorProps>({
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<boolean> = 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 (
<span
class={classNames(`${selectionPrefixCls.value}-item`, {
[`${selectionPrefixCls.value}-item-disabled`]: itemDisabled,
})}
title={
typeof title === 'string' || typeof title === 'number' ? title.toString() : undefined
}
>
<span class={`${selectionPrefixCls.value}-item-content`}>{content}</span>
{closable && (
<TransBtn
class={`${selectionPrefixCls.value}-item-remove`}
onMousedown={onPreventMouseDown}
onClick={onClose}
customizeIcon={props.removeIcon}
>
×
</TransBtn>
)}
</span>
);
}
function customizeRenderSelector(
value: RawValueType,
content: VueNode,
itemDisabled: boolean,
closable: boolean,
onClose: (e: MouseEvent) => void,
) {
const onMouseDown = (e: MouseEvent) => {
onPreventMouseDown(e);
props.onToggleOpen(!open);
};
return (
<span onMousedown={onMouseDown}>
{props.tagRender({
label: content,
value,
disabled: itemDisabled,
closable,
onClose,
})}
</span>
);
}
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 = (
<div
class={`${selectionPrefixCls.value}-search`}
style={{ width: inputWidth.value + 'px' }}
key="input"
>
<Input
inputRef={inputRef}
open={open}
prefixCls={prefixCls}
id={id}
inputElement={null}
disabled={disabled}
autofocus={autofocus}
autocomplete={autocomplete}
editable={inputEditable.value}
activeDescendantId={activeDescendantId}
value={inputValue.value}
onKeydown={onInputKeyDown}
onMousedown={onInputMouseDown}
onChange={onInputChange}
onPaste={onInputPaste}
onCompositionstart={onInputCompositionStart}
onCompositionend={onInputCompositionEnd}
tabindex={tabindex}
attrs={pickAttrs(props, true)}
onFocus={() => (focused.value = true)}
onBlur={() => (focused.value = false)}
/>
{/* Measure Node */}
<span ref={measureRef} class={`${selectionPrefixCls.value}-search-mirror`} aria-hidden>
{inputValue.value}&nbsp;
</span>
</div>
);
// >>> Selections
const selectionNode = (
<Overflow
prefixCls={`${selectionPrefixCls.value}-overflow`}
data={values}
renderItem={renderItem}
renderRest={renderRest}
suffix={inputNode}
itemKey="key"
maxCount={props.maxTagCount}
key="overflow"
/>
);
return (
<>
{selectionNode}
{!values.length && !inputValue.value && (
<span class={`${selectionPrefixCls.value}-placeholder`}>{placeholder}</span>
)}
</>
);
};
},
});
export default SelectSelector;

View File

@ -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<SelectorProps>({
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 (
<span class={`${props.prefixCls}-selection-placeholder`} style={hiddenStyle}>
{props.placeholder}
</span>
);
};
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 (
<>
<span class={`${prefixCls}-selection-search`}>
<Input
inputRef={inputRef}
prefixCls={prefixCls}
id={id}
open={open}
inputElement={inputElement}
disabled={disabled}
autofocus={autofocus}
autocomplete={autocomplete}
editable={inputEditable.value}
activeDescendantId={activeDescendantId}
value={inputValue.value}
onKeydown={onInputKeyDown}
onMousedown={onInputMouseDown}
onChange={e => {
inputChanged.value = true;
onInputChange(e as any);
}}
onPaste={onInputPaste}
onCompositionstart={onInputCompositionStart}
onCompositionend={onInputCompositionEnd}
tabindex={tabindex}
attrs={pickAttrs(props, true)}
/>
</span>
{/* Display value */}
{!combobox.value && item && !hasTextInput.value && (
<span class={`${prefixCls}-selection-item`} title={title.value}>
<Fragment key={item.key || item.value}>{titleNode}</Fragment>
</span>
)}
{/* Display placeholder */}
{renderPlaceholder()}
</>
);
};
},
});
SingleSelector.props = props;
SingleSelector.inheritAttrs = false;
export default SingleSelector;

View File

@ -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<SelectorProps>({
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<EventHandler> },
/**
* @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' ? (
<MultipleSelector {...props} {...sharedProps} />
) : (
<SingleSelector {...props} {...sharedProps} />
);
return (
<div
ref={domRef}
class={`${prefixCls}-selector`}
onClick={onClick}
onMousedown={onMousedown}
>
{selectNode}
</div>
);
};
},
});
export default Selector;

View File

@ -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;
}

View File

@ -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<TransBtnProps> {
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 (
<span
class={className}
onMousedown={event => {
event.preventDefault();
if (onMousedown) {
onMousedown(event);
}
}}
style={{
userSelect: 'none',
WebkitUserSelect: 'none',
}}
unselectable="on"
onClick={onClick}
aria-hidden
>
{icon !== undefined ? (
icon
) : (
<span class={className.split(/\s+/).map((cls: any) => `${cls}-icon`)}>
{slots.default?.()}
</span>
)}
</span>
);
};
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;

File diff suppressed because it is too large Load Diff

View File

@ -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<BaseSelectContextProps> = Symbol('BaseSelectContextKey');
export function useProvideBaseSelectProps(props: BaseSelectContextProps) {
return provide(BaseSelectContextKey, props);
}
export default function useBaseProps() {
return inject(BaseSelectContextKey, {} as BaseSelectContextProps);
}

View File

@ -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<LabelInValueType[]>,
valueOptions: Ref<Map<RawValueType, DefaultOptionType>>,
): [Ref<LabelInValueType[]>, (val: RawValueType) => DefaultOptionType] => {
const cacheRef = shallowRef({
values: new Map<RawValueType, LabelInValueType>(),
options: new Map<RawValueType, DefaultOptionType>(),
});
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<RawValueType, LabelInValueType>();
const optionCache = new Map<RawValueType, DefaultOptionType>();
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];
};

View File

@ -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<Boolean>, (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];
}

View File

@ -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<DefaultOptionType[]>,
fieldNames: Ref<FieldNames>,
searchValue?: Ref<string>,
filterOption?: Ref<SelectProps['filterOption']>,
optionFilterProp?: Ref<string>,
) =>
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<BaseOptionType>)
: (_: 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;
});

View File

@ -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;
}

View File

@ -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];
}

View File

@ -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<OptionType>(
options: Ref<OptionType[]>,
children: Ref<any>,
fieldNames: Ref<FieldNames>,
) {
return computed(() => {
let mergedOptions = options.value;
const childrenAsData = !options.value;
if (childrenAsData) {
mergedOptions = convertChildrenToData(children.value);
}
const valueOptions = new Map<RawValueType, OptionType>();
const labelOptions = new Map<any, OptionType>();
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,
};
});
}

View File

@ -0,0 +1,32 @@
import type { Ref } from 'vue';
import { onBeforeUnmount, onMounted } from 'vue';
export default function useSelectTriggerControl(
refs: Ref[],
open: Ref<boolean>,
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);
});
}

View File

@ -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;

View File

@ -0,0 +1,11 @@
import type { Key } from '../_util/type';
import type { RawValueType } from './BaseSelect';
export interface FlattenOptionData<OptionType> {
label?: any;
data: OptionType;
key: Key;
value?: RawValueType;
groupOption?: boolean;
group?: boolean;
}

View File

@ -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<string, any> {
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> = 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<FOT extends FlattenOptionsType> = (
value: RawValueType,
config: {
options: FOT;
prevValueMap: Map<RawValueType, LabelValueType>;
labelInValue: boolean;
optionLabelProp: string;
},
) => LabelValueType;
export type FilterOptions<OptionsType extends object[]> = (
searchValue: string,
options: OptionsType,
/** Component props, since Select & TreeSelect use different prop name, use any here */
config: {
optionFilterProp: string;
filterOption: boolean | FilterFunc<OptionsType[number]>;
},
) => OptionsType;
export type FilterFunc<OptionType> = (inputValue: string, option?: OptionType) => boolean;
export type FlattenOptionsType<OptionType = object> = {
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<string, any>;
};
export type DropdownRender = (opt?: DropdownObject) => VueNode;

View File

@ -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;
}

View File

@ -0,0 +1,12 @@
export function toArray<T>(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;

View File

@ -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);
}

View File

@ -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<OptionType extends BaseOptionType = DefaultOptionType>(
node: VNode,
): OptionType {
const {
key,
children,
props: { value, disabled, ...restProps },
} = node as Omit<VNode, 'key'> & {
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 <a-select-option disabled />
...(restProps as any),
};
}
export function convertChildrenToData<OptionType extends BaseOptionType = DefaultOptionType>(
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;
}

View File

@ -0,0 +1,4 @@
/* istanbul ignore file */
export function isPlatformMac(): boolean {
return /(mac\sos|macintosh)/i.test(navigator.appVersion);
}

View File

@ -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<OptionType extends BaseOptionType = DefaultOptionType>(
options: OptionType[],
{ fieldNames, childrenAsData }: { fieldNames?: FieldNames; childrenAsData?: boolean } = {},
): FlattenOptionData<OptionType>[] {
const flattenList: FlattenOptionData<OptionType>[] = [];
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<T>(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;
}

View File

@ -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<RawValueType | LabelInValueType>(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;

View File

@ -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 (
<div>
<h2>combobox</h2>
<p>
<button type="button" onClick={this.toggleDisabled}>
toggle disabled
</button>
<button
type="button"
onClick={() => {
this.value = '';
}}
>
reset
</button>
</p>
<div>
<Select
disabled={disabled}
style={{ width: '500px' }}
onChange={this.onChange}
onSelect={this.onSelect}
onSearch={this.onSearch}
onInputKeyDown={this.onKeyDown}
notFoundContent=""
allowClear
placeholder="please select"
value={value}
mode="combobox"
backfill
onFocus={() => console.log('focus')}
onBlur={() => console.log('blur')}
>
<Option value="jack">
<b style={{ color: 'red' }}>jack</b>
</Option>
<Option value="lucy">lucy</Option>
<Option value="disabled" disabled>
disabled
</Option>
<Option value="yiminghe">yiminghe</Option>
<Option value="竹林星光">竹林星光</Option>
</Select>
<h3>Customize Input Element</h3>
<Select
mode="combobox"
style={{ width: '200px' }}
getInputElement={() => (
<textarea style={{ background: 'red' }} rows={3} ref={this.textareaRef} />
)}
options={[{ value: 'light' }, { value: 'bamboo' }]}
allowClear
placeholder="2333"
/>
<h3>Async Input Element</h3>
<Select
mode="combobox"
notFoundContent={null}
style={{ width: '200px' }}
options={this.options}
onChange={this.onAsyncChange}
/>
</div>
</div>
);
},
};
export default Combobox;
/* eslint-enable */

View File

@ -1,10 +0,0 @@
// input {
// // height: 24px;
// // line-height: 24px;
// border: 1px solid #333;
// border-radius: 4px;
// }
// button {
// border: 1px solid #333;
// }

View File

@ -1,35 +0,0 @@
import jsonp from 'jsonp';
import querystring from 'querystring';
let timeout;
let currentValue;
export function fetch(value, callback) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
currentValue = value;
function fake() {
const str = querystring.encode({
code: 'utf-8',
q: value,
});
jsonp(`http://suggest.taobao.com/sug?${str}`, (err, d) => {
if (currentValue === value) {
const { result } = d;
const data = [];
result.forEach(r => {
data.push({
value: r[0],
text: r[0],
});
});
callback(data);
}
});
}
timeout = setTimeout(fake, 300);
}

View File

@ -1,99 +0,0 @@
/* eslint-disable no-console */
import Select, { Option } from '..';
import '../assets/index.less';
const Controlled = {
data: () => ({
destroy: false,
value: 9,
open: true,
}),
methods: {
onChange(e) {
let value;
if (e && e.target) {
({ value } = e.target);
} else {
value = e;
}
console.log('onChange', value);
this.value = value;
},
onDestroy() {
this.destroy = true;
},
onBlur(v) {
console.log('onBlur', v);
},
onFocus() {
console.log('onFocus');
},
onDropdownVisibleChange(open) {
this.open = open;
},
getPopupContainer(node) {
return node.parentNode;
},
},
render() {
const { open, destroy, value } = this;
if (destroy) {
return null;
}
return (
<div style={{ margin: '20px' }}>
<h2>controlled Select</h2>
<div style={{ width: '300px' }}>
<Select
id="my-select"
value={value}
placeholder="placeholder"
listHeight={200}
style={{ width: '500px' }}
onBlur={this.onBlur}
onFocus={this.onFocus}
open={open}
optionLabelProp="children"
optionFilterProp="text"
onChange={this.onChange}
onDropdownVisibleChange={this.onDropdownVisibleChange}
//getPopupContainer={this.getPopupContainer}
>
<Option value="01" text="jack" title="jack">
<b
style={{
color: 'red',
}}
>
jack
</b>
</Option>
<Option value="11" text="lucy">
lucy
</Option>
<Option value="21" disabled text="disabled">
disabled
</Option>
<Option value="31" text="yiminghe">
yiminghe
</Option>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
<Option key={i} value={i} text={String(i)}>
{i}-text
</Option>
))}
</Select>
</div>
</div>
);
},
};
export default Controlled;
/* eslint-enable */

View File

@ -1,118 +0,0 @@
/* eslint-disable no-console */
import Select, { Option } from '..';
import '../assets/index.less';
const children = [];
for (let i = 10; i < 36; i += 1) {
children.push(
<Option
key={i.toString(36) + i}
disabled={i === 10}
title={`中文${i}`}
v-slots={{ default: () => `中文${i}` }}
></Option>,
);
}
const Test = {
data: () => ({
state: {
useAnim: true,
showArrow: false,
loading: false,
value: ['a10'],
},
}),
methods: {
setState(state) {
Object.assign(this.state, state);
},
onChange(value, options) {
console.log('onChange', value, options);
this.setState({
value,
});
},
onSelect(...args) {
console.log(args);
},
onDeselect(...args) {
console.log(args);
},
useAnim(e) {
this.setState({
useAnim: e.target.checked,
});
},
showArrow(e) {
this.setState({
showArrow: e.target.checked,
});
},
loading(e) {
this.setState({
loading: e.target.checked,
});
},
},
render() {
const { useAnim, showArrow, loading, value } = this.state;
return (
<div style="margin: 20px">
<h2>multiple selectscroll the menu</h2>
<p>
<label html-for="useAnim">
anim
<input id="useAnim" checked={useAnim} type="checkbox" onChange={this.useAnim} />
</label>
<p />
<label html-for="showArrow">
showArrow
<input id="showArrow" checked={showArrow} type="checkbox" onChange={this.showArrow} />
</label>
</p>
<p>
<label html-for="loading">
loading
<input id="loading" checked={loading} type="checkbox" onChange={this.loading} />
</label>
</p>
<div style={{ width: '300px' }}>
<Select
value={value}
animation={useAnim ? 'slide-up' : null}
choiceTransitionName="rc-select-selection__choice-zoom"
style={{ width: '500px' }}
mode="multiple"
loading={loading}
showArrow={showArrow}
allowClear
optionFilterProp="children"
optionLabelProp="children"
onSelect={this.onSelect}
onDeselect={this.onDeselect}
placeholder="please select"
onChange={this.onChange}
onFocus={() => console.log('focus')}
onBlur={v => console.log('blur', v)}
tokenSeparators={[' ', ',']}
>
{children}
</Select>
</div>
</div>
);
},
};
export default Test;
/* eslint-enable */

View File

@ -1,154 +0,0 @@
import { defineComponent } from 'vue';
/* eslint-disable no-console */
import Select, { Option } from '..';
import '../assets/index.less';
import './single.less';
const Test = defineComponent({
data() {
return {
destroy: false,
value: '9',
};
},
methods: {
onChange(e) {
let value;
if (e && e.target) {
({ value } = e.target);
} else {
value = e;
}
console.log('onChange', value);
this.value = value;
},
onDestroy() {
this.destroy = 1;
},
onBlur(v) {
console.log('onBlur', v);
},
onFocus() {
console.log('onFocus');
},
onSearch(val) {
console.log('Search:', val);
},
},
render() {
const { value, destroy } = this;
if (destroy) {
return null;
}
return (
<div style={{ margin: '20px' }}>
<div
style={{ height: '150px', background: 'rgba(0, 255, 0, 0.1)' }}
onMousedown={e => {
e.preventDefault();
}}
>
Prevent Default
</div>
<h2>Single Select</h2>
<div style={{ width: '300px' }}>
<Select
autofocus
id="my-select"
value={value}
placeholder="placeholder"
showSearch
style={{ width: '500px' }}
onBlur={this.onBlur}
onFocus={this.onFocus}
onSearch={this.onSearch}
allowClear
optionFilterProp="text"
onChange={this.onChange}
onPopupScroll={() => {
console.log('Scroll!');
}}
>
<Option value={null}>不选择</Option>,
<Option value="01" text="jack" title="jack">
<b
style={{
color: 'red',
}}
>
jack
</b>
</Option>
,
<Option value="11" text="lucy">
<span>lucy</span>
</Option>
,
<Option value="21" disabled text="disabled">
disabled
</Option>
,
<Option value="31" text="yiminghe" class="test-option" style={{ background: 'yellow' }}>
yiminghe
</Option>
,
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
<Option key={i} value={String(i)} text={String(i)}>
{i}-text
</Option>
))}
</Select>
</div>
<h2>native select</h2>
<select value={value} style={{ width: '500px' }} onChange={this.onChange}>
<option value="01">jack</option>
<option value="11">lucy</option>
<option value="21" disabled>
disabled
</option>
<option value="31">yiminghe</option>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
<option value={i} key={i}>
{i}
</option>
))}
</select>
<h2>RTL Select</h2>
<div style={{ width: '300px' }}>
<Select
id="my-select-rtl"
placeholder="rtl"
direction="rtl"
dropdownMatchSelectWidth={300}
dropdownStyle={{ minWidth: '300px' }}
style={{ width: '500px' }}
>
<Option value="1">1</Option>
<Option value="2">2</Option>
</Select>
</div>
<p>
<button type="button" onClick={this.onDestroy}>
destroy
</button>
</p>
</div>
);
},
});
export default Test;
/* eslint-enable */

View File

@ -1,3 +0,0 @@
.test-option {
font-weight: bolder;
}