From 936f001d1bec47d096ee8eb879460d37c0418bb6 Mon Sep 17 00:00:00 2001 From: ming680 <827668037@qq.com> Date: Tue, 20 Aug 2024 15:59:06 +0800 Subject: [PATCH] feat(cascader): cascader cascader #426 --- site/mobile/mobile.config.js | 5 + site/web/site.config.js | 6 + src/cascader/Cascader.tsx | 307 +++++++++++++++++++++++ src/cascader/_example/base.tsx | 99 ++++++++ src/cascader/_example/check-strictly.tsx | 101 ++++++++ src/cascader/_example/index.tsx | 37 +++ src/cascader/_example/keys.tsx | 106 ++++++++ src/cascader/_example/style/index.less | 0 src/cascader/_example/theme-tab.tsx | 100 ++++++++ src/cascader/_example/with-title.tsx | 102 ++++++++ src/cascader/_example/with-value.tsx | 99 ++++++++ src/cascader/cascader.en-US.md | 26 ++ src/cascader/cascader.md | 67 +++++ src/cascader/defaultProps.ts | 16 ++ src/cascader/index.tsx | 8 + src/cascader/style/css.js | 1 + src/cascader/style/index.js | 3 + src/cascader/style/index.less | 11 + src/cascader/type.ts | 87 +++++++ src/common.ts | 2 +- src/index.ts | 11 +- 21 files changed, 1188 insertions(+), 6 deletions(-) create mode 100644 src/cascader/Cascader.tsx create mode 100644 src/cascader/_example/base.tsx create mode 100644 src/cascader/_example/check-strictly.tsx create mode 100644 src/cascader/_example/index.tsx create mode 100644 src/cascader/_example/keys.tsx create mode 100644 src/cascader/_example/style/index.less create mode 100644 src/cascader/_example/theme-tab.tsx create mode 100644 src/cascader/_example/with-title.tsx create mode 100644 src/cascader/_example/with-value.tsx create mode 100644 src/cascader/cascader.en-US.md create mode 100644 src/cascader/cascader.md create mode 100644 src/cascader/defaultProps.ts create mode 100644 src/cascader/index.tsx create mode 100644 src/cascader/style/css.js create mode 100644 src/cascader/style/index.js create mode 100644 src/cascader/style/index.less create mode 100644 src/cascader/type.ts diff --git a/site/mobile/mobile.config.js b/site/mobile/mobile.config.js index 45794183..493776af 100644 --- a/site/mobile/mobile.config.js +++ b/site/mobile/mobile.config.js @@ -117,6 +117,11 @@ export default { name: 'back-top', component: () => import('tdesign-mobile-react/back-top/_example/index.tsx'), }, + { + title: 'Cascader 级联选择器', + name: 'cascader', + component: () => import('tdesign-mobile-react/cascader/_example/index.tsx'), + }, { title: 'Checkbox 多选框', name: 'checkbox', diff --git a/site/web/site.config.js b/site/web/site.config.js index 09c74e11..cbeee9c5 100644 --- a/site/web/site.config.js +++ b/site/web/site.config.js @@ -131,6 +131,12 @@ export default { title: '输入', type: 'component', children: [ + { + title: 'Cascader 级联选择器', + name: 'cascader', + path: '/mobile-react/components/cascader', + component: () => import('tdesign-mobile-react/cascader/cascader.md'), + }, { title: 'CheckBox 多选框', name: 'checkbox', diff --git a/src/cascader/Cascader.tsx b/src/cascader/Cascader.tsx new file mode 100644 index 00000000..329f42e3 --- /dev/null +++ b/src/cascader/Cascader.tsx @@ -0,0 +1,307 @@ +import { useDeepCompareEffect } from 'ahooks'; +import classNames from 'classnames'; +import last from 'lodash/last'; +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { CheckIcon, Icon } from 'tdesign-icons-react'; +import useDefault from 'tdesign-mobile-react/_util/useDefault'; +import { Popup } from 'tdesign-mobile-react/popup'; +import { Radio, RadioGroup } from 'tdesign-mobile-react/radio'; +import Tabs from 'tdesign-mobile-react/tabs'; +import TabContext from 'tdesign-mobile-react/tabs/context'; +import { StyledProps, TNode, TreeOptionData } from '../common'; +import { usePrefixClass } from '../hooks/useClass'; +import useDefaultProps from '../hooks/useDefaultProps'; +import { cascaderDefaultProps } from './defaultProps'; +import { TdCascaderProps } from './type'; + +export interface CascaderProps extends TdCascaderProps, StyledProps {} + +const FixedTabs = ({ value }: { value: number }) => { + const { onChange } = useContext(TabContext); + + useEffect(() => { + onChange(value); + }, [value, onChange]); + + return null; +}; + +const Cascader = forwardRef((props) => { + const cascaderClass = usePrefixClass('cascader'); + + const { + className, + style, + value, + defaultValue, + visible, + title, + placeholder, + theme, + subTitles, + options: inputOptions, + keys, + checkStrictly, + closeBtn, + onChange, + onClose, + onPick, + } = useDefaultProps(props, cascaderDefaultProps); + + const [internalValue, setInternalValue] = useDefault(value, defaultValue, onChange); + const [internalVisible, setInternalVisible] = useDefault(visible, false, () => ({})); + + const [internalSelectedValues, setInternalSelectedValues] = useState([]); + + // 根据 inputOptions 和 key 重新构建 options + const options = useMemo(() => { + const { label = 'label', value = 'value', children = 'children' } = keys || {}; + + const convert = (options: TreeOptionData[]) => + options.map((item) => ({ + label: item[label], + value: item[value], + children: Array.isArray(item[children]) ? convert(item[children]) : false, + })); + + return convert(inputOptions); + }, [inputOptions, keys]); + + const getOptionsList = useCallback((options: TreeOptionData[], internalSelectedValues: CascaderProps['value'][]) => { + const optionsList: TreeOptionData[][] = [options]; + + for (const value of internalSelectedValues) { + const lastOptions = last(optionsList); + const next = lastOptions.find((item) => item.value === value); + if (!next || !Array.isArray(next.children)) { + break; + } + optionsList.push(next.children); + } + + return optionsList; + }, []); + + const optionsList = useMemo( + () => getOptionsList(options, internalSelectedValues), + [getOptionsList, options, internalSelectedValues], + ); + + const [stepIndex, setStepIndex] = useState(0); + + const labelList = useMemo(() => { + const labelList: { + label: TNode; + isPlaceholder: boolean; + }[] = []; + + optionsList.forEach((options, index) => { + const value = internalSelectedValues[index]; + const target = options.find((item) => item.value === value); + if (target) { + labelList.push({ + label: target.label, + isPlaceholder: false, + }); + return; + } + + labelList.push({ + label: placeholder, + isPlaceholder: true, + }); + }); + + return labelList; + }, [optionsList, internalSelectedValues, placeholder]); + + const selectedValuesByInterValue = useMemo(() => { + /** + * checkStrictly true 从外到内 匹配上就挺 返回整个链路上的value + * checkStrictly false 最后一级的 value 匹配时,返回整个链路上的value + */ + const findValues = (options: TreeOptionData[]): CascaderProps['value'][] => { + for (const item of options) { + if (checkStrictly && item.value === internalValue) { + return [item.value]; + } + + const isLast = !(Array.isArray(item.children) && item.children.length); + if (isLast) { + if (item.value === internalValue) { + return [item.value]; + } + continue; + } + const targetValue = findValues(item.children as TreeOptionData[]); + if (targetValue.length) { + return [item.value, ...targetValue]; + } + } + return []; + }; + + return findValues(options); + }, [options, internalValue, checkStrictly]); + + // 当 selectedValuesByInterValue 深度变化 的时候再控制 selectedValues + useDeepCompareEffect(() => { + setInternalSelectedValues(selectedValuesByInterValue); + setStepIndex(selectedValuesByInterValue.length); + }, [selectedValuesByInterValue]); + + useEffect(() => { + const reviseStepIndex = Math.max(Math.min(stepIndex, optionsList.length - 1), 0); + if (reviseStepIndex !== stepIndex) { + setStepIndex(reviseStepIndex); + } + }, [optionsList, stepIndex]); + + // 结束了 + const onFinish = useCallback( + (selectedValues: CascaderProps['value'][]) => { + const selectedOptions = [...optionsList].slice(0, selectedValues.length).map((options, index) => { + const target = options.find((item) => item.value === selectedValues[index]); + const { label = 'label', value = 'value' } = keys || {}; + return { + [label]: target?.label || '', + [value]: target?.value || '', + }; + }); + setInternalValue(last(selectedValues), selectedOptions as any); + onClose?.('finish'); + }, + [onClose, optionsList, setInternalValue, keys], + ); + + return ( + { + setInternalVisible(visible); + onClose?.(trigger); + }} + > +
+
{title}
+
{ + if (checkStrictly) { + onFinish(internalSelectedValues); + return; + } + + setInternalVisible(false); + onClose?.('close-btn'); + }} + > + {closeBtn === true ? : closeBtn} +
+
+ {labelList.length ? ( +
+ {theme === 'step' ? ( +
+ {labelList.map((labeItem, index) => ( +
{ + setStepIndex(index); + }} + > +
+
+ {labeItem.label} +
+ +
+ ))} +
+ ) : null} + {theme === 'tab' ? ( +
+ ({ + label: item.label as string, + value: index, + }))} + defaultValue={stepIndex} + change={(value) => { + setStepIndex(value); + }} + > + {/* TODO: Tabs 组加接收外部控制 通过子组件 调用 TabContext 中的 onChange 实现 */} + + +
+ ) : null} +
+ ) : null} + {subTitles[stepIndex] ? ( +
{subTitles[stepIndex]}
+ ) : null} +
+ {optionsList.map((curOptions, index) => ( +
+
+ { + const selectedValues = [...internalSelectedValues].slice(0, index); + selectedValues.push(value); + setInternalSelectedValues(selectedValues); + + setStepIndex(index + 1); + + onPick?.(value, index); + + const next = curOptions.find((item) => item.value === value); + if (Array.isArray(next?.children)) { + return; + } + + onFinish(selectedValues); + }} + > + {curOptions.map((item) => ( + ]} + /> + ))} + +
+
+ ))} +
+
+
+ + ); +}); + +Cascader.displayName = 'Cascader'; + +export default Cascader; diff --git a/src/cascader/_example/base.tsx b/src/cascader/_example/base.tsx new file mode 100644 index 00000000..575bc23c --- /dev/null +++ b/src/cascader/_example/base.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; + +import { Cascader, Cell } from 'tdesign-mobile-react'; +import './style/index.less'; + +const data = { + areaList: [ + { + label: '北京市', + value: '110000', + children: [ + { + value: '110100', + label: '北京市', + children: [ + { value: '110101', label: '东城区' }, + { value: '110102', label: '西城区' }, + { value: '110105', label: '朝阳区' }, + { value: '110106', label: '丰台区' }, + { value: '110107', label: '石景山区' }, + { value: '110108', label: '海淀区' }, + { value: '110109', label: '门头沟区' }, + { value: '110111', label: '房山区' }, + { value: '110112', label: '通州区' }, + { value: '110113', label: '顺义区' }, + { value: '110114', label: '昌平区' }, + { value: '110115', label: '大兴区' }, + { value: '110116', label: '怀柔区' }, + { value: '110117', label: '平谷区' }, + { value: '110118', label: '密云区' }, + { value: '110119', label: '延庆区' }, + ], + }, + ], + }, + { + label: '天津市', + value: '120000', + children: [ + { + value: '120100', + label: '天津市', + children: [ + { value: '120101', label: '和平区' }, + { value: '120102', label: '河东区' }, + { value: '120103', label: '河西区' }, + { value: '120104', label: '南开区' }, + { value: '120105', label: '河北区' }, + { value: '120106', label: '红桥区' }, + { value: '120110', label: '东丽区' }, + { value: '120111', label: '西青区' }, + { value: '120112', label: '津南区' }, + { value: '120113', label: '北辰区' }, + { value: '120114', label: '武清区' }, + { value: '120115', label: '宝坻区' }, + { value: '120116', label: '滨海新区' }, + { value: '120117', label: '宁河区' }, + { value: '120118', label: '静海区' }, + { value: '120119', label: '蓟州区' }, + ], + }, + ], + }, + ], +}; + +export default function BaseDemo() { + const [visible, setVisible] = useState(false); + + const [note, setNote] = useState('请选择地址'); + + const [value, setValue] = useState(); + + return ( + <> + { + setVisible(true); + }} + /> + { + setNote((selectedOptions as any).map((item) => item.label).join('/') || ''); + setValue(value); + }} + onClose={() => { + setVisible(false); + }} + /> + + ); +} diff --git a/src/cascader/_example/check-strictly.tsx b/src/cascader/_example/check-strictly.tsx new file mode 100644 index 00000000..745c402d --- /dev/null +++ b/src/cascader/_example/check-strictly.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; + +import { Cascader, Cell } from 'tdesign-mobile-react'; +import './style/index.less'; + +const data = { + areaList: [ + { + label: '北京市', + value: '110000', + children: [ + { + value: '110100', + label: '北京市', + children: [ + { value: '110101', label: '东城区' }, + { value: '110102', label: '西城区' }, + { value: '110105', label: '朝阳区' }, + { value: '110106', label: '丰台区' }, + { value: '110107', label: '石景山区' }, + { value: '110108', label: '海淀区' }, + { value: '110109', label: '门头沟区' }, + { value: '110111', label: '房山区' }, + { value: '110112', label: '通州区' }, + { value: '110113', label: '顺义区' }, + { value: '110114', label: '昌平区' }, + { value: '110115', label: '大兴区' }, + { value: '110116', label: '怀柔区' }, + { value: '110117', label: '平谷区' }, + { value: '110118', label: '密云区' }, + { value: '110119', label: '延庆区' }, + ], + }, + ], + }, + { + label: '天津市', + value: '120000', + children: [ + { + value: '120100', + label: '天津市', + children: [ + { value: '120101', label: '和平区' }, + { value: '120102', label: '河东区' }, + { value: '120103', label: '河西区' }, + { value: '120104', label: '南开区' }, + { value: '120105', label: '河北区' }, + { value: '120106', label: '红桥区' }, + { value: '120110', label: '东丽区' }, + { value: '120111', label: '西青区' }, + { value: '120112', label: '津南区' }, + { value: '120113', label: '北辰区' }, + { value: '120114', label: '武清区' }, + { value: '120115', label: '宝坻区' }, + { value: '120116', label: '滨海新区' }, + { value: '120117', label: '宁河区' }, + { value: '120118', label: '静海区' }, + { value: '120119', label: '蓟州区' }, + ], + }, + ], + }, + ], +}; + +export default function CheckStrictlyDemo() { + const [visible, setVisible] = useState(false); + + const [note, setNote] = useState('请选择地址'); + + const [value, setValue] = useState(); + + return ( + <> + { + setVisible(true); + }} + /> + 确定} + options={data.areaList} + onChange={(value, selectedOptions) => { + setNote((selectedOptions as any).map((item) => item.label).join('/') || ''); + setValue(value); + }} + onClose={() => { + setVisible(false); + }} + /> + + ); +} diff --git a/src/cascader/_example/index.tsx b/src/cascader/_example/index.tsx new file mode 100644 index 00000000..fb6f3391 --- /dev/null +++ b/src/cascader/_example/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; +import TDemoHeader from '../../../site/mobile/components/DemoHeader'; +import BaseDemo from './base'; +import CheckStrictlyDemo from './check-strictly'; +import KeysDemo from './keys'; +import ThemeTabDemo from './theme-tab'; +import WithTitleDemo from './with-title'; +import WithValueDemo from './with-value'; + +import './style/index.less'; + +export default function CascaderDemo() { + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/cascader/_example/keys.tsx b/src/cascader/_example/keys.tsx new file mode 100644 index 00000000..70c94cb5 --- /dev/null +++ b/src/cascader/_example/keys.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; + +import { Cascader, Cell } from 'tdesign-mobile-react'; +import './style/index.less'; + +const keys = { + label: 'name', + value: 'id', + children: 'sub', +}; + +const data = { + areaList: [ + { + name: '北京市', + id: '110000', + sub: [ + { + id: '110100', + name: '北京市', + sub: [ + { id: '110101', name: '东城区' }, + { id: '110102', name: '西城区' }, + { id: '110105', name: '朝阳区' }, + { id: '110106', name: '丰台区' }, + { id: '110107', name: '石景山区' }, + { id: '110108', name: '海淀区' }, + { id: '110109', name: '门头沟区' }, + { id: '110111', name: '房山区' }, + { id: '110112', name: '通州区' }, + { id: '110113', name: '顺义区' }, + { id: '110114', name: '昌平区' }, + { id: '110115', name: '大兴区' }, + { id: '110116', name: '怀柔区' }, + { id: '110117', name: '平谷区' }, + { id: '110118', name: '密云区' }, + { id: '110119', name: '延庆区' }, + ], + }, + ], + }, + { + name: '天津市', + id: '120000', + sub: [ + { + id: '120100', + name: '天津市', + sub: [ + { id: '120101', name: '和平区' }, + { id: '120102', name: '河东区' }, + { id: '120103', name: '河西区' }, + { id: '120104', name: '南开区' }, + { id: '120105', name: '河北区' }, + { id: '120106', name: '红桥区' }, + { id: '120110', name: '东丽区' }, + { id: '120111', name: '西青区' }, + { id: '120112', name: '津南区' }, + { id: '120113', name: '北辰区' }, + { id: '120114', name: '武清区' }, + { id: '120115', name: '宝坻区' }, + { id: '120116', name: '滨海新区' }, + { id: '120117', name: '宁河区' }, + { id: '120118', name: '静海区' }, + { id: '120119', name: '蓟州区' }, + ], + }, + ], + }, + ], +}; + +export default function KeysDemo() { + const [visible, setVisible] = useState(false); + + const [note, setNote] = useState('请选择地址'); + + const [value, setValue] = useState('120119'); + + return ( + <> + { + setVisible(true); + }} + /> + { + setNote((selectedOptions as any).map((item) => item[keys.label]).join('/') || ''); + setValue(value); + }} + onClose={() => { + setVisible(false); + }} + /> + + ); +} diff --git a/src/cascader/_example/style/index.less b/src/cascader/_example/style/index.less new file mode 100644 index 00000000..e69de29b diff --git a/src/cascader/_example/theme-tab.tsx b/src/cascader/_example/theme-tab.tsx new file mode 100644 index 00000000..00d62a6c --- /dev/null +++ b/src/cascader/_example/theme-tab.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; + +import { Cascader, Cell } from 'tdesign-mobile-react'; +import './style/index.less'; + +const data = { + areaList: [ + { + label: '北京市', + value: '110000', + children: [ + { + value: '110100', + label: '北京市', + children: [ + { value: '110101', label: '东城区' }, + { value: '110102', label: '西城区' }, + { value: '110105', label: '朝阳区' }, + { value: '110106', label: '丰台区' }, + { value: '110107', label: '石景山区' }, + { value: '110108', label: '海淀区' }, + { value: '110109', label: '门头沟区' }, + { value: '110111', label: '房山区' }, + { value: '110112', label: '通州区' }, + { value: '110113', label: '顺义区' }, + { value: '110114', label: '昌平区' }, + { value: '110115', label: '大兴区' }, + { value: '110116', label: '怀柔区' }, + { value: '110117', label: '平谷区' }, + { value: '110118', label: '密云区' }, + { value: '110119', label: '延庆区' }, + ], + }, + ], + }, + { + label: '天津市', + value: '120000', + children: [ + { + value: '120100', + label: '天津市', + children: [ + { value: '120101', label: '和平区' }, + { value: '120102', label: '河东区' }, + { value: '120103', label: '河西区' }, + { value: '120104', label: '南开区' }, + { value: '120105', label: '河北区' }, + { value: '120106', label: '红桥区' }, + { value: '120110', label: '东丽区' }, + { value: '120111', label: '西青区' }, + { value: '120112', label: '津南区' }, + { value: '120113', label: '北辰区' }, + { value: '120114', label: '武清区' }, + { value: '120115', label: '宝坻区' }, + { value: '120116', label: '滨海新区' }, + { value: '120117', label: '宁河区' }, + { value: '120118', label: '静海区' }, + { value: '120119', label: '蓟州区' }, + ], + }, + ], + }, + ], +}; + +export default function ThemeTabDemo() { + const [visible, setVisible] = useState(false); + + const [note, setNote] = useState('请选择地址'); + + const [value, setValue] = useState(); + + return ( + <> + { + setVisible(true); + }} + /> + { + setNote((selectedOptions as any).map((item) => item.label).join('/') || ''); + setValue(value); + }} + onClose={() => { + setVisible(false); + }} + /> + + ); +} diff --git a/src/cascader/_example/with-title.tsx b/src/cascader/_example/with-title.tsx new file mode 100644 index 00000000..2313003e --- /dev/null +++ b/src/cascader/_example/with-title.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; + +import { Cascader, Cell } from 'tdesign-mobile-react'; +import './style/index.less'; + +const data = { + areaList: [ + { + label: '北京市', + value: '110000', + children: [ + { + value: '110100', + label: '北京市', + children: [ + { value: '110101', label: '东城区' }, + { value: '110102', label: '西城区' }, + { value: '110105', label: '朝阳区' }, + { value: '110106', label: '丰台区' }, + { value: '110107', label: '石景山区' }, + { value: '110108', label: '海淀区' }, + { value: '110109', label: '门头沟区' }, + { value: '110111', label: '房山区' }, + { value: '110112', label: '通州区' }, + { value: '110113', label: '顺义区' }, + { value: '110114', label: '昌平区' }, + { value: '110115', label: '大兴区' }, + { value: '110116', label: '怀柔区' }, + { value: '110117', label: '平谷区' }, + { value: '110118', label: '密云区' }, + { value: '110119', label: '延庆区' }, + ], + }, + ], + }, + { + label: '天津市', + value: '120000', + children: [ + { + value: '120100', + label: '天津市', + children: [ + { value: '120101', label: '和平区' }, + { value: '120102', label: '河东区' }, + { value: '120103', label: '河西区' }, + { value: '120104', label: '南开区' }, + { value: '120105', label: '河北区' }, + { value: '120106', label: '红桥区' }, + { value: '120110', label: '东丽区' }, + { value: '120111', label: '西青区' }, + { value: '120112', label: '津南区' }, + { value: '120113', label: '北辰区' }, + { value: '120114', label: '武清区' }, + { value: '120115', label: '宝坻区' }, + { value: '120116', label: '滨海新区' }, + { value: '120117', label: '宁河区' }, + { value: '120118', label: '静海区' }, + { value: '120119', label: '蓟州区' }, + ], + }, + ], + }, + ], +}; + +const subTitles = ['请选择省份', '请选择城市', '请选择区/县']; + +export default function WithTitleDemo() { + const [visible, setVisible] = useState(false); + + const [note, setNote] = useState('请选择地址'); + + const [value, setValue] = useState(); + + return ( + <> + { + setVisible(true); + }} + /> + { + setNote((selectedOptions as any).map((item) => item.label).join('/') || ''); + setValue(value); + }} + onClose={() => { + setVisible(false); + }} + /> + + ); +} diff --git a/src/cascader/_example/with-value.tsx b/src/cascader/_example/with-value.tsx new file mode 100644 index 00000000..f8c74845 --- /dev/null +++ b/src/cascader/_example/with-value.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; + +import { Cascader, Cell } from 'tdesign-mobile-react'; +import './style/index.less'; + +const data = { + areaList: [ + { + label: '北京市', + value: '110000', + children: [ + { + value: '110100', + label: '北京市', + children: [ + { value: '110101', label: '东城区' }, + { value: '110102', label: '西城区' }, + { value: '110105', label: '朝阳区' }, + { value: '110106', label: '丰台区' }, + { value: '110107', label: '石景山区' }, + { value: '110108', label: '海淀区' }, + { value: '110109', label: '门头沟区' }, + { value: '110111', label: '房山区' }, + { value: '110112', label: '通州区' }, + { value: '110113', label: '顺义区' }, + { value: '110114', label: '昌平区' }, + { value: '110115', label: '大兴区' }, + { value: '110116', label: '怀柔区' }, + { value: '110117', label: '平谷区' }, + { value: '110118', label: '密云区' }, + { value: '110119', label: '延庆区' }, + ], + }, + ], + }, + { + label: '天津市', + value: '120000', + children: [ + { + value: '120100', + label: '天津市', + children: [ + { value: '120101', label: '和平区' }, + { value: '120102', label: '河东区' }, + { value: '120103', label: '河西区' }, + { value: '120104', label: '南开区' }, + { value: '120105', label: '河北区' }, + { value: '120106', label: '红桥区' }, + { value: '120110', label: '东丽区' }, + { value: '120111', label: '西青区' }, + { value: '120112', label: '津南区' }, + { value: '120113', label: '北辰区' }, + { value: '120114', label: '武清区' }, + { value: '120115', label: '宝坻区' }, + { value: '120116', label: '滨海新区' }, + { value: '120117', label: '宁河区' }, + { value: '120118', label: '静海区' }, + { value: '120119', label: '蓟州区' }, + ], + }, + ], + }, + ], +}; + +export default function WithValueDemo() { + const [visible, setVisible] = useState(false); + + const [note, setNote] = useState('请选择地址'); + + const [value, setValue] = useState('120119'); + + return ( + <> + { + setVisible(true); + }} + /> + { + setNote((selectedOptions as any).map((item) => item.label).join('/') || ''); + setValue(value); + }} + onClose={() => { + setVisible(false); + }} + /> + + ); +} diff --git a/src/cascader/cascader.en-US.md b/src/cascader/cascader.en-US.md new file mode 100644 index 00000000..6e2d4135 --- /dev/null +++ b/src/cascader/cascader.en-US.md @@ -0,0 +1,26 @@ +:: BASE_DOC :: + +## API + +### Cascader Props + +name | type | default | description | required +-- | -- | -- | -- | -- +className | String | - | className of component | N +style | Object | - | CSS(Cascading Style Sheets),Typescript:`React.CSSProperties` | N +checkStrictly | Boolean | false | \- | N +closeBtn | TNode | true | Typescript:`boolean \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +keys | Object | - | Typescript:`CascaderKeysType` `type CascaderKeysType = TreeKeysType`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/cascader/type.ts) | N +lazy | Boolean | false | \- | N +loadCompleted | Boolean | false | \- | N +options | Array | [] | Typescript:`Array` | N +placeholder | String | 选择选项 | \- | N +subTitles | Array | [] | Typescript:`Array` | N +theme | String | step | options: step/tab | N +title | TNode | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +value | String / Number | - | \- | N +defaultValue | String / Number | - | uncontrolled property | N +visible | Boolean | false | \- | N +onChange | Function | | Typescript:`(value: string \| number, selectedOptions: CascaderOption[]) => void`
| N +onClose | Function | | Typescript:`(trigger: TriggerSource) => void`
[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/cascader/type.ts)。
`type TriggerSource = 'overlay' \| 'close-btn' \| 'finish'`
| N +onPick | Function | | Typescript:`(value: string \| number, index: number) => void`
| N diff --git a/src/cascader/cascader.md b/src/cascader/cascader.md new file mode 100644 index 00000000..66210594 --- /dev/null +++ b/src/cascader/cascader.md @@ -0,0 +1,67 @@ +--- +title: Cascader 级联选择器 +description: 用于多层级数据选择,主要为树形结构,可展示更多的数据。 +spline: base +isComponent: true +toc: false +--- + +## 代码演示 + +### 基础用法 + +::: demo _example/base +::: + +### 选项卡风格 + +::: demo _example/theme-tab +::: + +## 进阶 + +### 带初始值 + +::: demo _example/with-value +::: + + +### 自定义keys + +::: demo _example/keys +::: + +### 使用次级标题 + +::: demo _example/with-title +::: + +### 选择任意一项 + +::: demo _example/check-strictly +::: + +## API + +### Cascader Props + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式,TS 类型:`React.CSSProperties` | N +checkStrictly | Boolean | false | 父子节点选中状态不再关联,可各自选中或取消 | N +closeBtn | TNode | true | 关闭按钮。TS 类型:`boolean \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +keys | Object | - | 用来定义 value / label 在 `options` 中对应的字段别名。TS 类型:`CascaderKeysType` `type CascaderKeysType = TreeKeysType`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/cascader/type.ts) | N +lazy | Boolean | false | 是否异步加载 | N +loadCompleted | Boolean | false | 是否完成异步加载 | N +options | Array | [] | 可选项数据源。TS 类型:`Array` | N +placeholder | String | 选择选项 | 未选中时的提示文案 | N +subTitles | Array | [] | 每级展示的次标题。TS 类型:`Array` | N +theme | String | step | 展示风格。可选项:step/tab | N +title | TNode | - | 标题。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +value | String / Number | - | 选项值 | N +defaultValue | String / Number | - | 选项值。非受控属性 | N +visible | Boolean | false | 是否展示 | N +onChange | Function | | TS 类型:`(value: string \| number, selectedOptions: CascaderOption[]) => void`
值发生变更时触发 | N +onClose | Function | | TS 类型:`(trigger: TriggerSource) => void`
关闭时触发。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/cascader/type.ts)。
`type TriggerSource = 'overlay' \| 'close-btn' \| 'finish'`
| N +onPick | Function | | TS 类型:`(value: string \| number, index: number) => void`
选择后触发 | N diff --git a/src/cascader/defaultProps.ts b/src/cascader/defaultProps.ts new file mode 100644 index 00000000..e9b0b519 --- /dev/null +++ b/src/cascader/defaultProps.ts @@ -0,0 +1,16 @@ +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TdCascaderProps } from './type'; + +export const cascaderDefaultProps: TdCascaderProps = { + closeBtn: true, + lazy: false, + loadCompleted: false, + options: [], + placeholder: '选择选项', + subTitles: [], + theme: 'step', + visible: false, +}; diff --git a/src/cascader/index.tsx b/src/cascader/index.tsx new file mode 100644 index 00000000..5342c8cb --- /dev/null +++ b/src/cascader/index.tsx @@ -0,0 +1,8 @@ +import _Cascader from './Cascader'; + +import './style'; + +export const Cascader = _Cascader; +export type { CascaderProps } from './Cascader'; + +export default Cascader; diff --git a/src/cascader/style/css.js b/src/cascader/style/css.js new file mode 100644 index 00000000..6a9a4b13 --- /dev/null +++ b/src/cascader/style/css.js @@ -0,0 +1 @@ +import './index.css'; diff --git a/src/cascader/style/index.js b/src/cascader/style/index.js new file mode 100644 index 00000000..6e1e2012 --- /dev/null +++ b/src/cascader/style/index.js @@ -0,0 +1,3 @@ +import '../../_common/style/mobile/components/cascader/v2/_index.less'; + +import './index.less'; diff --git a/src/cascader/style/index.less b/src/cascader/style/index.less new file mode 100644 index 00000000..8adbca9f --- /dev/null +++ b/src/cascader/style/index.less @@ -0,0 +1,11 @@ +.t-cascader { + .t-radio__border { + display: none; + } + + .t-tabs__nav.t-is-scrollable { + .t-tabs__nav-item { + flex: 0; + } + } +} diff --git a/src/cascader/type.ts b/src/cascader/type.ts new file mode 100644 index 00000000..8c7d12b8 --- /dev/null +++ b/src/cascader/type.ts @@ -0,0 +1,87 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TNode, TreeKeysType, TreeOptionData } from '../common'; + +export interface TdCascaderProps { + /** + * 父子节点选中状态不再关联,可各自选中或取消 + * @default false + */ + checkStrictly?: boolean; + /** + * 关闭按钮 + * @default true + */ + closeBtn?: TNode; + /** + * 用来定义 value / label 在 `options` 中对应的字段别名 + */ + keys?: CascaderKeysType; + /** + * 是否异步加载 + * @default false + */ + lazy?: boolean; + /** + * 是否完成异步加载 + * @default false + */ + loadCompleted?: boolean; + /** + * 可选项数据源 + * @default [] + */ + options?: Array; + /** + * 未选中时的提示文案 + * @default 选择选项 + */ + placeholder?: string; + /** + * 每级展示的次标题 + * @default [] + */ + subTitles?: Array; + /** + * 展示风格 + * @default step + */ + theme?: 'step' | 'tab'; + /** + * 标题 + */ + title?: TNode; + /** + * 选项值 + */ + value?: string | number; + /** + * 选项值,非受控属性 + */ + defaultValue?: string | number; + /** + * 是否展示 + * @default false + */ + visible?: boolean; + /** + * 值发生变更时触发 + */ + onChange?: (value: string | number, selectedOptions: CascaderOption[]) => void; + /** + * 关闭时触发 + */ + onClose?: (trigger: TriggerSource) => void; + /** + * 选择后触发 + */ + onPick?: (value: string | number, index: number) => void; +} + +export type CascaderKeysType = TreeKeysType; + +export type TriggerSource = 'overlay' | 'close-btn' | 'finish'; diff --git a/src/common.ts b/src/common.ts index 89c59d0e..cf41a595 100644 --- a/src/common.ts +++ b/src/common.ts @@ -5,7 +5,7 @@ import { ReactElement, ReactNode, CSSProperties, FormEvent, DragEvent, Synthetic // TElement 表示 API 只接受传入组件 export type TElement = T extends undefined ? ReactElement : (props: T) => ReactElement; // 1. TNode = ReactNode; 2. TNode = (props: T) => ReactNode -export type TNode = T extends undefined ? ReactNode : (props: T) => ReactNode; +export type TNode = T extends undefined ? ReactNode | (() => ReactNode) : ReactNode | ((props: T) => ReactNode); export type AttachNodeReturnValue = HTMLElement | Element | Document; export type AttachNode = CSSSelector | ((triggerNode?: HTMLElement) => AttachNodeReturnValue); diff --git a/src/index.ts b/src/index.ts index d65a3061..cbb89324 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,15 +9,16 @@ export * from './progress'; /** * 导航(5个) */ +export * from './indexes'; +export * from './navbar'; export * from './steps'; export * from './tab-bar'; -export * from './navbar'; export * from './tabs'; -export * from './indexes'; /** * 输入(11个) */ +export * from './cascader'; export * from './checkbox'; export * from './input'; export * from './picker'; @@ -39,24 +40,24 @@ export * from './cell'; export * from './count-down'; export * from './grid'; export * from './image'; +export * from './result'; export * from './skeleton'; export * from './sticky'; -export * from './swiper'; export * from './swipe-cell'; +export * from './swiper'; export * from './tag'; -export * from './result'; /** * 消息提醒(7个) */ export * from './back-top'; export * from './dialog'; +export * from './drawer'; export * from './loading'; export * from './message'; export * from './popup'; export * from './pull-down-refresh'; export * from './toast'; -export * from './drawer'; /** * 二期组件