diff --git a/src/cascader/__test__/__snapshots__/demo.test.jsx.snap b/src/cascader/__test__/__snapshots__/demo.test.jsx.snap index 9d458031d..bce4216ab 100644 --- a/src/cascader/__test__/__snapshots__/demo.test.jsx.snap +++ b/src/cascader/__test__/__snapshots__/demo.test.jsx.snap @@ -69,9 +69,7 @@ exports[`Cascader > Cascader baseVue demo works fine 1`] = `
- 选择地址 -
Cascader baseVue demo works fine 1`] = `
- 选择选项 -
Cascader baseVue demo works fine 1`] = `
- - +
Cascader baseVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader checkStrictlyVue demo works fine 1`] = `
- 选择地址 -
Cascader checkStrictlyVue demo works fine 1`] = `
- 选择选项 -
Cascader checkStrictlyVue demo works fine 1`] = `
-
- +
Cascader checkStrictlyVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader keysVue demo works fine 1`] = `
- 选择地址 -
Cascader keysVue demo works fine 1`] = `
- 选择选项 -
Cascader keysVue demo works fine 1`] = `
-
- +
Cascader keysVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader lazyVue demo works fine 1`] = `
- 选择地址 -
Cascader lazyVue demo works fine 1`] = `
- 选择选项 -
Cascader lazyVue demo works fine 1`] = `
-
- +
Cascader lazyVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
- 选择选项 -
Cascader mobileVue demo works fine 1`] = `
-
- +
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
-
- - -
- + +
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
- 选择选项 -
Cascader mobileVue demo works fine 1`] = `
-
- +
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
- 选择选项 -
Cascader mobileVue demo works fine 1`] = `
-
- +
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
- 选择选项 -
Cascader mobileVue demo works fine 1`] = `
-
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
- 选择选项 -
Cascader mobileVue demo works fine 1`] = `
-
- +
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader mobileVue demo works fine 1`] = `
- 选择地址 -
Cascader mobileVue demo works fine 1`] = `
- 选择选项 -
Cascader mobileVue demo works fine 1`] = `
-
- +
Cascader mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader themeTabVue demo works fine 1`] = `
- 选择地址 -
Cascader themeTabVue demo works fine 1`] = `
-
- - -
- + +
Cascader themeTabVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader withTitleVue demo works fine 1`] = `
- 选择地址 -
Cascader withTitleVue demo works fine 1`] = `
- 选择选项 -
Cascader withTitleVue demo works fine 1`] = `
-
Cascader withTitleVue demo works fine 1`] = ` class="t-cascader__options" >
Cascader withValueVue demo works fine 1`] = `
- 选择地址 -
Cascader withValueVue demo works fine 1`] = `
- 选择选项 -
Cascader withValueVue demo works fine 1`] = `
-
- +
Cascader withValueVue demo works fine 1`] = ` class="t-cascader__options" >
events > : pick 1`] = `
- + 标题
events > : pick 1`] = `
- 选择选项 -
events > : pick 1`] = `
-
- +
events > : pick 1`] = ` class="t-cascader__options" >
{ }); describe('events', () => { - it(': close', async () => { + it(': close trigger closeBtn', async () => { const onClose = vi.fn(); const wrapper = mount(); const $closeBtn = wrapper.find(`.${name}__close-btn`); await $closeBtn.trigger('click'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenLastCalledWith({ trigger: 'close-btn' }); + expect(onClose).toHaveBeenLastCalledWith('close-btn'); + + }); + + it(': close trigger overlay', async () => { + const onClose = vi.fn(); + const wrapper = mount(); // overlay - const $overlay = wrapper.find(`.${prefix}-overlay`); + const $overlay = wrapper.find(`.${prefix}-overlay--active`); + expect($overlay.exists()).toBeTruthy() $overlay.trigger('click'); - expect(onClose).toBeCalledTimes(2); - expect(onClose).toHaveBeenLastCalledWith({ trigger: 'overlay' }); + expect(onClose).toBeCalledTimes(1); + expect(onClose).toHaveBeenLastCalledWith('overlay'); }); it(': pick', async () => { diff --git a/src/cascader/cascader.en-US.md b/src/cascader/cascader.en-US.md index 4a1bff3ac..ba6723486 100644 --- a/src/cascader/cascader.en-US.md +++ b/src/cascader/cascader.en-US.md @@ -6,20 +6,20 @@ name | type | default | description | required -- | -- | -- | -- | -- +checkStrictly | Boolean | false | \- | N closeBtn | Boolean / Slot / Function | true | Typescript:`boolean \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N keys | Object | - | Typescript:`KeysType`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N options | Array | [] | Typescript:`Array` | N +placeholder | String / Slot / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N subTitles | Array | [] | Typescript:`Array` | N -theme | String | step | options:step/tab | N +theme | String | step | options: step/tab | N title | String / Slot / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N value | String / Number | - | `v-model` and `v-model:value` is supported | N defaultValue | String / Number | - | uncontrolled property | N visible | Boolean | false | \- | N -checkStrictly | Boolean | false | 父子节点选中状态不再关联,可各自选中或取消 | N -placeholder | String / Slot / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N onChange | Function | | Typescript:`(value: string \| number, selectedOptions: string[]) => void`
| N onClose | Function | | Typescript:`(trigger: TriggerSource) => void`
[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/cascader/type.ts)。
`type TriggerSource = 'overlay' \| 'close-btn' \| 'finish'`
| N -onPick | Function | | Typescript:`(context: { level: number; value: string | number; index: number }) => void`
| N +onPick | Function | | Typescript:`(context: { level: number, value: string \| number, index: number }) => void`
| N ### Cascader Events @@ -29,8 +29,8 @@ change | `(value: string \| number, selectedOptions: string[])` | \- close | `(trigger: TriggerSource)` | [see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/cascader/type.ts)。
`type TriggerSource = 'overlay' \| 'close-btn' \| 'finish'`
pick | `(context: { level: number, value: string \| number, index: number })` | \- +### CSS 变量 -### CSS Variables The component provides the following CSS variables, which can be used to customize styles. Name | Default Value | Description -- | -- | -- @@ -43,4 +43,4 @@ Name | Default Value | Description --td-cascader-step-dot-size | 8px | - --td-cascader-step-height | 44px | - --td-cascader-title-color | @font-gray-1 | - ---td-cascder-title-font-size | 18px | - +--td-cascder-title-font-size | 18px | - \ No newline at end of file diff --git a/src/cascader/cascader.md b/src/cascader/cascader.md index 571d66189..5b585caa2 100644 --- a/src/cascader/cascader.md +++ b/src/cascader/cascader.md @@ -1,21 +1,22 @@ :: BASE_DOC :: ## API + ### Cascader Props -名称 | 类型 | 默认值 | 说明 | 必传 +名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- +checkStrictly | Boolean | false | 父子节点选中状态不再关联,可各自选中或取消 | N closeBtn | Boolean / Slot / Function | true | 关闭按钮。TS 类型:`boolean \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N keys | Object | - | 用来定义 value / label 在 `options` 中对应的字段别名。TS 类型:`KeysType`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N options | Array | [] | 可选项数据源。TS 类型:`Array` | N +placeholder | String / Slot / Function | - | 未选中时的提示文案。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N subTitles | Array | [] | 每级展示的次标题。TS 类型:`Array` | N theme | String | step | 展示风格。可选项:step/tab | N title | String / Slot / Function | - | 标题。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N value | String / Number | - | 选项值。支持语法糖 `v-model` 或 `v-model:value` | N defaultValue | String / Number | - | 选项值。非受控属性 | N visible | Boolean | false | 是否展示 | N -checkStrictly | Boolean | false | 父子节点选中状态不再关联,可各自选中或取消 | N -placeholder | String / Slot / Function | 选择选项 | 未选中时的提示文案。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N onChange | Function | | TS 类型:`(value: string \| number, selectedOptions: string[]) => void`
值发生变更时触发 | N onClose | Function | | TS 类型:`(trigger: TriggerSource) => void`
关闭时触发。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/cascader/type.ts)。
`type TriggerSource = 'overlay' \| 'close-btn' \| 'finish'`
| N onPick | Function | | TS 类型:`(context: { level: number, value: string \| number, index: number }) => void`
选择后触发 | N @@ -28,8 +29,8 @@ change | `(value: string \| number, selectedOptions: string[])` | 值发生变 close | `(trigger: TriggerSource)` | 关闭时触发。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/cascader/type.ts)。
`type TriggerSource = 'overlay' \| 'close-btn' \| 'finish'`
pick | `(context: { level: number, value: string \| number, index: number })` | 选择后触发 - ### CSS 变量 + 组件提供了下列 CSS 变量,可用于自定义样式。 名称 | 默认值 | 描述 -- | -- | -- @@ -42,4 +43,4 @@ pick | `(context: { level: number, value: string \| number, index: number })` | --td-cascader-step-dot-size | 8px | - --td-cascader-step-height | 44px | - --td-cascader-title-color | @font-gray-1 | - ---td-cascder-title-font-size | 18px | - +--td-cascder-title-font-size | 18px | - \ No newline at end of file diff --git a/src/cascader/cascader.tsx b/src/cascader/cascader.tsx new file mode 100644 index 000000000..ab38af7cf --- /dev/null +++ b/src/cascader/cascader.tsx @@ -0,0 +1,357 @@ +import { CloseIcon, ChevronRightIcon } from 'tdesign-icons-vue-next'; +import { + defineComponent, + toRefs, + getCurrentInstance, + computed, + ref, + toRaw, + reactive, + watch, + onMounted, + Ref, + h, + Transition, +} from 'vue'; +import TPopup, { PopupSource } from '../popup'; +import { Tabs as TTabs, TabPanel as TTabPanel } from '../tabs'; +import { RadioValue, RadioGroup as TRadioGroup } from '../radio'; +import config from '../config'; +import TdCascaderProps from './props'; +import { useVModel } from '../shared'; +import { TreeOptionData } from '../common'; +import { useConfig } from '../config-provider/useConfig'; +import { useTNodeJSX } from '../hooks/tnode'; +import { usePrefixClass } from '../hooks/useClass'; +import { TriggerSource } from './type'; + +const { prefix } = config; +const name = `${prefix}-cascader`; + +interface ChildrenInfoType { + value: string | number | boolean; + level: number; +} + +const childrenInfo: ChildrenInfoType = { + value: '', + level: 0, +}; + +interface KeysType { + value?: string; + label?: string; + children?: string; +} + +export default defineComponent({ + name, + props: TdCascaderProps, + emits: ['update:visible'], + setup(props, context) { + const renderTNodeJSX = useTNodeJSX(); + const cascaderClass = usePrefixClass('cascader'); + const { globalConfig } = useConfig('cascader'); + + const { visible, value, modelValue } = toRefs(props); + const [cascaderValue, setCascaderValue] = useVModel(value, modelValue, props.defaultValue, props.onChange); + + const open = ref(visible.value || false); + const placeholder = computed(() => props.placeholder || globalConfig.value.placeholder); + + const stepIndex = ref(0); + const selectedIndexes = reactive([]); + const selectedValue = reactive([]); + const items: Array> = reactive([props.options ?? []]); + const steps = reactive([placeholder.value]); + + const initWithValue = () => { + if (cascaderValue.value != null) { + steps.pop(); + const path = getIndexesByValue(props.options, value.value); + path?.forEach((e: number) => { + // @ts-ignore + selectedIndexes.push(e); + }); + watchSelectedIndexes(); + } + }; + + const watchSelectedIndexes = () => { + if (props.options && props.options.length > 0) { + const keys = props.keys as KeysType; + for (let i = 0, size = selectedIndexes.length; i < size; i += 1) { + const index = selectedIndexes[i]; + const next = items[i]?.[index]; + selectedValue.push(next[keys?.value ?? 'value']); + steps.push(next[keys?.label ?? 'label']); + if (next[keys?.children ?? 'children']) { + items.push(next[keys?.children ?? 'children']); + } + } + } + if (steps.length < items.length) { + steps.push(placeholder.value); + } + stepIndex.value = items.length - 1; + }; + + const getIndexesByValue = (options: any, value: any) => { + const keys = props.keys as KeysType; + for (let i = 0; i < options.length; i++) { + if (options[i][keys?.value ?? 'value'] === value) { + return [i]; + } + if (options[i][keys?.children ?? 'children']) { + const res: any = getIndexesByValue(options[i][keys?.children ?? 'children'], value); + if (res) { + return [i, ...res]; + } + } + } + }; + + const chooseSelect = (value: RadioValue, level: number, index: number, item: any) => { + const keys = props.keys as KeysType; + selectedIndexes[level] = index; + selectedIndexes.length = level + 1; + selectedValue[level] = String(value); + selectedValue.length = level + 1; + steps[level] = item[keys?.label ?? 'label'] as string; + + if (item[keys?.children ?? 'children']?.length) { + items[level + 1] = item[keys?.children ?? 'children']; + items.length = level + 2; + stepIndex.value += 1; + steps[level + 1] = placeholder.value; + steps.length = level + 2; + } else if (item[keys?.children ?? 'children']?.length === 0) { + childrenInfo.value = value; + childrenInfo.level = level; + } else { + setCascaderValue( + item[keys?.value ?? 'value'], + items.map((item, index) => toRaw(item?.[selectedIndexes[index]])), + ); + close('finish'); + } + }; + + const cancelSelect = (value: RadioValue, level: number, index: number, item: any) => { + const keys = props.keys as KeysType; + selectedIndexes[level] = index; + selectedIndexes.length = level; + selectedValue.length = level; + steps[level] = String(placeholder.value); + steps[level + 1] = placeholder.value; + steps.length = level + 1; + + if (item[keys?.children ?? 'children']?.length) { + items[level + 1] = item[keys?.children ?? 'children']; + } else if (item[keys?.children ?? 'children']?.length === 0) { + childrenInfo.value = value; + childrenInfo.level = level; + } + }; + + const handleSelect = (value: RadioValue, level: number) => { + const keys = props.keys as KeysType; + const index = items[level].findIndex((item: any) => item[keys?.value ?? 'value'] === value); + const item = items[level][index]; + if (item.disabled) { + return; + } + props.onPick?.({ level, value: item[keys?.value ?? 'value'], index }); + + if (props.checkStrictly && selectedValue.includes(String(value))) { + cancelSelect(value, level, index, item); + } else { + chooseSelect(value, level, index, item); + } + }; + + const close = (trigger: TriggerSource) => { + props.onClose?.(trigger); + }; + + const handleVisibleChange = (visible: boolean, trigger: TriggerSource) => { + if (trigger !== 'overlay') return; + close('overlay'); + }; + + const updateCascaderValue = () => { + setCascaderValue( + selectedValue[selectedValue.length - 1], + items + .filter((item, index) => !!item && selectedIndexes.length > index) + .map((item, index) => toRaw(item?.[selectedIndexes[index]])), + ); + }; + + const onClose = () => { + open.value = false; + close('close-btn'); + }; + + const onCloseBtn = () => { + if (props.checkStrictly) { + updateCascaderValue(); + onClose(); + } else { + onClose(); + } + }; + + const onStepClick = (index: number) => { + stepIndex.value = index; + }; + + const onTabChange = (value: number | string) => { + stepIndex.value = Number(value); + }; + + watch(open, () => { + context.emit('update:visible', open.value); + }); + + watch(visible, () => { + open.value = visible.value; + }); + + watch( + () => props.options, + () => { + if (open.value) { + handleSelect(childrenInfo.value, childrenInfo.level); + } + }, + { + deep: true, + }, + ); + + watch(placeholder, (newValue, oldValue) => { + const index = steps.indexOf(oldValue); + if (index !== -1) { + steps[index] = newValue; + } + }); + + onMounted(() => { + initWithValue(); + }); + + return () => { + const title = renderTNodeJSX('title') || globalConfig.value.title; + const closeBtn = renderTNodeJSX('closeBtn', { defaultNode: }); + const PlaceholderNode = renderTNodeJSX('placeholder'); + + const readerStep = () => { + return ( +
+
+ {steps.map((step, index) => { + return ( +
{ + onStepClick(index); + }} + > +
+
+ {PlaceholderNode && !(typeof PlaceholderNode === 'string') && step === placeholder.value + ? PlaceholderNode + : step} +
+ +
+ ); + })} +
+
+ ); + }; + const readerSteps = () => { + if (steps.length === 0) { + return null; + } + if (props.theme === 'step') { + return readerStep(); + } + + if (open.value && props.theme === 'tab') { + return ( + + {steps.map((item, index) => ( + + ))} + + ); + } + }; + return ( + +
+
{title}
+
+ {closeBtn} +
+
+ {readerSteps()} + {props.subTitles && props.subTitles[stepIndex.value] && ( +
{props.subTitles[stepIndex.value]}
+ )} +
+ {items.map((options, index) => { + return ( +
+ +
+ { + handleSelect(value, index); + }} + /> +
+
+
+ ); + })} +
+
+
+
+ ); + }; + }, +}); diff --git a/src/cascader/cascader.vue b/src/cascader/cascader.vue deleted file mode 100644 index e3fdc5d82..000000000 --- a/src/cascader/cascader.vue +++ /dev/null @@ -1,353 +0,0 @@ - - - diff --git a/src/cascader/index.ts b/src/cascader/index.ts index 9188643ee..2bbcd00a2 100644 --- a/src/cascader/index.ts +++ b/src/cascader/index.ts @@ -1,4 +1,4 @@ -import Cascader from './cascader.vue'; +import Cascader from './cascader'; import { withInstall, WithInstallType } from '../shared'; import './style'; diff --git a/src/cascader/props.ts b/src/cascader/props.ts index dedacc5be..efa3ed48a 100644 --- a/src/cascader/props.ts +++ b/src/cascader/props.ts @@ -8,6 +8,8 @@ import { TdCascaderProps } from './type'; import { PropType } from 'vue'; export default { + /** 父子节点选中状态不再关联,可各自选中或取消 */ + checkStrictly: Boolean, /** 关闭按钮 */ closeBtn: { type: [Boolean, Function] as PropType, @@ -22,6 +24,10 @@ export default { type: Array as PropType, default: (): TdCascaderProps['options'] => [], }, + /** 未选中时的提示文案 */ + placeholder: { + type: [String, Function] as PropType, + }, /** 每级展示的次标题 */ subTitles: { type: Array as PropType, @@ -53,17 +59,8 @@ export default { defaultValue: { type: [String, Number] as PropType, }, - /** 未选中时的提示文案 */ - placeholder: { - type: [String, Function] as PropType, - }, /** 是否展示 */ visible: Boolean, - /** 父子节点选中状态不再关联,可各自选中或取消 */ - checkStrictly: { - type: Boolean, - default: false, - }, /** 值发生变更时触发 */ onChange: Function as PropType, /** 关闭时触发 */ diff --git a/src/cascader/type.ts b/src/cascader/type.ts index fcdae0a07..e8ffe1b83 100644 --- a/src/cascader/type.ts +++ b/src/cascader/type.ts @@ -7,6 +7,11 @@ import { TNode, TreeOptionData, KeysType } from '../common'; export interface TdCascaderProps { + /** + * 父子节点选中状态不再关联,可各自选中或取消 + * @default false + */ + checkStrictly?: boolean; /** * 关闭按钮 * @default true @@ -21,6 +26,10 @@ export interface TdCascaderProps; + /** + * 未选中时的提示文案 + */ + placeholder?: string | TNode; /** * 每级展示的次标题 * @default [] @@ -52,10 +61,6 @@ export interface TdCascaderProps Form horizontalVue demo works fine 1`] = `
- 选择地址 -
Form horizontalVue demo works fine 1`] = `
- 选择选项 -
Form horizontalVue demo works fine 1`] = `
-
- +
Form horizontalVue demo works fine 1`] = ` class="t-cascader__options" >
Form mobileVue demo works fine 1`] = `
- 选择地址 -
Form mobileVue demo works fine 1`] = `
- 选择选项 -
Form mobileVue demo works fine 1`] = `
-
- +
Form mobileVue demo works fine 1`] = ` class="t-cascader__options" >
Form verticalVue demo works fine 1`] = `
- 选择地址 -
Form verticalVue demo works fine 1`] = `
- 选择选项 -
Form verticalVue demo works fine 1`] = `
-
- +
Form verticalVue demo works fine 1`] = ` class="t-cascader__options" >