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 { filter?: (inputValue: string, options: OptionType[], fieldNames: FieldNames) => boolean; render?: (arg?: { inputValue: string; path: OptionType[]; prefixCls: string; fieldNames: FieldNames; }) => any; sort?: (a: OptionType[], b: OptionType[], inputValue: string, fieldNames: FieldNames) => number; matchInputWidth?: boolean; limit?: number | false; } export interface FieldNames { label?: string; value?: string; children?: string; } export interface InternalFieldNames extends Required { key: string; } export type SingleValueType = (string | number)[]; export type ValueType = SingleValueType | SingleValueType[]; export interface BaseOptionType { disabled?: boolean; [name: string]: any; } export interface DefaultOptionType extends BaseOptionType { label?: any; value?: string | number | null; children?: DefaultOptionType[]; } function baseCascaderProps() { return { ...omit(baseSelectPropsWithoutPrivate(), ['tokenSeparators', 'mode', 'showSearch']), // MISC id: String, prefixCls: String, fieldNames: Object as PropType, children: Array as PropType, // Value value: { type: [String, Number, Array] as PropType }, defaultValue: { type: [String, Number, Array] as PropType }, 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>, default: undefined as boolean | ShowSearchType, }, searchValue: String, onSearch: Function as PropType<(value: string) => void>, // Trigger expandTrigger: String as PropType<'hover' | 'click'>, // Options options: Array as PropType, /** @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, default: undefined as CSSProperties, }, /** @deprecated Use `dropdownStyle` instead */ popupStyle: { type: Object as PropType, default: undefined as CSSProperties }, dropdownStyle: { type: Object as PropType, default: undefined as CSSProperties }, /** @deprecated Use `placement` instead */ popupPlacement: String as PropType, placement: String as PropType, /** @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>>; type OnSingleChange = (value: SingleValueType, selectOptions: OptionType[]) => void; type OnMultipleChange = ( value: SingleValueType[], selectOptions: OptionType[][], ) => void; export function singleCascaderProps() { return { ...baseCascaderProps(), checkable: Boolean as PropType, onChange: Function as PropType>, }; } export type SingleCascaderProps = Partial>>; export function multipleCascaderProps() { return { ...baseCascaderProps(), checkable: Boolean as PropType, onChange: Function as PropType>, }; } export type MultipleCascaderProps = Partial< ExtractPropTypes> >; export function internalCascaderProps() { return { ...baseCascaderProps(), onChange: Function as PropType< (value: ValueType, selectOptions: OptionType[] | OptionType[][]) => void >, customSlots: Object as PropType>, }; } export type CascaderProps = Partial>>; export type CascaderRef = Omit; function isMultipleValue(value: ValueType): value is SingleValueType[] { return Array.isArray(value) && Array.isArray(value[0]); } function toRawValues(value: ValueType): SingleValueType[] { if (!value) { return []; } if (isMultipleValue(value)) { return value; } return value.length === 0 ? [] : [value]; } 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>( 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([]), ref([]), ref([]), ]; 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(); 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 ( slots.default?.()} v-slots={slots} /> ); }; }, });