baiyaaaaa %!s(int64=8) %!d(string=hai) anos
pai
achega
1a16fbb735

+ 2 - 1
components.json

@@ -58,5 +58,6 @@
   "scrollbar": "./packages/scrollbar/index.js",
   "carousel-item": "./packages/carousel-item/index.js",
   "collapse": "./packages/collapse/index.js",
-  "collapse-item": "./packages/collapse-item/index.js"
+  "collapse-item": "./packages/collapse-item/index.js",
+  "cascader": "./packages/cascader/index.js"
 }

+ 1 - 0
examples/docs/en-US/cascader.md

@@ -0,0 +1 @@
+## cascader

+ 109 - 0
examples/docs/zh-CN/cascader.md

@@ -0,0 +1,109 @@
+<script>
+  module.exports = {
+    data() {
+      return {
+        options: [{
+          value: 'zhejiang',
+          label: 'Zhejiang',
+          children: [{
+            value: 'hangzhou',
+            label: 'Hangzhou',
+            children: [{
+              value: 'xihu',
+              label: 'West Lake',
+            }],
+          }, {
+            value: 'ningbo',
+            label: 'NingBo',
+            children: [{
+              value: 'jiangbei',
+              label: 'Jiang Bei',
+            }],
+          }],
+        }, {
+          value: 'jiangsu',
+          label: 'Jiangsu',
+          children: [{
+            value: 'nanjing',
+            label: 'Nanjing',
+            children: [{
+              value: 'zhonghuamen',
+              label: 'Zhong Hua Men',
+            }],
+          }],
+        }],
+        selectedOptions: [],
+        selectedOptions2: ['jiangsu', 'nanjing', 'zhonghuamen'],
+        selectedOptions3: [],
+        selectedOptions4: [],
+        selectedOptions5: []
+      };
+    },
+    methods: {
+    }
+  };
+</script>
+
+## 级联选择
+
+需要从一组相关联的数据集合进行选择,例如省市区,公司层级,事物分类等。
+
+从一个较大的数据集合中进行选择时,用多级分类进行分隔,方便选择。
+
+### 基本使用
+
+:::demo
+```html
+<el-cascader
+  :options="options"
+  v-model="selectedOptions"
+></el-cascader>
+```
+:::
+
+### 默认值
+
+:::demo
+```html
+<el-cascader
+  :options="options"
+  v-model="selectedOptions2"
+></el-cascader>
+```
+:::
+
+### 移入展开
+
+:::demo
+```html
+<el-cascader
+  :options="options"
+  v-model="selectedOptions3"
+  expand-trigger="hover"
+></el-cascader>
+```
+:::
+
+### 选择即改变
+
+:::demo
+```html
+<el-cascader
+  :options="options"
+  v-model="selectedOptions4"
+  change-on-select
+></el-cascader>
+```
+:::
+
+### 可搜索
+
+:::demo
+```html
+<el-cascader
+  :options="options"
+  v-model="selectedOptions5"
+  show-search
+></el-cascader>
+```
+:::

+ 8 - 0
examples/nav.config.json

@@ -215,6 +215,10 @@
             {
               "path": "/collapse",
               "title": "Collapse 折叠面板"
+            },
+            {
+              "path": "/cascader",
+              "title": "Cascader 级联选择"
             }
           ]
         }
@@ -437,6 +441,10 @@
             {
               "path": "/collapse",
               "title": "Collapse"
+            },
+            {
+              "path": "/cascader",
+              "title": "Cascader"
             }
           ]
         }

+ 18 - 0
packages/cascader/cooking.conf.js

@@ -0,0 +1,18 @@
+var cooking = require('cooking');
+var path = require('path');
+var config = require('../../build/config');
+
+cooking.set({
+  entry: {
+    index: path.join(__dirname, 'index.js')
+  },
+  dist: path.join(__dirname, 'lib'),
+  template: false,
+  format: 'umd',
+  moduleName: 'ElCascader',
+  extends: ['vue2'],
+  alias: config.alias,
+  externals: { vue: config.vue }
+});
+
+module.exports = cooking.resolve();

+ 8 - 0
packages/cascader/index.js

@@ -0,0 +1,8 @@
+import Cascader from './src/main';
+
+/* istanbul ignore next */
+Cascader.install = function(Vue) {
+  Vue.component(Cascader.name, Cascader);
+};
+
+export default Cascader;

+ 15 - 0
packages/cascader/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "element-cascader",
+  "version": "0.0.0",
+  "description": "A cascader component for Vue.js.",
+  "keywords": [
+    "element",
+    "vue",
+    "component"
+  ],
+  "main": "./lib/index.js",
+  "repository": "https://github.com/ElemeFE/element/tree/master/packages/cascader",
+  "author": "elemefe",
+  "license": "MIT",
+  "dependencies": {}
+}

+ 207 - 0
packages/cascader/src/main.vue

@@ -0,0 +1,207 @@
+<template>
+  <span
+    class="el-cascader"
+    :class="{
+      'is-opened': menuVisible
+    }"
+    @click="menuVisible = !menuVisible"
+    @mouseenter="inputHover = true"
+    @mouseleave="inputHover = false"
+    ref="reference"
+    v-clickoutside="handleClickoutside"
+  >
+    <el-input
+      :readonly="!showSearch"
+      :placeholder="placeholder"
+      v-model="inputValue"
+      @change="handleInputChange"
+    >
+      <template slot="icon">
+        <i
+          key="1"
+          v-if="inputHover && displayValue !== ''"
+          class="el-input__icon el-icon-circle-close"
+          @click="clearValue"
+        ></i>
+        <i
+          key="2"
+          v-else
+          class="el-input__icon el-icon-caret-bottom"
+          :class="{ 'is-reverse': menuVisible }"
+        ></i>
+      </template>
+    </el-input>
+    <span class="el-cascader__label" v-show="inputValue === ''">{{displayValue}}</span>
+  </span>
+</template>
+
+<script>
+import Vue from 'vue';
+import ElCascaderMenu from './menu';
+import ElInput from 'element-ui/packages/input';
+import Popper from 'element-ui/src/utils/vue-popper';
+import Clickoutside from 'element-ui/src/utils/clickoutside';
+
+const popperMixin = {
+  props: {
+    placement: {
+      type: String,
+      default: 'bottom-start'
+    },
+    appendToBody: Popper.props.appendToBody,
+    offset: Popper.props.offset,
+    boundariesPadding: Popper.props.boundariesPadding,
+    popperOptions: Popper.props.popperOptions
+  },
+  methods: Popper.methods,
+  data: Popper.data,
+  beforeDestroy: Popper.beforeDestroy
+};
+
+export default {
+  name: 'ElCascader',
+
+  directives: { Clickoutside },
+
+  mixins: [popperMixin],
+
+  components: {
+    ElInput
+  },
+
+  props: {
+    options: {
+      type: Array,
+      required: true
+    },
+    value: {
+      type: Array,
+      default() {
+        return [];
+      }
+    },
+    placeholder: String,
+    disabled: Boolean,
+    clearable: {
+      type: Boolean,
+      default: true
+    },
+    changeOnSelect: Boolean,
+    popperClass: String,
+    expandTrigger: {
+      type: String,
+      default: 'click'
+    },
+    showSearch: Boolean
+  },
+
+  data() {
+    return {
+      currentValue: this.value,
+      displayValue: this.value.join('/'),
+      menuVisible: false,
+      inputHover: false,
+      inputValue: '',
+      flatOptions: this.showSearch && this.flattenOptions(this.options)
+    };
+  },
+
+  watch: {
+    menuVisible(value) {
+      value ? this.showMenu() : this.hideMenu();
+    },
+    value(value) {
+      this.currentValue = value;
+    },
+    currentValue(value) {
+      this.displayValue = value.join('/');
+    }
+  },
+
+  methods: {
+    showMenu() {
+      if (!this.menu) {
+        this.menu = new Vue(ElCascaderMenu).$mount(document.createElement('div'));
+        this.menu.options = this.options;
+        this.menu.expandTrigger = this.expandTrigger;
+        this.menu.changeOnSelect = this.changeOnSelect;
+        this.popperElm = this.menu.$el;
+      }
+
+      this.menu.value = this.currentValue.slice(0);
+      this.menu.visible = true;
+      this.menu.$on('change', this.handlePick);
+      this.updatePopper();
+    },
+    hideMenu() {
+      this.menu.visible = false;
+      this.inputValue = '';
+    },
+    handlePick(value, close = true) {
+      this.currentValue = value;
+      this.$emit('input', value);
+      if (close) {
+        this.menuVisible = false;
+      }
+    },
+    handleInputChange(value) {
+      const flatOptions = this.flatOptions;
+
+      if (!value) {
+        this.menu.options = this.options;
+        return;
+      }
+
+      let filteredFlatOptions = flatOptions.filter(optionsStack => {
+        return optionsStack.some(option => option.label.indexOf(value) > -1);
+      });
+
+      if (filteredFlatOptions.length > 0) {
+        this.menu.options = filteredFlatOptions.map(optionStack => {
+          return {
+            __IS__FLAT__OPTIONS: true,
+            value: optionStack.map(item => item.value),
+            label: this.renderRenderFilteredOption(value, optionStack)
+          };
+        });
+      } else {
+        return [{ label: 'notFoundContent', value: 'ANT_CASCADER_NOT_FOUND', disabled: true }];
+      }
+    },
+    renderRenderFilteredOption(inputValue, optionsStack) {
+      return optionsStack.map(({ label }, index) => {
+        const node = label.indexOf(inputValue) > -1 ? this.highlightKeyword(label, inputValue) : label;
+        return index === 0 ? node : [' / ', node];
+      });
+    },
+    highlightKeyword(label, keyword) {
+      const h = this._c;
+      return label.split(keyword)
+        .map((node, index) => index === 0 ? node : [
+          h('span', { class: { 'el-cascader-menu__item__keyword': true }}, [this._v(keyword)]),
+          node
+        ]);
+    },
+    flattenOptions(options, ancestor = []) {
+      let flatOptions = [];
+      options.forEach((option) => {
+        const optionsStack = ancestor.concat(option);
+        if (!option.children) {
+          flatOptions.push(optionsStack);
+        }
+        if (option.children) {
+          flatOptions = flatOptions.concat(this.flattenOptions(option.children, optionsStack));
+        }
+      });
+      return flatOptions;
+    },
+    clearValue(ev) {
+      ev.stopPropagation();
+      this.handlePick([], true);
+    },
+    handleClickoutside() {
+      this.menuVisible = false;
+    }
+  }
+};
+</script>

+ 133 - 0
packages/cascader/src/menu.vue

@@ -0,0 +1,133 @@
+<script>
+  export default {
+    name: 'ElCascaderMenu',
+
+    data() {
+      return {
+        options: [],
+        visible: false,
+        activeValue: [],
+        value: [],
+        expandTrigger: 'click',
+        changeOnSelect: false
+      };
+    },
+
+    watch: {
+      visible(value) {
+        if (value) {
+          this.activeValue = this.value;
+        }
+      },
+      value: {
+        immediate: true,
+        handler(value) {
+          this.activeValue = value;
+        }
+      }
+    },
+
+    computed: {
+      activeOptions: {
+        cache: false,
+        get() {
+          const activeValue = this.activeValue;
+          let options = this.options;
+
+          const loadActiveOptions = (options, activeOptions = []) => {
+            const level = activeOptions.length;
+            activeOptions[level] = options;
+            let active = activeValue[level];
+            if (active) {
+              options = options.filter(option => option.value === active)[0];
+              if (options && options.children) {
+                loadActiveOptions(options.children, activeOptions);
+              }
+            }
+            return activeOptions;
+          };
+
+          const result = loadActiveOptions(options);
+
+          return result;
+        }
+      }
+    },
+
+    methods: {
+      selectItem(item, menuIndex) {
+        const len = this.activeOptions.length;
+        const closeMenu = !item.children;
+
+        if (item.__IS__FLAT__OPTIONS) {
+          this.activeValue.splice(menuIndex, len, ...item.value);
+        } else {
+          this.activeValue.splice(menuIndex, len, item.value);
+        }
+
+        if (this.changeOnSelect) {
+          this.$emit('change', this.activeValue, closeMenu);
+        }
+      },
+      expandItem(item, menuIndex) {
+        const len = this.activeOptions.length;
+        if (item.children) {
+          this.activeValue.splice(menuIndex, len, item.value);
+          this.activeOptions.splice(menuIndex + 1, len, item.children);
+        }
+      },
+      handleItemClick(item, menuIndex) {
+        this.expandItem(item, menuIndex);
+        this.selectItem(item, menuIndex);
+
+        if (!item.children && !this.changeOnSelect) {
+          this.$emit('change', this.activeValue);
+        }
+      }
+    },
+
+    render(h) {
+      const {
+        activeValue,
+        activeOptions,
+        visible,
+        expandTrigger
+      } = this;
+
+      const menus = this._l(activeOptions, (menu, index) => {
+        const items = this._l(menu, item => {
+          const events = {
+            on: {}
+          };
+
+          if (expandTrigger === 'click' || !item.children) {
+            events.on['click'] = () => { this.handleItemClick(item, index); };
+          } else {
+            events.on['mouseenter'] = () => { this.expandItem(item, index); };
+          }
+
+          return (
+            <li
+              class={{
+                'el-cascader-menu__item': true,
+                'el-cascader-menu__item--extensible': item.children,
+                'is-active': item.value === activeValue[index]
+              }}
+              {...events}
+            >
+              {item.label}
+            </li>
+          );
+        });
+        return <ul class="el-cascader-menu">{items}</ul>;
+      });
+      return (
+        <transition name="el-zoom-in-top">
+          <div class="el-cascader-menus" v-show={visible}>
+            {menus}
+          </div>
+        </transition>
+      );
+    }
+  };
+</script>

+ 118 - 24
packages/theme-default/src/cascader.css

@@ -3,42 +3,136 @@
 @import "./common/var.css";
 /*@import "./core/dropdown.css";*/
 
-@component-namespace element {
+@component-namespace el {
 
   @b cascader {
     display: inline-block;
     position: relative;
+    background-color: #fff;
 
-    @e dropdown {
-      background-color: var(--cascader-menu-fill);
-      border: var(--cascader-menu-border);
-      border-radius: var(--cascader-menu-radius);
-      box-shadow: var(--cascader-menu-submenu-shadow);
-      margin-top: 5px;
-      max-height: var(--cascader-height);
+    .el-input,
+    .el-input__inner {
+      cursor: pointer;
+      background-color: transparent;
+      z-index: 1;
+    }
+
+    .el-input__icon {
+      transition: none;
+    }
+
+    .el-icon-caret-bottom {
+      transition: transform .3s;
+
+      @when reverse {
+        transform: rotateZ(180deg);
+      }
+    }
+
+    @e label {
       position: absolute;
+      left: 0;
+      top: 0;
+      height: var(--input-height);
+      line-height: @height;
+      padding: 0 15px 0 10px;
+      color: var(--input-color);
+      width: 100%;
       white-space: nowrap;
-      z-index: 10;
+      text-overflow: ellipsis;
+      overflow: hidden;
+      box-sizing: border-box;
+      cursor: pointer;
     }
+  }
 
-    @e wrap {
-      overflow: hidden;
+  @b cascader-menus {
+    white-space: nowrap;
+    background: #fff;
+    position: absolute;
+    margin: 5px 0;
+    z-index: 1001;
+    border: var(--select-dropdown-border);
+    border-radius: var(--border-radius-small);
+    overflow: hidden;
+    box-shadow: var(--select-dropdown-shadow);
+  }
+
+  @b cascader-menu {
+    display: inline-block;
+    vertical-align: top;
+    height: 180px;
+    overflow: auto;
+    border-right: var(--select-dropdown-border);
+    background-color: var(--select-dropdown-background);
+    box-sizing: border-box;
+    margin: 0;
+    padding: 0;
+    min-width: 110px;
+
+    &:last-child {
+      border-right: 0;
     }
 
-    @e menu {
-      border: 0;
-      box-shadow: none;
-      display: inline-block;
-      margin: 0;
+    @e item {
+      font-size: var(--select-font-size);
+      padding: 8px 30px 8px 10px;
       position: relative;
-      vertical-align: top;
-
-      &::before {
-        border-left: var(--cascader-menu-border);
-        content: " ";
-        height: var(--cascader-height);
-        left: 0;
-        position: absolute;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      color: var(--select-option-color);
+      height: var(--select-option-height);
+      line-height: 1.5;
+      box-sizing: border-box;
+      cursor: pointer;
+
+      @e keyword {
+        color: var(--color-danger);
+      }
+      
+      @m extensible {
+        &:after {
+          font-family: 'element-icons';
+          content: "\e602";
+          font-size: 12px;
+          transform: scale(0.8);
+          color: rgb(191, 203, 217);
+          position: absolute;
+          right: 10px;
+          margin-top: 1px;
+        }
+      }
+
+      @when disabled {
+        color: var(--select-option-disabled-color);
+        cursor: not-allowed;
+
+        &:hover {
+          background-color: var(--color-white);
+        }
+      }
+
+      @when active {
+        color: var(--color-white);
+        background-color: var(--select-option-selected);
+
+        &.hover {
+          background-color: var(--select-option-selected-hover);
+        }
+      }
+
+      &:hover {
+        background-color: var(--select-option-hover-background);
+      }
+
+      &.selected {
+        color: var(--color-white);
+        background-color: var(--select-option-selected);
+
+        &.hover {
+          background-color: var(--select-option-selected-hover);
+        }
       }
     }
   }

+ 1 - 0
packages/theme-default/src/index.css

@@ -44,3 +44,4 @@
 @import "./carousel.css";
 @import "./carousel-item.css";
 @import "./collapse.css";
+@import "./cascader.css";

+ 5 - 2
src/index.js

@@ -60,6 +60,7 @@ import Scrollbar from '../packages/scrollbar';
 import CarouselItem from '../packages/carousel-item';
 import Collapse from '../packages/collapse';
 import CollapseItem from '../packages/collapse-item';
+import Cascader from '../packages/cascader';
 import locale from 'element-ui/src/locale';
 
 const components = [
@@ -118,7 +119,8 @@ const components = [
   Scrollbar,
   CarouselItem,
   Collapse,
-  CollapseItem
+  CollapseItem,
+  Cascader
 ];
 
 const install = function(Vue, opts = {}) {
@@ -211,5 +213,6 @@ module.exports = {
   Scrollbar,
   CarouselItem,
   Collapse,
-  CollapseItem
+  CollapseItem,
+  Cascader
 };

+ 1 - 0
src/utils/vue-popper.js

@@ -83,6 +83,7 @@ export default {
           this.$slots.reference[0]) {
         reference = this.referenceElm = this.$slots.reference[0].elm;
       }
+
       if (!popper || !reference) return;
       if (this.visibleArrow) this.appendArrow(popper);
       if (this.appendToBody) document.body.appendChild(this.popperElm);

+ 15 - 0
test/unit/specs/cascader.spec.js

@@ -0,0 +1,15 @@
+import { createTest, destroyVM } from '../util';
+import Cascader from 'packages/cascader';
+
+describe('Cascader', () => {
+  let vm;
+  afterEach(() => {
+    destroyVM(vm);
+  });
+
+  it('create', () => {
+    vm = createTest(Cascader, true);
+    expect(vm.$el).to.exist;
+  });
+});
+