refactor: select
parent
54cdc3ff40
commit
1c508d61fb
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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];
|
||||
};
|
|
@ -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: () => {},
|
||||
}));
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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]);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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];
|
||||
});
|
||||
};
|
|
@ -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];
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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({});
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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}
|
||||
</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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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
|
@ -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);
|
||||
}
|
|
@ -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];
|
||||
};
|
|
@ -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];
|
||||
}
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
/* istanbul ignore file */
|
||||
export function isPlatformMac(): boolean {
|
||||
return /(mac\sos|macintosh)/i.test(navigator.appVersion);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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 */
|
|
@ -1,10 +0,0 @@
|
|||
// input {
|
||||
// // height: 24px;
|
||||
// // line-height: 24px;
|
||||
// border: 1px solid #333;
|
||||
// border-radius: 4px;
|
||||
// }
|
||||
|
||||
// button {
|
||||
// border: 1px solid #333;
|
||||
// }
|
|
@ -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);
|
||||
}
|
|
@ -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 */
|
|
@ -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 select(scroll 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 */
|
|
@ -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 */
|
|
@ -1,3 +0,0 @@
|
|||
.test-option {
|
||||
font-weight: bolder;
|
||||
}
|
Loading…
Reference in New Issue