浏览代码

Feat tabs closable and on tab close (#291)

* feat: move tab closable api to pannel && optimize code

* docs: add closable && onTabClose api to tab doc

* test: fix tab test

* docs: change close and onTabClose api version

* feat: [Tabs] TabPane support closable

* fix(typescript): DatePicker and Tooltip

Co-authored-by: yanqi.xu <[email protected]>
Co-authored-by: 走鹃 <[email protected]>
代强 3 年之前
父节点
当前提交
ad9aecaac6

+ 29 - 17
content/navigation/tabs/index-en-US.md

@@ -297,7 +297,7 @@ class App extends React.Component {
 **v>= 1.1.0**  
 You could use `collapsible` for a scrollable tabs with dropdown menu. Horizontal mode only.
 
-```jsx live=true
+```jsx live=true dir=column
 import React from 'react';
 import { Tabs, TabPane } from '@douyinfe/semi-ui';
 
@@ -491,24 +491,34 @@ Only card style tabs support the close option. Use `closable={true}` to turn it
 
 ```jsx live=true 
 import React from 'react';
-import {Tabs, TabPane} from '@douyinfe/semi-ui';
+import { Tabs, TabPane } from '@douyinfe/semi-ui';
 
 class App extends React.Component {
-     render() {
-         return (
-             <Tabs closable type="card" defaultActiveKey="1">
-                 <TabPane tab="document" itemKey="1">
-                     Documentation
-                 </TabPane>
-                 <TabPane tab="Quick Start" itemKey="2">
-                     Quick start
-                 </TabPane>
-                 <TabPane tab="Help" itemKey="3">
-                     help
-                 </TabPane>
-             </Tabs>
-         );
-     }
+    constructor(props){
+        super(props);
+        this.state = {
+            tabList: [
+                {tab: 'Doc', itemKey:'1', text:'Doc', closable:true},
+                {tab: 'Quick Start', itemKey:'2', text:'Quick Start', closable:true},
+                {tab: 'Help', itemKey:'3', text:'Help'},
+            ]
+        }
+    }
+    close(key){
+        const newTabList = [...this.state.tabList];
+        const closeIndex = newTabList.findIndex(t=>t.itemKey===key);
+        newTabList.splice(closeIndex, 1);
+        this.setState({tabList:newTabList});
+    }
+    render() {
+        return (
+            <Tabs type="card" defaultActiveKey="1" onTabClose={this.close.bind(this)}>
+                {
+                    this.state.tabList.map(t=><TabPane closable={t.closable} tab={t.tab} itemKey={t.itemKey} key={t.itemKey}>{t.text}</TabPane>)
+                }
+            </Tabs>
+        );
+    }
 }
 ```
 
@@ -535,6 +545,7 @@ tabPosition | The position of the tab, support `top` (horizontal), `left` (verti
 type | The style of the label bar, optional `line`, `card`, `button` | string | `line` |
 onChange | Callback function when switching tab pages | function(activeKey: string) | None |
 onTabClick | Click event | function(key: string, e: Event) | None |
+onTabClose | executed when tab closed by user, **>=2.1.0**  |  function(tabKey: string) | None
 
 ### TabPane
 
@@ -546,6 +557,7 @@ icon | Tab bar icon | ReactNode | None |
 itemKey | corresponding to `activeKey` | string | None |
 style | style object | CSSProperties | None |
 tab | Tab page bar display text | ReactNode | None |
+closable | whether user can close the tab **>=2.1.0** | boolean | false |
 
 ## Design Token
 

+ 24 - 12
content/navigation/tabs/index.md

@@ -329,7 +329,7 @@ class App extends React.Component {
 **v>= 1.1.0**  
 通过设置 `collapsible` 可以支持滚动折叠,目前只支持 horizontal 模式。
 
-```jsx live=true
+```jsx live=true dir=column
 import React from 'react';
 import { Tabs, TabPane } from '@douyinfe/semi-ui';
 
@@ -518,22 +518,33 @@ import React from 'react';
 import { Tabs, TabPane } from '@douyinfe/semi-ui';
 
 class App extends React.Component {
+    constructor(props){
+        super(props);
+        this.state = {
+            tabList: [
+                {tab: '文档', itemKey:'1', text:'文档', closable:true},
+                {tab: '快速起步', itemKey:'2', text:'快速起步', closable:true},
+                {tab: '帮助', itemKey:'3', text:'帮助'},
+            ]
+        }
+    }
+    close(key){
+        const newTabList = [...this.state.tabList];
+        const closeIndex = newTabList.findIndex(t=>t.itemKey===key);
+        newTabList.splice(closeIndex, 1);
+        this.setState({tabList:newTabList});
+    }
     render() {
         return (
-            <Tabs closable type="card" defaultActiveKey="1">
-                <TabPane tab="文档" itemKey="1">
-                    文档
-                </TabPane>
-                <TabPane tab="快速起步" itemKey="2">
-                    快速起步
-                </TabPane>
-                <TabPane tab="帮助" itemKey="3">
-                    帮助
-                </TabPane>
+            <Tabs type="card" defaultActiveKey="1" onTabClose={this.close.bind(this)}>
+                {
+                    this.state.tabList.map(t=><TabPane closable={t.closable} tab={t.tab} itemKey={t.itemKey} key={t.itemKey}>{t.text}</TabPane>)
+                }
             </Tabs>
         );
     }
 }
+
 ```
 
 ## API 参考
@@ -559,7 +570,7 @@ tabPosition | tab 的位置,支持`top`(水平), `left`(垂直),**>=1.0.0**
 type | 标签栏的样式,可选`line`、 `card`、 `button` | string | `line` |
 onChange | 切换 tab 页时的回调函数 | function(activeKey: string) | 无 |
 onTabClick | 单击事件 | function(key: string, e: Event) | 无 |
-closable | 关闭选中的tab | boolean | false |
+onTabClose | 关闭 tab 页时的回调函数 **>=2.1.0** |  function(tabKey: string) | 无
 
 ### TabPane
 
@@ -571,6 +582,7 @@ icon      | 标签页栏 icon    | ReactNode | 无     |
 itemKey   | 对应 `activeKey` | string             | 无     |
 style     | 样式对象         | CSSProperties             | 无     |
 tab       | 标签页栏显示文字 | ReactNode | 无     |
+closable  | 允许关闭tab **>=2.1.0**| boolean | false |
 
 ## 设计变量
 

+ 8 - 27
packages/semi-foundation/tabs/foundation.ts

@@ -1,14 +1,15 @@
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
-import { PlainTab } from '../../semi-ui/tabs/interface';
 import { noop } from 'lodash-es';
 
 export interface TabsAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     collectPane: () => void;
+    collectActiveKey: () => void;
     notifyTabClick: (activeKey: string, event: any) => void;
     notifyChange: (activeKey: string) => void;
     setNewActiveKey: (activeKey: string) => void;
-    setNewPanes: (panes: Array<PlainTab>) => void;
+    notifyPanesUpdate: (panes: Array<any>) => void;
     getDefaultActiveKeyFromChildren: () => string;
+    notifyTabDelete: (tabKey: string) => void;
 }
 
 class TabsFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<TabsAdapter<P, S>, P, S> {
@@ -23,8 +24,8 @@ class TabsFoundation<P = Record<string, any>, S = Record<string, any>> extends B
     destroy = noop;
 
     handleTabClick(activeKey: string, event: any): void {
-        const isControledComponent = this._isInProps('activeKey');
-        if (isControledComponent) {
+        const isControlledComponent = this._isInProps('activeKey');
+        if (isControlledComponent) {
             this._adapter.notifyChange(activeKey);
         } else {
             this._adapter.notifyChange(activeKey);
@@ -59,32 +60,12 @@ class TabsFoundation<P = Record<string, any>, S = Record<string, any>> extends B
 
     handleTabPanesChange(): void {
         this._adapter.collectPane();
-
-        let activeKey = this.getState('activeKey');
-        if (typeof activeKey === 'undefined') {
-            activeKey = this._adapter.getDefaultActiveKeyFromChildren();
-        }
-        if (typeof activeKey !== 'undefined') {
-            this.handleNewActiveKey(activeKey);
-        }
+        this._adapter.collectActiveKey();
     }
 
     handleTabDelete(tabKey: string): void {
-        this._adapter.collectPane();
-        const activeKey = this.getState('activeKey');
-        const panes = this.getState('panes');
-
-        if(tabKey === activeKey) {
-            const activeIndex = panes.findIndex(e => e.itemKey === tabKey) === 0 ?
-                0: panes.findIndex(e => e.itemKey === tabKey) - 1;
-            const newPanes = panes.filter(pane => pane.itemKey !== tabKey)
-            this._adapter.setNewPanes(newPanes);
-            this._adapter.setNewActiveKey(newPanes[activeIndex].itemKey);
-        }else {
-            this._adapter.setNewPanes(panes.filter(pane => pane.itemKey !== tabKey));
-        }
-        
+        this._adapter.notifyTabDelete(tabKey);
     }
 }
 
-export default TabsFoundation;
+export default TabsFoundation;

+ 92 - 83
packages/semi-foundation/tabs/tabs.scss

@@ -6,26 +6,26 @@ $module: #{$prefix}-tabs;
 .#{$module} {
     box-sizing: border-box;
     position: relative;
-
+    
     &-left {
         display: flex;
         flex-direction: row;
     }
-
+    
     &-bar {
         position: relative;
         white-space: nowrap;
         outline: none;
-
+        
         &-left {
             display: flex;
             flex-direction: column;
         }
-
+        
         &-extra {
             padding: $spacing-tabs_bar_extra-paddingY $spacing-tabs_bar_extra-paddingX;
         }
-
+        
         .#{$module}-tab {
             @include font-size-regular;
             cursor: pointer;
@@ -33,53 +33,53 @@ $module: #{$prefix}-tabs;
             position: relative;
             display: block;
             float: left;
-
+            
             font-weight: $font-tabs_tab-fontWeight;
             color: $color-tabs_tab_line_default-text-default;
-
+            
             user-select: none;
-
-            .#{$prefix}-icon-close {
-                font-size: 14px;
-                color: var(--semi-color-text-2);
-                padding-left: 10px;
-                cursor: pointer;
-            }
-
+            
             .#{$prefix}-icon {
                 position: relative;
                 margin-right: $spacing-tabs_tab_icon-marginRight;
                 top: $spacing-tabs_tab_icon-top;
                 color: $color-tabs_tab-icon-default;
             }
-
+            
+            .#{$prefix}-icon-close {
+                margin-right: 0;
+                font-size: 14px;
+                color: var(--semi-color-text-2);
+                margin-left: 10px;
+                cursor: pointer;
+            }
+            
             &:hover {
                 color: $color-tabs_tab_line_default-text-hover;
-
+                
                 .#{$prefix}-icon {
                     color: $color-tabs_tab-icon-hover;
                 }
             }
-
+            
             &:active {
                 color: $color-tabs_tab_line_default-text-active;
-
+                
                 .#{$prefix}-icon {
                     color: $color-tabs_tab-icon-active;
                 }
             }
         }
-
+        
         .#{$module}-tab-active {
-
+            
             &,
             &:hover {
-                color: $color-tabs_tab_line_selected-text-default;
                 cursor: default;
                 // border-bottom: 2px solid $color-tabs_tab_line_indicator_selected-icon-default;
                 font-weight: $font-tabs_tab_active-fontWeight;
                 color: $color-tabs_tab_line_selected-text-default;
-
+                
                 .#{$prefix}-icon {
                     color: $color-tabs_tab_selected-icon-default;
                 }
@@ -88,32 +88,37 @@ $module: #{$prefix}-tabs;
                     color: var(--semi-color-text-2);
                 }
             }
+            
+            .#{$prefix}-icon-close:hover {
+                color: var(--semi-color-text-1);
+            }
         }
-
+        
         .#{$module}-tab-disabled {
             cursor: not-allowed;
             color: $color-tabs_tab_line_disabled-text-default;
-
+            
             &:hover {
                 color: $color-tabs_tab_line_disabled-text-hover;
                 border-bottom: none;
             }
         }
     }
-
+    
     &-bar-collapse {
         &,
         .#{$module}-bar-overflow-list {
             display: flex;
             align-items: center;
         }
-
+        
         .#{$prefix}-overflow-list {
             flex: 1;
+            
             .#{$prefix}-overflow-list-scroll-wrapper {
                 -ms-overflow-style: none; /* Internet Explorer 10+ */
                 scrollbar-width: none; /* Firefox */
-
+                
                 &::-webkit-scrollbar {
                     display: none; /* Safari and Chrome */
                     width: 0;
@@ -121,98 +126,100 @@ $module: #{$prefix}-tabs;
                 }
             }
         }
-
+        
         .#{$module}-bar-arrow-start {
             margin-right: $spacing-tabs_overflow_icon-marginRight;
         }
-
+        
         .#{$module}-bar-arrow-end {
             margin-left: $spacing-tabs_overflow_icon-marginLeft;
         }
     }
-
+    
     &-bar-dropdown {
         max-height: $height-tabs_overflow_list;
         overflow-y: auto;
     }
-
+    
     &-bar:after {
         content: "";
         height: 0;
         display: block;
         clear: both;
     }
-
+    
     &-bar-line {
         &.#{$module}-bar-top {
             border-bottom: $width-tabs_bar_line-border solid $color-tabs_tab_line_default-border-default;
-
+            
             .#{$module}-tab {
                 padding: $spacing-tabs_bar_line_tab-paddingTop $spacing-tabs_bar_line_tab-paddingRight $spacing-tabs_bar_line_tab-paddingBottom $spacing-tabs_bar_line_tab-paddingLeft;
-                &:nth-of-type(1){
+                
+                &:nth-of-type(1) {
                     padding-left: 0;
                 }
+                
                 border-bottom: $width-tabs_bar_line_tab-border solid transparent;
-
+                
                 &:hover {
                     border-bottom: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-hover;
                 }
-
+                
                 &:active {
                     border-bottom: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-active;
                 }
-
+                
                 &:not(:last-of-type) {
                     margin-right: $spacing-tabs_bar_line_tab-marginRight;
                 }
-
+                
                 &-small {
                     padding: $spacing-tabs_bar_line_tab_small-paddingTop $spacing-tabs_bar_line_tab_small-paddingRight $spacing-tabs_bar_line_tab_small-paddingBottom $spacing-tabs_bar_line_tab_small-paddingLeft;
                 }
-
+                
                 &-medium {
                     padding: $spacing-tabs_bar_line_tab_medium-paddingTop $spacing-tabs_bar_line_tab_medium-paddingRight $spacing-tabs_bar_line_tab_medium-paddingBottom $spacing-tabs_bar_line_tab_medium-paddingLeft;
                 }
             }
-
+            
             .#{$module}-tab-active {
-
+                
                 &,
                 &:hover {
                     border-bottom: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_indicator_selected-icon-default;
                 }
             }
         }
-
+        
         &.#{$module}-bar-left {
             border-right: $width-tabs_bar_line-border solid $color-tabs_tab_line_default-border-default;
-
+            
             .#{$module}-tab {
                 padding: $spacing-tabs_bar_line_tab_left-padding;
                 border-left: $width-tabs_bar_line_tab-border solid transparent;
-
+                
                 &:hover {
                     border-left: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-hover;
                     background-color: $color-tabs_tab_line_vertical-bg-hover;
                 }
-
+                
                 &:active {
                     border-left: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-active;
                     background-color: $color-tabs_tab_line_vertical-bg-active;
                 }
-
+                
                 &-small {
                     padding: $spacing-tabs_bar_line_tab_left_small-padding;
                 }
-
+                
                 &-medium {
                     padding: $spacing-tabs_bar_line_tab_left_medium-padding;
                 }
             }
-
+            
             .#{$module}-tab-active {
                 background-color: $color-tabs_tab_line_vertical_selected-bg-default;
-
+                
                 &,
                 &:hover {
                     border-left: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_indicator_selected-icon-default;
@@ -220,47 +227,49 @@ $module: #{$prefix}-tabs;
                 }
             }
         }
-
+        
         // type='bar' extar need specific
         .#{$module}-bar-extra {
             height: $height-tabs_bar_extra_large;
             line-height: $font-tabs_bar_extra_large-lineHeight;
         }
+        
         .#{$module}-bar-line-extra-small {
             height: $height-tabs_bar_extra_small;
             line-height: $font-tabs_bar_extra_small-lineHeight;
         }
     }
-
+    
     &-bar-card {
         &.#{$module}-bar-top {
-
+            
             &::before {
                 position: absolute;
                 right: 0;
                 left: 0;
                 bottom: 0;
                 border-bottom: $width-tabs_bar_card-border solid $color-tabs_tab_card_default-border-default;
-                content: '';
+                content: "";
             }
+            
             // border-bottom: $border-thickness-control solid $color-tabs_tab_line_default-border-default;
-
+            
             .#{$module}-tab {
                 border: $width-tabs_bar_card-border solid transparent;
                 border-bottom: none;
                 border-radius: $radius-tabs_tab_card;
-
+                
                 &:hover {
                     border-bottom: none;
                 }
-
+                
                 &:not(:last-of-type) {
                     margin-right: $spacing-tabs_bar_card_tab-marginRight;
                 }
             }
-
+            
             .#{$module}-tab-active {
-
+                
                 &,
                 &:hover {
                     padding: $spacing-tabs_bar_card_tab_active-paddingTop $spacing-tabs_bar_card_tab_active-paddingRight $spacing-tabs_bar_card_tab_active-paddingBottom $spacing-tabs_bar_card_tab_active-paddingLeft;
@@ -271,26 +280,26 @@ $module: #{$prefix}-tabs;
                 }
             }
         }
-
+        
         &.#{$module}-bar-left {
             border-right: $width-tabs_bar_card-border solid $color-tabs_tab_line_default-border-default;
-
+            
             .#{$module}-tab {
                 border: $width-tabs_bar_card-border solid transparent;
                 border-right: none;
                 border-radius: $radius-tabs_tab_card_left;
-
+                
                 &:hover {
                     border-right: none;
                 }
-
+                
                 &:not(:last-of-type) {
                     margin-bottom: $spacing-tabs_bar_card_tab_left-marginBottom;
                 }
             }
-
+            
             .#{$module}-tab-active {
-
+                
                 &:after {
                     content: " ";
                     width: 1px;
@@ -300,7 +309,7 @@ $module: #{$prefix}-tabs;
                     bottom: 0;
                     background: $color-tabs_tab_card_selected-bg-default;
                 }
-
+                
                 &,
                 &:hover {
                     padding: $spacing-tabs_bar_card_tab_left_active-paddingY $spacing-tabs_bar_card_tab_left_active-paddingX;
@@ -311,59 +320,59 @@ $module: #{$prefix}-tabs;
                 }
             }
         }
-
+        
         .#{$module}-tab {
             padding: $spacing-tabs_bar_card_tab-paddingY $spacing-tabs_bar_card_tab-paddingX;
-
+            
             &:hover {
                 background: $color-tabs_tab_card-bg-hover;
             }
-
+            
             &:active {
                 background: $color-tabs_tab_card-bg-active;
             }
         }
     }
-
+    
     &-bar-button {
         border: none;
-
+        
         &.#{$module}-bar-left {
             .#{$module}-tab {
-
+                
                 &:not(:last-of-type) {
                     margin-bottom: $spacing-tabs_bar_button_tab_left-marginBottom;
                 }
             }
         }
-
+        
         &.#{$module}-bar-top {
             .#{$module}-tab {
-
+                
                 &:not(:last-of-type) {
                     margin-right: $spacing-tabs_bar_button_tab-marginRight;
                 }
             }
         }
-
+        
         .#{$module}-tab {
             padding: $spacing-tabs_bar_button_tab-paddingY $spacing-tabs_bar_button_tab-paddingX;
             border-radius: $radius-tabs_tab_button;
             color: $color-tabs_tab_button-text-default;
             border: none;
-
+            
             &:hover {
                 border: none;
                 background-color: $color-tabs_tab_button-bg-hover;
             }
-
+            
             &:active {
                 background-color: $color-tabs_tab_button-bg-active;
             }
         }
-
+        
         .#{$module}-tab-active {
-
+            
             &,
             &:hover {
                 color: $color-tabs_tab_button_selected-text-default;
@@ -372,19 +381,19 @@ $module: #{$prefix}-tabs;
             }
         }
     }
-
+    
     &-content {
         width: 100%;
         padding: $spacing-tabs_content-paddingY $spacing-tabs_content-paddingX;
         // overflow: hidden;
         // display: flex;
     }
-
+    
     &-content-left {
         height: 100%;
         padding: $spacing-tabs_content_left-paddingY $spacing-tabs_content_left-paddingX;
     }
-
+    
     &-pane {
         width: 100%;
         overflow: hidden;
@@ -392,7 +401,7 @@ $module: #{$prefix}-tabs;
         // flex-shrink: 0;
         // position: absolute;
     }
-
+    
     &-pane-inactive,
     &-content-no-animated &-pane-inactive {
         display: none;

+ 7 - 7
packages/semi-ui/tabs/TabBar.tsx

@@ -1,4 +1,4 @@
-import React, { ReactNode, ReactElement, MouseEvent } from 'react';
+import React, { MouseEvent, ReactElement, ReactNode } from 'react';
 import PropTypes from 'prop-types';
 import cls from 'classnames';
 import { cssClasses, strings } from '@douyinfe/semi-foundation/tabs/constants';
@@ -6,9 +6,9 @@ import getDataAttr from '@douyinfe/semi-foundation/utils/getDataAttr';
 import OverflowList from '../overflowList';
 import Dropdown from '../dropdown';
 import Button from '../button';
-import { TabBarProps, PlainTab } from './interface';
+import { PlainTab, TabBarProps } from './interface';
 import { isEmpty } from 'lodash-es';
-import { IconChevronRight, IconChevronLeft, IconClose } from '@douyinfe/semi-icons';
+import { IconChevronLeft, IconChevronRight, IconClose } from '@douyinfe/semi-icons';
 import { getUuidv4 } from '@douyinfe/semi-foundation/utils/uuid';
 
 export interface TabBarState {
@@ -89,9 +89,9 @@ class TabBar extends React.Component<TabBarProps, TabBarState> {
     };
 
     renderTabItem = (panel: PlainTab): ReactNode => {
-        const { size, type, closable, deleteTabItem } = this.props;
+        const { size, type, deleteTabItem } = this.props;
         const panelIcon = panel.icon ? this.renderIcon(panel.icon) : null;
-        const closableIcon = (type==='card' && closable) ? <span onClick={(e: React.MouseEvent<HTMLSpanElement>)=> deleteTabItem(panel.itemKey,e)}><IconClose className={`${cssClasses.TABS_TAB}-icon-close`} /></span>: null
+        const closableIcon = (type === 'card' && panel.closable) ? <IconClose className={`${cssClasses.TABS_TAB}-icon-close`} onClick={(e: React.MouseEvent<HTMLSpanElement>) => deleteTabItem(panel.itemKey, e)}/> : null
         let events = {};
         const key = panel.itemKey;
         if (!panel.disabled) {
@@ -195,7 +195,7 @@ class TabBar extends React.Component<TabBarProps, TabBarState> {
     };
 
     renderOverflow = (items: [Array<OverflowItem>, Array<OverflowItem>]): Array<ReactNode> => items.map((item, ind) => {
-        const icon = ind === 0 ? <IconChevronLeft /> : <IconChevronRight />;
+        const icon = ind === 0 ? <IconChevronLeft/> : <IconChevronRight/>;
         const pos = ind === 0 ? 'start' : 'end';
         return this.renderCollapse(item, icon, pos);
     });
@@ -245,4 +245,4 @@ class TabBar extends React.Component<TabBarProps, TabBarState> {
     private _getItemKey = (key: string): string => `${key}-bar`;
 }
 
-export default TabBar;
+export default TabBar;

+ 4 - 3
packages/semi-ui/tabs/TabPane.tsx

@@ -1,11 +1,11 @@
-import React, { PureComponent, createRef, ReactNode } from 'react';
+import React, { createRef, PureComponent, ReactNode } from 'react';
 import PropTypes from 'prop-types';
 import cls from 'classnames';
 import { cssClasses } from '@douyinfe/semi-foundation/tabs/constants';
 import getDataAttr from '@douyinfe/semi-foundation/utils/getDataAttr';
 import TabsContext from './tabs-context';
 import TabPaneTransition from './TabPaneTransition';
-import { TabPaneProps, PlainTab } from './interface';
+import { PlainTab, TabPaneProps } from './interface';
 
 class TabPane extends PureComponent<TabPaneProps> {
     static isTabPane = true;
@@ -19,6 +19,7 @@ class TabPane extends PureComponent<TabPaneProps> {
         itemKey: PropTypes.string,
         tab: PropTypes.node,
         icon: PropTypes.node,
+        closable: PropTypes.bool
     };
 
     lastActiveKey: string = null;
@@ -112,4 +113,4 @@ class TabPane extends PureComponent<TabPaneProps> {
     }
 }
 
-export default TabPane;
+export default TabPane;

+ 47 - 24
packages/semi-ui/tabs/__test__/tabs.test.js

@@ -1,4 +1,5 @@
-import { Icon, Tabs, TabPane, Button } from '../../index';
+import { useState } from 'react';
+import { TabPane, Tabs } from '../../index';
 import { BASE_CLASS_PREFIX } from '../../../semi-foundation/base/constants';
 
 let defaultTabPane = [
@@ -13,8 +14,8 @@ function getTabs(tabProps, tabPaneProps = defaultTabPane) {
         return <TabPane {...pane}></TabPane>
     });
     return <Tabs {...tabProps}>
-            {tabPane}
-        </Tabs>
+        {tabPane}
+    </Tabs>
 }
 
 describe('Tabs', () => {
@@ -47,11 +48,11 @@ describe('Tabs', () => {
         const tabs = mount(getTabs(tabProps));
         const activeTabContent = tabs.find(`.${BASE_CLASS_PREFIX}-tabs-tab-active`).text();
         expect(activeTabContent).toEqual('titleB');
-    });                        
+    });
 
     it('different type Tabs', () => {
         let lineTabs = mount(getTabs({ type: 'line' }));
-        let cardTabs  = mount(getTabs({ type: 'card' }));
+        let cardTabs = mount(getTabs({ type: 'card' }));
         let buttonTabs = mount(getTabs({ type: 'button' }));
         expect(lineTabs.exists(`.${BASE_CLASS_PREFIX}-tabs-bar-button`)).toEqual(false);
         expect(lineTabs.exists(`.${BASE_CLASS_PREFIX}-tabs-bar-card`)).toEqual(false);
@@ -61,19 +62,21 @@ describe('Tabs', () => {
 
     it('tabList', () => {
         let tabList = [
-            {tab:"文档", itemKey:"1"},
-            {tab:"快速起步", itemKey:"2"},
-            {tab:"帮助", itemKey:"3"}
+            { tab: "文档", itemKey: "1" },
+            { tab: "快速起步", itemKey: "2" },
+            { tab: "帮助", itemKey: "3" }
         ];
-        const contentList=[
+        const contentList = [
             (<div>文档</div>),
             (<div>快速起步</div>),
             (<div>帮助</div>)
-          ]
+        ]
+
         class TabListDemo extends React.Component {
             state = {
                 key: '1'
             };
+
             render() {
                 return (
                     <Tabs
@@ -84,6 +87,7 @@ describe('Tabs', () => {
                 )
             }
         }
+
         const tabs = mount(<TabListDemo></TabListDemo>);
         expect(tabs.find(`.${BASE_CLASS_PREFIX}-tabs-content`).children().length).toEqual(1);
     })
@@ -121,7 +125,7 @@ describe('Tabs', () => {
         expect(spyOnTabClick.calledWithMatch("itemKeyB")).toBe(true);
         expect(spyOnTabClick.calledOnce).toBe(true);
     });
-    
+
     it('onChange', () => {
         let onChange = value => {
             // debugger
@@ -167,19 +171,38 @@ describe('Tabs', () => {
         expect(tabs.contains(extraContent)).toEqual(true);
     });
 
-    // it('renderTabBar', () => {
-
-    // });
-    it('click right close icon will delete current tab', () => {
-        let tabsProps = {
-            activeKey: 'itemKeyB',
-            type: 'card',
-            closable: true
+    it('tabpane closable', () => {
+        const Demo = () => {
+            const [tabList, $tabList] = useState([
+                {tab: '文档', itemKey:'1', text:'文档', closable:true},
+                {tab: '快速起步', itemKey:'2', text:'快速起步', closable:true},
+                {tab: '帮助', itemKey:'3', text:'帮助'},
+            ]);
+            const close = (key)=>{
+                const newTabList = [...tabList];
+                const closeIndex = newTabList.findIndex(t=>t.itemKey===key);
+                newTabList.splice(closeIndex, 1);
+                $tabList(newTabList);
+            }
+            return <Tabs type="card" defaultActiveKey="1" onTabClose={close}>
+            {
+                tabList.map(t=><TabPane closable={t.closable} tab={t.tab} itemKey={t.itemKey} key={t.itemKey}>{t.text}</TabPane>)
+            }
+        </Tabs>
         }
-        let paneProps = defaultTabPane;
-        
-        const tabs = mount(getTabs(tabsProps, paneProps));
-        tabs.find(`.${BASE_CLASS_PREFIX}-tabs-tab-active`).find('span').at(0).simulate('click');
-        expect(tabs.props().activeKey).toEqual('itemKeyB');
+
+        const demo = mount(<Demo />);
+        const firstTab = demo.find(`.${BASE_CLASS_PREFIX}-tabs-tab`).at(0);
+        const secondTab = demo.find(`.${BASE_CLASS_PREFIX}-tabs-tab`).at(1);
+        const thirdTab = demo.find(`.${BASE_CLASS_PREFIX}-tabs-tab`).at(2);
+
+        expect(firstTab.exists(`.${BASE_CLASS_PREFIX}-tabs-tab-icon-close`)).toEqual(true);
+        expect(secondTab.exists(`.${BASE_CLASS_PREFIX}-tabs-tab-icon-close`)).toEqual(true);
+        expect(thirdTab.exists(`.${BASE_CLASS_PREFIX}-tabs-tab-icon-close`)).toEqual(false);
+
+        demo.find(`.${BASE_CLASS_PREFIX}-tabs-tab-icon-close`).at(0).simulate('click');
+
+        expect(demo.find(`.${BASE_CLASS_PREFIX}-tabs-tab`).length).toEqual(2)
+        expect(demo.find(`.${BASE_CLASS_PREFIX}-tabs-tab`).at(0).hasClass(`${BASE_CLASS_PREFIX}-tabs-tab-active`)).toEqual(true);
     });
 })

+ 41 - 13
packages/semi-ui/tabs/index.tsx

@@ -1,22 +1,23 @@
-import React, { createRef, RefObject, ReactElement, MouseEvent, RefCallback, ReactNode } from 'react';
+import React, { createRef, MouseEvent, ReactElement, ReactNode, RefCallback, RefObject } from 'react';
 import cls from 'classnames';
 import PropTypes from 'prop-types';
 import { cssClasses, strings } from '@douyinfe/semi-foundation/tabs/constants';
 import isNullOrUndefined from '@douyinfe/semi-foundation/utils/isNullOrUndefined';
 import getDataAttr from '@douyinfe/semi-foundation/utils/getDataAttr';
 import TabsFoundation, { TabsAdapter } from '@douyinfe/semi-foundation/tabs/foundation';
-import { isEqual, pick, omit } from 'lodash-es';
+import { isEqual, omit, pick } from 'lodash-es';
 import BaseComponent from '../_base/baseComponent';
 import '@douyinfe/semi-foundation/tabs/tabs.scss';
 
 import TabBar from './TabBar';
 import TabPane from './TabPane';
 import TabsContext from './tabs-context';
-import { TabsProps, PlainTab, TabPaneProps } from './interface';
+import { PlainTab, TabPaneProps, TabsProps } from './interface';
 
 const panePickKeys = Object.keys(omit(TabPane.propTypes, ['children']));
 
 export * from './interface';
+
 export interface TabsState {
     activeKey: string;
     panes: Array<PlainTab>;
@@ -45,7 +46,7 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
         tabPaneMotion: PropTypes.oneOfType([PropTypes.bool, PropTypes.object, PropTypes.func]),
         tabPosition: PropTypes.oneOf(strings.POSITION_MAP),
         type: PropTypes.oneOf(strings.TYPE_MAP),
-        closable: PropTypes.bool
+        onTabClose: PropTypes.func,
     };
 
     static defaultProps: TabsProps = {
@@ -59,7 +60,7 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
         tabPaneMotion: true,
         tabPosition: 'top',
         type: 'line',
-        closable: false
+        onTabClose: () => undefined
     };
 
     contentRef: RefObject<HTMLDivElement>;
@@ -90,13 +91,40 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
                 }
                 const panes = React.Children.map(children, (child: any) => {
                     if (child) {
-                        const { tab, icon, disabled, itemKey } = child.props;
-                        return { tab, icon, disabled, itemKey };
+                        const { tab, icon, disabled, itemKey, closable } = child.props;
+                        return { tab, icon, disabled, itemKey, closable };
                     }
                     return undefined;
                 });
                 this.setState({ panes });
             },
+            collectActiveKey: (): void => {
+                let panes = [];
+                const { tabList, children, activeKey: propsActiveKey } = this.props;
+                if(typeof propsActiveKey !== 'undefined'){
+                    return;
+                }
+                const { activeKey } = this.state;
+                if (Array.isArray(tabList) && tabList.length) {
+                    panes = tabList;
+                } else {
+                    panes = React.Children.map(children, (child: any) => {
+                        if (child) {
+                            const { tab, icon, disabled, itemKey, closable } = child.props;
+                            return { tab, icon, disabled, itemKey, closable };
+                        }
+                        return undefined;
+                    });
+                }
+                if(panes.findIndex(p => p.itemKey === activeKey) === -1){
+                    if(panes.length>0){
+                        this.setState({activeKey: panes[0].itemKey});
+                    } else {
+                        this.setState({activeKey: ''});
+                    }
+                }
+                
+            },
             notifyTabClick: (activeKey: string, event: MouseEvent<HTMLDivElement>): void => {
                 this.props.onTabClick(activeKey, event);
             },
@@ -106,7 +134,7 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
             setNewActiveKey: (activeKey: string): void => {
                 this.setState({ activeKey });
             },
-            setNewPanes: (panes: Array<PlainTab>): void => {
+            notifyPanesUpdate: (panes: Array<PlainTab>): void => {
                 this.setState({ panes });
             },
             getDefaultActiveKeyFromChildren: (): string => {
@@ -120,6 +148,9 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
                 });
                 return activeKey;
             },
+            notifyTabDelete: (tabKey: string) => {
+                this.props.onTabClose && this.props.onTabClose(tabKey);
+            }
         };
     }
 
@@ -132,7 +163,7 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
     }
 
     componentDidUpdate(prevProps: TabsProps): void {
-    // Panes state acts on tab bar, no need to compare TabPane children
+        // Panes state acts on tab bar, no need to compare TabPane children
         const prevChildrenProps = React.Children.toArray(prevProps.children).map((child: ReactElement<TabPaneProps>) =>
             pick(child.props, panePickKeys)
         );
@@ -192,7 +223,6 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
 
     deleteTabItem = (tabKey: string, event: MouseEvent<HTMLDivElement>) => {
         event.stopPropagation();
-        if(this.state.panes.length === 1) return
         this.foundation.handleTabDelete(tabKey)
     }
 
@@ -213,7 +243,6 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
             tabPaneMotion,
             tabPosition,
             type,
-            closable,
             ...restProps
         } = this.props;
         const { panes, activeKey } = this.state;
@@ -238,7 +267,6 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
             tabBarExtraContent,
             tabPosition,
             type,
-            closable,
             deleteTabItem: this.deleteTabItem
         };
 
@@ -271,4 +299,4 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
     }
 }
 
-export default Tabs;
+export default Tabs;

+ 6 - 3
packages/semi-ui/tabs/interface.ts

@@ -1,4 +1,4 @@
-import { MouseEvent, ReactNode, ComponentType, CSSProperties } from 'react';
+import { ComponentType, CSSProperties, MouseEvent, ReactNode } from 'react';
 import { Motion } from '../_base/base';
 
 export type TabType = 'line' | 'card' | 'button';
@@ -10,6 +10,7 @@ export interface PlainTab {
     icon?: ReactNode;
     itemKey: string;
     tab?: ReactNode;
+    closable: boolean;
 }
 
 
@@ -34,7 +35,7 @@ export interface TabsProps {
     tabPaneMotion?: boolean;
     tabPosition?: TabPosition;
     type?: TabType;
-    closable?: boolean;
+    onTabClose?: (tabKey: string) => void;
 }
 
 export interface TabBarProps {
@@ -61,10 +62,12 @@ export interface TabPaneProps {
     itemKey?: string;
     style?: CSSProperties;
     tab?: ReactNode;
+    closable?: boolean,
 }
 
 export interface TabPaneTransitionProps {
     [key: string]: any;
+
     children?: ((p: { transform?: string; opacity: number }) => ReactNode | undefined) | undefined;
     direction?: boolean;
     mode?: 'vertical' | 'horizontal';
@@ -77,4 +80,4 @@ export interface TabContextValue {
     panes?: Array<PlainTab>;
     tabPaneMotion?: boolean;
     tabPosition?: TabPosition;
-}
+}