feat(tabs): add tabs

This commit is contained in:
bastarder 2020-08-14 09:49:31 +08:00 committed by jeremywu
parent ed5d989bb5
commit 18aa2638f6
11 changed files with 1655 additions and 1 deletions

View File

@ -24,6 +24,8 @@ import ElScrollBar from '@element-plus/scrollbar'
import ElSteps from '@element-plus/steps'
import ElCollapse from '@element-plus/collapse'
import ElPopper from '@element-plus/popper'
import ElTabs from '@element-plus/tabs'
export {
ElAlert,
@ -50,6 +52,7 @@ export {
ElSteps,
ElRadio,
ElCollapse,
ElTabs,
}
export default function install(app: App): void {
@ -78,4 +81,5 @@ export default function install(app: App): void {
ElRadio(app)
ElCollapse(app)
ElPopper(app)
ElTabs(app)
}

View File

@ -34,6 +34,7 @@
"@element-plus/scrollbar": "^0.0.0",
"@element-plus/steps": "^0.0.0",
"@element-plus/notification": "^0.0.0",
"@element-plus/collapse": "^0.0.0"
"@element-plus/collapse": "^0.0.0",
"@element-plus/tabs": "^0.0.0"
}
}

View File

@ -0,0 +1,591 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Tabs from '../src/tabs.vue'
import TabPane from '../src/tab-pane.vue'
import TabNav from '../src/tab-nav.vue'
describe('Tabs.vue', () => {
test('create', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs>
<el-tab-pane label="label-1">A</el-tab-pane>
<el-tab-pane label="label-2">B</el-tab-pane>
<el-tab-pane label="label-3" ref="pane-click">C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const tabsWrapper = wrapper.findComponent(Tabs)
const navWrapper = wrapper.findComponent(TabNav)
const panesWrapper = wrapper.findAllComponents(TabPane)
await nextTick()
const navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper[0].classes('is-active')).toBe(true)
expect(panesWrapper[0].classes('el-tab-pane')).toBe(true)
expect(panesWrapper[0].attributes('id')).toBe('pane-0')
expect(panesWrapper[0].attributes('aria-hidden')).toEqual('false')
expect(tabsWrapper.vm.currentName).toEqual('0')
await navItemsWrapper[2].trigger('click')
expect(navItemsWrapper[0].classes('is-active')).toBe(false)
expect(panesWrapper[0].attributes('aria-hidden')).toEqual('true')
expect(navItemsWrapper[2].classes('is-active')).toBe(true)
expect(panesWrapper[2].attributes('aria-hidden')).toEqual('false')
expect(tabsWrapper.vm.currentName).toEqual('2')
})
test('active-name', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
data() {
return {
activeName: 'b',
}
},
methods: {
handleClick(tab) {
this.activeName = tab.setupState.paneName
},
},
template: `
<el-tabs :active-name="activeName" @tab-click="handleClick">
<el-tab-pane name="a" label="label-1">A</el-tab-pane>
<el-tab-pane name="b" label="label-2">B</el-tab-pane>
<el-tab-pane name="c" label="label-3" ref="pane-click">C</el-tab-pane>
<el-tab-pane name="d" label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const tabsWrapper = wrapper.findComponent(Tabs)
const navWrapper = wrapper.findComponent(TabNav)
const panesWrapper = wrapper.findAllComponents(TabPane)
await nextTick()
const navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper[1].classes('is-active')).toBe(true)
expect(panesWrapper[1].classes('el-tab-pane')).toBe(true)
expect(panesWrapper[1].attributes('id')).toBe('pane-b')
expect(panesWrapper[1].attributes('aria-hidden')).toEqual('false')
expect(tabsWrapper.vm.currentName).toEqual('b')
await navItemsWrapper[2].trigger('click')
expect(navItemsWrapper[1].classes('is-active')).toBe(false)
expect(panesWrapper[1].attributes('aria-hidden')).toEqual('true')
expect(navItemsWrapper[2].classes('is-active')).toBe(true)
expect(panesWrapper[2].attributes('aria-hidden')).toEqual('false')
expect(tabsWrapper.vm.currentName).toEqual('c')
})
test('card', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs type="card">
<el-tab-pane label="label-1">A</el-tab-pane>
<el-tab-pane label="label-2">B</el-tab-pane>
<el-tab-pane label="label-3" ref="pane-click">C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const tabsWrapper = wrapper.findComponent(Tabs)
expect(tabsWrapper.classes('el-tabs--card')).toBe(true)
})
test('border card', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs type="border-card">
<el-tab-pane label="label-1">A</el-tab-pane>
<el-tab-pane label="label-2">B</el-tab-pane>
<el-tab-pane label="label-3" ref="pane-click">C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const tabsWrapper = wrapper.findComponent(Tabs)
expect(tabsWrapper.classes('el-tabs--border-card')).toBe(true)
})
test('dynamic', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs type="card" ref="tabs">
<el-tab-pane :label="tab.label" :name="tab.name" v-for="tab in tabs" :key="tab.name">Test Content</el-tab-pane>
</el-tabs>
`,
data() {
return {
tabs: [{
label: 'tab1',
name: 'tab1',
}, {
label: 'tab2',
name: 'tab2',
}, {
label: 'tab3',
name: 'tab3',
}, {
label: 'tab4',
name: 'tab4',
}],
}
},
})
let navWrapper = wrapper.findComponent(TabNav)
let panesWrapper = wrapper.findAllComponents(TabPane)
await nextTick()
let navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper.length).toEqual(4)
expect(panesWrapper.length).toEqual(4)
wrapper.vm.tabs.push({ label: 'tab5', name: 'tab5' })
await nextTick()
navWrapper = wrapper.findComponent(TabNav)
panesWrapper = wrapper.findAllComponents(TabPane)
navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper.length).toEqual(5)
expect(panesWrapper.length).toEqual(5)
})
test('editable', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs ref="tabs" v-model="editableTabsValue" type="card" editable @edit="handleTabsEdit">
<el-tab-pane
v-for="(item, index) in editableTabs"
:key="item.name"
:label="item.title"
:name="item.name"
>
{{item.content}}
</el-tab-pane>
</el-tabs>
`,
data() {
return {
editableTabsValue: '2',
editableTabs: [{
title: 'Tab 1',
name: '1',
content: 'Tab 1 content',
}, {
title: 'Tab 2',
name: '2',
content: 'Tab 2 content',
}, {
title: 'Tab 3',
name: '3',
content: 'Tab 3 content',
}],
tabIndex: 3,
}
},
methods: {
handleTabsEdit(targetName, action) {
if (action === 'add') {
const newTabName = ++this.tabIndex + ''
this.editableTabs.push({
title: 'New Tab',
name: newTabName,
content: 'New Tab content',
})
this.editableTabsValue = newTabName
}
if (action === 'remove') {
const tabs = this.editableTabs
let activeName = this.editableTabsValue
if (activeName === targetName) {
tabs.forEach((tab, index) => {
if (tab.name === targetName) {
const nextTab = tabs[index + 1] || tabs[index - 1]
if (nextTab) {
activeName = nextTab.name
}
}
})
}
this.editableTabsValue = activeName
this.editableTabs = tabs.filter(tab => tab.name !== targetName)
}
},
},
})
const navWrapper = wrapper.findComponent(TabNav)
let panesWrapper = wrapper.findAllComponents(TabPane)
await nextTick()
let navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper.length).toEqual(3)
expect(panesWrapper.length).toEqual(3)
expect(navItemsWrapper[1].classes('is-active')).toBe(true)
// remove one tab, check panes length
await navItemsWrapper[1].find('.el-icon-close').trigger('click')
panesWrapper = wrapper.findAllComponents(TabPane)
navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper.length).toEqual(2)
expect(panesWrapper.length).toEqual(2)
// add one tab, check panes length and current tab
await navWrapper.find('.el-tabs__new-tab').trigger('click')
panesWrapper = wrapper.findAllComponents(TabPane)
navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper.length).toEqual(3)
expect(panesWrapper.length).toEqual(3)
expect(navItemsWrapper[2].classes('is-active')).toBe(true)
})
test('addable & closable', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs
ref="tabs"
v-model="editableTabsValue"
type="card"
addable
closable
@tab-add="addTab"
@tab-remove="removeTab"
>
<el-tab-pane
v-for="(item, index) in editableTabs"
:label="item.title"
:key="item.name"
:name="item.name"
>
{{item.content}}
</el-tab-pane>
</el-tabs>
`,
data() {
return {
editableTabsValue: '2',
editableTabs: [{
title: 'Tab 1',
name: '1',
content: 'Tab 1 content',
}, {
title: 'Tab 2',
name: '2',
content: 'Tab 2 content',
}],
tabIndex: 2,
}
},
methods: {
addTab() {
const newTabName = ++this.tabIndex + ''
this.editableTabs.push({
title: 'New Tab',
name: newTabName,
content: 'New Tab content',
})
this.editableTabsValue = newTabName
},
removeTab(targetName) {
const tabs = this.editableTabs
let activeName = this.editableTabsValue
if (activeName === targetName) {
tabs.forEach((tab, index) => {
if (tab.name === targetName) {
const nextTab = tabs[index + 1] || tabs[index - 1]
if (nextTab) {
activeName = nextTab.name
}
}
})
}
this.editableTabsValue = activeName
this.editableTabs = tabs.filter(tab => tab.name !== targetName)
},
},
})
const navWrapper = wrapper.findComponent(TabNav)
await nextTick()
await navWrapper.find('.el-tabs__new-tab').trigger('click')
let navItemsWrapper = navWrapper.findAll('.el-tabs__item')
let panesWrapper = wrapper.findAllComponents(TabPane)
expect(navItemsWrapper.length).toEqual(3)
expect(panesWrapper.length).toEqual(3)
expect(navItemsWrapper[2].classes('is-active')).toBe(true)
await navItemsWrapper[2].find('.el-icon-close').trigger('click')
panesWrapper = wrapper.findAllComponents(TabPane)
navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper.length).toEqual(2)
expect(panesWrapper.length).toEqual(2)
})
test('closable in tab-pane', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs type="card" ref="tabs">
<el-tab-pane label="label-1" closable>A</el-tab-pane>
<el-tab-pane label="label-2">B</el-tab-pane>
<el-tab-pane label="label-3" closable>C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const navWrapper = wrapper.findComponent(TabNav)
await nextTick()
expect(navWrapper.findAll('.el-icon-close').length).toBe(2)
})
test('disabled', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs type="card" ref="tabs">
<el-tab-pane label="label-1">A</el-tab-pane>
<el-tab-pane disabled label="label-2" ref="disabled">B</el-tab-pane>
<el-tab-pane label="label-3">C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const navWrapper = wrapper.findComponent(TabNav)
await nextTick()
const navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper[1].classes('is-active')).toBe(false)
await navItemsWrapper[1].trigger('click')
expect(navItemsWrapper[1].classes('is-active')).toBe(false)
})
test('tab-position', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs ref="tabs" tab-position="left">
<el-tab-pane label="label-1">A</el-tab-pane>
<el-tab-pane label="label-2">B</el-tab-pane>
<el-tab-pane label="label-3" ref="pane-click">C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
})
const tabsWrapper = wrapper.findComponent(Tabs)
await nextTick()
expect(tabsWrapper.classes('el-tabs--left')).toBe(true)
expect(tabsWrapper.find('.el-tabs__header').classes('is-left')).toBe(true)
expect(tabsWrapper.find('.el-tabs__nav-wrap').classes('is-left')).toBe(true)
expect(tabsWrapper.find('.el-tabs__nav').classes('is-left')).toBe(true)
expect(tabsWrapper.find('.el-tabs__active-bar').classes('is-left')).toBe(true)
expect(tabsWrapper.find('.el-tabs__item').classes('is-left')).toBe(true)
})
test('stretch', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs ref="tabs" stretch :tab-position="tabPosition">
<el-tab-pane label="label-1">A</el-tab-pane>
<el-tab-pane label="label-2">B</el-tab-pane>
<el-tab-pane label="label-3">C</el-tab-pane>
<el-tab-pane label="label-4">D</el-tab-pane>
</el-tabs>
`,
data() {
return {
tabPosition: 'bottom',
}
},
})
const tabsWrapper = wrapper.findComponent(Tabs)
await nextTick()
expect(tabsWrapper.find('.el-tabs__nav').classes('is-stretch')).toBe(true)
wrapper.vm.tabPosition = 'left'
await nextTick()
expect(tabsWrapper.find('.el-tabs__nav').classes('is-stretch')).toBe(false)
})
test('horizonal-scrollable', async () => {
// TODO: jsdom not support `clientWidth`.
})
test('vertical-scrollable', async () => {
// TODO: jsdom not support `clientWidth`.
})
test('should work with lazy', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs ref="tabs">
<el-tab-pane label="label-1" name="A">A</el-tab-pane>
<el-tab-pane label="label-2" name="B">B</el-tab-pane>
<el-tab-pane label="label-3" name="C">C</el-tab-pane>
<el-tab-pane label="label-4" lazy name="D">D</el-tab-pane>
</el-tabs>
`,
})
const navWrapper = wrapper.findComponent(TabNav)
await nextTick()
const navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(wrapper.findAll('.el-tab-pane').length).toBe(3)
await navItemsWrapper[3].trigger('click')
expect(wrapper.findAll('.el-tab-pane').length).toBe(4)
})
test('before leave', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs ref="tabs" v-model="activeName" :before-leave="beforeLeave">
<el-tab-pane name="tab-A" label="label-1">A</el-tab-pane>
<el-tab-pane name="tab-B" label="label-2">B</el-tab-pane>
<el-tab-pane name="tab-C" label="label-3">C</el-tab-pane>
<el-tab-pane name="tab-D" label="label-4">D</el-tab-pane>
</el-tabs>
`,
data() {
return {
activeName: 'tab-B',
}
},
methods: {
beforeLeave() {
return new window.Promise((resolve, reject) => {
reject()
})
},
},
})
const navWrapper = wrapper.findComponent(TabNav)
const panesWrapper = wrapper.findAllComponents(TabPane)
await nextTick()
const navItemsWrapper = navWrapper.findAll('.el-tabs__item')
expect(navItemsWrapper[1].classes('is-active')).toBe(true)
expect(panesWrapper[1].attributes('style')).toBeFalsy()
await navItemsWrapper[3].trigger('click')
expect(navItemsWrapper[1].classes('is-active')).toBe(true)
expect(panesWrapper[1].attributes('style')).toBeFalsy()
})
test('keyboard event', async () => {
const wrapper = mount({
components: {
'el-tabs': Tabs,
'el-tab-pane': TabPane,
},
template: `
<el-tabs v-model="activeName">
<el-tab-pane label="label-1" name="first">A</el-tab-pane>
<el-tab-pane label="label-2" name="second">B</el-tab-pane>
<el-tab-pane label="label-3" name="third">C</el-tab-pane>
<el-tab-pane label="label-4" name="fourth">D</el-tab-pane>
</el-tabs>
`,
data() {
return {
activeName: 'second',
}
},
})
const vm = wrapper.vm
await nextTick()
await wrapper.find('#tab-second').trigger('keydown', { keyCode: 39 })
expect(vm.activeName).toEqual('third')
await wrapper.find('#tab-third').trigger('keydown', { keyCode: 39 })
expect(vm.activeName).toEqual('fourth')
await wrapper.find('#tab-fourth').trigger('keydown', { keyCode: 39 })
expect(vm.activeName).toEqual('first')
await wrapper.find('#tab-first').trigger('keydown', { keyCode: 37 })
expect(vm.activeName).toEqual('fourth')
await wrapper.find('#tab-fourth').trigger('keydown', { keyCode: 37 })
expect(vm.activeName).toEqual('third')
})
})

265
packages/tabs/doc/basic.vue Normal file
View File

@ -0,0 +1,265 @@
<template>
<el-button @click="show = !show">
display/hidden tab item
</el-button>
<el-button
@click="activeName = activeName === 'first' ? 'second' : 'first'"
>
Change
</el-button>
<div class="flag">
<el-tabs v-model="activeName" @tab-click="handleClick">
<el-tab-pane>
<template #label>label-1slot-title</template>
label-1slot-title
</el-tab-pane>
<template v-if="true">
<el-tab-pane
label="label-1"
name="first"
>
label-1
</el-tab-pane>
</template>
<el-tab-pane
v-if="show"
label="label-2"
name="fourth"
>
label-2
</el-tab-pane>
<el-tab-pane label="label-3" name="second">label-3</el-tab-pane>
<el-tab-pane label="label-4" name="third">label-4</el-tab-pane>
<el-tab-pane v-for="i in 3" :key="i" :label="`a-${i}`">
{{
`name-${i}`
}}
</el-tab-pane>
</el-tabs>
</div>
<div class="flag">
<el-tabs v-model="activeName" type="card" @tab-click="handleClick">
<el-tab-pane
closable
label="label-1"
name="first"
>
label-1
</el-tab-pane>
<el-tab-pane
disabled
label="label-2"
name="fourth"
>
label-2
</el-tab-pane>
<el-tab-pane label="label-3" name="second">label-3</el-tab-pane>
<el-tab-pane label="label-4" name="third">label-4</el-tab-pane>
</el-tabs>
</div>
<div class="flag">
<el-tabs
v-model="activeName"
type="border-card"
@tab-click="handleClick"
>
<el-tab-pane
lazy
label="label-1"
name="first"
>
label-1
</el-tab-pane>
<el-tab-pane
lazy
label="label-2"
name="fourth"
>
label-2
</el-tab-pane>
<el-tab-pane
lazy
label="label-3"
name="second"
>
label-3
</el-tab-pane>
<el-tab-pane
lazy
label="label-4"
name="third"
>
label-4
</el-tab-pane>
</el-tabs>
</div>
<div class="flag">
<el-button @click="tabPosition = 'top'">top</el-button>
<el-button @click="tabPosition = 'right'">right</el-button>
<el-button @click="tabPosition = 'bottom'">bottom</el-button>
<el-button @click="tabPosition = 'left'">left</el-button>
<el-tabs :tab-position="tabPosition" style="height: 200px;">
<el-tab-pane label="label-1">label-1</el-tab-pane>
<el-tab-pane label="label-3">label-3</el-tab-pane>
<el-tab-pane label="label-4">label-4</el-tab-pane>
<el-tab-pane label="label-2">label-2</el-tab-pane>
</el-tabs>
</div>
<div class="flag">
<el-tabs type="border-card">
<el-tab-pane>
<template #label>
<i class="el-icon-date"></i> label-1
</template>
label-1
</el-tab-pane>
<el-tab-pane label="label-6">label-6</el-tab-pane>
<el-tab-pane label="label-4">label-4</el-tab-pane>
<el-tab-pane label="label-2">label-2</el-tab-pane>
</el-tabs>
</div>
<div class="flag">
<el-tabs
v-model="editableTabsValue"
type="card"
editable
style="width: 450px;"
@edit="handleTabsEdit"
>
<el-tab-pane
v-for="item in editableTabs"
:key="item.name"
:label="item.title"
:name="item.name"
>
{{ item.content }}
</el-tab-pane>
</el-tabs>
</div>
<div class="flag">
<div style="margin-bottom: 20px;">
<el-button size="small" @click="addTab(editableTabsValue)">
add tab
</el-button>
</div>
<el-tabs
v-model="editableTabsValue"
type="card"
stretch
addable
closable
@tab-remove="removeTab"
@tab-add="addTab(editableTabsValue)"
>
<el-tab-pane
v-for="item in editableTabs"
:key="item.name"
:label="item.title"
:name="item.name"
>
{{ item.content }}
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang='ts'>
export default {
data() {
return {
show: false,
activeName: 'second',
tabPosition: 'left',
editableTabsValue: '2',
editableTabs: [
{
title: 'Tab 1',
name: '1',
content: 'Tab 1 content',
},
{
title: 'Tab 2',
name: '2',
content: 'Tab 2 content',
},
],
tabIndex: 2,
}
},
methods: {
addTab() {
let newTabName = ++this.tabIndex + ''
this.editableTabs.push({
title: 'New Tab',
name: newTabName,
content: 'New Tab content',
})
this.editableTabsValue = newTabName
},
removeTab(targetName) {
let tabs = this.editableTabs
let activeName = this.editableTabsValue
if (activeName === targetName) {
tabs.forEach((tab, index) => {
if (tab.name === targetName) {
let nextTab = tabs[index + 1] || tabs[index - 1]
if (nextTab) {
activeName = nextTab.name
}
}
})
}
this.editableTabsValue = activeName
this.editableTabs = tabs.filter((tab) => tab.name !== targetName)
},
handleTabsEdit(targetName, action) {
if (action === 'add') {
let newTabName = ++this.tabIndex + ''
this.editableTabs.push({
title: 'New Tab',
name: newTabName,
content: 'New Tab content',
})
this.editableTabsValue = newTabName
}
if (action === 'remove') {
let tabs = this.editableTabs
let activeName = this.editableTabsValue
if (activeName === targetName) {
tabs.forEach((tab, index) => {
if (tab.name === targetName) {
let nextTab = tabs[index + 1] || tabs[index - 1]
if (nextTab) {
activeName = nextTab.name
}
}
})
}
this.editableTabsValue = activeName
this.editableTabs = tabs.filter(
(tab) => tab.name !== targetName,
)
}
},
handleClick(tab, event) {
console.log(tab, event)
},
},
}
</script>
<style scoped>
.flag {
border: 2px solid #eee;
margin: 15px 0;
padding: 10px;
min-height: 250px;
}
</style>

View File

@ -0,0 +1,6 @@
export { default as BasicUsage } from './basic.vue'
export default {
title: 'Tabs',
}

11
packages/tabs/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { App } from 'vue'
import Tabs from './src/tabs.vue'
import TabBar from './src/tab-bar.vue'
import TabNav from './src/tab-nav.vue'
import TabPane from './src/tab-pane.vue'
export default (app: App): void => {
app.component(Tabs.name, Tabs)
app.component(TabBar.name, TabBar)
app.component(TabNav.name, TabNav)
app.component(TabPane.name, TabPane)
}

View File

@ -0,0 +1,12 @@
{
"name": "@element-plus/tabs",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0-rc.1"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.0"
}
}

View File

@ -0,0 +1,78 @@
<template>
<div
:class="['el-tabs__active-bar', `is-${ rootTabs.props.tabPosition }`]"
:style="barStyle"
></div>
</template>
<script lang='ts'>
import { defineComponent, inject, getCurrentInstance, watch, nextTick, ref, ComponentInternalInstance } from 'vue'
import { capitalize } from '@vue/shared'
export default defineComponent({
name: 'ElTabBar',
props: {
tabs: {
type: Array as PropType<ComponentInternalInstance[]>,
default: () => ([] as ComponentInternalInstance[]),
},
},
setup(props) {
const rootTabs = inject('rootTabs')
if (!rootTabs) {
throw new Error(`ElTabBar must use with ElTabs`)
}
const instance = getCurrentInstance()
const getBarStyle = () => {
let style: CSSStyleDeclaration = {} as CSSStyleDeclaration
let offset = 0
let tabSize = 0
const sizeName = ['top', 'bottom'].includes(rootTabs.props.tabPosition) ? 'width' : 'height'
const sizeDir = sizeName === 'width' ? 'x' : 'y'
props.tabs.every((tab) => {
let $el = instance.parent.refs?.[`tab-${tab.setupState.paneName}`]
if (!$el) { return false }
if (!tab.setupState.active) {
offset += $el[`client${capitalize(sizeName)}`]
return true
} else {
tabSize = $el[`client${capitalize(sizeName)}`]
const tabStyles = window.getComputedStyle($el)
if (sizeName === 'width') {
if (props.tabs.length > 1) {
tabSize -= parseFloat(tabStyles.paddingLeft) + parseFloat(tabStyles.paddingRight)
}
offset += parseFloat(tabStyles.paddingLeft)
}
return false
}
})
const transform = `translate${capitalize(sizeDir)}(${offset}px)`
style[sizeName] = `${tabSize}px`
style.transform = transform
style.msTransform = transform
style.webkitTransform = transform
return style
}
const barStyle = ref(getBarStyle())
watch(() => props.tabs, () => {
nextTick(() => {
barStyle.value = getBarStyle()
})
})
return {
rootTabs,
barStyle,
}
},
})
</script>

View File

@ -0,0 +1,380 @@
<script lang='ts'>
import { h, defineComponent, ref, inject, computed, onUpdated, onMounted, onBeforeUnmount } from 'vue'
import { addResizeListener, removeResizeListener } from '@element-plus/utils/resize-event'
import TabBar from './tab-bar.vue'
import { NOOP, capitalize } from '@vue/shared'
type RefElement = Nullable<HTMLElement>
export default defineComponent({
name: 'ElTabNav',
components: {
TabBar,
},
props: {
panes: {
type: Array as PropType<ComponentInternalInstance[]>,
default: () => ([] as ComponentInternalInstance[]),
},
currentName: {
type: String,
default: '',
},
editable: Boolean,
onTabClick: {
type: Function,
default: NOOP,
},
onTabRemove: {
type: Function,
default: NOOP,
},
type: {
type: String,
default: '',
},
stretch: Boolean,
},
setup() {
const rootTabs = inject('rootTabs')
if (!rootTabs) {
throw new Error(`ElTabNav must use with ElTabs`)
}
const scrollable = ref(false)
const navOffset = ref(0)
const isFocus = ref(false)
const focusable = ref(true)
const navScroll$ = ref<RefElement>(null)
const nav$ = ref<RefElement>(null)
const el$ = ref<RefElement>(null)
const sizeName = computed(() => {
return ['top', 'bottom'].includes(rootTabs.props.tabPosition) ? 'width' : 'height'
})
const navStyle = computed(() => {
const dir = sizeName.value === 'width' ? 'X' : 'Y'
return {
transform: `translate${dir}(-${navOffset.value}px)`,
}
})
const scrollPrev = () => {
const containerSize = navScroll$.value[`offset${capitalize(sizeName.value)}`]
const currentOffset = navOffset.value
if (!currentOffset) return
let newOffset = currentOffset > containerSize
? currentOffset - containerSize
: 0
navOffset.value = newOffset
}
const scrollNext = () => {
const navSize = nav$.value[`offset${capitalize(sizeName.value)}`]
const containerSize = navScroll$.value[`offset${capitalize(sizeName.value)}`]
const currentOffset = navOffset.value
if (navSize - currentOffset <= containerSize) return
let newOffset = navSize - currentOffset > containerSize * 2
? currentOffset + containerSize
: (navSize - containerSize)
navOffset.value = newOffset
}
const scrollToActiveTab = () => {
if (!scrollable.value) return
const nav = nav$.value
const activeTab = el$.value.querySelector('.is-active')
if (!activeTab) return
const navScroll = navScroll$.value
const isHorizontal = ['top', 'bottom'].includes(rootTabs.props.tabPosition)
const activeTabBounding = activeTab.getBoundingClientRect()
const navScrollBounding = navScroll.getBoundingClientRect()
const maxOffset = isHorizontal
? nav.offsetWidth - navScrollBounding.width
: nav.offsetHeight - navScrollBounding.height
const currentOffset = navOffset.value
let newOffset = currentOffset
if (isHorizontal) {
if (activeTabBounding.left < navScrollBounding.left) {
newOffset = currentOffset - (navScrollBounding.left - activeTabBounding.left)
}
if (activeTabBounding.right > navScrollBounding.right) {
newOffset = currentOffset + activeTabBounding.right - navScrollBounding.right
}
} else {
if (activeTabBounding.top < navScrollBounding.top) {
newOffset = currentOffset - (navScrollBounding.top - activeTabBounding.top)
}
if (activeTabBounding.bottom > navScrollBounding.bottom) {
newOffset = currentOffset + (activeTabBounding.bottom - navScrollBounding.bottom)
}
}
newOffset = Math.max(newOffset, 0)
navOffset.value = Math.min(newOffset, maxOffset)
}
const update = () => {
if (!nav$.value) return
const navSize = nav$.value[`offset${capitalize(sizeName.value)}`]
const containerSize = navScroll$.value[`offset${capitalize(sizeName.value)}`]
const currentOffset = navOffset.value
if (containerSize < navSize) {
const currentOffset = navOffset.value
scrollable.value = scrollable.value || {}
scrollable.value.prev = currentOffset
scrollable.value.next = currentOffset + containerSize < navSize
if (navSize - currentOffset < containerSize) {
navOffset.value = navSize - containerSize
}
} else {
scrollable.value = false
if (currentOffset > 0) {
navOffset.value = 0
}
}
}
const changeTab = (e) => {
const keyCode = e.keyCode
let nextIndex
let currentIndex, tabList
if ([37, 38, 39, 40].indexOf(keyCode) !== -1) { // tab
tabList = e.currentTarget.querySelectorAll('[role=tab]')
currentIndex = Array.prototype.indexOf.call(tabList, e.target)
} else {
return
}
if (keyCode === 37 || keyCode === 38) { // left
if (currentIndex === 0) { // first
nextIndex = tabList.length - 1
} else {
nextIndex = currentIndex - 1
}
} else { // right
if (currentIndex < tabList.length - 1) { // not last
nextIndex = currentIndex + 1
} else {
nextIndex = 0
}
}
tabList[nextIndex].focus() //
tabList[nextIndex].click() // tab
setFocus()
}
const setFocus = () => {
if (focusable.value) {
isFocus.value = true
}
}
const removeFocus = () => {
isFocus.value = false
}
const visibilityChangeHandler = () => {
const visibility = document.visibilityState
if (visibility === 'hidden') {
focusable.value = false
} else if (visibility === 'visible') {
setTimeout(() => {
focusable.value = true
}, 50)
}
}
const windowBlurHandler = () => {
focusable.value = false
}
const windowFocusHandler = () => {
setTimeout(() => {
focusable.value = true
}, 50)
}
onUpdated(() => {
update()
})
onMounted(() => {
addResizeListener(el$.value, update)
document.addEventListener('visibilitychange', visibilityChangeHandler)
window.addEventListener('blur', windowBlurHandler)
window.addEventListener('focus', windowFocusHandler)
setTimeout(() => {
scrollToActiveTab()
}, 0)
})
onBeforeUnmount(() => {
if(el$.value) {
removeResizeListener(el$.value, update)
}
document.removeEventListener('visibilitychange', visibilityChangeHandler)
window.removeEventListener('blur', windowBlurHandler)
window.removeEventListener('focus', windowFocusHandler)
})
return {
rootTabs,
scrollable,
navOffset,
isFocus,
focusable,
navScroll$,
nav$,
el$,
sizeName,
navStyle,
scrollPrev,
scrollNext,
scrollToActiveTab,
update,
changeTab,
setFocus,
removeFocus,
visibilityChangeHandler,
windowBlurHandler,
windowFocusHandler,
}
},
render() {
const {
type,
panes,
editable,
stretch,
onTabClick,
onTabRemove,
navStyle,
scrollable,
scrollNext,
scrollPrev,
changeTab,
setFocus,
removeFocus,
rootTabs,
isFocus,
} = this
const scrollBtn = scrollable ? [
h(
'span',
{
class: ['el-tabs__nav-prev', scrollable.prev ? '' : 'is-disabled'],
onClick: scrollPrev,
},
[h('i', { class: 'el-icon-arrow-left' })],
),
h(
'span',
{
class: ['el-tabs__nav-next', scrollable.next ? '' : 'is-disabled'],
onClick: scrollNext,
},
[h('i', { class: 'el-icon-arrow-right' })],
),
] : null
const tabs = panes.map((pane, index) => {
let tabName = pane.props.name || pane.setupState.index || `${index}`
const closable = pane.setupState.isClosable || editable
pane.setupState.index = `${index}`
const btnClose = closable ?
h(
'span',
{
class: 'el-icon-close',
onClick: (ev) => { onTabRemove(pane, ev) },
},
) : null
const tabLabelContent = pane.slots.label?.() || pane.props.label
const tabindex = pane.setupState.active ? 0 : -1
return h(
'div',
{
class: {
'el-tabs__item': true,
[`is-${ rootTabs.props.tabPosition }`]: true,
'is-active': pane.setupState.active,
'is-disabled': pane.props.disabled,
'is-closable': closable,
'is-focus': isFocus,
},
id: `tab-${tabName}`,
key: `tab-${tabName}`,
'aria-controls': `pane-${tabName}`,
role: 'tab',
'aria-selected': pane.setupState.active ,
ref: `tab-${tabName}`,
tabindex: tabindex,
onFocus: () => { setFocus() },
onBlur: () => { removeFocus() },
onClick: (ev) => { removeFocus(); onTabClick(pane, tabName, ev) },
onKeydown: (ev) => { if (closable && (ev.keyCode === 46 || ev.keyCode === 8)) { onTabRemove(pane, ev)} },
},
[tabLabelContent, btnClose],
)
})
return h(
'div',
{
ref: 'el$',
class: ['el-tabs__nav-wrap', scrollable ? 'is-scrollable' : '', `is-${ rootTabs.props.tabPosition }`],
},
[
scrollBtn,
h(
'div',
{
class: 'el-tabs__nav-scroll',
ref: 'navScroll$',
},
[
h(
'div',
{
class: ['el-tabs__nav', `is-${ rootTabs.props.tabPosition }`, stretch && ['top', 'bottom'].includes(rootTabs.props.tabPosition) ? 'is-stretch' : ''],
ref: 'nav$',
style: navStyle,
role: 'tablist',
onKeydown: changeTab,
},
[
!type ? h(
TabBar,
{
tabs: panes,
},
) : null,
tabs,
],
),
],
),
],
)
},
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,74 @@
<template>
<div
v-if="shouldBeRender"
v-show="active"
:id="`pane-${paneName}`"
class="el-tab-pane"
role="tabpanel"
:aria-hidden="!active"
:aria-labelledby="`tab-${paneName}`"
>
<slot></slot>
</div>
</template>
<script lang='ts'>
import { defineComponent, ref, computed, inject } from 'vue'
export default defineComponent({
name: 'ElTabPane',
props: {
label: {
type: String,
default: '',
},
labelContent: {
type: Function,
default: null,
},
name: {
type: String,
default: '',
},
closable: Boolean,
disabled: Boolean,
lazy: Boolean,
},
setup(props) {
const index = ref(null)
const loaded = ref(false)
const rootTabs = inject('rootTabs')
if (!rootTabs) {
throw new Error(`ElTabPane must use with ElTabs`)
}
const isClosable = computed(() => {
return props.closable || rootTabs.props.closable
})
const active = computed(() => {
const active = rootTabs.setupState.currentName === (props.name || index.value)
if (active) {
loaded.value = true
}
return active
})
const paneName = computed(() => {
return props.name || index.value
})
const shouldBeRender = computed(() => {
return (!props.lazy || loaded.value) || active.value
})
return {
index,
loaded,
isClosable,
active,
paneName,
shouldBeRender,
}
},
})
</script>

232
packages/tabs/src/tabs.vue Normal file
View File

@ -0,0 +1,232 @@
<script lang='ts'>
import { h, defineComponent, ref, onMounted, onUpdated, provide, watch, nextTick, getCurrentInstance, ComponentInternalInstance } from 'vue'
import { Fragment } from '@vue/runtime-core'
import TabNav from './tab-nav.vue'
type RefElement = Nullable<HTMLElement>
export default defineComponent({
name: 'ElTabs',
components: { TabNav },
props: {
type: {
type: String,
default: '',
},
activeName: {
type: String,
default: '',
},
closable: Boolean,
addable: Boolean,
modelValue: {
type: String,
default: '',
},
editable: Boolean,
tabPosition: {
type: String,
default: 'top',
},
beforeLeave: {
type: Function,
default: null,
},
stretch: Boolean,
},
emits: ['tab-click', 'edit', 'tab-remove', 'tab-add', 'input', 'update:modelValue'],
setup(props, ctx) {
const nav$ = ref<RefElement>(null)
const currentName = ref(props.modelValue || props.activeName || '0')
const panes = ref<ComponentInternalInstance[]>([])
const instance = getCurrentInstance()
watch(() => props.activeName, (modelValue) => {
setCurrentName(modelValue)
})
watch(() => props.modelValue, (modelValue) => {
setCurrentName(modelValue)
})
watch(currentName, () => {
if (nav$.value) {
nextTick(() => {
nav$.value.$nextTick(() => {
nav$.value.scrollToActiveTab()
})
})
}
setPaneInstances(true)
})
const getPaneInstanceFromSlot = (vnode, paneInstanceList = []) => {
[...(vnode.children || [])].forEach((node) => {
let type = node.type
type = type.name || type
if (type === 'ElTabPane' && node.component) {
paneInstanceList.push(node.component)
} else if(type === Fragment || type === 'template') {
getPaneInstanceFromSlot(node, paneInstanceList)
}
})
return paneInstanceList
}
const setPaneInstances = (isForceUpdate = false) => {
if(ctx.slots.default) {
const children = instance.subTree.children
const content = [...children].find(({ props }) => {
return props.class === 'el-tabs__content'
})
if(!content) return
const paneInstanceList = getPaneInstanceFromSlot(content)
const panesChanged = !(paneInstanceList.length === panes.value.length && paneInstanceList.every((pane, index) => pane.uid === panes.value[index].uid))
if (isForceUpdate || panesChanged) {
panes.value = paneInstanceList
}
} else if (panes.value.length !== 0) {
panes.value = []
}
}
const changeCurrentName = (value) => {
currentName.value = value
ctx.emit('input', value)
ctx.emit('update:modelValue', value)
}
const setCurrentName = (value) => {
if(currentName.value !== value && props.beforeLeave) {
const before = props.beforeLeave(value, currentName.value)
if(before && before.then) {
before.then(() => {
changeCurrentName(value)
nav$.value && nav$.value.removeFocus()
}, () => {
// ignore promise rejection in `before-leave` hook
})
} else if(before !== false) {
changeCurrentName(value)
}
} else {
changeCurrentName(value)
}
}
const handleTabClick = (tab, tabName, event) => {
if(tab.props.disabled) return
setCurrentName(tabName)
ctx.emit('tab-click', tab, event)
}
const handleTabRemove = (pane, ev) => {
if(pane.props.disabled) return
ev.stopPropagation()
ctx.emit('edit', pane.props.name, 'remove')
ctx.emit('tab-remove', pane.props.name)
}
const handleTabAdd = () => {
ctx.emit('edit', null, 'add')
ctx.emit('tab-add')
}
provide('rootTabs', getCurrentInstance())
onUpdated(() => {
setPaneInstances()
})
onMounted(() => {
setPaneInstances()
})
return {
nav$,
handleTabClick,
handleTabRemove,
handleTabAdd,
currentName,
panes,
}
},
render(){
let {
type,
handleTabClick,
handleTabRemove,
handleTabAdd,
currentName,
panes,
editable,
addable,
tabPosition,
stretch,
} = this
const newButton = editable || addable ? h(
'span',
{
class: 'el-tabs__new-tab',
tabindex: '0',
onClick: handleTabAdd,
onKeydown: (ev) => { if (ev.keyCode === 13) { handleTabAdd() }},
},
[h('i', { class: 'el-icon-plus' })],
) : null
const header = h(
'div',
{
class: ['el-tabs__header', `is-${tabPosition}`],
},
[
newButton,
h(
TabNav,
{
currentName,
editable,
type,
panes,
stretch,
ref: 'nav$',
onTabClick: handleTabClick,
onTabRemove: handleTabRemove,
},
),
],
)
const panels = h(
'div',
{
class: 'el-tabs__content',
},
this.$slots?.default(),
)
return h(
'div',
{
class: {
'el-tabs': true,
'el-tabs--card': type === 'card',
[`el-tabs--${tabPosition}`]: true,
'el-tabs--border-card': type === 'border-card',
},
},
tabPosition !== 'bottom' ? [header, panels] : [panels, header],
)
},
})
</script>
<style scoped>
</style>