浏览代码

feat: cascader 级联选择器 (#856)

* feat: 新增级联选择器组件

* fix: fix lazyLoad

* fix: fix lazyLoad

* feat: add popup, code optimization

* fix: sync value

* test: add test

* style: add variables

* style: add variables

* feat: add taro

* fix: test

* upd: perf

* upd: code/docs
dongj0316 4 年之前
父节点
当前提交
da8aa93138

+ 1 - 1
package.json

@@ -79,7 +79,7 @@
     "@vue/compiler-sfc": "^3.0.11",
     "@vue/eslint-config-prettier": "^6.0.0",
     "@vue/eslint-config-typescript": "^7.0.0",
-    "@vue/test-utils": "^2.0.0-rc.6",
+    "@vue/test-utils": "^2.0.0-rc.17",
     "autoprefixer": "^10.3.4",
     "axios": "^0.21.0",
     "eslint": "^7.23.2",

+ 11 - 0
src/config.json

@@ -865,6 +865,17 @@
           "taro": true,
           "exportEmpty": true,
           "author": "richard1015"
+        },
+        {
+          "version": "3.0.0",
+          "name": "Cascader",
+          "type": "component",
+          "cName": "级联选择",
+          "desc": "级联选择,用于多层级数据的选择,典型场景为省市区选择.",
+          "sort": 23,
+          "show": true,
+          "taro": true,
+          "author": "dongj0316"
         }
       ]
     },

文件差异内容过多而无法显示
+ 1208 - 0
src/packages/__VUE/cascader/__tests__/__snapshots__/cascader.spec.ts.snap


+ 604 - 0
src/packages/__VUE/cascader/__tests__/cascader.spec.ts

@@ -0,0 +1,604 @@
+import { CascaderOption } from './../types';
+import { mount } from '@vue/test-utils';
+import Cascader from '../index.vue';
+import Tree from '../tree';
+import { formatTree, convertListToOptions } from '../helper';
+import Popup from '../../popup/index.vue';
+import Icon from '../../icon/index.vue';
+import Tabs from '../../tabs/index.vue';
+import TabPane from '../../tabpane/index.vue';
+
+const mountCascader = (options = {}) =>
+  mount(Cascader, {
+    attachTo: 'body',
+    global: {
+      components: {
+        [Popup.name]: Popup,
+        [Icon.name]: Icon,
+        [TabPane.name]: TabPane,
+        [Tabs.name]: Tabs
+      },
+      stubs: {
+        teleport: true
+      }
+    },
+    ...options
+  });
+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: 'sort'
+    });
+    expect(convertList).toMatchObject([
+      {
+        nodePid: 0,
+        nodeId: 2,
+        text: '广东省',
+        value: '广东省',
+        children: [
+          {
+            nodePid: 2,
+            nodeId: 21,
+            text: '广州市',
+            value: '广州市'
+          }
+        ]
+      },
+      {
+        nodePid: 0,
+        nodeId: 1,
+        text: '北京',
+        value: '北京',
+        children: [
+          {
+            nodePid: 1,
+            nodeId: 11,
+            text: '朝阳区',
+            value: '朝阳区',
+            children: [{ nodePid: 11, nodeId: 111, 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('getNodeByValue', () => {
+    const node = tree.getNodeByValue('西湖区');
+    expect(node).toBeTruthy();
+    expect(node).toMatchObject({ text: '西湖区', value: '西湖区' });
+  });
+
+  test('getPathNodesByNode', () => {
+    const node = tree.getNodeByValue('西湖区') as CascaderOption;
+    expect(node).toBeTruthy();
+    expect(node.value).toBe('西湖区');
+
+    const pathNodes = tree.getPathNodesByNode(node as CascaderOption);
+    const mappedPathNodes = pathNodes.map(({ text, value }) => ({
+      text,
+      value
+    }));
+
+    expect(mappedPathNodes).toMatchObject([
+      { text: '浙江', value: '浙江' },
+      { text: '杭州', value: '杭州' },
+      { text: '西湖区', value: '西湖区' }
+    ]);
+  });
+
+  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 wrapper = mountCascader({
+      propsData: {
+        modelValue: ['福建', '福州', '鼓楼区'],
+        options: mockOptions
+      }
+    });
+    await wrapper.vm.$nextTick();
+    expect(wrapper.html()).toMatchSnapshot();
+  });
+
+  it('options with valueKey/textKey/childrenKey', async () => {
+    const wrapper = mountCascader({
+      propsData: {
+        modelValue: ['福建', '福州', '鼓楼区'],
+        options: mockKeyConfigOptions,
+        valueKey: 'name',
+        textKey: 'name',
+        childrenKey: 'items'
+      }
+    });
+    await wrapper.vm.$nextTick();
+    expect(wrapper.html()).toMatchSnapshot();
+  });
+
+  it('options with convertConfig', async () => {
+    const wrapper = mountCascader({
+      propsData: {
+        modelValue: ['广东省', '广州市'],
+        options: mockConvertOptions,
+        convertConfig: {
+          topId: 0,
+          idKey: 'nodeId',
+          pidKey: 'nodePid',
+          sortKey: 'sort'
+        }
+      }
+    });
+    await wrapper.vm.$nextTick();
+    expect(wrapper.html()).toMatchSnapshot();
+  });
+
+  it('visible', async () => {
+    const wrapper = mountCascader({
+      props: {
+        visible: false,
+        modelValue: [],
+        options: mockOptions
+      }
+    });
+    await wrapper.vm.$nextTick();
+
+    expect(wrapper.find('.nut-cascader__popup').isVisible()).toBe(false);
+
+    await wrapper.setProps({
+      visible: true
+    });
+    expect(wrapper.find('.nut-cascader__popup').isVisible()).toBe(true);
+    expect(wrapper.html()).toMatchSnapshot();
+
+    // TODO: 无法通过,每次更新props都重新执行了setup,原因待发现
+    // // value为空时,会保留上次的选择记录
+    // await wrapper.find('.nut-cascader-item').trigger('click');
+    // expect(wrapper.html()).toMatchSnapshot();
+    // expect(wrapper.findAll('.nut-tabs__titles-item__text').length).toBe(2);
+    // await wrapper.setProps({
+    //   visible: false
+    // });
+    // expect(wrapper.html()).toMatchSnapshot();
+    // await wrapper.setProps({
+    //   visible: true
+    // });
+    // await wrapper.vm.$nextTick();
+    // expect(wrapper.html()).toMatchSnapshot();
+    // expect(wrapper.findAll('.nut-tabs__titles-item__text').length).toBe(2);
+
+    // 点击叶子节点时关闭popup
+    await wrapper.setProps({
+      modelValue: ['福建', '福州', '鼓楼区']
+    });
+    await wrapper.findAll('.nut-cascader-pane')[2].find('.nut-cascader-item').trigger('click');
+    expect((wrapper.emitted('update:visible') as any)[0][0]).toBe(false);
+  });
+
+  it('modelValue', async () => {
+    const wrapper = mountCascader({
+      props: {
+        modelValue: [],
+        options: mockOptions
+      }
+    });
+
+    expect(wrapper.findAll('.nut-cascader-item[aria-checked="true"]').length).toBe(0);
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.setProps({
+      modelValue: ['福建', '福州', '鼓楼区']
+    });
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.setProps({
+      modelValue: ['湖南']
+    });
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.setProps({
+      modelValue: []
+    });
+    expect(wrapper.html()).toMatchSnapshot();
+  });
+
+  it('select', async () => {
+    const wrapper = mountCascader({
+      props: {
+        options: mockOptions
+      }
+    });
+
+    // 模拟点击
+    await wrapper.findAll('.nut-cascader-item')[2].trigger('click');
+    let pathChange: any = wrapper.emitted().pathChange[0];
+    expect(pathChange[0][0].value).toBe('福建');
+    expect(wrapper.html()).toMatchSnapshot();
+    await wrapper.setProps({
+      modelValue: ['福建']
+    });
+    await wrapper.findAll('.nut-cascader-pane')[1].find('.nut-cascader-item').trigger('click');
+    pathChange = wrapper.emitted().pathChange[1];
+    expect(pathChange[0][1].value).toBe('福州');
+    expect(wrapper.html()).toMatchSnapshot();
+    await wrapper.setProps({
+      modelValue: ['福建', '福州']
+    });
+    await wrapper.findAll('.nut-cascader-pane')[2].find('.nut-cascader-item').trigger('click');
+    pathChange = wrapper.emitted().pathChange[2];
+    expect(pathChange[0][2].value).toBe('鼓楼区');
+    expect(wrapper.emitted().pathChange.length).toBe(3);
+    expect(wrapper.emitted().change.length).toBe(1);
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.setProps({
+      modelValue: []
+    });
+    await wrapper.findAll('.nut-cascader-item')[1].trigger('click');
+    expect(wrapper.emitted().pathChange.length).toBe(3);
+    expect(wrapper.emitted().change.length).toBe(1);
+    expect(wrapper.html()).toMatchSnapshot();
+  });
+
+  it('modelValue with lazy', async () => {
+    const wrapper = mountCascader({
+      props: {
+        modelValue: ['A0', 'A12', 'A21'],
+        lazy: true,
+        async lazyLoad(node: any, resolve: (children: any) => void) {
+          setTimeout(() => {
+            // root表示第一层数据
+            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(wrapper.html()).toMatchSnapshot();
+
+    wrapper.setProps({
+      modelValue: ['A0', 'A12', 'A21']
+    });
+    await later(160);
+    expect(wrapper.html()).toMatchSnapshot();
+  });
+
+  it('select with lazy', async () => {
+    const wrapper = mountCascader({
+      props: {
+        lazy: true,
+        async lazyLoad(node: any, resolve: (children: any) => void) {
+          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);
+        }
+      }
+    });
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await later(60);
+
+    // 模拟点击
+    expect(wrapper.html()).toMatchSnapshot();
+    await wrapper.findAll('.nut-cascader-item')[1].trigger('click');
+    await later(10);
+    await wrapper.findAll('.nut-cascader-item')[0].trigger('click');
+    await later(60);
+    let pathChange: any = wrapper.emitted().pathChange[0];
+    expect(pathChange[0][0].value).toBe('A0');
+    expect(wrapper.html()).toMatchSnapshot();
+    await wrapper.setProps({
+      modelValue: ['A0']
+    });
+    await later();
+    await wrapper.findAll('.nut-cascader-pane')[1].findAll('.nut-cascader-item')[0].trigger('click');
+    await later(10);
+    await wrapper.findAll('.nut-cascader-pane')[1].findAll('.nut-cascader-item')[1].trigger('click');
+    await later(60);
+    pathChange = wrapper.emitted().pathChange[1];
+    expect(pathChange[0][1].value).toBe('A12');
+    expect(wrapper.html()).toMatchSnapshot();
+    await wrapper.setProps({
+      modelValue: ['A0', 'A12']
+    });
+    await later();
+    await wrapper.findAll('.nut-cascader-pane')[2].findAll('.nut-cascader-item')[0].trigger('click');
+    pathChange = wrapper.emitted().pathChange[2];
+    expect(pathChange[0][2].value).toBe('A21');
+    expect(wrapper.emitted().pathChange.length).toBe(3);
+    expect(wrapper.emitted().change.length).toBe(1);
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.setProps({
+      modelValue: []
+    });
+    await later();
+    expect(wrapper.html()).toMatchSnapshot();
+    await wrapper.findAll('.nut-cascader-item')[1].trigger('click');
+    await later(60);
+    expect(wrapper.html()).toMatchSnapshot();
+    expect(wrapper.emitted().pathChange.length).toBe(4);
+    expect(wrapper.emitted().change.length).toBe(1);
+  });
+
+  it('change tab', async () => {
+    const wrapper = mountCascader({
+      props: {
+        visible: true,
+        modelValue: ['福建', '福州', '鼓楼区'],
+        options: mockOptions
+      }
+    });
+    await wrapper.vm.$nextTick();
+
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.findAll('.nut-tabs__titles-item__text')[1].trigger('click');
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.findAll('.nut-tabs__titles-item__text')[0].trigger('click');
+    expect(wrapper.html()).toMatchSnapshot();
+
+    await wrapper.find('.nut-tabs__content .nut-cascader-item').trigger('click');
+    expect(wrapper.findAll('.nut-tabs__titles-item__text').length).toBe(2);
+
+    await wrapper.setProps({
+      visible: false
+    });
+
+    // 重新打开时,绑定值和显示一致
+    await wrapper.setProps({
+      visible: true
+    });
+    expect(wrapper.findAll('.nut-tabs__titles-item__text').length).toBe(3);
+    expect(wrapper.findAll('.nut-tabs__titles-item__text')[2].text()).toBe('鼓楼区');
+  });
+});

+ 316 - 0
src/packages/__VUE/cascader/cascader-item.vue

@@ -0,0 +1,316 @@
+<template>
+  <nut-tabs class="nut-cascader" v-model="tabsCursor" @click="handleTabClick" title-scroll>
+    <template v-if="!initLoading && panes.length">
+      <nut-tabpane v-for="(pane, index) in panes" :title="formatTabTitle(pane)" :key="index">
+        <view role="menu" class="nut-cascader-pane">
+          <template v-for="node in pane.nodes" :key="node.value">
+            <view
+              class="nut-cascader-item"
+              :aria-checked="isSelected(pane, node)"
+              :aria-disabled="node.disabled || undefined"
+              :class="{ active: isSelected(pane, node), disabled: node.disabled }"
+              role="menuitemradio"
+              @click="handleNode(node, false)"
+            >
+              <view class="nut-cascader-item__title">{{ node.text }}</view>
+
+              <nut-icon v-if="node.loading" class="nut-cascader-item__icon-loading" name="loading" />
+              <nut-icon v-else class="nut-cascader-item__icon-check" name="checklist" />
+            </view>
+          </template>
+        </view>
+      </nut-tabpane>
+    </template>
+    <template v-else>
+      <nut-tabpane title="Loading...">
+        <view class="nut-cascader-pane"></view>
+      </nut-tabpane>
+    </template>
+  </nut-tabs>
+</template>
+<script lang="ts">
+import { watch, ref, Ref, computed } from 'vue';
+import { createComponent } from '../../utils/create';
+const { create } = createComponent('cascader-item');
+import { convertListToOptions } from './helper';
+import { CascaderPane, CascaderOption, CascaderValue, convertConfig } from './types';
+import Tree from './tree';
+
+export default create({
+  props: {
+    visible: Boolean,
+    modelValue: Array,
+    options: {
+      type: Array,
+      default: () => []
+    },
+    lazy: Boolean,
+    lazyLoad: Function,
+    valueKey: {
+      type: String,
+      default: 'value'
+    },
+    textKey: {
+      type: String,
+      default: 'text'
+    },
+    childrenKey: {
+      type: String,
+      default: 'children'
+    },
+    convertConfig: Object
+  },
+  components: {},
+  emits: ['update:modelValue', 'change', 'pathChange'],
+
+  setup(props, { emit }) {
+    const configs = computed(() => ({
+      lazy: props.lazy,
+      lazyLoad: props.lazyLoad,
+      valueKey: props.valueKey,
+      textKey: props.textKey,
+      childrenKey: props.childrenKey,
+      convertConfig: props.convertConfig
+    }));
+
+    const tabsCursor = ref(0);
+    const initLoading = ref(false);
+    const innerValue: Ref<CascaderValue> = ref(props.modelValue as CascaderValue);
+    const tree: Ref<Tree> = ref(new Tree([], {}));
+    const panes: Ref<CascaderPane[]> = ref([]);
+    const isLazy = computed(() => configs.value.lazy && Boolean(configs.value.lazyLoad));
+
+    const lazyLoadMap = new Map();
+    let currentProcessNode: CascaderOption | null;
+    const init = async () => {
+      lazyLoadMap.clear();
+      panes.value = [];
+      tabsCursor.value = 0;
+      initLoading.value = false;
+      currentProcessNode = null;
+
+      let { options } = props;
+
+      if (configs.value.convertConfig) {
+        options = convertListToOptions(options as CascaderOption[], configs.value.convertConfig as convertConfig);
+      }
+
+      tree.value = new Tree(options as CascaderOption[], {
+        value: configs.value.valueKey,
+        text: configs.value.textKey,
+        children: configs.value.childrenKey
+      });
+
+      if (isLazy.value && !tree.value.nodes.length) {
+        await invokeLazyLoad({
+          root: true,
+          loading: true,
+          text: '',
+          value: ''
+        });
+      }
+
+      panes.value = [{ nodes: tree.value.nodes, selectedNode: null }];
+      syncValue();
+    };
+
+    const syncValue = async () => {
+      const currentValue = innerValue.value;
+
+      if (currentValue === undefined || !tree.value.nodes.length) {
+        return;
+      }
+
+      if (currentValue.length === 0) {
+        tabsCursor.value = 0;
+        panes.value = [{ nodes: tree.value.nodes, selectedNode: null }];
+        return;
+      }
+
+      let needToSync = currentValue;
+
+      if (isLazy.value && Array.isArray(currentValue) && currentValue.length) {
+        needToSync = [];
+        let parent = tree.value.nodes.find((node) => node.value === currentValue[0]);
+
+        if (parent) {
+          needToSync = [parent.value];
+          initLoading.value = true;
+
+          const last = await currentValue.slice(1).reduce(async (p: Promise<CascaderOption | void>, value) => {
+            const parent = await p;
+
+            await invokeLazyLoad(parent);
+            const node = parent?.children?.find((item) => item.value === value);
+
+            if (node) {
+              needToSync.push(value);
+            }
+
+            return Promise.resolve(node);
+          }, Promise.resolve(parent));
+
+          await invokeLazyLoad(last);
+
+          initLoading.value = false;
+        }
+      }
+
+      if (needToSync.length && currentValue === props.modelValue) {
+        const pathNodes = tree.value.getPathNodesByValue(needToSync);
+        pathNodes.map((node, index) => {
+          tabsCursor.value = index;
+          methods.handleNode(node, true);
+        });
+      }
+    };
+
+    const invokeLazyLoad = async (node?: CascaderOption | void) => {
+      if (!node) {
+        return;
+      }
+
+      if (!configs.value.lazyLoad) {
+        node.leaf = true;
+        return;
+      }
+
+      if (tree.value.isLeaf(node, isLazy.value) || tree.value.hasChildren(node, isLazy.value)) {
+        return;
+      }
+
+      node.loading = true;
+
+      const parent = node.root ? null : node;
+      let lazyLoadPromise = lazyLoadMap.get(node);
+
+      if (!lazyLoadPromise) {
+        lazyLoadPromise = new Promise((resolve) => {
+          // 外部必须resolve
+          configs.value.lazyLoad?.(node, resolve);
+        });
+        lazyLoadMap.set(node, lazyLoadPromise);
+      }
+
+      const nodes: CascaderOption[] | void = await lazyLoadPromise;
+
+      if (Array.isArray(nodes) && nodes.length > 0) {
+        tree.value.updateChildren(nodes, parent);
+      } else {
+        // 如果加载完成后没有提供子节点,作为叶子节点处理
+        node.leaf = true;
+      }
+
+      node.loading = false;
+      lazyLoadMap.delete(node);
+    };
+
+    const emitChange = (pathNodes: CascaderOption[]) => {
+      const emitValue = pathNodes.map((node) => node.value);
+
+      innerValue.value = emitValue;
+      emit('change', emitValue, pathNodes);
+      emit('update:modelValue', emitValue, pathNodes);
+    };
+
+    const methods = {
+      // 选中一个节点,静默模式不触发事件
+      async handleNode(node: CascaderOption, silent?: boolean) {
+        const { disabled, loading } = node;
+
+        if ((!silent && disabled) || !panes.value[tabsCursor.value]) {
+          return;
+        }
+
+        if (tree.value.isLeaf(node, isLazy.value)) {
+          node.leaf = true;
+          panes.value[tabsCursor.value].selectedNode = node;
+          panes.value = panes.value.slice(0, (node.level as number) + 1);
+
+          if (!silent) {
+            const pathNodes = panes.value.map((pane) => pane.selectedNode);
+
+            emitChange(pathNodes as CascaderOption[]);
+            emit('pathChange', pathNodes);
+          }
+          return;
+        }
+
+        if (tree.value.hasChildren(node, isLazy.value)) {
+          const level = (node.level as number) + 1;
+
+          panes.value[tabsCursor.value].selectedNode = node;
+          panes.value = panes.value.slice(0, level);
+          panes.value.push({
+            nodes: node.children || [],
+            selectedNode: null
+          });
+
+          tabsCursor.value = level;
+
+          if (!silent) {
+            const pathNodes = panes.value.map((pane) => pane.selectedNode);
+            emit('pathChange', pathNodes);
+          }
+          return;
+        }
+
+        currentProcessNode = node;
+
+        if (loading) {
+          return;
+        }
+
+        await invokeLazyLoad(node);
+
+        if (currentProcessNode === node) {
+          panes.value[tabsCursor.value].selectedNode = node;
+          methods.handleNode(node, silent);
+        }
+      },
+      handleTabClick(tab: any) {
+        currentProcessNode = null;
+        tabsCursor.value = tab.paneKey as number;
+      },
+      formatTabTitle(pane: CascaderPane) {
+        return pane.selectedNode ? pane.selectedNode.text : '请选择';
+      },
+      isSelected(pane: CascaderPane, node: CascaderOption) {
+        return pane.selectedNode && pane.selectedNode.value === node.value;
+      }
+    };
+
+    watch(
+      [configs, () => props.options],
+      () => {
+        init();
+      },
+      {
+        deep: true,
+        immediate: true
+      }
+    );
+    watch(
+      () => props.modelValue,
+      (value) => {
+        if (value !== innerValue.value) {
+          innerValue.value = value as CascaderValue;
+          syncValue();
+        }
+      }
+    );
+    watch(
+      () => props.visible,
+      (val) => {
+        // console.log('watch: props.visible', val);
+        // TODO: value为空时,保留上次选择记录,修复单元测试问题
+        if (val && Array.isArray(innerValue.value) && innerValue.value.length > 0) {
+          syncValue();
+        }
+      }
+    );
+
+    return { panes, initLoading, tabsCursor, ...methods };
+  }
+});
+</script>

+ 318 - 0
src/packages/__VUE/cascader/demo.vue

@@ -0,0 +1,318 @@
+<template>
+  <div class="demo">
+    <h2>基础用法</h2>
+
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo1.visible = true"
+          :value="demo1.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo1.visible"
+          v-model="demo1.value"
+          @change="events.change"
+          @path-change="events.pathChange"
+          :options="demo1.options"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>自定义属性名称</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo2.visible = true"
+          :value="demo2.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo2.visible"
+          v-model="demo2.value"
+          label-key="text"
+          @change="events.change"
+          @path-change="events.pathChange"
+          value-key="text"
+          children-key="items"
+          :options="demo2.options"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>动态加载</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo3.visible = true"
+          :value="demo3.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo3.visible"
+          v-model="demo3.value"
+          @change="events.change"
+          @path-change="events.pathChange"
+          lazy
+          :lazy-load="demo3.lazyLoad"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>部分数据动态加载</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo4.visible = true"
+          :value="demo4.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo4.visible"
+          v-model="demo4.value"
+          @change="events.change"
+          @path-change="events.pathChange"
+          :options="demo4.options"
+          lazy
+          :lazy-load="demo4.lazyLoad"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>自动转换</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo5.visible = true"
+          :value="demo5.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo5.visible"
+          v-model="demo5.value"
+          @change="events.change"
+          @path-change="events.pathChange"
+          :options="demo5.options"
+          :convertConfig="demo5.convertConfig"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+  </div>
+</template>
+
+<script lang="ts">
+import { onMounted, reactive, watch } from 'vue';
+import { createComponent } from '../../utils/create';
+const { createDemo } = createComponent('cascader');
+
+export default createDemo({
+  props: {},
+  setup() {
+    // 基础用法
+    const demo1 = reactive({
+      visible: false,
+      // value: ['湖南'],
+      value: [],
+      options: [
+        {
+          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 demo2 = reactive({
+      visible: false,
+      value: ['福建', '福州', '台江区'],
+      options: [
+        {
+          text: '浙江',
+          items: [
+            {
+              text: '杭州',
+              disabled: true,
+              items: [{ text: '西湖区' }, { text: '余杭区' }]
+            },
+            {
+              text: '温州',
+              items: [{ text: '鹿城区' }, { text: '瓯海区' }]
+            }
+          ]
+        },
+        {
+          text: '福建',
+          items: [
+            {
+              text: '福州',
+              items: [{ text: '鼓楼区' }, { text: '台江区' }]
+            }
+          ]
+        }
+      ]
+    });
+
+    // 动态加载
+    const demo3 = reactive({
+      visible: false,
+      value: ['A0', 'A12', 'A23', 'A32'],
+      lazyLoad(node: any, resolve: (children: any) => void) {
+        setTimeout(() => {
+          // root表示第一层数据
+          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 demo4 = reactive({
+      visible: false,
+      value: [],
+      options: [
+        { value: 'A0', text: 'A0' },
+        {
+          value: 'B0',
+          text: 'B0',
+          children: [
+            { value: 'B11', text: 'B11', leaf: true },
+            { value: 'B12', text: 'B12' }
+          ]
+        },
+        { value: 'C0', text: 'C0' }
+      ],
+      lazyLoad(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 demo5 = reactive({
+      visible: false,
+      value: ['广东省', '广州市'],
+      convertConfig: {
+        topId: null,
+        idKey: 'id',
+        pidKey: 'pid',
+        sortKey: ''
+      },
+      options: [
+        { 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 }
+      ]
+    });
+
+    // onMounted(() => {
+    //   setTimeout(() => {
+    //     demo1.value = ['浙江', '温州', '瓯海区'];
+    //     setTimeout(() => {
+    //       demo1.value = ['浙江', '杭州', '西湖区'];
+    //     }, 2000);
+    //   }, 2000);
+    // });
+
+    const events = {
+      change(...args: any) {
+        console.log('change', ...args);
+      },
+      pathChange(...args: any) {
+        console.log('pathChange', ...args);
+      }
+    };
+
+    return {
+      demo1,
+      demo2,
+      demo3,
+      demo4,
+      demo5,
+      events
+    };
+  }
+});
+</script>

+ 402 - 0
src/packages/__VUE/cascader/doc.md

@@ -0,0 +1,402 @@
+# cascader组件
+
+### 介绍
+
+级联选择器,用于多层级数据的选择,典型场景为省市区选择。
+
+### 安装
+
+```js
+import { createApp } from 'vue';
+// vue
+import { Cascader } from '@nutui/nutui';
+// taro
+import { Cascader } from '@nutui/nutui-taro';
+
+const app = createApp();
+app.use(Cascader);
+```
+
+### 基础用法
+
+传入`options`列表。
+```html
+<nut-form>
+  <nut-form-item label="选择地址">
+    <input
+      class="nut-input-text"
+      @click="state.visible = true"
+      :value="state.value"
+      readonly
+      placeholder="请选择地址"
+      type="text"
+    />
+    <nut-cascader
+      title="地址选择"
+      v-model:visible="state.visible"
+      v-model="state.value"
+      @change="events.change"
+      @path-change="events.pathChange"
+      :options="state.options"
+    ></nut-cascader>
+  </nut-form-item>
+</nut-form>
+```
+
+```javascript
+setup() {
+  const state = reactive({
+    visible: false,
+    value: ['湖南'],
+    options: [
+      {
+        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 events = {
+    change(...args: any) {
+      console.log('change', ...args);
+    },
+    pathChange(...args: any) {
+      console.log('pathChange', ...args);
+    },
+  };
+
+  return { state, events };
+}
+```
+
+### 自定义属性名称
+
+可通过`textKey`、`valueKey`、`childrenKey`指定属性名。
+```html
+<nut-form>
+  <nut-form-item label="选择地址">
+    <input
+      class="nut-input-text"
+      @click="state.visible = true"
+      :value="state.value"
+      readonly
+      placeholder="请选择地址"
+      type="text"
+    />
+    <nut-cascader
+      title="地址选择"
+      v-model:visible="state.visible"
+      v-model="state.value"
+      label-key="text"
+      @change="events.change"
+      @path-change="events.pathChange"
+      value-key="text"
+      children-key="items"
+      :options="state.options"
+    ></nut-cascader>
+  </nut-form-item>
+</nut-form>
+```
+
+```javascript
+setup() {
+  const state = reactive({
+    visible: false,
+    value: ['浙江', '杭州', '西湖区'],
+    options: [
+      {
+        text: '浙江',
+        items: [
+          {
+            text: '杭州',
+            disabled: true,
+            items: [{ text: '西湖区' }, { text: '余杭区' }]
+          },
+          {
+            text: '温州',
+            items: [{ text: '鹿城区' }, { text: '瓯海区' }]
+          }
+        ]
+      },
+      {
+        text: '福建',
+        items: [
+          {
+            text: '福州',
+            items: [{ text: '鼓楼区' }, { text: '台江区' }]
+          }
+        ]
+      }
+    ]
+  });
+  const events = {
+    change(...args: any) {
+      console.log('change', ...args);
+    },
+    pathChange(...args: any) {
+      console.log('pathChange', ...args);
+    },
+  };
+
+  return { state, events };
+}
+```
+
+### 动态加载
+
+使用`lazy`标识是否需要动态获取数据,此时不传`options`代表所有数据都需要通过`lazyLoad`加载,首次加载通过`root`属性区分,当遇到非叶子节点时会调用`lazyLoad`方法,参数为当前节点和`resolve`方法,注意`resolve`方法必须调用,不传子节点时会被当做叶子节点处理。
+```html
+<nut-form>
+  <nut-form-item label="选择地址">
+    <input
+      class="nut-input-text"
+      @click="state.visible = true"
+      :value="state.value"
+      readonly
+      placeholder="请选择地址"
+      type="text"
+    />
+    <nut-cascader
+      title="地址选择"
+      v-model:visible="state.visible"
+      v-model="state.value"
+      @change="events.change"
+      @path-change="events.pathChange"
+      lazy
+      :lazy-load="state.lazyLoad"
+    ></nut-cascader>
+  </nut-form-item>
+</nut-form>
+```
+
+```javascript
+setup() {
+  const state = reactive({
+    visible: false,
+    value: ['A0', 'A12', 'A23', 'A32'],
+    lazyLoad(node: any, resolve: (children: any) => void) {
+      setTimeout(() => {
+        // root表示第一层数据
+        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 }
+          ]);
+        }
+      }, 300);
+    }
+  });
+  const events = {
+    change(...args: any) {
+      console.log('change', ...args);
+    },
+    pathChange(...args: any) {
+      console.log('pathChange', ...args);
+    },
+  };
+
+  return { state, events };
+}
+```
+
+### 部分数据动态加载
+
+```html
+<nut-form>
+  <nut-form-item label="选择地址">
+    <input
+      class="nut-input-text"
+      @click="state.visible = true"
+      :value="state.value"
+      readonly
+      placeholder="请选择地址"
+      type="text"
+    />
+    <nut-cascader
+      title="地址选择"
+      v-model:visible="state.visible"
+      v-model="state.value"
+      @change="events.change"
+      @path-change="events.pathChange"
+      :options="state.options"
+      lazy
+      :lazy-load="state.lazyLoad"
+    ></nut-cascader>
+  </nut-form-item>
+</nut-form>
+```
+
+```javascript
+setup() {
+  const state = reactive({
+    visible: false,
+    value: [],
+    options: [
+      { value: 'A0', text: 'A0' },
+      {
+        value: 'B0',
+        text: 'B0',
+        children: [
+          { value: 'B11', text: 'B11', leaf: true },
+          { value: 'B12', text: 'B12' }
+        ]
+      },
+      { value: 'C0', text: 'C0' }
+    ],
+    lazyLoad(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 events = {
+    change(...args: any) {
+      console.log('change', ...args);
+    },
+    pathChange(...args: any) {
+      console.log('pathChange', ...args);
+    },
+  };
+
+  return { state, events };
+}
+```
+
+### 自动转换
+
+如果你的数据为可转换为树形结构的扁平结构时,可以通过`convertConfig`告诉组件需要进行自动转换,`convertConfig`接受4个参数,`topId`为顶层节点的父级id,`idKey`为节点唯一id,`pidKey`为指向父节点id的属性名,存在`sortKey`将根据指定字段调用Array.prototype.sort()进行同层排序。
+
+
+```html
+<nut-form>
+  <nut-form-item label="选择地址" @click="state.visible = true">
+    <input
+      class="nut-input-text"
+      @click="state.visible = true"
+      :value="state.value"
+      readonly
+      placeholder="请选择地址"
+      type="text"
+    />
+    <nut-cascader
+      title="地址选择"
+      v-model:visible="state.visible"
+      v-model="state.value"
+      @change="events.change"
+      @path-change="events.pathChange"
+      :options="state.options"
+      :convertConfig="state.convertConfig"
+    ></nut-cascader>
+  </nut-form-item>
+</nut-form>
+```
+
+```javascript
+setup() {
+  const state = reactive({
+    visible: false,
+    value: ['广东省', '广州市'],
+    convertConfig: {
+      topId: null,
+      idKey: 'id',
+      pidKey: 'pid',
+      sortKey: ''
+    },
+    options: [
+      { 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 events = {
+    change(...args: any) {
+      console.log('change', ...args);
+    },
+    pathChange(...args: any) {
+      console.log('pathChange', ...args);
+    },
+  };
+
+  return { state, events };
+}
+```
+
+## API
+
+### Props
+
+| 参数          | 说明                                                  | 类型     | 默认值 |
+| ------------- | ----------------------------------------------------- | -------- | ------ |
+| v-model       | 选中值,双向绑定                                      | Array    | -      |
+| options       | 级联数据                                              | Array    | -      |
+| lazy          | 是否开启动态加载                                      | Boolean  | -      |
+| lazy-load      | 动态加载回调,开启动态加载时生效                      | Function | -      |
+| value-key      | 自定义`options`结构中`value`的字段                    | String   | -      |
+| text-key       | 自定义`options`结构中`text`的字段                     | String   | -      |
+| children-key   | 自定义`options`结构中`children`的字段                 | String   | -      |
+| convert-config | 当options为可转换为树形结构的扁平结构时,配置转换规则 | Object   | -      |
+
+### Events
+
+| 事件名     | 说明             | 回调参数           |
+| ---------- | ---------------- | ------------------ |
+| change     | 选中值改变时触发 | (value, pathNodes) |
+| pathChange | 选中项改变时触发 | (pathNodes)        |

+ 86 - 0
src/packages/__VUE/cascader/helper.ts

@@ -0,0 +1,86 @@
+import { CascaderOption, CascaderConfig, convertConfig } from './types';
+
+export const formatTree = (
+  tree: CascaderOption[],
+  parent: CascaderOption | null,
+  config: CascaderConfig
+): CascaderOption[] =>
+  tree.map((node: CascaderOption) => {
+    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) => any): 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: CascaderOption) => {
+    node = { ...node };
+    const { [idKey]: id, [pidKey]: pid } = node;
+    const children = (map[pid] = 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: CascaderOption, b: CascaderOption) => a[sortKey] - b[sortKey]);
+      }
+    });
+  }
+
+  map = null;
+
+  return result;
+};

+ 81 - 0
src/packages/__VUE/cascader/index.scss

@@ -0,0 +1,81 @@
+.nut-cascader {
+  width: 100%;
+  font-size: $nut-cascader-font-size;
+  line-height: $nut-cascader-line-height;
+
+  .nut-tabpane {
+    padding: 0;
+  }
+
+  .nut-tabs__titles-item {
+    flex: initial;
+    min-width: auto;
+    padding: $nut-cascader-tabs-item-padding;
+    white-space: nowrap;
+  }
+
+  .nut-tabs__titles {
+    padding: $nut-cascader-tabs-item-padding;
+    background: #fff;
+  }
+
+  &__bar {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: $nut-cascader-bar-padding;
+    text-align: center;
+    font-weight: bold;
+    line-height: $nut-cascader-bar-line-height;
+    color: $nut-cascader-bar-color;
+    font-size: $nut-cascader-bar-font-size;
+  }
+
+  &-pane {
+    display: block;
+    padding: 0;
+    margin: 0;
+    width: 100%;
+    padding-top: 10px;
+    height: 342px;
+    overflow-y: auto;
+    -webkit-overflow-scrolling: touch;
+  }
+
+  &-item {
+    display: flex;
+    align-items: center;
+    padding: $nut-cascader-item-padding;
+    margin: 0;
+    cursor: pointer;
+    font-size: $nut-cascader-item-font-size;
+    color: $nut-cascader-item-color;
+
+    &__title {
+      flex: 1;
+    }
+
+    &__icon-check {
+      margin-left: 10px;
+      visibility: hidden;
+    }
+
+    &__icon-loading {
+      margin-left: 10px;
+    }
+
+    &.active:not(.disabled) {
+      color: $nut-cascader-item-active-color;
+    }
+
+    &.active &__icon-check {
+      visibility: visible;
+      color: $nut-cascader-item-active-color;
+    }
+
+    &.disabled {
+      opacity: 0.6;
+      cursor: not-allowed;
+    }
+  }
+}

+ 104 - 0
src/packages/__VUE/cascader/index.taro.vue

@@ -0,0 +1,104 @@
+<template>
+  <nut-popup
+    v-model:visible="innerVisible"
+    position="bottom"
+    pop-class="nut-cascader__popup"
+    round
+    :closeable="true"
+    :destroy-on-close="false"
+  >
+    <template v-if="title">
+      <view class="nut-cascader__bar">{{ title }}</view>
+    </template>
+
+    <nut-cascader-item
+      @change="onChange"
+      @path-change="onPathChange"
+      :modelValue="innerValue"
+      :options="options"
+      :lazy="lazy"
+      :lazy-load="lazyLoad"
+      :value-key="valueKey"
+      :text-key="textKey"
+      :children-key="childrenKey"
+      :convert-config="convertConfig"
+      :visible="innerVisible"
+    />
+  </nut-popup>
+</template>
+<script lang="ts">
+import { watch, ref, Ref, computed } from 'vue';
+import { CascaderValue, CascaderOption } from './types';
+import { createComponent } from '../../utils/create';
+const { create } = createComponent('cascader');
+import CascaderItem from './cascader-item.vue';
+
+export default create({
+  components: {
+    [CascaderItem.name]: CascaderItem
+  },
+  props: {
+    modelValue: Array,
+    visible: Boolean,
+    title: String,
+    options: {
+      type: Array,
+      default: () => []
+    },
+    lazy: Boolean,
+    lazyLoad: Function,
+    valueKey: {
+      type: String,
+      default: 'value'
+    },
+    textKey: {
+      type: String,
+      default: 'text'
+    },
+    childrenKey: {
+      type: String,
+      default: 'children'
+    },
+    convertConfig: Object
+  },
+  emits: ['update:modelValue', 'change', 'pathChange', 'update:visible'],
+  setup(props, { emit }) {
+    const innerValue: Ref<CascaderValue> = ref(props.modelValue as CascaderValue);
+    const innerVisible = computed({
+      get() {
+        return props.visible;
+      },
+      set(value) {
+        emit('update:visible', value);
+      }
+    });
+
+    const onChange = (value: CascaderValue, pathNodes: CascaderOption[]) => {
+      innerValue.value = value;
+      innerVisible.value = false;
+      emit('change', value, pathNodes);
+      emit('update:modelValue', value);
+    };
+
+    const onPathChange = (pathNodes: CascaderOption[]) => {
+      emit('pathChange', pathNodes);
+    };
+
+    watch(
+      () => props.modelValue,
+      (value) => {
+        if (value !== innerValue.value) {
+          innerValue.value = value as CascaderValue;
+        }
+      }
+    );
+
+    return {
+      onChange,
+      onPathChange,
+      innerValue,
+      innerVisible
+    };
+  }
+});
+</script>

+ 104 - 0
src/packages/__VUE/cascader/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <nut-popup
+    v-model:visible="innerVisible"
+    position="bottom"
+    pop-class="nut-cascader__popup"
+    round
+    :closeable="true"
+    :destroy-on-close="false"
+  >
+    <template v-if="title">
+      <view class="nut-cascader__bar">{{ title }}</view>
+    </template>
+
+    <nut-cascader-item
+      @change="onChange"
+      @path-change="onPathChange"
+      :modelValue="innerValue"
+      :options="options"
+      :lazy="lazy"
+      :lazy-load="lazyLoad"
+      :value-key="valueKey"
+      :text-key="textKey"
+      :children-key="childrenKey"
+      :convert-config="convertConfig"
+      :visible="innerVisible"
+    />
+  </nut-popup>
+</template>
+<script lang="ts">
+import { watch, ref, Ref, computed } from 'vue';
+import { CascaderValue, CascaderOption } from './types';
+import { createComponent } from '../../utils/create';
+const { create } = createComponent('cascader');
+import CascaderItem from './cascader-item.vue';
+
+export default create({
+  components: {
+    [CascaderItem.name]: CascaderItem
+  },
+  props: {
+    modelValue: Array,
+    visible: Boolean,
+    title: String,
+    options: {
+      type: Array,
+      default: () => []
+    },
+    lazy: Boolean,
+    lazyLoad: Function,
+    valueKey: {
+      type: String,
+      default: 'value'
+    },
+    textKey: {
+      type: String,
+      default: 'text'
+    },
+    childrenKey: {
+      type: String,
+      default: 'children'
+    },
+    convertConfig: Object
+  },
+  emits: ['update:modelValue', 'change', 'pathChange', 'update:visible'],
+  setup(props, { emit }) {
+    const innerValue: Ref<CascaderValue> = ref(props.modelValue as CascaderValue);
+    const innerVisible = computed({
+      get() {
+        return props.visible;
+      },
+      set(value) {
+        emit('update:visible', value);
+      }
+    });
+
+    const onChange = (value: CascaderValue, pathNodes: CascaderOption[]) => {
+      innerValue.value = value;
+      innerVisible.value = false;
+      emit('change', value, pathNodes);
+      emit('update:modelValue', value);
+    };
+
+    const onPathChange = (pathNodes: CascaderOption[]) => {
+      emit('pathChange', pathNodes);
+    };
+
+    watch(
+      () => props.modelValue,
+      (value) => {
+        if (value !== innerValue.value) {
+          innerValue.value = value as CascaderValue;
+        }
+      }
+    );
+
+    return {
+      onChange,
+      onPathChange,
+      innerValue,
+      innerVisible
+    };
+  }
+});
+</script>

+ 83 - 0
src/packages/__VUE/cascader/tree.ts

@@ -0,0 +1,83 @@
+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);
+    }
+  }
+
+  getNodeByValue(value: CascaderOption['value']): CascaderOption | void {
+    let foundNode;
+    eachTree(this.nodes, (node: CascaderOption) => {
+      if (node.value === value) {
+        foundNode = node;
+        return true;
+      }
+    });
+
+    return foundNode;
+  }
+
+  getPathNodesByNode(node: CascaderOption): CascaderOption[] {
+    const nodes = [];
+
+    while (node) {
+      nodes.unshift(node);
+      node = node._parent;
+    }
+
+    return nodes;
+  }
+
+  getPathNodesByValue(value: CascaderValue): CascaderOption[] {
+    if (Array.isArray(value) && !value.length) {
+      return [];
+    }
+
+    const tail = Array.isArray(value) ? value[value.length - 1] : value;
+
+    const node = this.getNodeByValue(tail);
+    if (!node) {
+      return [];
+    }
+
+    return this.getPathNodesByNode(node);
+  }
+
+  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;

+ 31 - 0
src/packages/__VUE/cascader/types.ts

@@ -0,0 +1,31 @@
+export type CascaderPane = {
+  nodes: CascaderOption[];
+  selectedNode: CascaderOption | null;
+};
+
+export type CascaderConfig = {
+  value?: string;
+  text?: string;
+  children?: string;
+};
+
+export type CascaderOption = {
+  text?: string;
+  value?: number | string;
+  disabled?: boolean;
+  children?: CascaderOption[];
+  leaf?: boolean;
+  level?: number;
+  loading?: boolean;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [key: PropertyKey]: any;
+};
+
+export type CascaderValue = CascaderOption['value'][];
+
+export type convertConfig = {
+  topId?: string | number | null;
+  idKey?: string;
+  pidKey?: string;
+  sortKey?: string;
+};

+ 13 - 0
src/packages/styles/variables.scss

@@ -386,6 +386,19 @@ $searchbar-background: $white !default;
 $searchbar-input-background: #f7f7f7 !default;
 $searchbar-right-out-color: $black !default;
 
+// cascader
+$nut-cascader-font-size: $font-size-2;
+$nut-cascader-line-height: 22px;
+$nut-cascader-tabs-item-padding: 0 10px;
+$nut-cascader-bar-padding: 24px 20px 17px;
+$nut-cascader-bar-font-size: $font-size-4;
+$nut-cascader-bar-line-height: 20px;
+$nut-cascader-bar-color: $title-color;
+$nut-cascader-item-padding: 10px 20px;
+$nut-cascader-item-color: $title-color;
+$nut-cascader-item-font-size: $font-size-2;
+$nut-cascader-item-active-color: $primary-color;
+
 // form
 $form-item-error-line-color: $primary-color !default;
 $form-item-required-color: $primary-color !default;

+ 2 - 1
src/sites/mobile-taro/vue/src/app.config.ts

@@ -73,7 +73,8 @@ export default {
         'pages/numberkeyboard/index',
         'pages/badge/index',
         'pages/tag/index',
-        'pages/popover/index'
+        'pages/popover/index',
+        'pages/cascader/index'
       ]
     },
     {

+ 3 - 0
src/sites/mobile-taro/vue/src/dentry/pages/cascader/index.config.ts

@@ -0,0 +1,3 @@
+export default {
+  navigationBarTitleText: 'Cascader'
+};

+ 315 - 0
src/sites/mobile-taro/vue/src/dentry/pages/cascader/index.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="demo">
+    <h2>基础用法</h2>
+
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo1.visible = true"
+          :value="demo1.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo1.visible"
+          v-model="demo1.value"
+          @change="events.change"
+          @pathChange="events.pathChange"
+          :options="demo1.options"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>自定义属性名称</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo2.visible = true"
+          :value="demo2.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo2.visible"
+          v-model="demo2.value"
+          labelKey="text"
+          @change="events.change"
+          @pathChange="events.pathChange"
+          valueKey="text"
+          childrenKey="items"
+          :options="demo2.options"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>动态加载</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo3.visible = true"
+          :value="demo3.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo3.visible"
+          v-model="demo3.value"
+          @change="events.change"
+          @pathChange="events.pathChange"
+          lazy
+          :lazyLoad="demo3.lazyLoad"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>部分数据动态加载</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo4.visible = true"
+          :value="demo4.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo4.visible"
+          v-model="demo4.value"
+          @change="events.change"
+          @pathChange="events.pathChange"
+          :options="demo4.options"
+          lazy
+          :lazyLoad="demo4.lazyLoad"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+
+    <h2>自动转换</h2>
+    <nut-form>
+      <nut-form-item label="选择地址">
+        <input
+          class="nut-input-text"
+          @click="demo5.visible = true"
+          :value="demo5.value"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <nut-cascader
+          title="地址选择"
+          v-model:visible="demo5.visible"
+          v-model="demo5.value"
+          @change="events.change"
+          @pathChange="events.pathChange"
+          :options="demo5.options"
+          :convertConfig="demo5.convertConfig"
+        ></nut-cascader>
+      </nut-form-item>
+    </nut-form>
+  </div>
+</template>
+
+<script lang="ts">
+import { reactive } from 'vue';
+
+export default {
+  props: {},
+  setup() {
+    // 基础用法
+    const demo1 = reactive({
+      visible: false,
+      value: ['湖南'],
+      options: [
+        {
+          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 demo2 = reactive({
+      visible: false,
+      value: ['福建', '福州', '台江区'],
+      options: [
+        {
+          text: '浙江',
+          items: [
+            {
+              text: '杭州',
+              disabled: true,
+              items: [{ text: '西湖区' }, { text: '余杭区' }]
+            },
+            {
+              text: '温州',
+              items: [{ text: '鹿城区' }, { text: '瓯海区' }]
+            }
+          ]
+        },
+        {
+          text: '福建',
+          items: [
+            {
+              text: '福州',
+              items: [{ text: '鼓楼区' }, { text: '台江区' }]
+            }
+          ]
+        }
+      ]
+    });
+
+    // 动态加载
+    const demo3 = reactive({
+      visible: false,
+      value: ['A0', 'A12', 'A23', 'A32'],
+      lazyLoad(node: any, resolve: (children: any) => void) {
+        setTimeout(() => {
+          // root表示第一层数据
+          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 demo4 = reactive({
+      visible: false,
+      value: [],
+      options: [
+        { value: 'A0', text: 'A0' },
+        {
+          value: 'B0',
+          text: 'B0',
+          children: [
+            { value: 'B11', text: 'B11', leaf: true },
+            { value: 'B12', text: 'B12' }
+          ]
+        },
+        { value: 'C0', text: 'C0' }
+      ],
+      lazyLoad(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 demo5 = reactive({
+      visible: false,
+      value: ['广东省', '广州市'],
+      convertConfig: {
+        topId: null,
+        idKey: 'id',
+        pidKey: 'pid',
+        sortKey: ''
+      },
+      options: [
+        { 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 }
+      ]
+    });
+
+    // onMounted(() => {
+    //   setTimeout(() => {
+    //     demo1.value = ['浙江', '温州', '瓯海区'];
+    //     setTimeout(() => {
+    //       demo1.value = ['浙江', '杭州', '西湖区'];
+    //     }, 2000);
+    //   }, 2000);
+    // });
+
+    const events = {
+      change(...args: any) {
+        console.log('change', ...args);
+      },
+      pathChange(...args: any) {
+        console.log('pathChange', ...args);
+      }
+    };
+
+    return {
+      demo1,
+      demo2,
+      demo3,
+      demo4,
+      demo5,
+      events
+    };
+  }
+};
+</script>