From 5c2589677a20cd74c91cac04a2a5a21716de2fad Mon Sep 17 00:00:00 2001 From: baiyaaaaa Date: Sun, 30 Jul 2017 16:12:06 +0800 Subject: [PATCH] Tab: add vertical tab (#6096) * add tabs position * add tabs test * Update tabs.md --- examples/docs/en-US/tabs.md | 38 ++++++- examples/docs/zh-CN/tabs.md | 38 ++++++- packages/tabs/src/tab-bar.vue | 18 ++-- packages/tabs/src/tab-nav.vue | 80 +++++++------- packages/tabs/src/tabs.vue | 35 ++++-- packages/theme-default/src/tabs.css | 160 ++++++++++++++++++++++++++-- test/unit/specs/tabs.spec.js | 107 +++++++++++++++++++ 7 files changed, 416 insertions(+), 60 deletions(-) diff --git a/examples/docs/en-US/tabs.md b/examples/docs/en-US/tabs.md index e80c0e05e..fbcb40180 100644 --- a/examples/docs/en-US/tabs.md +++ b/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 + + +``` +::: + ### 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 | diff --git a/examples/docs/zh-CN/tabs.md b/examples/docs/zh-CN/tabs.md index 24110a176..80d00dda1 100644 --- a/examples/docs/zh-CN/tabs.md +++ b/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 + + +``` +::: + ### 自定义标签页 可以通过具名 `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 | 事件名称 | 说明 | 回调参数 | diff --git a/packages/tabs/src/tab-bar.vue b/packages/tabs/src/tab-bar.vue index 792d54d50..3e1eb4d16 100644 --- a/packages/tabs/src/tab-bar.vue +++ b/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; diff --git a/packages/tabs/src/tab-nav.vue b/packages/tabs/src/tab-nav.vue index 4af467b53..048ab2b98 100644 --- a/packages/tabs/src/tab-nav.vue +++ b/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 (!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 (containerWidth < navWidth) { - const currentOffset = this.getCurrentScrollOffset(); + 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; } } } diff --git a/packages/tabs/src/tabs.vue b/packages/tabs/src/tabs.vue index f1bba2a80..faa257242 100644 --- a/packages/tabs/src/tabs.vue +++ b/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 = ( +
+ {newButton} + +
+ ); + const panels = ( +
+ {this.$slots.default} +
+ ); return (
-
- {newButton} - -
-
- {this.$slots.default} -
+ { tabPosition !== 'bottom' ? [header, panels] : [panels, header] }
); }, diff --git a/packages/theme-default/src/tabs.css b/packages/theme-default/src/tabs.css index 07b73aede..8787cb972 100644 --- a/packages/theme-default/src/tabs.css +++ b/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); } } } diff --git a/test/unit/specs/tabs.spec.js b/test/unit/specs/tabs.spec.js index 81fd3f0a2..900fd35c0 100644 --- a/test/unit/specs/tabs.spec.js +++ b/test/unit/specs/tabs.spec.js @@ -348,4 +348,111 @@ describe('Tabs', () => { }); }); }); + it('tab-position', done => { + vm = createVue({ + template: ` + + A + B + C + D + + ` + }, 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: ` + + A + B + A + B + A + B + D + + ` + }, 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: ` + + A + B + A + B + A + B + D + + ` + }, 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); + }); });