diff --git a/src/cascader/README.md b/src/cascader/README.md
new file mode 100644
index 000000000..7b2ffbd62
--- /dev/null
+++ b/src/cascader/README.md
@@ -0,0 +1,178 @@
+# Cascader
+
+### Install
+
+```js
+import Vue from 'vue';
+import { Cascader } from 'vant';
+
+Vue.use(Cascader);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+
+
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ show: false,
+ fieldValue: '',
+ cascaderValue: '',
+ options: [
+ {
+ text: 'Zhejiang',
+ value: '330000',
+ children: [{ text: 'Hangzhou', value: '330100' }],
+ },
+ {
+ text: 'Jiangsu',
+ value: '320000',
+ children: [{ text: 'Nanjing', value: '320100' }],
+ },
+ ],
+ };
+ },
+ methods: {
+ onFinish({ selectedOptions }) {
+ this.show = false;
+ this.fieldValue = selectedOptions.map((option) => option.text).join('/');
+ },
+ },
+};
+```
+
+### Custom Color
+
+```html
+
+```
+
+### Async Options
+
+```html
+
+
+
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ show: false,
+ fieldValue: '',
+ cascaderValue: '',
+ options: [
+ {
+ text: 'Zhejiang',
+ value: '330000',
+ children: [],
+ },
+ ],
+ };
+ },
+ methods: {
+ onChange({ value }) {
+ if (value === this.options[0].value) {
+ setTimeout(() => {
+ this.options[0].children = [
+ { text: 'Hangzhou', value: '330100' },
+ { text: 'Ningbo', value: '330200' },
+ ];
+ }, 500);
+ }
+ },
+ onFinish({ selectedOptions }) {
+ this.show = false;
+ this.fieldValue = selectedOptions.map((option) => option.text).join('/');
+ },
+ },
+};
+```
+
+## API
+
+### Props
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| title | Title | _string_ | - |
+| value | Value of selected option | _string \| number_ | - |
+| options | Options | _Option[]_ | `[]` |
+| placeholder | Placeholder of unselected tab | _string_ | `Select` |
+| active-color | Active color | _string_ | `#ee0a24` |
+| closeable | Whether to show close icon | _boolean_ | `true` |
+
+### Events
+
+| Event | Description | Arguments |
+| --- | --- | --- |
+| change | Emitted when active option changed | `{ value, selectedOptions, tabIndex }` |
+| finish | Emitted when all options is selected | `{ value, selectedOptions, tabIndex }` |
+| close | Emmitted when the close icon is clicked | - |
+
+### Slots
+
+| Name | Description |
+| ----- | ------------ |
+| title | Custom title |
+
+### Less Variables
+
+How to use: [Custom Theme](#/en-US/theme).
+
+| Name | Default Value | Description |
+| --------------------------------- | --------------- | ----------- |
+| @cascader-header-height | `48px` | - |
+| @cascader-title-font-size | `@font-size-lg` | - |
+| @cascader-title-line-height | `20px` | - |
+| @cascader-close-icon-size | `22px` | - |
+| @cascader-close-icon-color | `@gray-5` | - |
+| @cascader-close-icon-active-color | `@gray-6` | - |
+| @cascader-selected-icon-size | `18px` | - |
+| @cascader-tabs-height | `48px` | - |
+| @cascader-active-color | `@red` | - |
+| @cascader-options-height | `384px` | - |
+| @cascader-tab-color | `@text-color` | - |
+| @cascader-unselected-tab-color | `@gray-6` | - |
diff --git a/src/cascader/README.zh-CN.md b/src/cascader/README.zh-CN.md
new file mode 100644
index 000000000..47f020c7a
--- /dev/null
+++ b/src/cascader/README.zh-CN.md
@@ -0,0 +1,190 @@
+# Cascader 级联选择
+
+### 介绍
+
+级联选择框,用于多层级数据的选择,典型场景为省市区选择,2.12 版本开始支持此组件。
+
+### 引入
+
+```js
+import Vue from 'vue';
+import { Cascader } from 'vant';
+
+Vue.use(Cascader);
+```
+
+## 代码演示
+
+### 基础用法
+
+级联选择组件可以搭配 Field 和 Popup 组件使用,示例如下:
+
+```html
+
+
+
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ show: false,
+ fieldValue: '',
+ cascaderValue: '',
+ // 选项列表,children 代表子选项,支持多级嵌套
+ options: [
+ {
+ text: '浙江省',
+ value: '330000',
+ children: [{ text: '杭州市', value: '330100' }],
+ },
+ {
+ text: '江苏省',
+ value: '320000',
+ children: [{ text: '南京市', value: '320100' }],
+ },
+ ],
+ };
+ },
+ methods: {
+ // 全部选项选择完毕后,会触发 finish 事件
+ onFinish({ selectedOptions }) {
+ this.show = false;
+ this.fieldValue = selectedOptions.map((option) => option.text).join('/');
+ },
+ },
+};
+```
+
+### 自定义颜色
+
+通过 `active-color` 属性来设置选中状态的高亮颜色。
+
+```html
+
+```
+
+### 异步加载选项
+
+可以监听 `change` 事件并动态设置 `options`,实现异步加载选项。
+
+```html
+
+
+
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ show: false,
+ fieldValue: '',
+ cascaderValue: '',
+ options: [
+ {
+ text: '浙江省',
+ value: '330000',
+ children: [],
+ },
+ ],
+ };
+ },
+ methods: {
+ onChange({ value }) {
+ if (value === this.options[0].value) {
+ setTimeout(() => {
+ this.options[0].children = [
+ { text: '杭州市', value: '330100' },
+ { text: '宁波市', value: '330200' },
+ ];
+ }, 500);
+ }
+ },
+ onFinish({ selectedOptions }) {
+ this.show = false;
+ this.fieldValue = selectedOptions.map((option) => option.text).join('/');
+ },
+ },
+};
+```
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ------------ | ------------------ | ------------------ | --------- |
+| title | 顶部标题 | _string_ | - |
+| value | 选中项的值 | _string \| number_ | - |
+| options | 可选项数据源 | _Option[]_ | `[]` |
+| placeholder | 未选中时的提示文案 | _string_ | `请选择` |
+| active-color | 选中状态的高亮颜色 | _string_ | `#ee0a24` |
+| closeable | 是否显示关闭图标 | _boolean_ | `true` |
+
+### Events
+
+| 事件 | 说明 | 回调参数 |
+| ------ | ---------------------- | -------------------------------------- |
+| change | 选中项变化时触发 | `{ value, selectedOptions, tabIndex }` |
+| finish | 全部选项选择完成后触发 | `{ value, selectedOptions, tabIndex }` |
+| close | 点击关闭图标时触发 | - |
+
+### Slots
+
+| 名称 | 说明 |
+| ----- | -------------- |
+| title | 自定义顶部标题 |
+
+### 样式变量
+
+组件提供了下列 Less 变量,可用于自定义样式,使用方法请参考[主题定制](#/zh-CN/theme)。
+
+| 名称 | 默认值 | 描述 |
+| --------------------------------- | --------------- | ---- |
+| @cascader-header-height | `48px` | - |
+| @cascader-title-font-size | `@font-size-lg` | - |
+| @cascader-title-line-height | `20px` | - |
+| @cascader-close-icon-size | `22px` | - |
+| @cascader-close-icon-color | `@gray-5` | - |
+| @cascader-close-icon-active-color | `@gray-6` | - |
+| @cascader-selected-icon-size | `18px` | - |
+| @cascader-tabs-height | `48px` | - |
+| @cascader-active-color | `@red` | - |
+| @cascader-options-height | `384px` | - |
+| @cascader-tab-color | `@text-color` | - |
+| @cascader-unselected-tab-color | `@gray-6` | - |
diff --git a/src/cascader/demo/area-en-US.js b/src/cascader/demo/area-en-US.js
new file mode 100644
index 000000000..8f0fa88be
--- /dev/null
+++ b/src/cascader/demo/area-en-US.js
@@ -0,0 +1,122 @@
+export default [
+ {
+ text: 'Zhejiang',
+ value: '330000',
+ children: [
+ {
+ text: 'Hangzhou',
+ value: '330100',
+ children: [
+ {
+ text: 'Shangcheng',
+ value: '330102',
+ },
+ {
+ text: 'Xiacheng',
+ value: '330103',
+ },
+ {
+ text: 'Jianggan',
+ value: '330104',
+ },
+ ],
+ },
+ {
+ text: 'Ningbo',
+ value: '330200',
+ children: [
+ {
+ text: 'Haishu',
+ value: '330203',
+ },
+ {
+ text: 'Jiangbei',
+ value: '330205',
+ },
+ {
+ text: 'Beilun',
+ value: '330206',
+ },
+ ],
+ },
+ {
+ text: 'Wenzhou',
+ value: '330300',
+ children: [
+ {
+ text: 'Lucheng',
+ value: '330302',
+ },
+ {
+ text: 'Longwan',
+ value: '330303',
+ },
+ {
+ text: 'Ouhai',
+ value: '330304',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ text: 'Jiangsu',
+ value: '320000',
+ children: [
+ {
+ text: 'Nanjing',
+ value: '320100',
+ children: [
+ {
+ text: 'Xuanwu',
+ value: '320102',
+ },
+ {
+ text: 'Qinghuai',
+ value: '320104',
+ },
+ {
+ text: 'Jianye',
+ value: '320105',
+ },
+ ],
+ },
+ {
+ text: 'Wuxi',
+ value: '320200',
+ children: [
+ {
+ text: 'Xishan',
+ value: '320205',
+ },
+ {
+ text: 'Huishan',
+ value: '320206',
+ },
+ {
+ text: 'Binhu',
+ value: '320211',
+ },
+ ],
+ },
+ {
+ text: 'Xuzhou',
+ value: '320300',
+ children: [
+ {
+ text: 'Gulou',
+ value: '320302',
+ },
+ {
+ text: 'Yunlong',
+ value: '320303',
+ },
+ {
+ text: 'Jiawang',
+ value: '320305',
+ },
+ ],
+ },
+ ],
+ },
+];
diff --git a/src/cascader/demo/area-zh-CN.js b/src/cascader/demo/area-zh-CN.js
new file mode 100644
index 000000000..c9f297f14
--- /dev/null
+++ b/src/cascader/demo/area-zh-CN.js
@@ -0,0 +1,122 @@
+export default [
+ {
+ text: '浙江省',
+ value: '330000',
+ children: [
+ {
+ text: '杭州市',
+ value: '330100',
+ children: [
+ {
+ text: '上城区',
+ value: '330102',
+ },
+ {
+ text: '下城区',
+ value: '330103',
+ },
+ {
+ text: '江干区',
+ value: '330104',
+ },
+ ],
+ },
+ {
+ text: '宁波市',
+ value: '330200',
+ children: [
+ {
+ text: '海曙区',
+ value: '330203',
+ },
+ {
+ text: '江北区',
+ value: '330205',
+ },
+ {
+ text: '北仑区',
+ value: '330206',
+ },
+ ],
+ },
+ {
+ text: '温州市',
+ value: '330300',
+ children: [
+ {
+ text: '鹿城区',
+ value: '330302',
+ },
+ {
+ text: '龙湾区',
+ value: '330303',
+ },
+ {
+ text: '瓯海区',
+ value: '330304',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ text: '江苏省',
+ value: '320000',
+ children: [
+ {
+ text: '南京市',
+ value: '320100',
+ children: [
+ {
+ text: '玄武区',
+ value: '320102',
+ },
+ {
+ text: '秦淮区',
+ value: '320104',
+ },
+ {
+ text: '建邺区',
+ value: '320105',
+ },
+ ],
+ },
+ {
+ text: '无锡市',
+ value: '320200',
+ children: [
+ {
+ text: '锡山区',
+ value: '320205',
+ },
+ {
+ text: '惠山区',
+ value: '320206',
+ },
+ {
+ text: '滨湖区',
+ value: '320211',
+ },
+ ],
+ },
+ {
+ text: '徐州市',
+ value: '320300',
+ children: [
+ {
+ text: '鼓楼区',
+ value: '320302',
+ },
+ {
+ text: '云龙区',
+ value: '320303',
+ },
+ {
+ text: '贾汪区',
+ value: '320305',
+ },
+ ],
+ },
+ ],
+ },
+];
diff --git a/src/cascader/demo/index.vue b/src/cascader/demo/index.vue
new file mode 100644
index 000000000..e6d7bebf7
--- /dev/null
+++ b/src/cascader/demo/index.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/cascader/index.js b/src/cascader/index.js
new file mode 100644
index 000000000..fba099ba4
--- /dev/null
+++ b/src/cascader/index.js
@@ -0,0 +1,252 @@
+import { createNamespace } from '../utils';
+import Tab from '../tab';
+import Tabs from '../tabs';
+import Icon from '../icon';
+
+const [createComponent, bem, t] = createNamespace('cascader');
+
+export default createComponent({
+ props: {
+ title: String,
+ modelValue: [Number, String],
+ placeholder: String,
+ activeColor: String,
+ options: {
+ type: Array,
+ default: () => [],
+ },
+ closeable: {
+ type: Boolean,
+ default: true,
+ },
+ },
+
+ emits: ['close', 'change', 'finish', 'update:modelValue'],
+
+ data() {
+ return {
+ tabs: [],
+ activeTab: 0,
+ };
+ },
+
+ watch: {
+ options: {
+ deep: true,
+ handler: 'updateTabs',
+ },
+
+ modelValue(value) {
+ if (value || value === 0) {
+ const values = this.tabs.map((tab) => tab.selectedOption?.value);
+ if (values.indexOf(value) !== -1) {
+ return;
+ }
+ }
+ this.updateTabs();
+ },
+ },
+
+ created() {
+ this.updateTabs();
+ },
+
+ methods: {
+ getSelectedOptionsByValue(options, value) {
+ for (let i = 0; i < options.length; i++) {
+ const option = options[i];
+
+ if (option.value === value) {
+ return [option];
+ }
+
+ if (option.children) {
+ const selectedOptions = this.getSelectedOptionsByValue(
+ option.children,
+ value
+ );
+ if (selectedOptions) {
+ return [option, ...selectedOptions];
+ }
+ }
+ }
+ },
+
+ updateTabs() {
+ if (this.modelValue || this.modelValue === 0) {
+ const selectedOptions = this.getSelectedOptionsByValue(
+ this.options,
+ this.modelValue
+ );
+
+ if (selectedOptions) {
+ let optionsCursor = this.options;
+
+ this.tabs = selectedOptions.map((option) => {
+ const tab = {
+ options: optionsCursor,
+ selectedOption: option,
+ };
+
+ const next = optionsCursor.filter(
+ (item) => item.value === option.value
+ );
+ if (next.length) {
+ optionsCursor = next[0].children;
+ }
+
+ return tab;
+ });
+
+ if (optionsCursor) {
+ this.tabs.push({
+ options: optionsCursor,
+ selectedOption: null,
+ });
+ }
+
+ this.$nextTick(() => {
+ this.activeTab = this.tabs.length - 1;
+ });
+
+ return;
+ }
+ }
+
+ this.tabs = [
+ {
+ options: this.options,
+ selectedOption: null,
+ },
+ ];
+ },
+
+ onSelect(option, tabIndex) {
+ this.tabs[tabIndex].selectedOption = option;
+
+ if (this.tabs.length > tabIndex + 1) {
+ this.tabs = this.tabs.slice(0, tabIndex + 1);
+ }
+
+ if (option.children) {
+ const nextTab = {
+ options: option.children,
+ selectedOption: null,
+ };
+
+ if (this.tabs[tabIndex + 1]) {
+ this.$set(this.tabs, tabIndex + 1, nextTab);
+ } else {
+ this.tabs.push(nextTab);
+ }
+
+ this.$nextTick(() => {
+ this.activeTab++;
+ });
+ }
+
+ const selectedOptions = this.tabs
+ .map((tab) => tab.selectedOption)
+ .filter((item) => !!item);
+
+ const eventParams = {
+ value: option.value,
+ tabIndex,
+ selectedOptions,
+ };
+ this.$emit('update:modelValue', option.value);
+ this.$emit('change', eventParams);
+
+ if (!option.children) {
+ this.$emit('finish', eventParams);
+ }
+ },
+
+ onClose() {
+ this.$emit('close');
+ },
+
+ renderHeader() {
+ return (
+
+ );
+ },
+
+ renderOptions(options, selectedOption, tabIndex) {
+ const renderOption = (option) => {
+ const isSelected =
+ selectedOption && option.value === selectedOption.value;
+
+ return (
+ {
+ this.onSelect(option, tabIndex);
+ }}
+ >
+ {option.text}
+ {isSelected ? (
+
+ ) : null}
+
+ );
+ };
+
+ return {options.map(renderOption)}
;
+ },
+
+ renderTab(item, tabIndex) {
+ const { options, selectedOption } = item;
+ const title = selectedOption
+ ? selectedOption.text
+ : this.placeholder || t('select');
+
+ return (
+
+ {this.renderOptions(options, selectedOption, tabIndex)}
+
+ );
+ },
+
+ renderTabs() {
+ return (
+
+ {this.tabs.map(this.renderTab)}
+
+ );
+ },
+ },
+
+ render() {
+ return (
+
+ {this.renderHeader()}
+ {this.renderTabs()}
+
+ );
+ },
+});
diff --git a/src/cascader/index.less b/src/cascader/index.less
new file mode 100644
index 000000000..cc43b2750
--- /dev/null
+++ b/src/cascader/index.less
@@ -0,0 +1,76 @@
+@import '../style/var';
+
+.van-cascader {
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: @cascader-header-height;
+ padding: 0 @padding-md;
+ }
+
+ &__title {
+ font-weight: @font-weight-bold;
+ font-size: @cascader-title-font-size;
+ line-height: @cascader-title-line-height;
+ }
+
+ &__close-icon {
+ color: @cascader-close-icon-color;
+ font-size: @cascader-close-icon-color;
+
+ &:active {
+ color: @cascader-close-icon-active-color;
+ }
+ }
+
+ &__tabs {
+ .van-tab {
+ flex: none;
+ padding: 0 10px;
+ }
+
+ &.van-tabs--line .van-tabs__wrap {
+ height: @cascader-tabs-height;
+ padding: 0 6px;
+ }
+ }
+
+ &__tab {
+ color: @cascader-tab-color;
+ font-weight: @font-weight-bold;
+
+ &--unselected {
+ color: @cascader-unselected-tab-color;
+ }
+ }
+
+ &__option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px @padding-md;
+ font-size: @font-size-md;
+ line-height: @line-height-md;
+
+ &:active {
+ background-color: @active-color;
+ }
+
+ &--selected {
+ color: @cascader-active-color;
+ }
+ }
+
+ &__selected-icon {
+ font-size: @cascader-selected-icon-size;
+ }
+
+ &__options {
+ box-sizing: border-box;
+ height: @cascader-options-height;
+ padding-top: 6px;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+}
diff --git a/src/cascader/test/__snapshots__/demo.spec.js.snap b/src/cascader/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..e0cce98f3
--- /dev/null
+++ b/src/cascader/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,85 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render demo and match snapshot 1`] = `
+
+
+
+`;
diff --git a/src/cascader/test/demo.spec.js b/src/cascader/test/demo.spec.js
new file mode 100644
index 000000000..5c70922b5
--- /dev/null
+++ b/src/cascader/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import { snapshotDemo } from '../../../test/demo';
+
+snapshotDemo(Demo);
diff --git a/src/cascader/test/index.legacy.js b/src/cascader/test/index.legacy.js
new file mode 100644
index 000000000..25b87094e
--- /dev/null
+++ b/src/cascader/test/index.legacy.js
@@ -0,0 +1,124 @@
+import Cascader from '..';
+import { mount, later } from '../../../test';
+import options from '../demo/area-en-US';
+
+test('should emit change event when active option changed', async () => {
+ const wrapper = mount(Cascader, {
+ propsData: {
+ options,
+ },
+ });
+
+ await later();
+ wrapper.find('.van-cascader__option').trigger('click');
+
+ const firstOption = options[0];
+ expect(wrapper.emitted('change')[0]).toEqual([
+ {
+ value: firstOption.value,
+ tabIndex: 0,
+ selectedOptions: [firstOption],
+ },
+ ]);
+
+ await later();
+ wrapper
+ .findAll('.van-cascader__options')
+ .at(1)
+ .find('.van-cascader__option')
+ .trigger('click');
+ const secondOption = options[0].children[0];
+ expect(wrapper.emitted('change')[1]).toEqual([
+ {
+ value: secondOption.value,
+ tabIndex: 1,
+ selectedOptions: [firstOption, secondOption],
+ },
+ ]);
+});
+
+test('should emit finish event when all options is selected', async () => {
+ const option = { value: '1', text: 'foo' };
+ const wrapper = mount(Cascader, {
+ propsData: {
+ options: [option],
+ },
+ });
+
+ await later();
+ wrapper.find('.van-cascader__option').trigger('click');
+ expect(wrapper.emitted('finish')[0]).toEqual([
+ {
+ value: option.value,
+ tabIndex: 0,
+ selectedOptions: [option],
+ },
+ ]);
+});
+
+test('should emit close event when close icon is clicked', () => {
+ const wrapper = mount(Cascader);
+ wrapper.find('.van-cascader__close-icon').trigger('click');
+ expect(wrapper.emitted('close')[0]).toBeTruthy();
+});
+
+test('should not render close icon when closeable is false', () => {
+ const wrapper = mount(Cascader, {
+ propsData: {
+ closeable: false,
+ },
+ });
+ expect(wrapper.contains('.van-cascader__close-icon')).toBeFalsy();
+});
+
+test('should render title slot correctly', () => {
+ const wrapper = mount(Cascader, {
+ scopedSlots: {
+ title: () => 'Custom Title',
+ },
+ });
+ expect(wrapper.find('.van-cascader__title').html()).toMatchSnapshot();
+});
+
+test('should select correct option when value changed', async () => {
+ const wrapper = mount(Cascader, {
+ propsData: {
+ options,
+ },
+ });
+
+ await later();
+ wrapper.setProps({ value: '330304' });
+ await later();
+ const selectedOptions = wrapper.findAll('.van-cascader__option--selected');
+ const lastSelectedOption = selectedOptions.at(selectedOptions.length - 1);
+ expect(lastSelectedOption).toMatchSnapshot();
+});
+
+test('should reset selected options when value is set to emtpy', async () => {
+ const wrapper = mount(Cascader, {
+ propsData: {
+ value: '330304',
+ options,
+ },
+ });
+
+ await later();
+ wrapper.setProps({ value: '' });
+ await later();
+ expect(wrapper.contains('.van-cascader__option--selected')).toBeFalsy();
+});
+
+test('should update tabs when previous tab is clicked', async () => {
+ const wrapper = mount(Cascader, {
+ propsData: {
+ value: '330304',
+ options,
+ },
+ });
+
+ await later();
+ wrapper.findAll('.van-cascader__option').at(1).trigger('click');
+ await later();
+ expect(wrapper.html()).toMatchSnapshot();
+});
diff --git a/src/locale/lang/de-DE-formal.ts b/src/locale/lang/de-DE-formal.ts
index 38d1d1c25..5ea45622e 100644
--- a/src/locale/lang/de-DE-formal.ts
+++ b/src/locale/lang/de-DE-formal.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `Wähle nicht mehr als ${maxRange} Tage`,
},
+ vanCascader: {
+ select: 'Wählen',
+ },
vanContactCard: {
addText: 'Kontaktinformationen hinzufügen',
},
diff --git a/src/locale/lang/de-DE.ts b/src/locale/lang/de-DE.ts
index 2acd455ae..137ce3b87 100644
--- a/src/locale/lang/de-DE.ts
+++ b/src/locale/lang/de-DE.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `Wähle nicht mehr als ${maxRange} Tage`,
},
+ vanCascader: {
+ select: 'Wählen',
+ },
vanContactCard: {
addText: 'Kontaktinformationen hinzufügen',
},
diff --git a/src/locale/lang/en-US.ts b/src/locale/lang/en-US.ts
index 99268eae0..51555ce8d 100644
--- a/src/locale/lang/en-US.ts
+++ b/src/locale/lang/en-US.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `Choose no more than ${maxRange} days`,
},
+ vanCascader: {
+ select: 'Select',
+ },
vanContactCard: {
addText: 'Add contact info',
},
diff --git a/src/locale/lang/es-ES.ts b/src/locale/lang/es-ES.ts
index 85dc38d7f..262e1c03b 100644
--- a/src/locale/lang/es-ES.ts
+++ b/src/locale/lang/es-ES.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `Elija no más de ${maxRange} días`,
},
+ vanCascader: {
+ select: 'Seleccione',
+ },
vanContactCard: {
addText: 'Añadir información de contacto',
},
diff --git a/src/locale/lang/ja-JP.ts b/src/locale/lang/ja-JP.ts
index 83e2b9497..d37c26632 100644
--- a/src/locale/lang/ja-JP.ts
+++ b/src/locale/lang/ja-JP.ts
@@ -22,6 +22,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}年${month}月`,
rangePrompt: (maxRange: number) => `${maxRange}日以内を選択してください`,
},
+ vanCascader: {
+ select: '選択する',
+ },
vanContactCard: {
addText: '連絡先を追加',
},
diff --git a/src/locale/lang/nb-NO.ts b/src/locale/lang/nb-NO.ts
index 35df25da2..21d808cdf 100644
--- a/src/locale/lang/nb-NO.ts
+++ b/src/locale/lang/nb-NO.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `Maks. ${maxRange} dager`,
},
+ vanCascader: {
+ select: 'Plukke ut',
+ },
vanContactCard: {
addText: 'Legg til kontakt info',
},
diff --git a/src/locale/lang/ro-RO.ts b/src/locale/lang/ro-RO.ts
index 95ec534db..562705967 100644
--- a/src/locale/lang/ro-RO.ts
+++ b/src/locale/lang/ro-RO.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `Alege maxim ${maxRange} zile`,
},
+ vanCascader: {
+ select: 'Selectați',
+ },
vanContactCard: {
addText: 'Adaugă informațiile de contact',
},
diff --git a/src/locale/lang/tr-TR.ts b/src/locale/lang/tr-TR.ts
index 76218b8fa..96e6b005f 100644
--- a/src/locale/lang/tr-TR.ts
+++ b/src/locale/lang/tr-TR.ts
@@ -21,6 +21,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `En fazla ${maxRange} gün seçin`,
},
+ vanCascader: {
+ select: 'Seçiniz',
+ },
vanContactCard: {
addText: 'Kişi bilgisi ekle',
},
diff --git a/src/locale/lang/zh-CN.ts b/src/locale/lang/zh-CN.ts
index c9010ff82..35692b09e 100644
--- a/src/locale/lang/zh-CN.ts
+++ b/src/locale/lang/zh-CN.ts
@@ -22,6 +22,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}年${month}月`,
rangePrompt: (maxRange: number) => `选择天数不能超过 ${maxRange} 天`,
},
+ vanCascader: {
+ select: '请选择',
+ },
vanContactCard: {
addText: '添加联系人',
},
diff --git a/src/locale/lang/zh-HK.ts b/src/locale/lang/zh-HK.ts
index 3d567b08f..5f2ab68e7 100644
--- a/src/locale/lang/zh-HK.ts
+++ b/src/locale/lang/zh-HK.ts
@@ -22,6 +22,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}年${month}月`,
rangePrompt: (maxRange: number) => `選擇天數不能超過 ${maxRange} 天`,
},
+ vanCascader: {
+ select: '請選擇',
+ },
vanContactCard: {
addText: '添加聯系人',
},
diff --git a/src/locale/lang/zh-TW.ts b/src/locale/lang/zh-TW.ts
index aecda17ec..7d20dcc66 100644
--- a/src/locale/lang/zh-TW.ts
+++ b/src/locale/lang/zh-TW.ts
@@ -22,6 +22,9 @@ export default {
monthTitle: (year: number, month: number) => `${year}年${month}月`,
rangePrompt: (maxRange: number) => `選擇天數不能超過 ${maxRange} 天`,
},
+ vanCascader: {
+ select: '請選擇',
+ },
vanContactCard: {
addText: '新增聯絡人',
},
diff --git a/src/style/var.less b/src/style/var.less
index e3b56f13e..928dbaf1e 100644
--- a/src/style/var.less
+++ b/src/style/var.less
@@ -201,6 +201,20 @@
@card-price-integer-font-size: @font-size-lg;
@card-price-font-family: @price-integer-font-family;
+// Cascader
+@cascader-header-height: 48px;
+@cascader-title-font-size: @font-size-lg;
+@cascader-title-line-height: 20px;
+@cascader-close-icon-size: 22px;
+@cascader-close-icon-color: @gray-5;
+@cascader-close-icon-active-color: @gray-6;
+@cascader-selected-icon-size: 18px;
+@cascader-tabs-height: 48px;
+@cascader-active-color: @red;
+@cascader-options-height: 384px;
+@cascader-tab-color: @text-color;
+@cascader-unselected-tab-color: @gray-6;
+
// Cell
@cell-font-size: @font-size-md;
@cell-line-height: 24px;
diff --git a/src/tab/README.md b/src/tab/README.md
index 2423818b0..be8281dcf 100644
--- a/src/tab/README.md
+++ b/src/tab/README.md
@@ -267,6 +267,7 @@ export default {
| to | Target route of the link, same as to of vue-router | _string \| object_ | - |
| replace | If true, the navigation will not leave a history record | _boolean_ | `false` |
| title-style | Custom title style | _any_ | - |
+| title-class | Custom title class name | _any_ | - |
### Tabs Events
diff --git a/src/tab/README.zh-CN.md b/src/tab/README.zh-CN.md
index c193ee021..60c41d265 100644
--- a/src/tab/README.zh-CN.md
+++ b/src/tab/README.zh-CN.md
@@ -274,6 +274,7 @@ export default {
| to | 点击后跳转的目标路由对象,同 vue-router 的 [to 属性](https://router.vuejs.org/zh/api/#to) | _string \| object_ | - |
| replace | 是否在跳转时替换当前页面历史 | _boolean_ | `false` |
| title-style | 自定义标题样式 | _any_ | - |
+| title-class | 自定义标题类名 | _any_ | - |
### Tabs Events
diff --git a/src/tab/index.js b/src/tab/index.js
index 03bff277a..ac2a79955 100644
--- a/src/tab/index.js
+++ b/src/tab/index.js
@@ -19,6 +19,7 @@ export default createComponent({
badge: [Number, String],
title: String,
titleStyle: null,
+ titleClass: null,
disabled: Boolean,
},
@@ -56,6 +57,7 @@ export default createComponent({
() => props.title,
() => {
parent.setLine();
+ parent.scrollIntoView();
}
);
diff --git a/src/tabs/index.js b/src/tabs/index.js
index 343bdf820..a78c700f4 100644
--- a/src/tabs/index.js
+++ b/src/tabs/index.js
@@ -317,6 +317,7 @@ export default createComponent({
title={item.title}
color={props.color}
style={item.titleStyle}
+ class={item.titleClass}
isActive={index === state.currentIndex}
disabled={item.disabled}
scrollable={scrollable.value}
@@ -418,6 +419,7 @@ export default createComponent({
props,
setLine,
currentName,
+ scrollIntoView,
});
return () => (
diff --git a/types/index.d.ts b/types/index.d.ts
index f258b1306..ced16e008 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -37,6 +37,7 @@ export class AddressList extends VanComponent {}
export class Badge extends VanComponent {}
export class Button extends VanComponent {}
export class Card extends VanComponent {}
+export class Cascader extends VanComponent {}
export class Cell extends VanComponent {}
export class CellGroup extends VanComponent {}
export class Circle extends VanComponent {}
diff --git a/vant.config.js b/vant.config.js
index 881d4fcde..3d7babc4a 100644
--- a/vant.config.js
+++ b/vant.config.js
@@ -149,6 +149,10 @@ module.exports = {
path: 'calendar',
title: 'Calendar 日历',
},
+ {
+ path: 'cascader',
+ title: 'Cascader 级联选择',
+ },
{
path: 'checkbox',
title: 'Checkbox 复选框',
@@ -498,6 +502,10 @@ module.exports = {
path: 'calendar',
title: 'Calendar',
},
+ {
+ path: 'cascader',
+ title: 'Cascader',
+ },
{
path: 'checkbox',
title: 'Checkbox',