567 lines
18 KiB
Vue
567 lines
18 KiB
Vue
import { computed, defineComponent, ref, toRef, toRefs, watchEffect } from 'vue';
|
|
import type { CSSProperties, ExtractPropTypes, PropType, Ref } from 'vue';
|
|
import type { BaseSelectRef, BaseSelectProps } from '../vc-select';
|
|
import type { DisplayValueType, Placement } from '../vc-select/BaseSelect';
|
|
import { baseSelectPropsWithoutPrivate } from '../vc-select/BaseSelect';
|
|
import omit from '../_util/omit';
|
|
import type { Key, VueNode } from '../_util/type';
|
|
import PropTypes from '../_util/vue-types';
|
|
import { initDefaultProps } from '../_util/props-util';
|
|
import useId from '../vc-select/hooks/useId';
|
|
import useMergedState from '../_util/hooks/useMergedState';
|
|
import { fillFieldNames, toPathKey, toPathKeys } from './utils/commonUtil';
|
|
import useEntities from './hooks/useEntities';
|
|
import useSearchConfig from './hooks/useSearchConfig';
|
|
import useSearchOptions from './hooks/useSearchOptions';
|
|
import useMissingValues from './hooks/useMissingValues';
|
|
import { formatStrategyValues, toPathOptions } from './utils/treeUtil';
|
|
import { conductCheck } from '../vc-tree/utils/conductUtil';
|
|
import useDisplayValues from './hooks/useDisplayValues';
|
|
import { useProvideCascader } from './context';
|
|
import OptionList from './OptionList';
|
|
import { BaseSelect } from '../vc-select';
|
|
import devWarning from '../vc-util/devWarning';
|
|
|
|
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?: any;
|
|
value?: string | number | null;
|
|
children?: DefaultOptionType[];
|
|
}
|
|
|
|
function baseCascaderProps<OptionType extends BaseOptionType = DefaultOptionType>() {
|
|
return {
|
|
...omit(baseSelectPropsWithoutPrivate(), ['tokenSeparators', 'mode', 'showSearch']),
|
|
// MISC
|
|
id: String,
|
|
prefixCls: String,
|
|
fieldNames: Object as PropType<FieldNames>,
|
|
children: Array as PropType<VueNode[]>,
|
|
|
|
// Value
|
|
value: { type: [String, Number, Array] as PropType<ValueType> },
|
|
defaultValue: { type: [String, Number, Array] as PropType<ValueType> },
|
|
changeOnSelect: { type: Boolean, default: undefined },
|
|
onChange: Function as PropType<
|
|
(value: ValueType, selectedOptions?: OptionType[] | OptionType[][]) => void
|
|
>,
|
|
displayRender: Function as PropType<
|
|
(opt: { labels: string[]; selectedOptions?: OptionType[] }) => any
|
|
>,
|
|
checkable: { type: Boolean, default: undefined },
|
|
|
|
// Search
|
|
showSearch: {
|
|
type: [Boolean, Object] as PropType<boolean | ShowSearchType<OptionType>>,
|
|
default: undefined as boolean | ShowSearchType<OptionType>,
|
|
},
|
|
searchValue: String,
|
|
onSearch: Function as PropType<(value: string) => void>,
|
|
|
|
// Trigger
|
|
expandTrigger: String as PropType<'hover' | 'click'>,
|
|
|
|
// Options
|
|
options: Array as PropType<OptionType[]>,
|
|
/** @private Internal usage. Do not use in your production. */
|
|
dropdownPrefixCls: String,
|
|
loadData: Function as PropType<(selectOptions: OptionType[]) => void>,
|
|
|
|
// Open
|
|
/** @deprecated Use `open` instead */
|
|
popupVisible: { type: Boolean, default: undefined },
|
|
|
|
/** @deprecated Use `dropdownClassName` instead */
|
|
popupClassName: String,
|
|
dropdownClassName: String,
|
|
dropdownMenuColumnStyle: {
|
|
type: Object as PropType<CSSProperties>,
|
|
default: undefined as CSSProperties,
|
|
},
|
|
|
|
/** @deprecated Use `dropdownStyle` instead */
|
|
popupStyle: { type: Object as PropType<CSSProperties>, default: undefined as CSSProperties },
|
|
dropdownStyle: { type: Object as PropType<CSSProperties>, default: undefined as CSSProperties },
|
|
|
|
/** @deprecated Use `placement` instead */
|
|
popupPlacement: String as PropType<Placement>,
|
|
placement: String as PropType<Placement>,
|
|
|
|
/** @deprecated Use `onDropdownVisibleChange` instead */
|
|
onPopupVisibleChange: Function as PropType<(open: boolean) => void>,
|
|
onDropdownVisibleChange: Function as PropType<(open: boolean) => void>,
|
|
|
|
// Icon
|
|
expandIcon: PropTypes.any,
|
|
loadingIcon: PropTypes.any,
|
|
};
|
|
}
|
|
|
|
export type BaseCascaderProps = Partial<ExtractPropTypes<ReturnType<typeof baseCascaderProps>>>;
|
|
|
|
type OnSingleChange<OptionType> = (value: SingleValueType, selectOptions: OptionType[]) => void;
|
|
type OnMultipleChange<OptionType> = (
|
|
value: SingleValueType[],
|
|
selectOptions: OptionType[][],
|
|
) => void;
|
|
|
|
export function singleCascaderProps<OptionType extends BaseOptionType = DefaultOptionType>() {
|
|
return {
|
|
...baseCascaderProps(),
|
|
checkable: Boolean as PropType<false>,
|
|
onChange: Function as PropType<OnSingleChange<OptionType>>,
|
|
};
|
|
}
|
|
|
|
export type SingleCascaderProps = Partial<ExtractPropTypes<ReturnType<typeof singleCascaderProps>>>;
|
|
|
|
export function multipleCascaderProps<OptionType extends BaseOptionType = DefaultOptionType>() {
|
|
return {
|
|
...baseCascaderProps(),
|
|
checkable: Boolean as PropType<true>,
|
|
onChange: Function as PropType<OnMultipleChange<OptionType>>,
|
|
};
|
|
}
|
|
|
|
export type MultipleCascaderProps = Partial<
|
|
ExtractPropTypes<ReturnType<typeof singleCascaderProps>>
|
|
>;
|
|
|
|
export function internalCascaderProps<OptionType extends BaseOptionType = DefaultOptionType>() {
|
|
return {
|
|
...baseCascaderProps(),
|
|
onChange: Function as PropType<
|
|
(value: ValueType, selectOptions: OptionType[] | OptionType[][]) => void
|
|
>,
|
|
customSlots: Object as PropType<Record<string, Function>>,
|
|
};
|
|
}
|
|
|
|
export type CascaderProps = Partial<ExtractPropTypes<ReturnType<typeof internalCascaderProps>>>;
|
|
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];
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'Cascader',
|
|
inheritAttrs: false,
|
|
props: initDefaultProps(internalCascaderProps(), {}),
|
|
setup(props, { attrs, expose, slots }) {
|
|
const mergedId = useId(toRef(props, 'id'));
|
|
const multiple = computed(() => !!props.checkable);
|
|
|
|
// =========================== Values ===========================
|
|
const [rawValues, setRawValues] = useMergedState<ValueType, Ref<SingleValueType[]>>(
|
|
props.defaultValue,
|
|
{
|
|
value: computed(() => props.value),
|
|
postState: toRawValues,
|
|
},
|
|
);
|
|
|
|
// ========================= FieldNames =========================
|
|
const mergedFieldNames = computed(() => fillFieldNames(props.fieldNames));
|
|
|
|
// =========================== Option ===========================
|
|
const mergedOptions = computed(() => props.options || []);
|
|
|
|
// Only used in multiple mode, this fn will not call in single mode
|
|
const pathKeyEntities = useEntities(mergedOptions, mergedFieldNames);
|
|
|
|
/** Convert path key back to value format */
|
|
const getValueByKeyPath = (pathKeys: Key[]): SingleValueType[] => {
|
|
const ketPathEntities = pathKeyEntities.value;
|
|
|
|
return pathKeys.map(pathKey => {
|
|
const { nodes } = ketPathEntities[pathKey];
|
|
|
|
return nodes.map(node => node[mergedFieldNames.value.value]);
|
|
});
|
|
};
|
|
|
|
// =========================== Search ===========================
|
|
const [mergedSearchValue, setSearchValue] = useMergedState('', {
|
|
value: computed(() => props.searchValue),
|
|
postState: search => search || '',
|
|
});
|
|
|
|
const onInternalSearch: BaseSelectProps['onSearch'] = (searchText, info) => {
|
|
setSearchValue(searchText);
|
|
|
|
if (info.source !== 'blur' && props.onSearch) {
|
|
props.onSearch(searchText);
|
|
}
|
|
};
|
|
|
|
const { showSearch: mergedShowSearch, searchConfig: mergedSearchConfig } = useSearchConfig(
|
|
toRef(props, 'showSearch'),
|
|
);
|
|
|
|
const searchOptions = useSearchOptions(
|
|
mergedSearchValue,
|
|
mergedOptions,
|
|
mergedFieldNames,
|
|
computed(() => props.dropdownPrefixCls || props.prefixCls),
|
|
mergedSearchConfig,
|
|
toRef(props, 'changeOnSelect'),
|
|
);
|
|
|
|
// =========================== Values ===========================
|
|
const missingValuesInfo = useMissingValues(mergedOptions, mergedFieldNames, rawValues);
|
|
|
|
// Fill `rawValues` with checked conduction values
|
|
const [checkedValues, halfCheckedValues, missingCheckedValues] = [
|
|
ref<SingleValueType[]>([]),
|
|
ref<SingleValueType[]>([]),
|
|
ref<SingleValueType[]>([]),
|
|
];
|
|
watchEffect(() => {
|
|
const [existValues, missingValues] = missingValuesInfo.value;
|
|
|
|
if (!multiple.value || !rawValues.value.length) {
|
|
[checkedValues.value, halfCheckedValues.value, missingCheckedValues.value] = [
|
|
existValues,
|
|
[],
|
|
missingValues,
|
|
];
|
|
return;
|
|
}
|
|
|
|
const keyPathValues = toPathKeys(existValues);
|
|
const ketPathEntities = pathKeyEntities.value;
|
|
|
|
const { checkedKeys, halfCheckedKeys } = conductCheck(keyPathValues, true, ketPathEntities);
|
|
|
|
// Convert key back to value cells
|
|
[checkedValues.value, halfCheckedValues.value, missingCheckedValues.value] = [
|
|
getValueByKeyPath(checkedKeys),
|
|
getValueByKeyPath(halfCheckedKeys),
|
|
missingValues,
|
|
];
|
|
});
|
|
|
|
const deDuplicatedValues = computed(() => {
|
|
const checkedKeys = toPathKeys(checkedValues.value);
|
|
const deduplicateKeys = formatStrategyValues(checkedKeys, pathKeyEntities.value);
|
|
return [...missingCheckedValues.value, ...getValueByKeyPath(deduplicateKeys)];
|
|
});
|
|
|
|
const displayValues = useDisplayValues(
|
|
deDuplicatedValues,
|
|
mergedOptions,
|
|
mergedFieldNames,
|
|
multiple,
|
|
toRef(props, 'displayRender'),
|
|
);
|
|
|
|
// =========================== Change ===========================
|
|
const triggerChange = (nextValues: ValueType) => {
|
|
setRawValues(nextValues);
|
|
|
|
// Save perf if no need trigger event
|
|
if (props.onChange) {
|
|
const nextRawValues = toRawValues(nextValues);
|
|
|
|
const valueOptions = nextRawValues.map(valueCells =>
|
|
toPathOptions(valueCells, mergedOptions.value, mergedFieldNames.value).map(
|
|
valueOpt => valueOpt.option,
|
|
),
|
|
);
|
|
|
|
const triggerValues = multiple.value ? nextRawValues : nextRawValues[0];
|
|
const triggerOptions = multiple.value ? valueOptions : valueOptions[0];
|
|
|
|
props.onChange(triggerValues, triggerOptions);
|
|
}
|
|
};
|
|
|
|
// =========================== Select ===========================
|
|
const onInternalSelect = (valuePath: SingleValueType) => {
|
|
if (!multiple.value) {
|
|
triggerChange(valuePath);
|
|
} else {
|
|
// Prepare conduct required info
|
|
const pathKey = toPathKey(valuePath);
|
|
const checkedPathKeys = toPathKeys(checkedValues.value);
|
|
const halfCheckedPathKeys = toPathKeys(halfCheckedValues.value);
|
|
|
|
const existInChecked = checkedPathKeys.includes(pathKey);
|
|
const existInMissing = missingCheckedValues.value.some(
|
|
valueCells => toPathKey(valueCells) === pathKey,
|
|
);
|
|
|
|
// Do update
|
|
let nextCheckedValues = checkedValues.value;
|
|
let nextMissingValues = missingCheckedValues.value;
|
|
|
|
if (existInMissing && !existInChecked) {
|
|
// Missing value only do filter
|
|
nextMissingValues = missingCheckedValues.value.filter(
|
|
valueCells => toPathKey(valueCells) !== pathKey,
|
|
);
|
|
} else {
|
|
// Update checked key first
|
|
const nextRawCheckedKeys = existInChecked
|
|
? checkedPathKeys.filter(key => key !== pathKey)
|
|
: [...checkedPathKeys, pathKey];
|
|
|
|
// Conduction by selected or not
|
|
let checkedKeys: Key[];
|
|
if (existInChecked) {
|
|
({ checkedKeys } = conductCheck(
|
|
nextRawCheckedKeys,
|
|
{ checked: false, halfCheckedKeys: halfCheckedPathKeys },
|
|
pathKeyEntities.value,
|
|
));
|
|
} else {
|
|
({ checkedKeys } = conductCheck(nextRawCheckedKeys, true, pathKeyEntities.value));
|
|
}
|
|
|
|
// Roll up to parent level keys
|
|
const deDuplicatedKeys = formatStrategyValues(checkedKeys, pathKeyEntities.value);
|
|
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') {
|
|
watchEffect(() => {
|
|
devWarning(
|
|
!props.onPopupVisibleChange,
|
|
'Cascader',
|
|
'`popupVisibleChange` is deprecated. Please use `dropdownVisibleChange` instead.',
|
|
);
|
|
devWarning(
|
|
props.popupVisible === undefined,
|
|
'Cascader',
|
|
'`popupVisible` is deprecated. Please use `open` instead.',
|
|
);
|
|
devWarning(
|
|
props.popupClassName === undefined,
|
|
'Cascader',
|
|
'`popupClassName` is deprecated. Please use `dropdownClassName` instead.',
|
|
);
|
|
devWarning(
|
|
props.popupPlacement === undefined,
|
|
'Cascader',
|
|
'`popupPlacement` is deprecated. Please use `placement` instead.',
|
|
);
|
|
devWarning(
|
|
props.popupStyle === undefined,
|
|
'Cascader',
|
|
'`popupStyle` is deprecated. Please use `dropdownStyle` instead.',
|
|
);
|
|
});
|
|
}
|
|
|
|
const mergedOpen = computed(() => (props.open !== undefined ? props.open : props.popupVisible));
|
|
|
|
const mergedDropdownClassName = computed(() => props.dropdownClassName || props.popupClassName);
|
|
|
|
const mergedDropdownStyle = computed(() => props.dropdownStyle || props.popupStyle || {});
|
|
|
|
const mergedPlacement = computed(() => props.placement || props.popupPlacement);
|
|
|
|
const onInternalDropdownVisibleChange = (nextVisible: boolean) => {
|
|
props.onDropdownVisibleChange?.(nextVisible);
|
|
props.onPopupVisibleChange?.(nextVisible);
|
|
};
|
|
const {
|
|
changeOnSelect,
|
|
checkable,
|
|
dropdownPrefixCls,
|
|
loadData,
|
|
expandTrigger,
|
|
expandIcon,
|
|
loadingIcon,
|
|
dropdownMenuColumnStyle,
|
|
customSlots,
|
|
} = toRefs(props);
|
|
useProvideCascader({
|
|
options: mergedOptions,
|
|
fieldNames: mergedFieldNames,
|
|
values: checkedValues,
|
|
halfValues: halfCheckedValues,
|
|
changeOnSelect,
|
|
onSelect: onInternalSelect,
|
|
checkable,
|
|
searchOptions,
|
|
dropdownPrefixCls,
|
|
loadData,
|
|
expandTrigger,
|
|
expandIcon,
|
|
loadingIcon,
|
|
dropdownMenuColumnStyle,
|
|
customSlots,
|
|
});
|
|
const selectRef = ref<BaseSelectRef>();
|
|
|
|
expose({
|
|
focus() {
|
|
selectRef.value?.focus();
|
|
},
|
|
blur() {
|
|
selectRef.value?.blur();
|
|
},
|
|
scrollTo(arg) {
|
|
selectRef.value?.scrollTo(arg);
|
|
},
|
|
} as BaseSelectRef);
|
|
|
|
const pickProps = computed(() => {
|
|
return omit(props, [
|
|
'id',
|
|
'prefixCls',
|
|
'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',
|
|
'customSlots',
|
|
|
|
// Children
|
|
'children',
|
|
]);
|
|
});
|
|
return () => {
|
|
const emptyOptions = !(mergedSearchValue.value ? searchOptions.value : mergedOptions.value)
|
|
.length;
|
|
|
|
const dropdownStyle: CSSProperties =
|
|
// Search to match width
|
|
(mergedSearchValue.value && mergedSearchConfig.value.matchInputWidth) ||
|
|
// Empty keep the width
|
|
emptyOptions
|
|
? {}
|
|
: {
|
|
minWidth: 'auto',
|
|
};
|
|
return (
|
|
<BaseSelect
|
|
{...pickProps.value}
|
|
{...attrs}
|
|
// MISC
|
|
ref={selectRef}
|
|
id={mergedId}
|
|
prefixCls={props.prefixCls}
|
|
dropdownMatchSelectWidth={false}
|
|
dropdownStyle={{ ...mergedDropdownStyle.value, ...dropdownStyle }}
|
|
// Value
|
|
displayValues={displayValues.value}
|
|
onDisplayValuesChange={onDisplayValuesChange}
|
|
mode={multiple.value ? 'multiple' : undefined}
|
|
// Search
|
|
searchValue={mergedSearchValue.value}
|
|
onSearch={onInternalSearch}
|
|
showSearch={mergedShowSearch.value}
|
|
// Options
|
|
OptionList={OptionList}
|
|
emptyOptions={emptyOptions}
|
|
// Open
|
|
open={mergedOpen.value}
|
|
dropdownClassName={mergedDropdownClassName.value}
|
|
placement={mergedPlacement.value}
|
|
onDropdownVisibleChange={onInternalDropdownVisibleChange}
|
|
// Children
|
|
getRawInputElement={() => slots.default?.()}
|
|
v-slots={slots}
|
|
/>
|
|
);
|
|
};
|
|
},
|
|
});
|