From fcc2a4727c665b07055de500e1f6c6fd5dac7d4b Mon Sep 17 00:00:00 2001
From: ailululu <912429321@qq.com>
Date: Thu, 25 Aug 2022 16:11:12 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20cascader=20?=
=?UTF-8?q?=E7=BB=84=E4=BB=B6=20(#202)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.eslintrc.js | 2 +
src/config.json | 10 +
.../__snapshots__/cascader.spec.tsx.snap | 775 ++++++++++++++++++
.../cascader/__tests__/cascader.spec.tsx | 466 +++++++++++
src/packages/cascader/cascader.scss | 70 ++
src/packages/cascader/cascader.tsx | 426 ++++++++++
src/packages/cascader/cascaderItem.tsx | 103 +++
src/packages/cascader/demo.tsx | 419 ++++++++++
src/packages/cascader/doc.en-US.md | 465 +++++++++++
src/packages/cascader/doc.md | 465 +++++++++++
src/packages/cascader/doc.zh-TW.md | 465 +++++++++++
src/packages/cascader/helper.ts | 102 +++
src/packages/cascader/index.ts | 3 +
src/packages/cascader/tree.ts | 82 ++
src/packages/cascader/types.ts | 32 +
src/styles/variables.scss | 15 +
16 files changed, 3900 insertions(+)
create mode 100644 src/packages/cascader/__tests__/__snapshots__/cascader.spec.tsx.snap
create mode 100644 src/packages/cascader/__tests__/cascader.spec.tsx
create mode 100644 src/packages/cascader/cascader.scss
create mode 100644 src/packages/cascader/cascader.tsx
create mode 100644 src/packages/cascader/cascaderItem.tsx
create mode 100644 src/packages/cascader/demo.tsx
create mode 100644 src/packages/cascader/doc.en-US.md
create mode 100644 src/packages/cascader/doc.md
create mode 100644 src/packages/cascader/doc.zh-TW.md
create mode 100644 src/packages/cascader/helper.ts
create mode 100644 src/packages/cascader/index.ts
create mode 100644 src/packages/cascader/tree.ts
create mode 100644 src/packages/cascader/types.ts
diff --git a/.eslintrc.js b/.eslintrc.js
index 0e0a322..45e7cc0 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -97,5 +97,7 @@ module.exports = {
],
'react/require-default-props': 0,
'no-bitwise': 0,
+ 'no-multi-assign': 0, // 禁止连续赋值
+ 'no-cond-assign': 0, // 禁止条件表达式中出现赋值操作符
},
}
diff --git a/src/config.json b/src/config.json
index e229834..23efc21 100644
--- a/src/config.json
+++ b/src/config.json
@@ -474,6 +474,16 @@
"taro": false,
"author": "swag~jun"
},
+ {
+ "version": "0.1.0",
+ "name": "Cascader",
+ "type": "component",
+ "cName": "级联选择器",
+ "desc": "级联选择,用于多层级数据的选择,典型场景为省市区选择。",
+ "sort": 1,
+ "show": true,
+ "author": "ailululu"
+ },
{
"version": "1.0.0",
"name": "SearchBar",
diff --git a/src/packages/cascader/__tests__/__snapshots__/cascader.spec.tsx.snap b/src/packages/cascader/__tests__/__snapshots__/cascader.spec.tsx.snap
new file mode 100644
index 0000000..7732ddb
--- /dev/null
+++ b/src/packages/cascader/__tests__/__snapshots__/cascader.spec.tsx.snap
@@ -0,0 +1,775 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Cascader change tab 1`] = `
+
+`;
+
+exports[`Cascader change tab 2`] = `
+
+`;
+
+exports[`Cascader no visible 1`] = `
+
+`;
+
+exports[`Cascader options 1`] = `
+
+`;
+
+exports[`Cascader options with convertConfig 1`] = `
+
+`;
+
+exports[`Cascader options with valueKey/textKey/childrenKey 1`] = `
+
+`;
+
+exports[`Cascader select with lazy 1`] = `
+
+`;
+
+exports[`Cascader select with lazy 2`] = `
+
+`;
+
+exports[`Cascader value 1`] = `
+
+`;
+
+exports[`Cascader value with lazy 1`] = `
+
+`;
+
+exports[`Cascader visible true 1`] = `
+
+`;
diff --git a/src/packages/cascader/__tests__/cascader.spec.tsx b/src/packages/cascader/__tests__/cascader.spec.tsx
new file mode 100644
index 0000000..3aa0afc
--- /dev/null
+++ b/src/packages/cascader/__tests__/cascader.spec.tsx
@@ -0,0 +1,466 @@
+import * as React from 'react'
+import { render, fireEvent } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { Cascader } from '../cascader'
+
+import { CascaderOption } from '../types'
+import Tree from '../tree'
+import { formatTree, convertListToOptions } from '../helper'
+
+const later = (t = 0) => new Promise((r) => setTimeout(r, t))
+const mockOptions = [
+ {
+ value: '浙江',
+ text: '浙江',
+ children: [
+ {
+ value: '杭州',
+ text: '杭州',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区' },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '湖南',
+ text: '湖南',
+ disabled: true,
+ },
+ {
+ value: '福建',
+ text: '福建',
+ children: [
+ {
+ value: '福州',
+ text: '福州',
+ children: [
+ { value: '鼓楼区', text: '鼓楼区' },
+ { value: '台江区', text: '台江区' },
+ ],
+ },
+ ],
+ },
+]
+const mockKeyConfigOptions = [
+ {
+ name: '浙江',
+ items: [
+ {
+ name: '杭州',
+ disabled: true,
+ items: [{ name: '西湖区' }, { name: '余杭区' }],
+ },
+ {
+ name: '温州',
+ items: [{ name: '鹿城区' }, { name: '瓯海区' }],
+ },
+ ],
+ },
+ {
+ name: '湖南',
+ disabled: true,
+ },
+ {
+ name: '福建',
+ items: [
+ {
+ name: '福州',
+ items: [{ name: '鼓楼区' }, { name: '台江区' }],
+ },
+ ],
+ },
+]
+const mockConvertOptions = [
+ { value: '北京', text: '北京', nodeId: 1, nodePid: 0, sort: 2 },
+ { value: '朝阳区', text: '朝阳区', nodeId: 11, nodePid: 1 },
+ { value: '亦庄', text: '亦庄', nodeId: 111, nodePid: 11 },
+ { value: '广东省', text: '广东省', nodeId: 2, nodePid: 0, sort: 1 },
+ { value: '广州市', text: '广州市', nodeId: 21, nodePid: 2 },
+]
+
+describe('helpers', () => {
+ test('formatTree', () => {
+ const fromatedTree = formatTree(mockKeyConfigOptions, null, {
+ children: 'items',
+ text: 'name',
+ value: 'name',
+ })
+
+ expect(fromatedTree).toMatchObject(mockOptions)
+ })
+
+ test('convertListToOptions', () => {
+ const convertList = convertListToOptions(mockConvertOptions, {
+ topId: 0,
+ idKey: 'nodeId',
+ pidKey: 'nodePid',
+ sortKey: '',
+ })
+ expect(convertList).toMatchObject([
+ {
+ nodePid: 0,
+ nodeId: 1,
+ text: '北京',
+ value: '北京',
+ sort: 2,
+ children: [
+ {
+ nodePid: 1,
+ nodeId: 11,
+ text: '朝阳区',
+ value: '朝阳区',
+ children: [
+ {
+ nodePid: 11,
+ nodeId: 111,
+ text: '亦庄',
+ value: '亦庄',
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ nodePid: 0,
+ nodeId: 2,
+ text: '广东省',
+ value: '广东省',
+ children: [
+ {
+ nodePid: 2,
+ nodeId: 21,
+ text: '广州市',
+ value: '广州市',
+ },
+ ],
+ },
+ ])
+ })
+})
+
+describe('Tree', () => {
+ test('tree', () => {
+ const tree = new Tree([
+ {
+ text: '浙江',
+ value: '浙江',
+ },
+ {
+ text: '福建',
+ value: '福建',
+ },
+ ])
+ expect(tree.nodes).toMatchObject([
+ {
+ text: '浙江',
+ value: '浙江',
+ },
+ {
+ text: '福建',
+ value: '福建',
+ },
+ ])
+ })
+
+ test('tree with config', () => {
+ const tree = new Tree(mockKeyConfigOptions, {
+ value: 'name',
+ text: 'name',
+ children: 'items',
+ })
+ expect(tree.nodes).toMatchObject(mockOptions)
+ })
+
+ const tree = new Tree(mockOptions)
+ test('getPathNodesByValue', () => {
+ const pathNodes = tree.getPathNodesByValue(['浙江', '杭州', '西湖区'])
+ const mappedPathNodes = pathNodes.map(({ text, value }) => ({
+ text,
+ value,
+ }))
+
+ expect(mappedPathNodes).toMatchObject([
+ { text: '浙江', value: '浙江' },
+ { text: '杭州', value: '杭州' },
+ { text: '西湖区', value: '西湖区' },
+ ])
+ })
+
+ test('isLeaf', () => {
+ const node = tree.getNodeByValue('西湖区')
+ let isLeaf = tree.isLeaf(node as CascaderOption, false)
+ expect(isLeaf).toBeTruthy()
+ isLeaf = tree.isLeaf(node as CascaderOption, true)
+ expect(isLeaf).toBeFalsy()
+ })
+
+ test('hasChildren', () => {
+ let node = tree.getNodeByValue('西湖区')
+
+ let hasChildren = tree.hasChildren(node as CascaderOption, false)
+ expect(hasChildren).toBeFalsy()
+
+ hasChildren = tree.hasChildren(node as CascaderOption, true)
+ expect(hasChildren).toBeFalsy()
+
+ node = tree.getNodeByValue('杭州')
+
+ hasChildren = tree.hasChildren(node as CascaderOption, false)
+ expect(hasChildren).toBeTruthy()
+
+ hasChildren = tree.hasChildren(node as CascaderOption, true)
+ expect(hasChildren).toBeTruthy()
+ })
+
+ test('updateChildren', () => {
+ let node = tree.getNodeByValue('福建')
+ expect(node).toBeTruthy()
+
+ tree.updateChildren(
+ [{ text: '福州', value: '福州' }],
+ node as CascaderOption
+ )
+ node = tree.getNodeByValue('福州') as CascaderOption
+ expect(node).toBeTruthy()
+ expect(node.value).toBe('福州')
+
+ tree.updateChildren(
+ [{ text: '鼓楼区', value: '鼓楼区' }],
+ node as CascaderOption
+ )
+ node = tree.getNodeByValue('鼓楼区') as CascaderOption
+ expect(node).toBeTruthy()
+ expect(node.value).toBe('鼓楼区')
+ })
+
+ // test('updateChildren with CascaderConfig', () => {
+ // const tree = new Tree(
+ // [
+ // {
+ // name: '福建',
+ // items: [{ name: '福州' }],
+ // },
+ // ],
+ // {
+ // value: 'name',
+ // text: 'name',
+ // children: 'items',
+ // }
+ // )
+ // expect(tree.nodes).toMatchObject([
+ // {
+ // text: '福建',
+ // value: '福建',
+ // children: [{ text: '福州', value: '福州' }],
+ // },
+ // ])
+
+ // let node = tree.getNodeByValue('福州') as CascaderOption
+ // expect(node).toBeTruthy()
+ // tree.updateChildren([{ name: '鼓楼区' }], node)
+ // node = tree.getNodeByValue('鼓楼区') as CascaderOption
+ // expect(node).toBeTruthy()
+ // expect(node).toMatchObject({
+ // text: '鼓楼区',
+ // value: '鼓楼区',
+ // })
+ // })
+})
+
+describe('Cascader', () => {
+ it('options', async () => {
+ const { container } = render(
+
+ )
+ expect(container).toMatchSnapshot()
+ })
+
+ it('options with valueKey/textKey/childrenKey', async () => {
+ const { container } = render(
+
+ )
+ expect(container).toMatchSnapshot()
+ })
+
+ it('options with convertConfig', async () => {
+ const { container } = render(
+
+ )
+ expect(container).toMatchSnapshot()
+ })
+
+ it('visible false', async () => {
+ const { container } = render(
+
+ )
+ expect(container.querySelector('.nut-popup')).toBeNull()
+ })
+
+ it('visible true', async () => {
+ const { container } = render(
+
+ )
+ expect(container.querySelector('.nut-popup')).toBe
+ expect(container).toMatchSnapshot()
+ // 点击叶子节点时关闭popup
+ const el = container.querySelectorAll('.nut-cascader-pane')[2].childNodes[0]
+ fireEvent.click(el)
+ // expect(
+ // container.querySelectorAll('.nut-cascader-pane')[2].childNodes[0]
+ // ).toEqual(
+ // ''
+ // )
+ })
+
+ it('value', async () => {
+ const { container } = render()
+ expect(
+ container.querySelectorAll('.nut-cascader-item[aria-checked="true"]')
+ .length
+ ).toBe(0)
+ expect(container).toMatchSnapshot()
+ })
+
+ it('select', async () => {
+ const change = jest.fn()
+ const pathChange = jest.fn()
+ const { container } = render(
+
+ )
+ // 模拟点击
+ const pane = container.querySelectorAll('.nut-cascader-pane')[2]
+ fireEvent.click(pane)
+ const item = pane.childNodes[0]
+ // console.log('item', item)
+ fireEvent.click(item)
+ // let pathChange: any = container.emitted().pathChange[0];
+ expect(pathChange).toBeCalled()
+ // ...
+ })
+
+ it('value with lazy', async () => {
+ const { container } = render(
+ void) => {
+ setTimeout(() => {
+ if (node.root) {
+ resolve([
+ { value: 'A0', text: 'A0' },
+ { value: 'B0', text: 'B0' },
+ { value: 'C0', text: 'C0' },
+ ])
+ } else {
+ const { value, level } = node
+ const text = value.substring(0, 1)
+ const value1 = `${text}${level + 1}1`
+ const value2 = `${text}${level + 1}2`
+ resolve([
+ { value: value1, text: value1, leaf: level >= 1 },
+ { value: value2, text: value2, leaf: level >= 1 },
+ ])
+ }
+ }, 50)
+ }}
+ />
+ )
+ await later(60)
+ expect(container).toMatchSnapshot()
+ })
+
+ it('select with lazy', async () => {
+ const { container } = render(
+ void) => {
+ setTimeout(() => {
+ setTimeout(() => {
+ // root表示第一层数据
+ if (node.root) {
+ resolve([
+ { value: 'A0', text: 'A0' },
+ { value: 'B0', text: 'B0' },
+ ])
+ } else {
+ const { value, level } = node
+ const text = value.substring(0, 1)
+ const value1 = `${text}${level + 1}1`
+ const value2 = `${text}${level + 1}2`
+ resolve([
+ { value: value1, text: value1, leaf: level >= 1 },
+ { value: value2, text: value2, leaf: level >= 1 },
+ ])
+ }
+ }, 50)
+ }, 50)
+ }}
+ />
+ )
+
+ expect(container).toMatchSnapshot()
+ await later(60)
+ expect(container).toMatchSnapshot()
+ // ...
+ })
+
+ it('change tab', async () => {
+ const change = jest.fn()
+ const pathChange = jest.fn()
+ const { container } = render(
+
+ )
+
+ expect(container).toMatchSnapshot()
+
+ expect(container.querySelector('.nut-popup')).toBe
+
+ const tabPane = container.querySelectorAll('.nut-tabs__titles-item')[0]
+ fireEvent.click(tabPane)
+ expect(container).toMatchSnapshot()
+ })
+})
diff --git a/src/packages/cascader/cascader.scss b/src/packages/cascader/cascader.scss
new file mode 100644
index 0000000..3e36ef7
--- /dev/null
+++ b/src/packages/cascader/cascader.scss
@@ -0,0 +1,70 @@
+@import '../popup/popup.scss';
+.nut-cascader {
+ width: 100%;
+ font-size: $cascader-font-size;
+ line-height: $cascader-line-height;
+ &__title {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: $cascader-title-padding;
+ text-align: center;
+ font-weight: bold;
+ line-height: $cascader-title-line-height;
+ color: #1a1a1a;
+ font-size: $cascader-title-font-size;
+ }
+ .nut-tabs__titles {
+ padding: $cascader-tabs-item-padding;
+ background: #fff;
+ }
+ .nut-tabs__titles-item {
+ flex: initial;
+ min-width: auto;
+ width: auto;
+ padding: $cascader-tabs-item-padding;
+ white-space: nowrap;
+ }
+ .nut-tabpane {
+ padding: 0;
+ }
+ &-pane {
+ display: block;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ padding-top: $cascader-pane-paddingTop;
+ height: $cascader-pane-height;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+ &-item {
+ display: flex;
+ align-items: center;
+ padding: $cascader-item-padding;
+ margin: 0;
+ cursor: pointer;
+ font-size: $cascader-item-font-size;
+ color: $cascader-item-color;
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ &.active {
+ &:not(.disabled) {
+ color: $cascader-item-active-color;
+ }
+ .nut-cascader-item__icon-check {
+ visibility: visible;
+ color: $cascader-item-active-color;
+ }
+ }
+ }
+ &-item__title {
+ flex: 1;
+ }
+ .nut-icon-checklist {
+ margin-left: $cascader-icon-checklist-marginLeft;
+ visibility: hidden;
+ }
+}
diff --git a/src/packages/cascader/cascader.tsx b/src/packages/cascader/cascader.tsx
new file mode 100644
index 0000000..31f8666
--- /dev/null
+++ b/src/packages/cascader/cascader.tsx
@@ -0,0 +1,426 @@
+import React, {
+ ForwardRefRenderFunction,
+ PropsWithChildren,
+ useState,
+ useEffect,
+ CSSProperties,
+} from 'react'
+import classNames from 'classnames'
+import Popup from '@/packages/popup'
+import { Tabs } from '@/packages/tabs/tabs'
+import { TabPane } from '@/packages/tabpane/tabpane'
+import { CascaderItem } from './cascaderItem'
+import bem from '@/utils/bem'
+import { convertListToOptions } from './helper'
+import {
+ CascaderPane,
+ CascaderOption,
+ CascaderValue,
+ convertConfig,
+} from './types'
+import Tree from './tree'
+
+export interface CascaderProps {
+ className: string
+ style: CSSProperties
+ visible: boolean // popup 显示状态
+ options: CascaderOption[]
+ value: string[]
+ title: string
+ textKey: string
+ valueKey: string
+ childrenKey: string
+ convertConfig: Record
+ closeable: boolean
+ closeIconPosition: string
+ closeIcon: string
+ lazy: boolean
+ lazyLoad: (node: any, resolve: any) => void
+ onClose?: () => void
+ onChange: (value: any, params: any) => void
+ onPathChange: (value: any, params: any) => void
+}
+
+const defaultProps = {
+ className: '',
+ style: {},
+ visible: false,
+ options: [],
+ value: [],
+ title: '',
+ textKey: 'text',
+ valueKey: 'value',
+ childrenKey: 'children',
+ convertConfig: {},
+ closeable: false,
+ closeIconPosition: 'top-right',
+ closeIcon: 'close',
+ lazy: false,
+ lazyLoad: () => {},
+ onClose: () => {},
+ onChange: () => {},
+ onPathChange: () => {},
+ ...Popup.defaultProps,
+} as CascaderProps
+const InternalCascader: ForwardRefRenderFunction<
+ unknown,
+ PropsWithChildren>
+> = (props) => {
+ const {
+ className,
+ style,
+ visible,
+ options,
+ value,
+ title,
+ textKey,
+ valueKey,
+ childrenKey,
+ convertConfig,
+ closeable,
+ closeIconPosition,
+ closeIcon,
+ lazy,
+ lazyLoad,
+ onClose,
+ onChange,
+ onPathChange,
+ } = { ...defaultProps, ...props }
+
+ const [tabvalue, setTabvalue] = useState('c1')
+ const [optiosData, setOptiosData] = useState([])
+
+ const isLazy = () => state.configs.lazy && Boolean(state.configs.lazyLoad)
+
+ const [state] = useState({
+ optionsData: [] as any,
+ panes: [
+ {
+ nodes: [] as any,
+ selectedNode: [] as CascaderOption | null,
+ paneKey: '',
+ },
+ ],
+ innerValue: value as CascaderValue,
+ tree: new Tree([], {}),
+ tabsCursor: 0, // 选中的tab项
+ initLoading: false,
+ currentProcessNode: [] as CascaderOption | null,
+ configs: {
+ lazy,
+ lazyLoad,
+ valueKey,
+ textKey,
+ childrenKey,
+ convertConfig,
+ },
+ lazyLoadMap: new Map(),
+ })
+
+ const b = bem('cascader')
+
+ const classes = classNames(b(''))
+
+ const classesPane = classNames({
+ [`${b('')}-pane`]: true,
+ })
+
+ useEffect(() => {
+ initData()
+ }, [])
+
+ useEffect(() => {
+ if (value !== state.innerValue) {
+ state.innerValue = value as CascaderValue
+ }
+ }, [value])
+
+ useEffect(() => {
+ initData()
+ }, [options])
+
+ const initData = async () => {
+ // 初始化开始处理数据
+ state.lazyLoadMap.clear()
+ if (convertConfig && Object.keys(convertConfig).length > 0) {
+ state.optionsData = convertListToOptions(
+ options as CascaderOption[],
+ convertConfig as convertConfig
+ )
+ } else {
+ state.optionsData = options
+ }
+ state.tree = new Tree(state.optionsData as CascaderOption[], {
+ value: state.configs.valueKey,
+ text: state.configs.textKey,
+ children: state.configs.childrenKey,
+ })
+
+ if (isLazy() && !state.tree.nodes.length) {
+ await invokeLazyLoad({
+ root: true,
+ loading: true,
+ text: '',
+ value: '',
+ })
+ }
+
+ state.panes = [
+ {
+ nodes: state.tree.nodes,
+ selectedNode: null,
+ paneKey: 'c1',
+ },
+ ]
+ syncValue()
+
+ setOptiosData(state.panes)
+ }
+ // 处理有默认值时的数据
+ const syncValue = async () => {
+ const currentValue = state.innerValue
+ if (currentValue === undefined || !state.tree.nodes.length) {
+ return
+ }
+
+ if (currentValue.length === 0) {
+ state.tabsCursor = 0
+ // state.panes = [{ nodes: state.tree.nodes, selectedNode: null }];
+ return
+ }
+
+ let needToSync = currentValue
+
+ if (isLazy() && Array.isArray(currentValue) && currentValue.length) {
+ needToSync = []
+ const parent: any = state.tree.nodes.find(
+ (node) => node.value === currentValue[0]
+ )
+
+ if (parent) {
+ needToSync = [parent.value]
+ state.initLoading = true
+
+ const last = await currentValue
+ .slice(1)
+ .reduce(async (p: Promise, value) => {
+ const parent = await p
+
+ await invokeLazyLoad(parent)
+ const node: any = parent?.children?.find(
+ (item: any) => item.value === value
+ )
+
+ if (node) {
+ needToSync.push(value)
+ }
+
+ return Promise.resolve(node)
+ }, Promise.resolve(parent))
+
+ await invokeLazyLoad(last)
+
+ state.initLoading = false
+ }
+ }
+
+ if (needToSync.length && currentValue === value) {
+ const pathNodes = state.tree.getPathNodesByValue(needToSync)
+ pathNodes.forEach((node, index) => {
+ state.tabsCursor = index
+ // 当有默认值时,不触发 chooseItem 里的 emit 事件
+ chooseItem(node, true)
+ })
+ }
+ }
+
+ const invokeLazyLoad = async (node?: CascaderOption | void) => {
+ if (!node) {
+ return
+ }
+
+ if (!state.configs.lazyLoad) {
+ node.leaf = true
+ return
+ }
+
+ if (
+ state.tree.isLeaf(node, isLazy()) ||
+ state.tree.hasChildren(node, isLazy())
+ ) {
+ return
+ }
+
+ node.loading = true
+
+ const parent = node.root ? null : node
+ let lazyLoadPromise = state.lazyLoadMap.get(node)
+
+ if (!lazyLoadPromise) {
+ lazyLoadPromise = new Promise((resolve) => {
+ // 外部必须resolve
+ state.configs.lazyLoad?.(node, resolve)
+ })
+ state.lazyLoadMap.set(node, lazyLoadPromise)
+ }
+
+ const nodes: CascaderOption[] | void = await lazyLoadPromise
+
+ if (Array.isArray(nodes) && nodes.length > 0) {
+ state.tree.updateChildren(nodes, parent)
+ } else {
+ // 如果加载完成后没有提供子节点,作为叶子节点处理
+ node.leaf = true
+ }
+ node.loading = false
+ state.lazyLoadMap.delete(node)
+ }
+
+ const close = () => {
+ onClose && onClose()
+ }
+
+ const closePopup = () => {
+ close()
+ }
+
+ /* type: 是否是静默模式,是的话不触发事件
+ tabsCursor: tab的索引 */
+ const chooseItem = async (node: CascaderOption, type: boolean) => {
+ // console.log('chooseItem', node)
+ if ((!type && node.disabled) || !state.panes[state.tabsCursor]) {
+ return
+ }
+ // 如果没有子节点
+ if (state.tree.isLeaf(node, isLazy())) {
+ node.leaf = true
+ state.panes[state.tabsCursor].selectedNode = node
+ state.panes = state.panes.slice(0, (node.level as number) + 1)
+ if (!type) {
+ const pathNodes = state.panes.map((item) => item.selectedNode)
+ const optionParams = pathNodes.map((item: any) => item.value)
+ onChange(optionParams, pathNodes)
+ onPathChange(optionParams, pathNodes)
+ }
+ setOptiosData(state.panes)
+ close()
+ return
+ }
+ // 如果有子节点,滑到下一个
+ // if (node.children && node.children.length > 0) {
+ if (state.tree.hasChildren(node, isLazy())) {
+ const level = (node.level as number) + 1
+
+ state.panes[state.tabsCursor].selectedNode = node
+ state.panes = state.panes.slice(0, level)
+ state.tabsCursor = level
+ state.panes.push({
+ nodes: node.children || [],
+ selectedNode: null,
+ paneKey: `c${state.tabsCursor + 1}`,
+ })
+ setTabvalue(`c${state.tabsCursor + 1}`)
+ setOptiosData(state.panes)
+
+ if (!type) {
+ const pathNodes = state.panes.map((item) => item.selectedNode)
+ const optionParams = pathNodes.map((item: any) => item?.value)
+ onPathChange(optionParams, pathNodes)
+ }
+ return
+ }
+ state.currentProcessNode = node
+
+ if (node.loading) {
+ return
+ }
+
+ await invokeLazyLoad(node)
+
+ if (state.currentProcessNode === node) {
+ state.panes[state.tabsCursor].selectedNode = node
+ chooseItem(node, type)
+ }
+ setOptiosData(state.panes)
+ }
+
+ return (
+
+
+ {title}
+ {
+ return optiosData.map((pane, index) => (
+ {
+ setTabvalue(pane.paneKey)
+ state.tabsCursor = index
+ }}
+ className={`nut-tabs__titles-item ${
+ tabvalue === pane.paneKey ? 'active' : ''
+ }`}
+ key={pane.paneKey}
+ >
+
+ {/* {!state.initLoading && state.panes.length
+ ? pane?.selectedNode?.text
+ ? pane.selectedNode.text
+ : '请选择'
+ : 'Loading...'} */}
+ {!state.initLoading &&
+ state.panes.length &&
+ pane?.selectedNode?.text}
+ {!state.initLoading &&
+ state.panes.length &&
+ !pane?.selectedNode?.text &&
+ '请选择'}
+ {!(!state.initLoading && state.panes.length) && 'Loading...'}
+
+
+
+ ))
+ }}
+ >
+ {!state.initLoading && state.panes.length ? (
+ optiosData.map((pane) => (
+
+
+ {pane.nodes &&
+ pane.nodes.map((node: any, index: number) => (
+ chooseItem(node, false)}
+ />
+ ))}
+
+
+ ))
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
+
+export const Cascader = React.forwardRef(InternalCascader)
+
+Cascader.defaultProps = defaultProps
+Cascader.displayName = 'NutCascader'
diff --git a/src/packages/cascader/cascaderItem.tsx b/src/packages/cascader/cascaderItem.tsx
new file mode 100644
index 0000000..810a272
--- /dev/null
+++ b/src/packages/cascader/cascaderItem.tsx
@@ -0,0 +1,103 @@
+import React, {
+ ForwardRefRenderFunction,
+ PropsWithChildren,
+ useEffect,
+} from 'react'
+import classNames from 'classnames'
+import { Icon } from '@/packages/icon/icon'
+import bem from '@/utils/bem'
+
+interface OptiosInfo {
+ text: string
+ value: string
+ paneKey: string
+ disabled?: boolean
+ loading?: boolean
+ children?: OptiosInfo[]
+}
+
+export interface CascaderItemProps {
+ data: {
+ text: string
+ value: string
+ paneKey: string
+ disabled?: boolean
+ loading?: boolean
+ children?: OptiosInfo[]
+ }
+ checked: boolean
+ chooseItem: (data: any) => void
+}
+
+const defaultProps = {
+ data: {
+ text: '',
+ value: '',
+ paneKey: '',
+ disabled: false,
+ loading: false,
+ children: [],
+ },
+ checked: false,
+ chooseItem: () => {},
+} as CascaderItemProps
+
+const InternalCascaderItem: ForwardRefRenderFunction<
+ unknown,
+ PropsWithChildren>
+> = (props) => {
+ const { data, checked, chooseItem } = {
+ ...defaultProps,
+ ...props,
+ }
+
+ const b = bem('cascader-item')
+
+ const classes = classNames(
+ {
+ active: checked,
+ disabled: data.disabled,
+ },
+ b('')
+ )
+
+ const classesTitle = classNames({
+ [`${b('')}__title`]: true,
+ })
+
+ useEffect(() => {
+ initData()
+ }, [data])
+
+ const initData = () => {
+ // console.log('------data', data)
+ }
+
+ return (
+ {
+ chooseItem(data)
+ }}
+ >
+
{data.text}
+ {data.loading ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export const CascaderItem = React.forwardRef(InternalCascaderItem)
+
+CascaderItem.defaultProps = defaultProps
+CascaderItem.displayName = 'NutCascaderItem'
diff --git a/src/packages/cascader/demo.tsx b/src/packages/cascader/demo.tsx
new file mode 100644
index 0000000..13cd92b
--- /dev/null
+++ b/src/packages/cascader/demo.tsx
@@ -0,0 +1,419 @@
+import React, { useState, useEffect } from 'react'
+import { useTranslate } from '../../sites/assets/locale'
+import { Cascader } from './cascader'
+import { Cell } from '@/packages/cell/cell'
+
+interface T {
+ basic: string
+ title1: string
+ title2: string
+ title3: string
+ title4: string
+ addressTip: string
+ addressTip1: string
+}
+
+const CascaderDemo = () => {
+ const [translated] = useTranslate({
+ 'zh-CN': {
+ basic: '基本用法',
+ title1: '自定义属性名称',
+ title2: '动态加载',
+ title3: '部分数据动态加载',
+ title4: '自动转换',
+ addressTip: '选择地址',
+ addressTip1: '请选择地址',
+ },
+ 'zh-TW': {
+ basic: '基本用法',
+ title1: '自定義屬性名稱',
+ title2: '動態加載',
+ title3: '部分數據動態加載',
+ title4: '自動轉換',
+ addressTip: '選擇地址',
+ addressTip1: '請選擇地址',
+ },
+ 'en-US': {
+ basic: 'Basic Usage',
+ title1: 'Custom attribute name',
+ title2: 'Async loading',
+ title3: 'Async loading of partial data',
+ title4: 'Automatic data conversion',
+ addressTip: 'Select address',
+ addressTip1: 'Please select an address',
+ },
+ })
+
+ const [isVisibleDemo1, setIsVisibleDemo1] = useState(false)
+ const [isVisibleDemo2, setIsVisibleDemo2] = useState(false)
+ const [isVisibleDemo3, setIsVisibleDemo3] = useState(false)
+ const [isVisibleDemo4, setIsVisibleDemo4] = useState(false)
+ const [isVisibleDemo5, setIsVisibleDemo5] = useState(false)
+
+ const [text, setText] = useState({
+ desc1: translated.addressTip1,
+ desc2: '福建福州台江区',
+ desc3: 'A0A12A23A32',
+ desc4: translated.addressTip1,
+ desc5: '广东省广州市',
+ })
+ useEffect(() => {
+ setText({
+ ...text,
+ desc1: translated.addressTip1,
+ desc4: translated.addressTip1,
+ })
+ }, [translated])
+ const [value1, setValue1] = useState([])
+ const [value2, setValue2] = useState(['福建', '福州', '台江区'])
+ const [value3, setValue3] = useState(['A0', 'A12', 'A23', 'A32'])
+ const [value4, setValue4] = useState([])
+ const [value5, setValue5] = useState(['广东省', '广州市'])
+ const [optionsDemo1, setOptionsDemo1] = useState([
+ {
+ value: '浙江',
+ text: '浙江',
+ children: [
+ {
+ value: '杭州',
+ text: '杭州',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区', disabled: true },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '湖南',
+ text: '湖南',
+ disabled: true,
+ children: [
+ {
+ value: '长沙',
+ text: '长沙',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区' },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '福建',
+ text: '福建',
+ children: [
+ {
+ value: '福州',
+ text: '福州',
+ children: [
+ { value: '鼓楼区', text: '鼓楼区' },
+ { value: '台江区', text: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const [optionsDemo2, setOptionsDemo2] = useState([
+ {
+ value1: '浙江',
+ text1: '浙江',
+ items: [
+ {
+ value1: '杭州',
+ text1: '杭州',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区', disabled: true },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '湖南',
+ text1: '湖南',
+ disabled: true,
+ items: [
+ {
+ value1: '长沙',
+ text1: '长沙',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区' },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '福建',
+ text1: '福建',
+ items: [
+ {
+ value1: '福州',
+ text1: '福州',
+ items: [
+ { value1: '鼓楼区', text1: '鼓楼区' },
+ { value1: '台江区', text1: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const [optionsDemo4, setOptionsDemo4] = useState([
+ { value: 'A0', text: 'A0' },
+ {
+ value: 'B0',
+ text: 'B0',
+ children: [
+ { value: 'B11', text: 'B11', leaf: true },
+ { value: 'B12', text: 'B12' },
+ ],
+ },
+ { value: 'C0', text: 'C0' },
+ ])
+ const [optionsDemo5, setOptionsDemo5] = useState([
+ { value: '北京', text: '北京', id: 1, pid: null },
+ { value: '朝阳区', text: '朝阳区', id: 11, pid: 1 },
+ { value: '亦庄', text: '亦庄', id: 111, pid: 11 },
+ { value: '广东省', text: '广东省', id: 2, pid: null },
+ { value: '广州市', text: '广州市', id: 21, pid: 2 },
+ ])
+ const [convertConfigDemo5, setConvertConfigDemo5] = useState({
+ topId: null,
+ idKey: 'id',
+ pidKey: 'pid',
+ sortKey: '',
+ })
+
+ const change1 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setText({
+ ...text,
+ desc1: value,
+ })
+ setValue1(value)
+ }
+ const change2 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setText({
+ ...text,
+ desc2: value,
+ })
+ setValue2(value)
+ }
+ const change3 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setText({
+ ...text,
+ desc3: value,
+ })
+ setValue3(value)
+ }
+ const change4 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setText({
+ ...text,
+ desc4: value,
+ })
+ setValue4(value)
+ }
+ const change5 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setText({
+ ...text,
+ desc5: value,
+ })
+ setValue5(value)
+ }
+ const onPathChange = (value: any, path: any) => {
+ console.log('onPathChange', value, path)
+ }
+
+ const lazyLoadDemo3 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ if (node.root) {
+ resolve([
+ { value: 'A0', text: 'A0' },
+ { value: 'B0', text: 'B0' },
+ { value: 'C0', text: 'C0' },
+ ])
+ } else {
+ const { value, level } = node
+ const text = value.substring(0, 1)
+ const value1 = `${text}${level + 1}1`
+ const value2 = `${text}${level + 1}2`
+ const value3 = `${text}${level + 1}3`
+ resolve([
+ { value: value1, text: value1, leaf: level >= 6 },
+ { value: value2, text: value2, leaf: level >= 6 },
+ { value: value3, text: value3, leaf: level >= 6 },
+ ])
+ }
+ }, 2000)
+ }
+
+ const lazyLoadDemo4 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ const { value, level } = node
+ const text = value.substring(0, 1)
+ const value1 = `${text}${level + 1}1`
+ const value2 = `${text}${level + 1}2`
+ resolve([
+ { value: value1, text: value1, leaf: level >= 2 },
+ { value: value2, text: value2, leaf: level >= 1 },
+ ])
+ }, 500)
+ }
+
+ return (
+ <>
+
+
{translated.basic}
+ {
+ setIsVisibleDemo1(true)
+ }}
+ />
+ {
+ setIsVisibleDemo1(false)
+ }}
+ onChange={change1}
+ onPathChange={onPathChange}
+ />
+ {translated.title1}
+ {
+ setIsVisibleDemo2(true)
+ }}
+ />
+ {
+ setIsVisibleDemo2(false)
+ }}
+ onChange={change2}
+ onPathChange={onPathChange}
+ />
+ {translated.title2}
+ {
+ setIsVisibleDemo3(true)
+ }}
+ />
+ {
+ setIsVisibleDemo3(false)
+ }}
+ onChange={change3}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo3}
+ />
+ {translated.title3}
+ {
+ setIsVisibleDemo4(true)
+ }}
+ />
+ {
+ setIsVisibleDemo4(false)
+ }}
+ onChange={change4}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo4}
+ />
+ {translated.title4}
+ {
+ setIsVisibleDemo5(true)
+ }}
+ />
+ {
+ setIsVisibleDemo5(false)
+ }}
+ onChange={change5}
+ onPathChange={onPathChange}
+ />
+ | | | | |
+ >
+ )
+}
+
+export default CascaderDemo
diff --git a/src/packages/cascader/doc.en-US.md b/src/packages/cascader/doc.en-US.md
new file mode 100644
index 0000000..baef88f
--- /dev/null
+++ b/src/packages/cascader/doc.en-US.md
@@ -0,0 +1,465 @@
+# Cascader
+
+### Introduce
+
+The cascader component is used for the selection of multi-level data. The typical scene is the selection of provinces and cities.
+
+### Install
+
+```js
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+```
+
+## Demo
+### Basic Usage
+
+Pass in the `options` list.
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo1, setIsVisibleDemo1] = useState(false)
+ const [value1, setValue1] = useState([])
+ const [optionsDemo1, setOptionsDemo1] = useState([
+ {
+ value: '浙江',
+ text: '浙江',
+ children: [
+ {
+ value: '杭州',
+ text: '杭州',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区', disabled: true },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '湖南',
+ text: '湖南',
+ disabled: true,
+ children: [
+ {
+ value: '长沙',
+ text: '长沙',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区' },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '福建',
+ text: '福建',
+ children: [
+ {
+ value: '福州',
+ text: '福州',
+ children: [
+ { value: '鼓楼区', text: '鼓楼区' },
+ { value: '台江区', text: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const change1 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setValue1(value)
+ }
+ const onPathChange = (value: any, path: any) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo1(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo1(false)}}
+ onChange={change1}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### Custom attribute name
+
+use `textKey`、`valueKey`、`childrenKey`Specify the property name.
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo2, setIsVisibleDemo2] = useState(false)
+ const [value2, setValue2] = useState(['福建', '福州', '台江区'])
+ const [optionsDemo2, setOptionsDemo2] = useState([
+ {
+ value1: '浙江',
+ text1: '浙江',
+ items: [
+ {
+ value1: '杭州',
+ text1: '杭州',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区', disabled: true },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '湖南',
+ text1: '湖南',
+ disabled: true,
+ items: [
+ {
+ value1: '长沙',
+ text1: '长沙',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区' },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '福建',
+ text1: '福建',
+ items: [
+ {
+ value1: '福州',
+ text1: '福州',
+ items: [
+ { value1: '鼓楼区', text1: '鼓楼区' },
+ { value1: '台江区', text1: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const change2 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue2(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo2(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo2(false)}}
+ onChange={change2}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### Async loading
+
+Use `lazy` to identify whether data needs to be obtained dynamically. At this time, not transmitting `options` means that all data needs to be loaded through `lazyload` . The first loading is distinguished by the `root` attribute. When a non leaf node is encountered, the `lazyload` method will be called. The parameters are the current node and the `resolve` method. Note that the `resolve` method must be called. If it is not transmitted to a child node, it will be treated as a leaf node.
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo3, setIsVisibleDemo3] = useState(false)
+ const [value3, setValue3] = useState(['A0', 'A12', 'A23', 'A32'])
+
+ const lazyLoadDemo3 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ if (node.root) {
+ resolve([
+ { value: 'A0', text: 'A0' },
+ { value: 'B0', text: 'B0' },
+ { value: 'C0', text: 'C0' }
+ ]);
+ } else {
+ const { value, level } = node;
+ const text = value.substring(0, 1);
+ const value1 = `${text}${level + 1}1`;
+ const value2 = `${text}${level + 1}2`;
+ const value3 = `${text}${level + 1}3`;
+ resolve([
+ { value: value1, text: value1, leaf: level >= 6 },
+ { value: value2, text: value2, leaf: level >= 6 },
+ { value: value3, text: value3, leaf: level >= 6 }
+ ]);
+ }
+ }, 2000);
+ }
+ const change3 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue3(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+ return (
+ <>
+ {
+ setIsVisibleDemo3(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo3(false)}}
+ onChange={change3}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo3}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+
+### Async loading of partial data
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo4, setIsVisibleDemo4] = useState(false)
+ const [value4, setValue4] = useState([])
+ const [optionsDemo4, setOptionsDemo4] = useState([
+ { value: 'A0', text: 'A0' },
+ {
+ value: 'B0',
+ text: 'B0',
+ children: [
+ { value: 'B11', text: 'B11', leaf: true },
+ { value: 'B12', text: 'B12' }
+ ]
+ },
+ { value: 'C0', text: 'C0' }
+ ])
+
+ const lazyLoadDemo4 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ const { value, level } = node;
+ const text = value.substring(0, 1);
+ const value1 = `${text}${level + 1}1`;
+ const value2 = `${text}${level + 1}2`;
+ resolve([
+ { value: value1, text: value1, leaf: level >= 2 },
+ { value: value2, text: value2, leaf: level >= 1 }
+ ]);
+ }, 500);
+ }
+ const change4 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue4(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo4(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo4(false)}}
+ onChange={change4}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo4}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### Automatic data conversion
+
+If your data is a flat structure that can be converted into a tree structure, you can tell the component that automatic conversion is required through `convertConfig`, ` convertConfig` accepts four parameters, `topid` is the parent ID of the top-level node, `idkey` is the unique ID of the node, `pidkey` is the attribute name pointing to the parent node ID, and if there is a `sortkey`, `Array.prototype.sort()` to sort at the same level.
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo5, setIsVisibleDemo5] = useState(false)
+ const [value5, setValue5] = useState(['广东省', '广州市'])
+ const [optionsDemo5, setOptionsDemo5] = useState([
+ { value: '北京', text: '北京', id: 1, pid: null },
+ { value: '朝阳区', text: '朝阳区', id: 11, pid: 1 },
+ { value: '亦庄', text: '亦庄', id: 111, pid: 11 },
+ { value: '广东省', text: '广东省', id: 2, pid: null },
+ { value: '广州市', text: '广州市', id: 21, pid: 2 }
+ ])
+ const [convertConfigDemo5, setConvertConfigDemo5] = useState({
+ topId: null,
+ idKey: 'id',
+ pidKey: 'pid',
+ sortKey: ''
+ })
+ const change5 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue5(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo5(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo5(false)}}
+ onChange={change5}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+## API
+
+### Props
+
+| Props | Description | Type | Default |
+| ------------- | --------------------------------------------- | -------- | ------ |
+| value | Selected value | Array | - |
+| options | Cascade data | Array | - |
+| visible | Cascading show hidden states | Boolean | false |
+| lazy | Whether to enable dynamic loading | Boolean | false |
+| lazyLoad | Dynamic loading callback, which takes effect when dynamic loading is enabled | Function | - |
+| valueKey | Customize the field of `value` in the `options` structure | String | - |
+| textKey | Customize the fields of `text` in the `options` structure | String | - |
+| childrenKey | Customize the fields of `children` in the `options` structure | String | - |
+| convertConfig | When options is a flat structure that can be converted into a tree structure, configure the conversion rules | Object | - |
+| title | Title | String | '' |
+| closeIconPosition | Cancel the button position and inherit the popup component | String | "top-right" |
+| close-icon | Customize the close button and inherit the popup component | String | "close" |
+| closeable | Whether to display the close button and inherit the popup component | Boolean | true |
+
+### Events
+
+| Event | Description | Callback parameters |
+| ---------- | ---------------- | ------------------ |
+| onChange | Triggered when the selected value changes | (value, pathNodes) |
+| onPathChange | Triggered when the selected item changes | (pathNodes) |
diff --git a/src/packages/cascader/doc.md b/src/packages/cascader/doc.md
new file mode 100644
index 0000000..0f57f55
--- /dev/null
+++ b/src/packages/cascader/doc.md
@@ -0,0 +1,465 @@
+# Cascader 级联选择
+
+### 介绍
+
+级联选择器,用于多层级数据的选择,典型场景为省市区选择。
+
+### 安装
+
+```js
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+```
+
+## 代码演示
+### 基础用法
+
+传入`options`列表。
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo1, setIsVisibleDemo1] = useState(false)
+ const [value1, setValue1] = useState([])
+ const [optionsDemo1, setOptionsDemo1] = useState([
+ {
+ value: '浙江',
+ text: '浙江',
+ children: [
+ {
+ value: '杭州',
+ text: '杭州',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区', disabled: true },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '湖南',
+ text: '湖南',
+ disabled: true,
+ children: [
+ {
+ value: '长沙',
+ text: '长沙',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区' },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '福建',
+ text: '福建',
+ children: [
+ {
+ value: '福州',
+ text: '福州',
+ children: [
+ { value: '鼓楼区', text: '鼓楼区' },
+ { value: '台江区', text: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const change1 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setValue1(value)
+ }
+ const onPathChange = (value: any, path: any) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo1(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo1(false)}}
+ onChange={change1}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### 自定义属性名称
+
+可通过`textKey`、`valueKey`、`childrenKey`指定属性名。
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo2, setIsVisibleDemo2] = useState(false)
+ const [value2, setValue2] = useState(['福建', '福州', '台江区'])
+ const [optionsDemo2, setOptionsDemo2] = useState([
+ {
+ value1: '浙江',
+ text1: '浙江',
+ items: [
+ {
+ value1: '杭州',
+ text1: '杭州',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区', disabled: true },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '湖南',
+ text1: '湖南',
+ disabled: true,
+ items: [
+ {
+ value1: '长沙',
+ text1: '长沙',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区' },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '福建',
+ text1: '福建',
+ items: [
+ {
+ value1: '福州',
+ text1: '福州',
+ items: [
+ { value1: '鼓楼区', text1: '鼓楼区' },
+ { value1: '台江区', text1: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const change2 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue2(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo2(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo2(false)}}
+ onChange={change2}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### 动态加载
+
+使用`lazy`标识是否需要动态获取数据,此时不传`options`代表所有数据都需要通过`lazyLoad`加载,首次加载通过`root`属性区分,当遇到非叶子节点时会调用`lazyLoad`方法,参数为当前节点和`resolve`方法,注意`resolve`方法必须调用,不传子节点时会被当做叶子节点处理。
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo3, setIsVisibleDemo3] = useState(false)
+ const [value3, setValue3] = useState(['A0', 'A12', 'A23', 'A32'])
+
+ const lazyLoadDemo3 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ if (node.root) {
+ resolve([
+ { value: 'A0', text: 'A0' },
+ { value: 'B0', text: 'B0' },
+ { value: 'C0', text: 'C0' }
+ ]);
+ } else {
+ const { value, level } = node;
+ const text = value.substring(0, 1);
+ const value1 = `${text}${level + 1}1`;
+ const value2 = `${text}${level + 1}2`;
+ const value3 = `${text}${level + 1}3`;
+ resolve([
+ { value: value1, text: value1, leaf: level >= 6 },
+ { value: value2, text: value2, leaf: level >= 6 },
+ { value: value3, text: value3, leaf: level >= 6 }
+ ]);
+ }
+ }, 2000);
+ }
+ const change3 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue3(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+ return (
+ <>
+ {
+ setIsVisibleDemo3(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo3(false)}}
+ onChange={change3}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo3}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+
+### 部分数据动态加载
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo4, setIsVisibleDemo4] = useState(false)
+ const [value4, setValue4] = useState([])
+ const [optionsDemo4, setOptionsDemo4] = useState([
+ { value: 'A0', text: 'A0' },
+ {
+ value: 'B0',
+ text: 'B0',
+ children: [
+ { value: 'B11', text: 'B11', leaf: true },
+ { value: 'B12', text: 'B12' }
+ ]
+ },
+ { value: 'C0', text: 'C0' }
+ ])
+
+ const lazyLoadDemo4 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ const { value, level } = node;
+ const text = value.substring(0, 1);
+ const value1 = `${text}${level + 1}1`;
+ const value2 = `${text}${level + 1}2`;
+ resolve([
+ { value: value1, text: value1, leaf: level >= 2 },
+ { value: value2, text: value2, leaf: level >= 1 }
+ ]);
+ }, 500);
+ }
+ const change4 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue4(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo4(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo4(false)}}
+ onChange={change4}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo4}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### 自动转换
+
+如果你的数据为可转换为树形结构的扁平结构时,可以通过`convertConfig`告诉组件需要进行自动转换,`convertConfig`接受4个参数,`topId`为顶层节点的父级id,`idKey`为节点唯一id,`pidKey`为指向父节点id的属性名,存在`sortKey`将根据指定字段调用Array.prototype.sort()进行同层排序。
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo5, setIsVisibleDemo5] = useState(false)
+ const [value5, setValue5] = useState(['广东省', '广州市'])
+ const [optionsDemo5, setOptionsDemo5] = useState([
+ { value: '北京', text: '北京', id: 1, pid: null },
+ { value: '朝阳区', text: '朝阳区', id: 11, pid: 1 },
+ { value: '亦庄', text: '亦庄', id: 111, pid: 11 },
+ { value: '广东省', text: '广东省', id: 2, pid: null },
+ { value: '广州市', text: '广州市', id: 21, pid: 2 }
+ ])
+ const [convertConfigDemo5, setConvertConfigDemo5] = useState({
+ topId: null,
+ idKey: 'id',
+ pidKey: 'pid',
+ sortKey: ''
+ })
+ const change5 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue5(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo5(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo5(false)}}
+ onChange={change5}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ------------- | ---------------------------------------------- | -------- | ------ |
+| value | 选中值 | Array | - |
+| options | 级联数据 | Array | - |
+| visible | 级联显示隐藏状态 | Boolean | false |
+| lazy | 是否开启动态加载 | Boolean | false |
+| lazyLoad | 动态加载回调,开启动态加载时生效 | Function | - |
+| valueKey | 自定义`options`结构中`value`的字段 | String | - |
+| textKey | 自定义`options`结构中`text`的字段 | String | - |
+| childrenKey | 自定义`options`结构中`children`的字段 | String | - |
+| convertConfig | 当options为可转换为树形结构的扁平结构时,配置转换规则 | Object | - |
+| title | 标题 | String | '' |
+| closeIconPosition | 取消按钮位置,继承 Popup 组件 | String | "top-right" |
+| close-icon | 自定义关闭按钮,继承 Popup 组件 | String | "close" |
+| closeable | 是否显示关闭按钮,继承 Popup 组件 | Boolean | true |
+
+### Events
+
+| 事件名 | 说明 | 回调参数 |
+| ---------- | ---------------- | ------------------ |
+| onChange | 选中值改变时触发 | (value, pathNodes) |
+| onPathChange | 选中项改变时触发 | (pathNodes) |
diff --git a/src/packages/cascader/doc.zh-TW.md b/src/packages/cascader/doc.zh-TW.md
new file mode 100644
index 0000000..2d88b9f
--- /dev/null
+++ b/src/packages/cascader/doc.zh-TW.md
@@ -0,0 +1,465 @@
+# Cascader 級聯選擇
+
+### 介紹
+
+級聯選擇器,用於多層級數據的選擇,典型場景為省市區選擇。
+
+### 安裝
+
+```js
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+```
+
+## 代碼演示
+### 基礎用法
+
+傳入`options`列表。
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo1, setIsVisibleDemo1] = useState(false)
+ const [value1, setValue1] = useState([])
+ const [optionsDemo1, setOptionsDemo1] = useState([
+ {
+ value: '浙江',
+ text: '浙江',
+ children: [
+ {
+ value: '杭州',
+ text: '杭州',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区', disabled: true },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '湖南',
+ text: '湖南',
+ disabled: true,
+ children: [
+ {
+ value: '长沙',
+ text: '长沙',
+ disabled: true,
+ children: [
+ { value: '西湖区', text: '西湖区' },
+ { value: '余杭区', text: '余杭区' },
+ ],
+ },
+ {
+ value: '温州',
+ text: '温州',
+ children: [
+ { value: '鹿城区', text: '鹿城区' },
+ { value: '瓯海区', text: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value: '福建',
+ text: '福建',
+ children: [
+ {
+ value: '福州',
+ text: '福州',
+ children: [
+ { value: '鼓楼区', text: '鼓楼区' },
+ { value: '台江区', text: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const change1 = (value: any, path: any) => {
+ console.log('onChange', value, path)
+ setValue1(value)
+ }
+ const onPathChange = (value: any, path: any) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo1(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo1(false)}}
+ onChange={change1}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### 自定義屬性名稱
+
+可通過`textKey`、`valueKey`、`childrenKey`指定屬性名。
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo2, setIsVisibleDemo2] = useState(false)
+ const [value2, setValue2] = useState(['福建', '福州', '台江区'])
+ const [optionsDemo2, setOptionsDemo2] = useState([
+ {
+ value1: '浙江',
+ text1: '浙江',
+ items: [
+ {
+ value1: '杭州',
+ text1: '杭州',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区', disabled: true },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '湖南',
+ text1: '湖南',
+ disabled: true,
+ items: [
+ {
+ value1: '长沙',
+ text1: '长沙',
+ disabled: true,
+ items: [
+ { value1: '西湖区', text1: '西湖区' },
+ { value1: '余杭区', text1: '余杭区' },
+ ],
+ },
+ {
+ value1: '温州',
+ text1: '温州',
+ items: [
+ { value1: '鹿城区', text1: '鹿城区' },
+ { value1: '瓯海区', text1: '瓯海区' },
+ ],
+ },
+ ],
+ },
+ {
+ value1: '福建',
+ text1: '福建',
+ items: [
+ {
+ value1: '福州',
+ text1: '福州',
+ items: [
+ { value1: '鼓楼区', text1: '鼓楼区' },
+ { value1: '台江区', text1: '台江区' },
+ ],
+ },
+ ],
+ },
+ ])
+ const change2 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue2(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo2(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo2(false)}}
+ onChange={change2}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### 動態加載
+
+使用`lazy`標識是否需要動態獲取數據,此時不傳`options`代表所有數據都需要通過`lazyLoad`加載,首次加載通過`root`屬性區分,當遇到非葉子節點時會調用`lazyLoad`方法,參數為當前節點和`resolve`方法,註意`resolve`方法必須調用,不傳子節點時會被當做葉子節點處理。
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo3, setIsVisibleDemo3] = useState(false)
+ const [value3, setValue3] = useState(['A0', 'A12', 'A23', 'A32'])
+
+ const lazyLoadDemo3 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ if (node.root) {
+ resolve([
+ { value: 'A0', text: 'A0' },
+ { value: 'B0', text: 'B0' },
+ { value: 'C0', text: 'C0' }
+ ]);
+ } else {
+ const { value, level } = node;
+ const text = value.substring(0, 1);
+ const value1 = `${text}${level + 1}1`;
+ const value2 = `${text}${level + 1}2`;
+ const value3 = `${text}${level + 1}3`;
+ resolve([
+ { value: value1, text: value1, leaf: level >= 6 },
+ { value: value2, text: value2, leaf: level >= 6 },
+ { value: value3, text: value3, leaf: level >= 6 }
+ ]);
+ }
+ }, 2000);
+ }
+ const change3 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue3(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+ return (
+ <>
+ {
+ setIsVisibleDemo3(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo3(false)}}
+ onChange={change3}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo3}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+
+### 部分數據動態加載
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo4, setIsVisibleDemo4] = useState(false)
+ const [value4, setValue4] = useState([])
+ const [optionsDemo4, setOptionsDemo4] = useState([
+ { value: 'A0', text: 'A0' },
+ {
+ value: 'B0',
+ text: 'B0',
+ children: [
+ { value: 'B11', text: 'B11', leaf: true },
+ { value: 'B12', text: 'B12' }
+ ]
+ },
+ { value: 'C0', text: 'C0' }
+ ])
+
+ const lazyLoadDemo4 = (node: any, resolve: (children: any) => void) => {
+ setTimeout(() => {
+ const { value, level } = node;
+ const text = value.substring(0, 1);
+ const value1 = `${text}${level + 1}1`;
+ const value2 = `${text}${level + 1}2`;
+ resolve([
+ { value: value1, text: value1, leaf: level >= 2 },
+ { value: value2, text: value2, leaf: level >= 1 }
+ ]);
+ }, 500);
+ }
+ const change4 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue4(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo4(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo4(false)}}
+ onChange={change4}
+ onPathChange={onPathChange}
+ lazy
+ lazyLoad={lazyLoadDemo4}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+### 自動轉換
+
+如果你的數據為可轉換為樹形結構的扁平結構時,可以通過`convertConfig`告訴組件需要進行自動轉換,`convertConfig`接受4個參數,`topId`為頂層節點的父級id,`idKey`為節點唯一id,`pidKey`為指向父節點id的屬性名,存在`sortKey`將根據指定字段調用Array.prototype.sort()進行同層排序。
+
+:::demo
+```jsx
+import React from "react";
+import { Cascader, Tabs, TabPane } from '@nutui/nutui-react';
+
+const App = () => {
+ const [isVisibleDemo5, setIsVisibleDemo5] = useState(false)
+ const [value5, setValue5] = useState(['广东省', '广州市'])
+ const [optionsDemo5, setOptionsDemo5] = useState([
+ { value: '北京', text: '北京', id: 1, pid: null },
+ { value: '朝阳区', text: '朝阳区', id: 11, pid: 1 },
+ { value: '亦庄', text: '亦庄', id: 111, pid: 11 },
+ { value: '广东省', text: '广东省', id: 2, pid: null },
+ { value: '广州市', text: '广州市', id: 21, pid: 2 }
+ ])
+ const [convertConfigDemo5, setConvertConfigDemo5] = useState({
+ topId: null,
+ idKey: 'id',
+ pidKey: 'pid',
+ sortKey: ''
+ })
+ const change5 = (value, path) => {
+ console.log('onChange', value, path)
+ setValue5(value)
+ }
+ const onPathChange = (value, path) => {
+ console.log('onPathChange', value, path)
+ }
+
+ return (
+ <>
+ {
+ setIsVisibleDemo5(true)
+ }}
+ >
+ |
+ {setIsVisibleDemo5(false)}}
+ onChange={change5}
+ onPathChange={onPathChange}
+ />
+ >
+ );
+};
+export default App;
+```
+:::
+
+## API
+
+### Props
+
+| 屬性 | 說明 | 類型 | 默認值 |
+| ------------- | ---------------------------------------------- | -------- | ------ |
+| value | 選中值 | Array | - |
+| options | 級聯數據 | Array | - |
+| visible | 級聯顯示隱藏狀態 | Boolean | false |
+| lazy | 是否開啟動態加載 | Boolean | false |
+| lazyLoad | 動態加載回調,開啟動態加載時生效 | Function | - |
+| valueKey | 自定義`options`結構中`value`的字段 | String | - |
+| textKey | 自定義`options`結構中`text`的字段 | String | - |
+| childrenKey | 自定義`options`結構中`children`的字段 | String | - |
+| convertConfig | 當options為可轉換為樹形結構的扁平結構時,配置轉換規則 | Object | - |
+| title | 標題 | String | '' |
+| closeIconPosition | 取消按鈕位置,繼承 Popup 組件 | String | "top-right" |
+| close-icon | 自定義關閉按鈕,繼承 Popup 組件 | String | "close" |
+| closeable | 是否顯示關閉按鈕,繼承 Popup 組件 | Boolean | true |
+
+### Events
+
+| 事件名 | 說明 | 回調參數 |
+| ---------- | --------------- | ------------------ |
+| onChange | 選中值改變時觸發 | (value, pathNodes) |
+| onPathChange | 選中項改變時觸發 | (pathNodes) |
diff --git a/src/packages/cascader/helper.ts b/src/packages/cascader/helper.ts
new file mode 100644
index 0000000..6b7c551
--- /dev/null
+++ b/src/packages/cascader/helper.ts
@@ -0,0 +1,102 @@
+import { CascaderOption, CascaderConfig, convertConfig } from './types'
+
+export const formatTree = (
+ tree: CascaderOption[],
+ parent: CascaderOption | null,
+ config: CascaderConfig
+): CascaderOption[] =>
+ tree.map((node: any) => {
+ const {
+ value: valueKey = 'value',
+ text: textKey = 'text',
+ children: childrenKey = 'children',
+ } = config
+
+ const {
+ [valueKey]: value,
+ [textKey]: text,
+ [childrenKey]: children,
+ ...others
+ } = node
+
+ const newNode: CascaderOption = {
+ loading: false,
+ ...others,
+ level: parent ? ((parent && parent.level) || 0) + 1 : 0,
+ value,
+ text,
+ children,
+ _parent: parent,
+ }
+
+ if (newNode.children && newNode.children.length) {
+ newNode.children = formatTree(newNode.children, newNode, config)
+ }
+
+ return newNode
+ })
+
+export const eachTree = (
+ tree: CascaderOption[],
+ cb: (node: CascaderOption) => unknown
+): void => {
+ let i = 0
+ let node: CascaderOption
+ while ((node = tree[i++])) {
+ if (cb(node) === true) {
+ break
+ }
+
+ if (node.children && node.children.length) {
+ eachTree(node.children, cb)
+ }
+ }
+}
+
+const defaultConvertConfig = {
+ topId: null,
+ idKey: 'id',
+ pidKey: 'pid',
+ sortKey: '',
+}
+export const convertListToOptions = (
+ list: CascaderOption[],
+ options: convertConfig
+): CascaderOption[] => {
+ const mergedOptions = {
+ ...defaultConvertConfig,
+ ...(options || {}),
+ }
+
+ const { topId, idKey, pidKey, sortKey } = mergedOptions
+
+ let result: CascaderOption[] = []
+ let map: any = {}
+
+ list.forEach((node: any) => {
+ node = { ...node }
+ const { [idKey]: id, [pidKey]: pid } = node
+ const children = (map[pid] = map[pid] || [])
+ // const children = map[pid] || []
+
+ if (!result.length && pid === topId) {
+ result = children
+ }
+
+ children.push(node)
+
+ node.children = map[id] || (map[id] = [])
+ })
+
+ if (sortKey) {
+ Object.keys(map).forEach((i) => {
+ if (map[i].length > 1) {
+ map[i].sort((a: any, b: any) => a[sortKey] - b[sortKey])
+ }
+ })
+ }
+
+ map = null
+
+ return result
+}
diff --git a/src/packages/cascader/index.ts b/src/packages/cascader/index.ts
new file mode 100644
index 0000000..ddba6bd
--- /dev/null
+++ b/src/packages/cascader/index.ts
@@ -0,0 +1,3 @@
+import { Cascader } from './cascader'
+
+export default Cascader
diff --git a/src/packages/cascader/tree.ts b/src/packages/cascader/tree.ts
new file mode 100644
index 0000000..09bd8e1
--- /dev/null
+++ b/src/packages/cascader/tree.ts
@@ -0,0 +1,82 @@
+import { CascaderOption, CascaderConfig, CascaderValue } from './types'
+import { formatTree, eachTree } from './helper'
+
+class Tree {
+ nodes: CascaderOption[]
+
+ readonly config: CascaderConfig
+
+ constructor(nodes: CascaderOption[], config?: CascaderConfig) {
+ this.config = {
+ value: 'value',
+ text: 'text',
+ children: 'children',
+ ...(config || {}),
+ }
+ this.nodes = formatTree(nodes, null, this.config)
+ }
+
+ updateChildren(nodes: CascaderOption[], parent: CascaderOption | null): void {
+ if (!parent) {
+ this.nodes = formatTree(nodes, null, this.config)
+ } else {
+ parent.children = formatTree(nodes, parent, this.config)
+ }
+ }
+
+ // for test
+ getNodeByValue(value: CascaderOption['value']): CascaderOption | void {
+ let foundNode
+ eachTree(this.nodes, (node: CascaderOption) => {
+ if (node.value === value) {
+ foundNode = node
+ return true
+ }
+ return null
+ })
+ return foundNode
+ }
+
+ getPathNodesByValue(value: CascaderValue): CascaderOption[] {
+ if (!value.length) {
+ return []
+ }
+
+ const pathNodes = []
+ let currentNodes: CascaderOption[] | void = this.nodes
+
+ while (currentNodes && currentNodes.length) {
+ const foundNode: CascaderOption | void = currentNodes.find(
+ (node) => node.value === value[node.level as number]
+ )
+
+ if (!foundNode) {
+ break
+ }
+
+ pathNodes.push(foundNode)
+ currentNodes = foundNode.children
+ }
+
+ return pathNodes
+ }
+
+ isLeaf = (node: CascaderOption, lazy: boolean): boolean => {
+ const { leaf, children } = node
+ const hasChildren = Array.isArray(children) && Boolean(children.length)
+ return leaf || (!hasChildren && !lazy)
+ }
+
+ hasChildren = (node: CascaderOption, lazy: boolean): boolean => {
+ const isLeaf = this.isLeaf(node, lazy)
+
+ if (isLeaf) {
+ return false
+ }
+
+ const { children } = node
+ return Array.isArray(children) && Boolean(children.length)
+ }
+}
+
+export default Tree
diff --git a/src/packages/cascader/types.ts b/src/packages/cascader/types.ts
new file mode 100644
index 0000000..08ef8c2
--- /dev/null
+++ b/src/packages/cascader/types.ts
@@ -0,0 +1,32 @@
+export interface CascaderPane {
+ nodes: []
+ selectedNode: CascaderOption | null
+ paneKey: string
+}
+
+export interface CascaderOption {
+ text?: string
+ value?: number | string
+ paneKey?: string
+ disabled?: boolean
+ children?: CascaderOption[]
+ leaf?: boolean
+ level?: number
+ loading?: boolean
+ root?: boolean
+}
+
+export interface CascaderConfig {
+ value?: string
+ text?: string
+ children?: string
+}
+
+export type CascaderValue = CascaderOption['value'][]
+
+export interface convertConfig {
+ topId?: string | number | null
+ idKey?: string
+ pidKey?: string
+ sortKey?: string
+}
diff --git a/src/styles/variables.scss b/src/styles/variables.scss
index 0cd3a77..b715e3f 100644
--- a/src/styles/variables.scss
+++ b/src/styles/variables.scss
@@ -647,3 +647,18 @@ $countup-lr-margin: 1px !default;
$countup-bgcolor: #031f63 !default;
$countup-color: #ffffff !default;
$countup-width: 24px !default;
+
+// cascader
+$cascader-font-size: 14px !default;
+$cascader-line-height: 22px !default;
+$cascader-tabs-item-padding: 0 10px !default;
+$cascader-title-padding: 24px 20px 17px !default;
+$cascader-title-font-size: 18px !default;
+$cascader-title-line-height: 20px !default;
+$cascader-item-padding: 10px 20px !default;
+$cascader-item-font-size: 14px !default;
+$cascader-item-color: #1a1a1a !default;
+$cascader-item-active-color: #fa2c19 !default;
+$cascader-pane-height: 342px !default;
+$cascader-pane-paddingTop: 10px !default;
+$cascader-icon-checklist-marginLeft: 10px !default;
--
GitLab