feat: cascader add clearIcon & removeIcon slot

pull/5485/head
tangjinzhou 2022-04-12 14:03:30 +08:00
parent f1f6085dbb
commit 6d2bcf0ab8
14 changed files with 170 additions and 72 deletions

View File

@ -13,6 +13,17 @@ import {
Transition as T,
TransitionGroup as TG,
} from 'vue';
import { tuple } from './type';
const SelectPlacements = tuple('bottomLeft', 'bottomRight', 'topLeft', 'topRight');
export type SelectCommonPlacement = typeof SelectPlacements[number];
const getTransitionDirection = (placement: SelectCommonPlacement | undefined) => {
if (placement !== undefined && (placement === 'topLeft' || placement === 'topRight')) {
return `slide-down`;
}
return `slide-up`;
};
export const getTransitionProps = (transitionName: string, opt: TransitionProps = {}) => {
if (process.env.NODE_ENV === 'test') {
@ -176,6 +187,6 @@ const getTransitionName = (rootPrefixCls: string, motion: string, transitionName
return `${rootPrefixCls}-${motion}`;
};
export { Transition, TransitionGroup, collapseMotion, getTransitionName };
export { Transition, TransitionGroup, collapseMotion, getTransitionName, getTransitionDirection };
export default Transition;

View File

@ -16,21 +16,23 @@ Custom suffix icon
</docs>
<template>
<a-cascader
v-model:value="value1"
style="margin-top: 1rem"
:options="options"
placeholder="Please select"
>
<template #suffixIcon><smile-outlined class="test" /></template>
</a-cascader>
<a-cascader
v-model:value="value2"
suffix-icon="ab"
style="margin-top: 1rem"
:options="options"
placeholder="Please select"
/>
<a-space>
<a-cascader
v-model:value="value1"
style="margin-top: 1rem"
:options="options"
placeholder="Please select"
>
<template #suffixIcon><smile-outlined class="test" /></template>
</a-cascader>
<a-cascader
v-model:value="value2"
suffix-icon="ab"
style="margin-top: 1rem"
:options="options"
placeholder="Please select"
/>
</a-space>
</template>
<script lang="ts">
import { SmileOutlined } from '@ant-design/icons-vue';

View File

@ -23,6 +23,8 @@ Cascade selection box.
| --- | --- | --- | --- | --- |
| allowClear | whether allow clear | boolean | true | |
| autofocus | get focus when component mounted | boolean | false | |
| bordered | Whether has border style | boolean | true | 3.2 |
| clearIcon | The custom clear icon | slot | - | 3.2 |
| changeOnSelect | (Work on single select) change value on each selection if set to true, see above demo for details | boolean | false | |
| disabled | whether disabled select | boolean | false | |
| displayRender | render function of displaying selected options, you can use #displayRender="{labels, selectedOptions}". | `({labels, selectedOptions}) => VNode` | `labels => labels.join(' / ')` | |
@ -41,6 +43,7 @@ Cascade selection box.
| options | data options of cascade | [Option](#option)\[] | - | |
| placeholder | input placeholder | string | 'Please select' | |
| placement | Use preset popup align config from builtinPlacements | `bottomLeft` \| `bottomRight` \| `topLeft` \| `topRight` | `bottomLeft` | 3.0 |
| removeIcon | The custom remove icon | slot | - | 3.2 |
| searchValue | Set search valueNeed work with `showSearch` | string | - | 3.0 |
| showSearch | Whether show search input in single mode. | boolean \| [object](#showsearch) | false | |
| size | input size | `large` \| `default` \| `small` | `default` | |

View File

@ -15,7 +15,8 @@ import useConfigInject from '../_util/hooks/useConfigInject';
import classNames from '../_util/classNames';
import type { SizeType } from '../config-provider';
import devWarning from '../vc-util/devWarning';
import { getTransitionName } from '../_util/transition';
import type { SelectCommonPlacement } from '../_util/transition';
import { getTransitionDirection, getTransitionName } from '../_util/transition';
import { useInjectFormItemContext } from '../form';
import type { ValueType } from '../vc-cascader/Cascader';
@ -96,7 +97,7 @@ export function cascaderProps<DataNodeType extends CascaderOptionType = Cascader
multiple: { type: Boolean, default: undefined },
size: String as PropType<SizeType>,
bordered: { type: Boolean, default: undefined },
placement: { type: String as PropType<SelectCommonPlacement> },
suffixIcon: PropTypes.any,
options: Array as PropType<DataNodeType[]>,
'onUpdate:value': Function as PropType<(value: ValueType) => void>,
@ -191,7 +192,17 @@ const Cascader = defineComponent({
emit('blur', ...args);
formItemContext.onFieldBlur();
};
const mergedShowArrow = computed(() =>
props.showArrow !== undefined ? props.showArrow : props.loading || !props.multiple,
);
const placement = computed(() => {
if (props.placement !== undefined) {
return props.placement;
}
return direction.value === 'rtl'
? ('bottomRight' as SelectCommonPlacement)
: ('bottomLeft' as SelectCommonPlacement);
});
return () => {
const {
notFoundContent = slots.notFoundContent?.(),
@ -225,6 +236,7 @@ const Cascader = defineComponent({
...props,
multiple,
prefixCls: prefixCls.value,
showArrow: mergedShowArrow.value,
},
slots,
);
@ -245,6 +257,7 @@ const Cascader = defineComponent({
attrs.class,
]}
direction={direction.value}
placement={placement.value}
notFoundContent={mergedNotFoundContent}
allowClear={allowClear}
showSearch={mergedShowSearch.value}
@ -257,7 +270,11 @@ const Cascader = defineComponent({
dropdownClassName={mergedDropdownClassName.value}
dropdownPrefixCls={cascaderPrefixCls.value}
choiceTransitionName={getTransitionName(rootPrefixCls.value, '', choiceTransitionName)}
transitionName={getTransitionName(rootPrefixCls.value, 'slide-up', transitionName)}
transitionName={getTransitionName(
rootPrefixCls.value,
getTransitionDirection(placement.value),
transitionName,
)}
getPopupContainer={getPopupContainer.value}
customSlots={{
...slots,
@ -265,6 +282,7 @@ const Cascader = defineComponent({
}}
displayRender={props.displayRender || slots.displayRender}
maxTagPlaceholder={props.maxTagPlaceholder || slots.maxTagPlaceholder}
showArrow={props.showArrow}
onChange={handleChange}
onBlur={handleBlur}
v-slots={slots}

View File

@ -24,6 +24,8 @@ cover: https://gw.alipayobjects.com/zos/alicdn/UdS8y8xyZ/Cascader.svg
| --- | --- | --- | --- | --- |
| allowClear | 是否支持清除 | boolean | true | |
| autofocus | 自动获取焦点 | boolean | false | |
| bordered | 是否有边框 | boolean | true | 3.2 |
| clearIcon | 自定义的选择框清空图标 | slot | - | 3.2 |
| changeOnSelect | (单选时生效)当此项为 true 时,点选每级菜单选项值都会发生变化,具体见上面的演示 | boolean | false | |
| defaultValue | 默认的选中项 | string\[] \| number\[] | \[] | |
| disabled | 禁用 | boolean | false | |
@ -43,6 +45,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/UdS8y8xyZ/Cascader.svg
| options | 可选项数据源 | [Option](#option)\[] | - | |
| placeholder | 输入框占位文本 | string | '请选择' | |
| placement | 浮层预设位置 | `bottomLeft` \| `bottomRight` \| `topLeft` \| `topRight` | `bottomLeft` | 3.0 |
| removeIcon | 自定义的多选框清除图标 | slot | - | 3.2 |
| searchValue | 设置搜索的值,需要与 `showSearch` 配合使用 | string | - | 3.0 |
| showSearch | 在选择框中显示搜索框 | boolean \| [object](#showsearch) | false | |
| size | 输入框大小 | `large` \| `default` \| `small` | `default` | |

View File

@ -9,7 +9,7 @@ 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 { fillFieldNames, toPathKey, toPathKeys, SHOW_PARENT, SHOW_CHILD } from './utils/commonUtil';
import useEntities from './hooks/useEntities';
import useSearchConfig from './hooks/useSearchConfig';
import useSearchOptions from './hooks/useSearchOptions';
@ -23,6 +23,7 @@ import { BaseSelect } from '../vc-select';
import devWarning from '../vc-util/devWarning';
import useMaxLevel from '../vc-tree/useMaxLevel';
export { SHOW_PARENT, SHOW_CHILD };
export interface ShowSearchType<OptionType extends BaseOptionType = DefaultOptionType> {
filter?: (inputValue: string, options: OptionType[], fieldNames: FieldNames) => boolean;
render?: (arg?: {
@ -49,6 +50,7 @@ export interface InternalFieldNames extends Required<FieldNames> {
export type SingleValueType = (string | number)[];
export type ValueType = SingleValueType | SingleValueType[];
export type ShowCheckedStrategy = typeof SHOW_PARENT | typeof SHOW_CHILD;
export interface BaseOptionType {
disabled?: boolean;
@ -73,14 +75,11 @@ function baseCascaderProps<OptionType extends BaseOptionType = DefaultOptionType
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 },
showCheckedStrategy: { type: String as PropType<ShowCheckedStrategy>, default: SHOW_PARENT },
// Search
showSearch: {
type: [Boolean, Object] as PropType<boolean | ShowSearchType<OptionType>>,
@ -184,7 +183,7 @@ function toRawValues(value: ValueType): SingleValueType[] {
return value;
}
return value.length === 0 ? [] : [value];
return (value.length === 0 ? [] : [value]).map(val => (Array.isArray(val) ? val : [val]));
}
export default defineComponent({
@ -215,10 +214,10 @@ export default defineComponent({
/** Convert path key back to value format */
const getValueByKeyPath = (pathKeys: Key[]): SingleValueType[] => {
const ketPathEntities = pathKeyEntities.value;
const keyPathEntities = pathKeyEntities.value;
return pathKeys.map(pathKey => {
const { nodes } = ketPathEntities[pathKey];
const { nodes } = keyPathEntities[pathKey];
return nodes.map(node => node[mergedFieldNames.value.value]);
});
@ -275,12 +274,12 @@ export default defineComponent({
}
const keyPathValues = toPathKeys(existValues);
const ketPathEntities = pathKeyEntities.value;
const keyPathEntities = pathKeyEntities.value;
const { checkedKeys, halfCheckedKeys } = conductCheck(
keyPathValues,
true,
ketPathEntities,
keyPathEntities,
maxLevel.value,
levelEntities.value,
);
@ -295,7 +294,11 @@ export default defineComponent({
const deDuplicatedValues = computed(() => {
const checkedKeys = toPathKeys(checkedValues.value);
const deduplicateKeys = formatStrategyValues(checkedKeys, pathKeyEntities.value);
const deduplicateKeys = formatStrategyValues(
checkedKeys,
pathKeyEntities.value,
props.showCheckedStrategy,
);
return [...missingCheckedValues.value, ...getValueByKeyPath(deduplicateKeys)];
});
@ -330,6 +333,7 @@ export default defineComponent({
// =========================== Select ===========================
const onInternalSelect = (valuePath: SingleValueType) => {
setSearchValue('');
if (!multiple.value) {
triggerChange(valuePath);
} else {
@ -379,7 +383,11 @@ export default defineComponent({
}
// Roll up to parent level keys
const deDuplicatedKeys = formatStrategyValues(checkedKeys, pathKeyEntities.value);
const deDuplicatedKeys = formatStrategyValues(
checkedKeys,
pathKeyEntities.value,
props.showCheckedStrategy,
);
nextCheckedValues = getValueByKeyPath(deDuplicatedKeys);
}
@ -537,7 +545,7 @@ export default defineComponent({
return () => {
const emptyOptions = !(mergedSearchValue.value ? searchOptions.value : mergedOptions.value)
.length;
const { dropdownMatchSelectWidth = false } = props;
const dropdownStyle: CSSProperties =
// Search to match width
(mergedSearchValue.value && mergedSearchConfig.value.matchInputWidth) ||
@ -555,7 +563,7 @@ export default defineComponent({
ref={selectRef}
id={mergedId}
prefixCls={props.prefixCls}
dropdownMatchSelectWidth={false}
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
dropdownStyle={{ ...mergedDropdownStyle.value, ...dropdownStyle }}
// Value
displayValues={displayValues.value}

View File

@ -4,7 +4,7 @@ import type { DefaultOptionType, SingleValueType } from '../Cascader';
import { SEARCH_MARK } from '../hooks/useSearchOptions';
import type { Key } from '../../_util/type';
import { useInjectCascader } from '../context';
export const FIX_LABEL = '__cascader_fix_label__';
export interface ColumnProps {
prefixCls: string;
multiple?: boolean;
@ -58,7 +58,7 @@ export default function Column({
{options.map(option => {
const { disabled } = option;
const searchOptions = option[SEARCH_MARK];
const label = option[fieldNames.value.label];
const label = option[FIX_LABEL] ?? option[fieldNames.value.label];
const value = option[fieldNames.value.value];
const isMergedLeaf = isLeaf(option, fieldNames.value);
@ -132,6 +132,10 @@ export default function Column({
triggerOpenPath();
}
}}
onMousedown={e => {
// Prevent selector from blurring
e.preventDefault();
}}
>
{multiple && (
<Checkbox
@ -145,7 +149,7 @@ export default function Column({
}}
/>
)}
<div class={`${menuItemPrefixCls}-content`}>{option[fieldNames.value.label]}</div>
<div class={`${menuItemPrefixCls}-content`}>{label}</div>
{!isLoading && expandIcon && !isMergedLeaf && (
<div class={`${menuItemPrefixCls}-expand-icon`}>{expandIcon}</div>
)}

View File

@ -1,16 +1,21 @@
/* eslint-disable default-case */
import Column from './Column';
import type { DefaultOptionType, SingleValueType } from '../Cascader';
import { isLeaf, toPathKey, toPathKeys, toPathValueStr } from '../utils/commonUtil';
import {
isLeaf,
toPathKey,
toPathKeys,
toPathValueStr,
scrollIntoParentView,
} from '../utils/commonUtil';
import useActive from './useActive';
import useKeyboard from './useKeyboard';
import { toPathOptions } from '../utils/treeUtil';
import { computed, defineComponent, ref, shallowRef, watchEffect } from 'vue';
import { computed, defineComponent, onMounted, ref, shallowRef, watch, watchEffect } from 'vue';
import { useBaseProps } from '../../vc-select';
import { useInjectCascader } from '../context';
import type { Key } from '../../_util/type';
import type { EventHandler } from '../../_util/EventInterface';
import Column, { FIX_LABEL } from './Column';
export default defineComponent({
name: 'OptionList',
inheritAttrs: false,
@ -149,18 +154,29 @@ export default defineComponent({
}
};
useKeyboard(
context,
mergedOptions,
fieldNames,
activeValueCells,
onPathOpen,
containerRef,
onKeyboardSelect,
);
useKeyboard(context, mergedOptions, fieldNames, activeValueCells, onPathOpen, onKeyboardSelect);
const onListMouseDown: EventHandler = event => {
event.preventDefault();
};
onMounted(() => {
watch(
activeValueCells,
cells => {
for (let i = 0; i < cells.length; i += 1) {
const cellPath = cells.slice(0, i + 1);
const cellKeyPath = toPathKey(cellPath);
const ele = containerRef.value?.querySelector<HTMLElement>(
`li[data-path-key="${cellKeyPath.replace(/\\{0,2}"/g, '\\"')}"]`, // matches unescaped double quotes
);
if (ele) {
scrollIntoParentView(ele);
}
}
},
{ flush: 'post', immediate: true },
);
});
return () => {
// ========================== Render ==========================
const {
@ -173,8 +189,8 @@ export default defineComponent({
const emptyList: DefaultOptionType[] = [
{
[fieldNames.value.label as 'label']: notFoundContent,
[fieldNames.value.value as 'value']: '__EMPTY__',
[FIX_LABEL as 'label']: notFoundContent,
disabled: true,
},
];

View File

@ -3,9 +3,9 @@ import type { Key } from '../../_util/type';
import type { Ref, SetupContext } from 'vue';
import { computed, ref, watchEffect } from 'vue';
import type { DefaultOptionType, InternalFieldNames, SingleValueType } from '../Cascader';
import { toPathKey } from '../utils/commonUtil';
import { useBaseProps } from '../../vc-select';
import KeyCode from '../../_util/KeyCode';
import { SEARCH_MARK } from '../hooks/useSearchOptions';
export default (
context: SetupContext,
@ -13,7 +13,7 @@ export default (
fieldNames: Ref<InternalFieldNames>,
activeValueCells: Ref<Key[]>,
setActiveValueCells: (activeValueCells: Key[]) => void,
containerRef: Ref<HTMLElement>,
// containerRef: Ref<HTMLElement>,
onKeyBoardSelect: (valueCells: SingleValueType, option: DefaultOptionType) => void,
) => {
const baseProps = useBaseProps();
@ -32,7 +32,7 @@ export default (
const len = activeValueCells.value.length;
// Fill validate active value cells and index
for (let i = 0; i < len; i += 1) {
for (let i = 0; i < len && currentOptions; i += 1) {
// Mark the active index for current options
const nextActiveIndex = currentOptions.findIndex(
option => option[fieldNames.value.value] === activeValueCells.value[i],
@ -65,9 +65,6 @@ export default (
// Update active value cells and scroll to target element
const internalSetActiveValueCells = (next: Key[]) => {
setActiveValueCells(next);
const ele = containerRef.value?.querySelector(`li[data-path-key="${toPathKey(next)}"]`);
ele?.scrollIntoView?.({ block: 'nearest' });
};
// Same options offset
@ -165,10 +162,18 @@ export default (
// >>> Select
case KeyCode.ENTER: {
if (validActiveValueCells.value.length) {
onKeyBoardSelect(
validActiveValueCells.value,
lastActiveOptions.value[lastActiveIndex.value],
);
const option = lastActiveOptions.value[lastActiveIndex.value];
// Search option should revert back of origin options
const originOptions: DefaultOptionType[] = option?.[SEARCH_MARK] || [];
if (originOptions.length) {
onKeyBoardSelect(
originOptions.map(opt => opt[fieldNames.value.value]),
originOptions[originOptions.length - 1],
);
} else {
onKeyBoardSelect(validActiveValueCells.value, option);
}
}
break;
}

View File

@ -51,10 +51,11 @@ export default (
labels: valueOptions.map(({ option, value }) => option?.[fieldNames.value.label] ?? value),
selectedOptions: valueOptions.map(({ option }) => option),
});
const value = toPathKey(valueCells);
return {
label,
value: toPathKey(valueCells),
value,
key: value,
valueCells,
};
});

View File

@ -39,6 +39,7 @@ export default (
if (
// If is leaf option
!children ||
children.length === 0 ||
// If is changeOnSelect
changeOnSelect.value
) {

View File

@ -1,5 +1,9 @@
// rc-cascader@3.0.0-alpha.6
import Cascader, { internalCascaderProps as cascaderProps } from './Cascader';
// rc-cascader@3.4.2
import Cascader, {
internalCascaderProps as cascaderProps,
SHOW_PARENT,
SHOW_CHILD,
} from './Cascader';
export type {
CascaderProps,
@ -8,5 +12,5 @@ export type {
DefaultOptionType,
BaseOptionType,
} from './Cascader';
export { cascaderProps };
export { cascaderProps, SHOW_PARENT, SHOW_CHILD };
export default Cascader;

View File

@ -6,6 +6,8 @@ import type {
} from '../Cascader';
export const VALUE_SPLIT = '__RC_CASCADER_SPLIT__';
export const SHOW_PARENT = 'SHOW_PARENT';
export const SHOW_CHILD = 'SHOW_CHILD';
export function toPathKey(value: SingleValueType) {
return value.join(VALUE_SPLIT);
@ -33,3 +35,17 @@ export function fillFieldNames(fieldNames?: FieldNames): InternalFieldNames {
export function isLeaf(option: DefaultOptionType, fieldNames: FieldNames) {
return option.isLeaf ?? !option[fieldNames.children]?.length;
}
export function scrollIntoParentView(element: HTMLElement) {
const parent = element.parentElement;
if (!parent) {
return;
}
const elementToParent = element.offsetTop - parent.offsetTop; // offsetParent may not be parent.
if (elementToParent - parent.scrollTop < 0) {
parent.scrollTo({ top: elementToParent });
} else if (elementToParent + element.offsetHeight - parent.scrollTop > parent.offsetHeight) {
parent.scrollTo({ top: elementToParent + element.offsetHeight - parent.offsetHeight });
}
}

View File

@ -1,21 +1,27 @@
import type { Key } from '../../_util/type';
import type { SingleValueType, DefaultOptionType, InternalFieldNames } from '../Cascader';
import type {
SingleValueType,
DefaultOptionType,
InternalFieldNames,
ShowCheckedStrategy,
} from '../Cascader';
import type { OptionsInfo } from '../hooks/useEntities';
import { SHOW_CHILD } from './commonUtil';
export function formatStrategyValues(
pathKeys: Key[],
keyPathEntities: OptionsInfo['pathKeyEntities'],
showCheckedStrategy: ShowCheckedStrategy,
) {
const valueSet = new Set(pathKeys);
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;
const children = entity ? entity.children : null;
return showCheckedStrategy === SHOW_CHILD
? !(children && children.some(child => child.key && valueSet.has(child.key)))
: !(parent && !parent.node.disabled && valueSet.has(parent.key));
});
}