diff --git a/packages/element-plus/index.ts b/packages/element-plus/index.ts index aa023d44e6..ecab3c436b 100644 --- a/packages/element-plus/index.ts +++ b/packages/element-plus/index.ts @@ -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) } diff --git a/packages/element-plus/package.json b/packages/element-plus/package.json index 8f37ef1d66..216a24d1e9 100644 --- a/packages/element-plus/package.json +++ b/packages/element-plus/package.json @@ -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" } } diff --git a/packages/tabs/__tests__/tabs.spec.ts b/packages/tabs/__tests__/tabs.spec.ts new file mode 100644 index 0000000000..88ec044b58 --- /dev/null +++ b/packages/tabs/__tests__/tabs.spec.ts @@ -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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + Test Content + + `, + 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: ` + + + {{item.content}} + + + `, + 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: ` + + + {{item.content}} + + + `, + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + 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: ` + + A + B + C + D + + `, + }) + + 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: ` + + A + B + C + D + + `, + 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: ` + + A + B + C + D + + `, + 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') + }) +}) diff --git a/packages/tabs/doc/basic.vue b/packages/tabs/doc/basic.vue new file mode 100644 index 0000000000..e005a95425 --- /dev/null +++ b/packages/tabs/doc/basic.vue @@ -0,0 +1,265 @@ + + + diff --git a/packages/tabs/doc/index.stories.ts b/packages/tabs/doc/index.stories.ts new file mode 100644 index 0000000000..d9615f8236 --- /dev/null +++ b/packages/tabs/doc/index.stories.ts @@ -0,0 +1,6 @@ +export { default as BasicUsage } from './basic.vue' + +export default { + title: 'Tabs', +} + diff --git a/packages/tabs/index.ts b/packages/tabs/index.ts new file mode 100644 index 0000000000..994759489f --- /dev/null +++ b/packages/tabs/index.ts @@ -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) +} diff --git a/packages/tabs/package.json b/packages/tabs/package.json new file mode 100644 index 0000000000..6b67ac93a0 --- /dev/null +++ b/packages/tabs/package.json @@ -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" + } +} diff --git a/packages/tabs/src/tab-bar.vue b/packages/tabs/src/tab-bar.vue new file mode 100644 index 0000000000..0c4856a518 --- /dev/null +++ b/packages/tabs/src/tab-bar.vue @@ -0,0 +1,78 @@ + + diff --git a/packages/tabs/src/tab-nav.vue b/packages/tabs/src/tab-nav.vue new file mode 100644 index 0000000000..d584d168f0 --- /dev/null +++ b/packages/tabs/src/tab-nav.vue @@ -0,0 +1,380 @@ + + diff --git a/packages/tabs/src/tab-pane.vue b/packages/tabs/src/tab-pane.vue new file mode 100644 index 0000000000..2c18157723 --- /dev/null +++ b/packages/tabs/src/tab-pane.vue @@ -0,0 +1,74 @@ + + diff --git a/packages/tabs/src/tabs.vue b/packages/tabs/src/tabs.vue new file mode 100644 index 0000000000..ad097014c3 --- /dev/null +++ b/packages/tabs/src/tabs.vue @@ -0,0 +1,232 @@ + +