501 lines
14 KiB
Vue
501 lines
14 KiB
Vue
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;
|