Explorar o código

Tab: add vertical tab (#6096)

* add tabs position

* add tabs test

* Update tabs.md
baiyaaaaa %!s(int64=8) %!d(string=hai) anos
pai
achega
5c2589677a

+ 37 - 1
examples/docs/en-US/tabs.md

@@ -24,7 +24,8 @@
           name: '2',
           content: 'Tab 2 content'
         }],
-        tabIndex: 2
+        tabIndex: 2,
+        tabPosition: 'top'
       }
     },
     methods: {
@@ -174,6 +175,40 @@ Border card tabs.
 
 :::
 
+### Tab position
+
+You can use `tab-position` attribute to set the tab's position.
+
+:::demo You can choose from four directions: `tabPosition="left|right|top|bottom"`
+
+```html
+<template>
+  <el-radio-group v-model="tabPosition" style="margin-bottom: 30px;">
+    <el-radio-button label="top">top</el-radio-button>
+    <el-radio-button label="right">right</el-radio-button>
+    <el-radio-button label="bottom">bottom</el-radio-button>
+    <el-radio-button label="left">left</el-radio-button>
+  </el-radio-group>
+
+  <el-tabs :tab-position="tabPosition" style="height: 200px;">
+    <el-tab-pane label="User">User</el-tab-pane>
+    <el-tab-pane label="Config">Config</el-tab-pane>
+    <el-tab-pane label="Role">Role</el-tab-pane>
+    <el-tab-pane label="Task">Task</el-tab-pane>
+  </el-tabs>
+</template>
+<script>
+  export default {
+    data() {
+      return {
+        tabPosition: 'top'
+      };
+    }
+  };
+</script>
+```
+:::
+
 ### Custom Tab
 
 You can use named slot to customize the tab label content.
@@ -341,6 +376,7 @@ Only card type Tabs support addable & closeable.
 | editable  | whether Tab is addable and closable | boolean   | — |  false  |
 | active-name(deprecated)  | name of the selected tab  | string   |  —  |  name of first tab |
 | value  | name of the selected tab  | string   |  —  |  name of first tab |
+| tab-position  | position of tabs | string   |  top/right/bottom/left  |  top |
 
 ### Tabs Events
 | Event Name | Description | Parameters |

+ 37 - 1
examples/docs/zh-CN/tabs.md

@@ -24,7 +24,8 @@
           name: '2',
           content: 'Tab 2 content'
         }],
-        tabIndex: 2
+        tabIndex: 2,
+        tabPosition: 'top'
       }
     },
     methods: {
@@ -172,6 +173,40 @@
 ```
 :::
 
+### 位置
+
+可以通过 `tab-position` 设置标签的位置
+
+:::demo 标签一共有四个方向的设置 `tabPosition="left|right|top|bottom"`
+
+```html
+<template>
+  <el-radio-group v-model="tabPosition" style="margin-bottom: 30px;">
+    <el-radio-button label="top">top</el-radio-button>
+    <el-radio-button label="right">right</el-radio-button>
+    <el-radio-button label="bottom">bottom</el-radio-button>
+    <el-radio-button label="left">left</el-radio-button>
+  </el-radio-group>
+
+  <el-tabs :tab-position="tabPosition" style="height: 200px;">
+    <el-tab-pane label="用户管理">用户管理</el-tab-pane>
+    <el-tab-pane label="配置管理">配置管理</el-tab-pane>
+    <el-tab-pane label="角色管理">角色管理</el-tab-pane>
+    <el-tab-pane label="定时任务补偿">定时任务补偿</el-tab-pane>
+  </el-tabs>
+</template>
+<script>
+  export default {
+    data() {
+      return {
+        tabPosition: 'top'
+      };
+    }
+  };
+</script>
+```
+:::
+
 ### 自定义标签页
 
 可以通过具名 `slot` 来实现自定义标签页的内容
@@ -339,6 +374,7 @@
 | editable  | 标签是否同时可增加和关闭   | boolean   | — |  false  |
 | active-name(deprecated)  | 选中选项卡的 name  | string   |  —  |  第一个选项卡的 name |
 | value  | 绑定值,选中选项卡的 name  | string   |  —  |  第一个选项卡的 name |
+| tab-position  | 选项卡所在位置 | string   |  top/right/bottom/left  |  top |
 
 ### Tabs Events
 | 事件名称 | 说明 | 回调参数 |

+ 12 - 6
packages/tabs/src/tab-bar.vue

@@ -9,6 +9,8 @@
       tabs: Array
     },
 
+    inject: ['rootTabs'],
+
     computed: {
       barStyle: {
         cache: false,
@@ -16,23 +18,27 @@
           if (!this.$parent.$refs.tabs) return {};
           let style = {};
           let offset = 0;
-          let tabWidth = 0;
-
+          let tabSize = 0;
+          const sizeName = ['top', 'bottom'].indexOf(this.rootTabs.tabPosition) !== -1 ? 'width' : 'height';
+          const sizeDir = sizeName === 'width' ? 'x' : 'y';
+          const firstUpperCase = str => {
+            return str.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase());
+          };
           this.tabs.every((tab, index) => {
             let $el = this.$parent.$refs.tabs[index];
             if (!$el) { return false; }
 
             if (!tab.active) {
-              offset += $el.clientWidth;
+              offset += $el[`client${firstUpperCase(sizeName)}`];
               return true;
             } else {
-              tabWidth = $el.clientWidth;
+              tabSize = $el[`client${firstUpperCase(sizeName)}`];
               return false;
             }
           });
 
-          const transform = `translateX(${offset}px)`;
-          style.width = tabWidth + 'px';
+          const transform = `translate${firstUpperCase(sizeDir)}(${offset}px)`;
+          style[sizeName] = tabSize + 'px';
           style.transform = transform;
           style.msTransform = transform;
           style.webkitTransform = transform;

+ 45 - 37
packages/tabs/src/tab-nav.vue

@@ -3,6 +3,9 @@
   import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
 
   function noop() {}
+  const firstUpperCase = str => {
+    return str.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase());
+  };
 
   export default {
     name: 'TabNav',
@@ -11,6 +14,8 @@
       TabBar
     },
 
+    inject: ['rootTabs'],
+
     props: {
       panes: Array,
       currentName: String,
@@ -29,37 +34,47 @@
     data() {
       return {
         scrollable: false,
-        navStyle: {
-          transform: ''
-        }
+        navOffset: 0
       };
     },
 
+    computed: {
+      navStyle() {
+        const dir = ['top', 'bottom'].indexOf(this.rootTabs.tabPosition) !== -1 ? 'X' : 'Y';
+        return {
+          transform: `translate${dir}(-${this.navOffset}px)`
+        };
+      },
+      sizeName() {
+        return ['top', 'bottom'].indexOf(this.rootTabs.tabPosition) !== -1 ? 'width' : 'height';
+      }
+    },
+
     methods: {
       scrollPrev() {
-        const containerWidth = this.$refs.navScroll.offsetWidth;
-        const currentOffset = this.getCurrentScrollOffset();
+        const containerSize = this.$refs.navScroll[`offset${firstUpperCase(this.sizeName)}`];
+        const currentOffset = this.navOffset;
 
         if (!currentOffset) return;
 
-        let newOffset = currentOffset > containerWidth
-          ? currentOffset - containerWidth
+        let newOffset = currentOffset > containerSize
+          ? currentOffset - containerSize
           : 0;
 
-        this.setOffset(newOffset);
+        this.navOffset = newOffset;
       },
       scrollNext() {
-        const navWidth = this.$refs.nav.offsetWidth;
-        const containerWidth = this.$refs.navScroll.offsetWidth;
-        const currentOffset = this.getCurrentScrollOffset();
+        const navSize = this.$refs.nav[`offset${firstUpperCase(this.sizeName)}`];
+        const containerSize = this.$refs.navScroll[`offset${firstUpperCase(this.sizeName)}`];
+        const currentOffset = this.navOffset;
 
-        if (navWidth - currentOffset <= containerWidth) return;
+        if (navSize - currentOffset <= containerSize) return;
 
-        let newOffset = navWidth - currentOffset > containerWidth * 2
-          ? currentOffset + containerWidth
-          : (navWidth - containerWidth);
+        let newOffset = navSize - currentOffset > containerSize * 2
+          ? currentOffset + containerSize
+          : (navSize - containerSize);
 
-        this.setOffset(newOffset);
+        this.navOffset = newOffset;
       },
       scrollToActiveTab() {
         if (!this.scrollable) return;
@@ -69,7 +84,7 @@
         const activeTabBounding = activeTab.getBoundingClientRect();
         const navScrollBounding = navScroll.getBoundingClientRect();
         const navBounding = nav.getBoundingClientRect();
-        const currentOffset = this.getCurrentScrollOffset();
+        const currentOffset = this.navOffset;
         let newOffset = currentOffset;
 
         if (activeTabBounding.left < navScrollBounding.left) {
@@ -81,34 +96,27 @@
         if (navBounding.right < navScrollBounding.right) {
           newOffset = nav.offsetWidth - navScrollBounding.width;
         }
-        this.setOffset(Math.max(newOffset, 0));
-      },
-      getCurrentScrollOffset() {
-        const { navStyle } = this;
-        return navStyle.transform
-          ? Number(navStyle.transform.match(/translateX\(-(\d+(\.\d+)*)px\)/)[1])
-          : 0;
-      },
-      setOffset(value) {
-        this.navStyle.transform = `translateX(-${value}px)`;
+        this.navOffset = Math.max(newOffset, 0);
       },
       update() {
-        const navWidth = this.$refs.nav.offsetWidth;
-        const containerWidth = this.$refs.navScroll.offsetWidth;
-        const currentOffset = this.getCurrentScrollOffset();
-
-        if (containerWidth < navWidth) {
-          const currentOffset = this.getCurrentScrollOffset();
+        if (!this.$refs.nav) return;
+        const sizeName = this.sizeName;
+        const navSize = this.$refs.nav[`offset${firstUpperCase(sizeName)}`];
+        const containerSize = this.$refs.navScroll[`offset${firstUpperCase(sizeName)}`];
+        const currentOffset = this.navOffset;
+
+        if (containerSize < navSize) {
+          const currentOffset = this.navOffset;
           this.scrollable = this.scrollable || {};
           this.scrollable.prev = currentOffset;
-          this.scrollable.next = currentOffset + containerWidth < navWidth;
-          if (navWidth - currentOffset < containerWidth) {
-            this.setOffset(navWidth - containerWidth);
+          this.scrollable.next = currentOffset + containerSize < navSize;
+          if (navSize - currentOffset < containerSize) {
+            this.navOffset = navSize - containerSize;
           }
         } else {
           this.scrollable = false;
           if (currentOffset > 0) {
-            this.setOffset(0);
+            this.navOffset = 0;
           }
         }
       }

+ 26 - 9
packages/tabs/src/tabs.vue

@@ -14,7 +14,17 @@
       closable: Boolean,
       addable: Boolean,
       value: {},
-      editable: Boolean
+      editable: Boolean,
+      tabPosition: {
+        type: String,
+        default: 'top'
+      }
+    },
+
+    provide() {
+      return {
+        rootTabs: this
+      };
     },
 
     data() {
@@ -83,7 +93,8 @@
         currentName,
         panes,
         editable,
-        addable
+        addable,
+        tabPosition
       } = this;
 
       const newButton = editable || addable
@@ -108,20 +119,26 @@
         },
         ref: 'nav'
       };
+      const header = (
+        <div class="el-tabs__header">
+          {newButton}
+          <tab-nav { ...navData }></tab-nav>
+        </div>
+      );
+      const panels = (
+        <div class="el-tabs__content">
+          {this.$slots.default}
+        </div>
+      );
 
       return (
         <div class={{
           'el-tabs': true,
           'el-tabs--card': type === 'card',
+          [`el-tabs--${tabPosition}`]: true,
           'el-tabs--border-card': type === 'border-card'
         }}>
-          <div class="el-tabs__header">
-            {newButton}
-            <tab-nav { ...navData }></tab-nav>
-          </div>
-          <div class="el-tabs__content">
-            {this.$slots.default}
-          </div>
+          { tabPosition !== 'bottom' ? [header, panels] : [panels, header] }
         </div>
       );
     },

+ 153 - 7
packages/theme-default/src/tabs.css

@@ -48,6 +48,7 @@
 
       @when scrollable {
         padding: 0 15px;
+        box-sizing: border-box;
       }
     }
     @e nav-scroll {
@@ -76,7 +77,7 @@
       padding: 0 16px;
       height: 42px;
       box-sizing: border-box;
-      line-height: @height;
+      line-height: 40px;
       display: inline-block;
       list-style: none;
       font-size: 14px;
@@ -176,19 +177,164 @@
       >.el-tabs__header .el-tabs__item {
         transition: all .3s cubic-bezier(.645,.045,.355,1);
         border: 1px solid transparent;
-        border-top: 0;
-        margin: * -1px;
+        margin: -1px -1px 0;
 
         &.is-active {
           background-color: var(--color-white);
           border-right-color: var(--color-base-gray);
           border-left-color: var(--color-base-gray);
+        }
+      }
+    }
+    @m bottom {
+      .el-tabs__header {
+        margin-bottom: 0;
+        margin-top: 10px;
+      }
+      &.el-tabs--border-card {
+        .el-tabs__header {
+          border-bottom: 0;
+          border-top: 1px solid var(--color-base-gray);
+        }
+        .el-tabs__nav-wrap {
+          margin-top: -1px;
+          margin-bottom: 0;
+        }
+        .el-tabs__item {
+          border: 1px solid transparent;
+          margin: 0 -1px -1px -1px;
+        }
+      }
+    }
+    @m left, right {
+      overflow: hidden;
 
-          &:first-child {
-            border-left-color: var(--color-base-gray);
+      .el-tabs__header,
+      .el-tabs__nav-wrap,
+      .el-tabs__nav-scroll {
+        height: 100%;
+      }
+
+      .el-tabs__active-bar {
+        top: 0;
+        bottom: auto;
+        width: 3px;
+        height: auto;
+      }
+
+      .el-tabs__nav-wrap {
+        margin-bottom: 0;
+
+        &.is-scrollable {
+          padding: 30px 0;
+        }
+      }
+
+      .el-tabs__nav {
+        float: none;
+      }
+      .el-tabs__item {
+        display: block;
+      }
+
+      .el-tabs__nav-prev,
+      .el-tabs__nav-next {
+        height: 30px;
+        line-height: 30px;
+        width: 100%;
+        text-align: center;
+        cursor: pointer;
+
+        i {
+          transform: rotateZ(90deg);
+        }
+      }
+      .el-tabs__nav-prev {
+        left: auto;
+        top: 0;
+      }
+      .el-tabs__nav-next {
+        right: auto;
+        bottom: 0;
+      }
+    }
+    @m left {
+      .el-tabs__header {
+        float: left;
+        border-bottom: none;
+        border-right: 1px solid var(--color-base-gray);
+        margin-bottom: 0;
+        margin-right: 10px;
+      }
+      .el-tabs__nav-wrap {
+        margin-right: -1px;
+      }
+      .el-tabs__active-bar {
+        right: 0;
+        left: auto;
+      }
+      .el-tabs__item {
+        text-align: right;
+      }
+
+      &.el-tabs--card {
+        .el-tabs__item.is-active {
+          border: 1px solid rgb(209, 219, 229);
+          border-right-color: #fff;
+          border-radius: 4px 0 0 4px;
+        }
+
+        .el-tabs__new-tab {
+          float: none;
+        }
+      }
+
+      &.el-tabs--border-card {
+        .el-tabs__item {
+          border: 1px solid transparent;
+          margin: -1px 0 -1px -1px;
+
+          &.is-active {
+            border-color: transparent;
+            border-top-color: rgb(209, 219, 229);
+            border-bottom-color: rgb(209, 219, 229);
           }
-          &:last-child {
-            border-right-color: var(--color-base-gray);
+        }
+      }
+    }
+    @m right {
+      .el-tabs__header {
+        float: right;
+        border-bottom: none;
+        border-left: 1px solid var(--color-base-gray);
+        margin-bottom: 0;
+        margin-left: 10px;
+      }
+
+      .el-tabs__nav-wrap {
+        margin-left: -1px;
+      }
+
+      .el-tabs__active-bar {
+        left: 0;
+      }
+
+      &.el-tabs--card {
+        .el-tabs__item.is-active {
+          border: 1px solid rgb(209, 219, 229);
+          border-left-color: #fff;
+          border-radius: 0 4px 4px 0;
+        }
+      }
+      &.el-tabs--border-card {
+        .el-tabs__item {
+          border: 1px solid transparent;
+          margin: -1px -1px -1px 0;
+
+          &.is-active {
+            border-color: transparent;
+            border-top-color: rgb(209, 219, 229);
+            border-bottom-color: rgb(209, 219, 229);
           }
         }
       }

+ 107 - 0
test/unit/specs/tabs.spec.js

@@ -348,4 +348,111 @@ describe('Tabs', () => {
       });
     });
   });
+  it('tab-position', done => {
+    vm = createVue({
+      template: `
+        <el-tabs ref="tabs" tab-position="left">
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="角色管理" ref="pane-click">C</el-tab-pane>
+          <el-tab-pane label="定时任务补偿">D</el-tab-pane>
+        </el-tabs>
+      `
+    }, true);
+
+    let paneList = vm.$el.querySelector('.el-tabs__content').children;
+    let spy = sinon.spy();
+
+    vm.$refs.tabs.$on('tab-click', spy);
+
+    setTimeout(_ => {
+      const tabList = vm.$refs.tabs.$refs.nav.$refs.tabs;
+      expect(tabList[0].classList.contains('is-active')).to.be.true;
+      expect(paneList[0].style.display).to.not.ok;
+
+      tabList[2].click();
+      vm.$nextTick(_ => {
+        expect(spy.withArgs(vm.$refs['pane-click']).calledOnce).to.true;
+        expect(tabList[2].classList.contains('is-active')).to.be.true;
+        expect(paneList[2].style.display).to.not.ok;
+        done();
+      });
+    }, 100);
+  });
+  it('horizonal-scrollable', done => {
+    vm = createVue({
+      template: `
+        <el-tabs ref="tabs" style="width: 200px;">
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="定时任务补偿">D</el-tab-pane>
+        </el-tabs>
+      `
+    }, true);
+
+    setTimeout(_ => {
+      const btnPrev = vm.$el.querySelector('.el-tabs__nav-prev');
+      btnPrev.click();
+      vm.$nextTick(_ => {
+        const tabNav = vm.$el.querySelector('.el-tabs__nav-wrap');
+        expect(tabNav.__vue__.navOffset).to.be.equal(0);
+
+        const btnNext = vm.$el.querySelector('.el-tabs__nav-next');
+        btnNext.click();
+
+        vm.$nextTick(_ => {
+          expect(tabNav.__vue__.navOffset).to.not.be.equal(0);
+
+          btnPrev.click();
+
+          vm.$nextTick(_ => {
+            expect(tabNav.__vue__.navOffset).to.be.equal(0);
+            done();
+          });
+        });
+      });
+    }, 100);
+  });
+  it('vertical-scrollable', done => {
+    vm = createVue({
+      template: `
+        <el-tabs ref="tabs" tab-position="left" style="height: 200px;">
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="用户管理">A</el-tab-pane>
+          <el-tab-pane label="配置管理">B</el-tab-pane>
+          <el-tab-pane label="定时任务补偿">D</el-tab-pane>
+        </el-tabs>
+      `
+    }, true);
+
+    setTimeout(_ => {
+      const btnPrev = vm.$el.querySelector('.el-tabs__nav-prev');
+      btnPrev.click();
+      vm.$nextTick(_ => {
+        const tabNav = vm.$el.querySelector('.el-tabs__nav-wrap');
+        expect(tabNav.__vue__.navOffset).to.be.equal(0);
+
+        const btnNext = vm.$el.querySelector('.el-tabs__nav-next');
+        btnNext.click();
+
+        vm.$nextTick(_ => {
+          expect(tabNav.__vue__.navOffset).to.not.be.equal(0);
+
+          btnPrev.click();
+
+          vm.$nextTick(_ => {
+            expect(tabNav.__vue__.navOffset).to.be.equal(0);
+            done();
+          });
+        });
+      });
+    }, 100);
+  });
 });