From 8165c76331e7fb0ca8899ebf6d4fde03a5088584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 28 May 2020 17:17:45 +0800 Subject: [PATCH] feat: New tabs (#265) * init * accessibility link * add line * support extra * move tabpanel into components * add overflow example * add measure * auto ellipsis * add more * fix ts error * scroll tabs * update more * accessibility improvement * move into TabPane * add className * animation * visited render * add bottom style * support left right * left right scroll bar * use scroll instead of hidden * fix holder added * update scroll logic * connect renderTabBar * support sticky * support react dnd * fix ts define * touch to show * init to scroll * click to scroll * trigger now * trigger now * init test * test case * add more test case * fix gutter logic * use offsetLeft to help on calculation * fix gutter logic * gutter test case * add cleanup * mobile test * coverage * more test case * not crash onScrol * update dem * fix resize logic * update layout * add rtl className * tabPane rtl * gutter rtl * fix adjust logic * fix rtl * rtl it * update mix demo * update mix demo to reproduce * fix logic * unique collction * fix style * fix style * use hover display * not allow in dropdown * update snapshot * fix snapshot * support aria label of dropdown * rm useless test case * fix relative position * fix rtl switch * full coverage * fix lint * use scroll * fix offset of scroller * clean up content * fix scroll * update snapshot * resize of measure * add editable * remove event * auto reactive when removed active key * update pin operation logic * remove operation in mobile * move add button out * remove animate when no need * add button logic update * fix re-children logic * update remove style * update style * update style * support gutter * add scroll logic * clean up * update destroy intactive * fix typo * fix mobile * fix test case * raise coverage * update coverage * adjust logic * touch to scroll * fix mac scroll * not accessible in screen reader * show add when editable * wheel opt * auto realign * scroll correct * lock touch moving * adjust scroll smooth * fix scroll dropdown * fix rtl * update animated * update hidden style * remove to keep the blink * support key tab * fix test case * fix mobile * fix logic * fix scroll prevent logic * opt code * fix scroll on keyboard * fix test case * rm useless code * fix coverage * vertiacal test * line 100% coverage * fix warning * fix additional moving * update test case * clean up --- .eslintrc.js | 9 +- .vscode/settings.json | 3 + assets/dropdown.less | 32 + assets/index.less | 105 ++- assets/index/bottom.less | 111 --- assets/index/common.less | 168 ----- assets/index/left.less | 103 --- assets/index/right.less | 100 --- assets/index/top.less | 119 ---- assets/panels.less | 21 + assets/position.less | 84 +++ assets/rtl.less | 7 + examples/activeKey.js | 82 --- examples/add.js | 136 ---- examples/antd.js | 245 ------- examples/basic.tsx | 48 ++ examples/defaultActiveKey.js | 100 --- examples/destroyInactiveTabpanel.js | 76 -- examples/dnd.js | 122 ---- examples/mix.tsx | 168 +++++ examples/overflow.tsx | 53 ++ examples/position.tsx | 58 ++ examples/renderTabBar-dragable.tsx | 140 ++++ examples/renderTabBar-sticky.tsx | 36 + examples/router.js | 73 -- examples/rtl.js | 242 ------- examples/swipeInkTabBar.js | 136 ---- jest.config.js | 4 + package.json | 29 +- src/InkTabBar.js | 25 - src/InkTabBarNode.js | 128 ---- src/KeyCode.js | 18 - src/SaveRef.js | 19 - src/ScrollableInkTabBar.js | 30 - src/ScrollableTabBar.js | 22 - src/ScrollableTabBarNode.js | 313 --------- src/SwipeableInkTabBar.js | 24 - src/SwipeableTabBarNode.js | 218 ------ src/SwipeableTabContent.js | 163 ----- src/TabBar.js | 19 - src/TabBarRootNode.js | 73 -- src/TabBarSwipeableTabs.js | 65 -- src/TabBarTabsNode.js | 84 --- src/TabContent.js | 83 --- src/TabContext.ts | 9 + src/TabNavList/AddButton.tsx | 37 + src/TabNavList/OperationNode.tsx | 182 +++++ src/TabNavList/TabNode.tsx | 115 +++ src/TabNavList/index.tsx | 405 +++++++++++ src/TabPane.js | 52 -- src/TabPanelList/TabPane.tsx | 58 ++ src/TabPanelList/index.tsx | 55 ++ src/Tabs.js | 209 ------ src/Tabs.tsx | 227 ++++++ src/hooks/useOffsets.ts | 33 + src/hooks/useRaf.ts | 53 ++ src/hooks/useRefs.ts | 22 + src/hooks/useSyncState.ts | 15 + src/hooks/useTouchMove.ts | 168 +++++ src/hooks/useVisibleRange.ts | 54 ++ src/index.js | 6 - src/index.ts | 6 + src/interface.ts | 47 ++ src/sugar/TabPane.tsx | 16 + src/utils.js | 143 ---- tests/__snapshots__/a11y.spec.js.snap | 514 -------------- tests/__snapshots__/index.spec.js.snap | 296 -------- tests/__snapshots__/index.test.tsx.snap | 111 +++ tests/__snapshots__/overflow.test.tsx.snap | 17 + tests/__snapshots__/rtl.spec.js.snap | 775 --------------------- tests/__snapshots__/suger.test.tsx.snap | 3 + tests/__snapshots__/swipe.spec.js.snap | 257 ------- tests/a11y.spec.js | 102 --- tests/common/util.tsx | 73 ++ tests/index.spec.js | 390 ----------- tests/index.test.tsx | 230 ++++++ tests/mobile.test.tsx | 217 ++++++ tests/overflow.test.tsx | 319 +++++++++ tests/rtl.spec.js | 271 ------- tests/rtl.test.tsx | 76 ++ tests/setup.js | 2 +- tests/suger.test.tsx | 9 + tests/{swipe.spec.js => swipe.js} | 0 83 files changed, 3336 insertions(+), 6132 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 assets/dropdown.less delete mode 100644 assets/index/bottom.less delete mode 100644 assets/index/common.less delete mode 100644 assets/index/left.less delete mode 100644 assets/index/right.less delete mode 100644 assets/index/top.less create mode 100644 assets/panels.less create mode 100644 assets/position.less create mode 100644 assets/rtl.less delete mode 100755 examples/activeKey.js delete mode 100755 examples/add.js delete mode 100755 examples/antd.js create mode 100644 examples/basic.tsx delete mode 100755 examples/defaultActiveKey.js delete mode 100755 examples/destroyInactiveTabpanel.js delete mode 100755 examples/dnd.js create mode 100644 examples/mix.tsx create mode 100644 examples/overflow.tsx create mode 100644 examples/position.tsx create mode 100644 examples/renderTabBar-dragable.tsx create mode 100644 examples/renderTabBar-sticky.tsx delete mode 100644 examples/router.js delete mode 100644 examples/rtl.js delete mode 100644 examples/swipeInkTabBar.js create mode 100644 jest.config.js delete mode 100755 src/InkTabBar.js delete mode 100644 src/InkTabBarNode.js delete mode 100755 src/KeyCode.js delete mode 100644 src/SaveRef.js delete mode 100755 src/ScrollableInkTabBar.js delete mode 100755 src/ScrollableTabBar.js delete mode 100755 src/ScrollableTabBarNode.js delete mode 100755 src/SwipeableInkTabBar.js delete mode 100755 src/SwipeableTabBarNode.js delete mode 100755 src/SwipeableTabContent.js delete mode 100644 src/TabBar.js delete mode 100644 src/TabBarRootNode.js delete mode 100644 src/TabBarSwipeableTabs.js delete mode 100644 src/TabBarTabsNode.js delete mode 100755 src/TabContent.js create mode 100644 src/TabContext.ts create mode 100644 src/TabNavList/AddButton.tsx create mode 100644 src/TabNavList/OperationNode.tsx create mode 100644 src/TabNavList/TabNode.tsx create mode 100644 src/TabNavList/index.tsx delete mode 100755 src/TabPane.js create mode 100644 src/TabPanelList/TabPane.tsx create mode 100644 src/TabPanelList/index.tsx delete mode 100755 src/Tabs.js create mode 100644 src/Tabs.tsx create mode 100644 src/hooks/useOffsets.ts create mode 100644 src/hooks/useRaf.ts create mode 100644 src/hooks/useRefs.ts create mode 100644 src/hooks/useSyncState.ts create mode 100644 src/hooks/useTouchMove.ts create mode 100644 src/hooks/useVisibleRange.ts delete mode 100755 src/index.js create mode 100644 src/index.ts create mode 100644 src/interface.ts create mode 100644 src/sugar/TabPane.tsx delete mode 100755 src/utils.js delete mode 100644 tests/__snapshots__/a11y.spec.js.snap delete mode 100644 tests/__snapshots__/index.spec.js.snap create mode 100644 tests/__snapshots__/index.test.tsx.snap create mode 100644 tests/__snapshots__/overflow.test.tsx.snap delete mode 100644 tests/__snapshots__/rtl.spec.js.snap create mode 100644 tests/__snapshots__/suger.test.tsx.snap delete mode 100644 tests/__snapshots__/swipe.spec.js.snap delete mode 100644 tests/a11y.spec.js create mode 100644 tests/common/util.tsx delete mode 100755 tests/index.spec.js create mode 100644 tests/index.test.tsx create mode 100644 tests/mobile.test.tsx create mode 100644 tests/overflow.test.tsx delete mode 100644 tests/rtl.spec.js create mode 100644 tests/rtl.test.tsx create mode 100644 tests/suger.test.tsx rename tests/{swipe.spec.js => swipe.js} (100%) diff --git a/.eslintrc.js b/.eslintrc.js index 36bad06b..ae0b57d7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,20 +4,23 @@ module.exports = { ...base, rules: { ...base.rules, + 'default-case': 0, 'react/sort-comp': 0, 'react/no-array-index-key': 0, 'react/no-access-state-in-setstate': 0, 'no-plusplus': 0, 'no-param-reassign': 0, 'react/require-default-props': 0, - 'react/require-default-props': 0, 'no-underscore-dangle': 0, 'react/no-find-dom-node': 0, 'no-mixed-operators': 0, 'prefer-destructuring': 0, 'react/no-unused-prop-types': 0, 'max-len': 0, - "import/no-extraneous-dependencies": ["error", {"devDependencies": true, "optionalDependencies": false, "peerDependencies": false}], - 'brace-style': 0 + 'import/no-extraneous-dependencies': [ + 'error', + { devDependencies: true, optionalDependencies: false, peerDependencies: false }, + ], + 'brace-style': 0, }, }; diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..3662b370 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/assets/dropdown.less b/assets/dropdown.less new file mode 100644 index 00000000..4089e70a --- /dev/null +++ b/assets/dropdown.less @@ -0,0 +1,32 @@ +@tabs-prefix-cls: rc-tabs; + +.@{tabs-prefix-cls}-dropdown { + position: absolute; + background: #fefefe; + border: 1px solid black; + max-height: 200px; + overflow: auto; + + &-hidden { + display: none; + } + + &-menu { + margin: 0; + padding: 0; + list-style: none; + + &-item { + padding: 4px 8px; + + &-selected { + background: red; + } + + &-disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + } +} diff --git a/assets/index.less b/assets/index.less index 428a5e7c..620c00b6 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,11 +1,104 @@ +@import './dropdown.less'; +@import './panels.less'; +@import './position.less'; +@import './rtl.less'; + @tabs-prefix-cls: rc-tabs; @easing-in-out: cubic-bezier(0.35, 0, 0.25, 1); -@effect-duration: .3s; +@effect-duration: 0.3s; + +.@{tabs-prefix-cls} { + border: 1px solid gray; + font-size: 14px; + overflow: hidden; + + // ========================== Navigation ========================== + &-nav { + display: flex; + flex: none; + position: relative; + + &-measure, + &-wrap { + transform: translate(0); + position: relative; + display: inline-block; + flex: auto; + white-space: nowrap; + overflow: hidden; + display: flex; + } + + &-list { + display: flex; + position: relative; + transition: transform 0.3s; + } + + // >>>>>>>> Operations + &-operations { + display: flex; + + &-hidden { + position: absolute; + visibility: hidden; + pointer-events: none; + } + } + + &-more { + border: 1px solid blue; + background: rgba(255, 0, 0, 0.1); + } + &-add { + border: 1px solid green; + background: rgba(0, 255, 0, 0.1); + } + } + + &-tab { + border: 0; + font-size: 20px; + background: rgba(255, 255, 255, 0.5); + margin: 0; + padding: 8px 16px; + outline: none; + cursor: pointer; + position: relative; + + &-with-remove { + padding-right: 32px; + } + + &-remove { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + + &:hover { + color: red; + } + } + + &:focus { + background: rgba(0, 0, 255, 0.1); + } + } + + &-ink-bar { + position: absolute; + background: red; + pointer-events: none; + + &-animated { + transition: all 0.3s; + } + } -@import "index/common"; -@import "index/left"; -@import "index/right"; -@import "index/bottom"; -@import "index/top"; \ No newline at end of file + &-extra-content { + flex: none; + } +} diff --git a/assets/index/bottom.less b/assets/index/bottom.less deleted file mode 100644 index 15c86fc8..00000000 --- a/assets/index/bottom.less +++ /dev/null @@ -1,111 +0,0 @@ -.@{tabs-prefix-cls} { - &-bottom { - border-top: 2px solid #f3f3f3; - } - - &-bottom &-content { - width: 100%; - } - - &-bottom &-bar { - border-top: 1px solid #f3f3f3; - } - - &-bottom &-nav-container-scrolling { - padding-left: 32px; - padding-right: 32px; - } - - &-bottom &-nav-scroll { - width: 99999px; - } - - &-bottom &-nav-swipe { - position: relative; - left: 0; - .@{tabs-prefix-cls}-nav { - display: flex; - flex: 1; - width: 100%; - .@{tabs-prefix-cls}-tab { - display: flex; - flex-shrink: 0; - margin-right: 0; - padding: 8px 0; - justify-content: center; - } - } - } - &-bottom &-nav-wrap { - width: 100%; - } - - &-bottom &-content-animated { - flex-direction: row; - - .@{tabs-prefix-cls}-tabpane { - width: 100%; - } - } - - &-bottom &-tab-next { - right: 2px; - - &-icon:before { - content: ">"; - } - } - - &-bottom&-rtl &-tab-next { - left: 2px; - right:auto; - } - - &-bottom &-tab-prev { - left: 0; - &-icon:before { - content: "<"; - } - } - - &-bottom&-rtl &-tab-prev { - right: 0; - left:auto; - } - - - &-bottom &-tab-prev, &-bottom &-tab-next { - margin-right: -2px; - width: 32px; - height: 100%; - top: 0; - text-align: center; - } - - &-bottom &-ink-bar { - height: 2px; - top: 3px; - left: 0; - } - - &-bottom&-rtl &-ink-bar { - right: 0; - left:auto; - } - - &-bottom &-tab { - float: left; - height: 100%; - margin-right: 30px; - } - - &-bottom&-rtl &-tab { - float: right; - margin-left: 30px; - margin-right: 0; - } - &-bottom &-tabpane-inactive { - height: 0; - overflow: visible; - } -} diff --git a/assets/index/common.less b/assets/index/common.less deleted file mode 100644 index 6239205c..00000000 --- a/assets/index/common.less +++ /dev/null @@ -1,168 +0,0 @@ -.@{tabs-prefix-cls} { - box-sizing: border-box; - position: relative; - overflow: hidden; - - &-bar, &-nav-container { - font-size: 14px; - line-height: 1.5; - box-sizing: border-box; - overflow: hidden; - position: relative; - white-space: nowrap; - outline: none; - zoom: 1; - transition: padding .45s; - } - - &-ink-bar { - z-index: 1; - position: absolute; - box-sizing: border-box; - margin-top: -3px; - background-color: #108ee9; - transform-origin: 0 0; - - width: 0; - height: 0; - - &-animated { - transition: - transform @effect-duration @easing-in-out, - left @effect-duration @easing-in-out, - top @effect-duration @easing-in-out, - height @effect-duration @easing-in-out, - width @effect-duration @easing-in-out; - } - } - - &-tab-prev, &-tab-next { - user-select: none; - z-index: 1; - line-height: 36px; - cursor: pointer; - border: none; - background-color: transparent; - position: absolute; - - &-icon { - position: relative; - display: inline-block; - font-style: normal; - font-weight: normal; - font-variant: normal; - line-height: inherit; - vertical-align: baseline; - text-align: center; - text-transform: none; - font-smoothing: antialiased; - text-stroke-width: 0; - font-family: sans-serif; - - &:before { - display: block; - } - } - } - - &-tab-btn-disabled { - cursor: default; - color: #ccc; - } - - &-nav-wrap { - overflow: hidden; - } - - &-nav { - box-sizing: border-box; - padding-left: 0; - position: relative; - margin: 0; - float: left; - list-style: none; - display: inline-block; - transform-origin: 0 0; - &-animated { - transition: transform 0.5s @easing-in-out; - } - - &:before, &:after { - display: table; - content: " "; - } - - &:after { - clear: both; - } - } - - &-rtl &-nav { - float: right; - } - - &-tab { - box-sizing: border-box; - position: relative; - display: block; - transition: color @effect-duration @easing-in-out; - padding: 8px 20px; - font-weight: 500; - cursor: pointer; - - &:hover { - color: #23c0fa; - } - } - - &-tab-active { - &, &:hover { - color: #108ee9; - cursor: default; - // fix chrome render - transform: translateZ(0); - } - } - - &-tab-disabled { - cursor: default; - color: #ccc; - &:hover { - color: #ccc; - } - } - - &-content { - zoom: 1; - - .@{tabs-prefix-cls}-tabpane { - overflow: auto; - } - - &-animated { - transition: transform @effect-duration @easing-in-out, - margin-left @effect-duration @easing-in-out, - margin-top @effect-duration @easing-in-out; - display: flex; - will-change: transform; - - .@{tabs-prefix-cls}-tabpane { - flex-shrink: 0; - } - } - } - - .no-flexbox &-content { - transform: none !important; - overflow: auto; - } - - .no-csstransitions &-tabpane-inactive, - .no-flexbox &-tabpane-inactive, - &-content-no-animated &-tabpane-inactive { - display: none; - } - &-rtl{ - direction: rtl; - } -} diff --git a/assets/index/left.less b/assets/index/left.less deleted file mode 100644 index ee354b48..00000000 --- a/assets/index/left.less +++ /dev/null @@ -1,103 +0,0 @@ -.@{tabs-prefix-cls} { - &-left { - border-right: 2px solid #f3f3f3; - } - - &-left &-bar { - float: left; - height:100%; - margin-right: 10px; - border-right: 1px solid #f3f3f3; - } - &-left &-nav-container { - height:100%; - } - &-left &-nav-container-scrolling { - padding-top: 32px; - padding-bottom: 32px; - } - - &-left &-nav-wrap { - height: 100%; - } - - &-left &-content-animated { - flex-direction: column; - - .@{tabs-prefix-cls}-tabpane { - height: 100%; - } - } - - &-left &-nav-scroll { - height: 99999px; - } - - &-left &-nav-swipe { - position: relative; - top: 0; - .@{tabs-prefix-cls}-nav { - display: flex; - flex: 1; - flex-direction: column; - height: 100%; - .@{tabs-prefix-cls}-tab { - display: flex; - flex-shrink: 0; - justify-content: center; - } - } - } - - &-left &-tab-prev, &-left &-tab-next { - margin-top: -2px; - height: 0; - line-height: 32px; - width: 0; - display: block; - text-align: center; - opacity: 0; - transition: width .3s, height .3s, opacity .3s; - } - - &-top &-tab-arrow-show, - &-left &-tab-arrow-show, - &-bottom &-tab-arrow-show, - &-right &-tab-arrow-show { - opacity: 1; - width: 100%; - height: 32px; - } - - &-left &-tab-next { - bottom: 0; - &-icon { - transform: rotate(90deg); - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); - } - &-icon:before { - content: ">"; - } - } - - &-left &-tab-prev { - top: 2px; - &-icon { - transform: rotate(270deg); - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); - } - &-icon:before { - content: ">"; - } - } - - &-left &-ink-bar { - width: 2px; - right: 0; - top: 0; - } - - &-left &-tab { - padding: 16px 24px; - } -} diff --git a/assets/index/right.less b/assets/index/right.less deleted file mode 100644 index 8b2425d1..00000000 --- a/assets/index/right.less +++ /dev/null @@ -1,100 +0,0 @@ -.@{tabs-prefix-cls} { - &-right { - border-left: 2px solid #f3f3f3; - } - - &-right &-bar { - float: right; - height: 100%; - margin-left: 10px; - border-left: 1px solid #f3f3f3; - } - &-right &-nav-container { - height:100%; - } - &-right &-nav-container-scrolling { - padding-top: 32px; - padding-bottom: 32px; - } - - &-right &-nav-wrap { - height: 100%; - } - - &-right &-nav-scroll { - height: 99999px; - } - - &-right &-nav-swipe { - position: relative; - .@{tabs-prefix-cls}-nav { - display: flex; - flex: 1; - flex-direction: column; - height: 100%; - .@{tabs-prefix-cls}-tab { - display: flex; - flex-shrink: 0; - justify-content: center; - } - } - } - - &-right &-tab-prev, &-right &-tab-next { - margin-top: -2px; - height: 0; - width: 0; - display: block; - text-align: center; - line-height: 32px; - opacity: 0; - transition: width .3s, height .3s, opacity .3s; - } - - - &-top &-tab-arrow-show { - opacity: 1; - width: 100%; - height: 32px; - } - - &-right &-tab-next { - bottom: 0; - &-icon { - transform: rotate(90deg); - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); - } - &-icon:before { - content: ">"; - } - } - - &-right &-tab-prev { - top: 2px; - &-icon { - transform: rotate(270deg); - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); - } - &-icon:before { - content: ">"; - } - } - - &-right &-content-animated { - flex-direction: column; - - .@{tabs-prefix-cls}-tabpane { - height: 100%; - } - } - - &-right &-ink-bar { - width: 2px; - left: 0; - top: 0; - } - - &-right &-tab { - padding: 16px 24px; - } -} diff --git a/assets/index/top.less b/assets/index/top.less deleted file mode 100644 index f051d0a9..00000000 --- a/assets/index/top.less +++ /dev/null @@ -1,119 +0,0 @@ -.@{tabs-prefix-cls} { - &-top { - border-bottom: 2px solid #f3f3f3; - } - - &-top &-content { - width: 100%; - } - - &-top &-bar { - border-bottom: 1px solid #f3f3f3; - } - - &-top &-nav-container-scrolling { - padding-left: 32px; - padding-right: 32px; - } - - &-top &-nav-scroll { - width: 99999px; - } - - &-top &-nav-swipe { - position: relative; - left: 0; - .@{tabs-prefix-cls}-nav { - display: flex; - flex: 1; - width: 100%; - .@{tabs-prefix-cls}-tab { - display: flex; - flex-shrink: 0; - margin-right: 0; - padding: 8px 0; - justify-content: center; - } - } - } - - &-top &-nav-wrap { - width: 100%; - } - - &-top &-content-animated { - flex-direction: row; - .@{tabs-prefix-cls}-tabpane { - width: 100%; - } - } - - &-top &-tab-next { - right: 2px; - - &-icon:before { - content: ">"; - } - } - - &-top&-rtl &-tab-next { - left: 2px; - right:auto; - } - - &-top &-tab-prev { - left: 0; - &-icon:before { - content: "<"; - } - } - - &-top&-rtl &-tab-prev { - right: 0; - left:auto; - } - - &-top &-tab-prev, &-top &-tab-next { - margin-right: -2px; - width: 0; - height: 0; - top: 0; - text-align: center; - opacity: 0; - transition: width .3s, height .3s, opacity .3s; - } - - &-top &-tab-arrow-show { - opacity: 1; - width: 32px; - height: 100%; - } - - &-top &-ink-bar { - height: 2px; - bottom: 0; - left: 0; - } - - &-top&-rtl &-ink-bar { - right: 0; - left:auto; - } - - &-top &-tab { - float: left; - height: 100%; - margin-right: 30px; - } - - &-top&-rtl &-tab { - float: right; - margin-left: 30px; - margin-right: 0; - } - - &-top &-tabpane-inactive { - height: 0; - overflow: visible; - } -} diff --git a/assets/panels.less b/assets/panels.less new file mode 100644 index 00000000..55be4b13 --- /dev/null +++ b/assets/panels.less @@ -0,0 +1,21 @@ +@tabs-prefix-cls: rc-tabs; + +.@{tabs-prefix-cls} { + &-content { + &-holder { + flex: auto; + } + + display: flex; + width: 100%; + + &-animated { + transition: margin 0.3s; + } + } + + &-tabpane { + width: 100%; + flex: none; + } +} diff --git a/assets/position.less b/assets/position.less new file mode 100644 index 00000000..c405badb --- /dev/null +++ b/assets/position.less @@ -0,0 +1,84 @@ +@tabs-prefix-cls: rc-tabs; + +.@{tabs-prefix-cls} { + display: flex; + + // ========================== Vertical ========================== + &-top, + &-bottom { + flex-direction: column; + + .@{tabs-prefix-cls}-ink-bar { + height: 3px; + } + } + + &-top { + .@{tabs-prefix-cls}-ink-bar { + bottom: 0; + } + } + + &-bottom { + .@{tabs-prefix-cls}-nav { + order: 1; + } + + .@{tabs-prefix-cls}-content { + order: 0; + } + + .@{tabs-prefix-cls}-ink-bar { + top: 0; + } + } + + // ========================= Horizontal ========================= + &-left, + &-right { + &.@{tabs-prefix-cls}-editable .@{tabs-prefix-cls}-tab { + padding-right: 32px; + } + + .@{tabs-prefix-cls}-nav-wrap { + flex-direction: column; + } + + .@{tabs-prefix-cls}-ink-bar { + width: 3px; + } + + .@{tabs-prefix-cls}-nav { + flex-direction: column; + min-width: 50px; + + &-list { + flex-direction: column; + } + + &-operations { + flex-direction: column; + } + } + } + + &-left { + .@{tabs-prefix-cls}-ink-bar { + right: 0; + } + } + + &-right { + .@{tabs-prefix-cls}-nav { + order: 1; + } + + .@{tabs-prefix-cls}-content { + order: 0; + } + + .@{tabs-prefix-cls}-ink-bar { + left: 0; + } + } +} diff --git a/assets/rtl.less b/assets/rtl.less new file mode 100644 index 00000000..9c4687be --- /dev/null +++ b/assets/rtl.less @@ -0,0 +1,7 @@ +@tabs-prefix-cls: rc-tabs; + +.@{tabs-prefix-cls} { + &-rtl { + direction: rtl; + } +} diff --git a/examples/activeKey.js b/examples/activeKey.js deleted file mode 100755 index 424fe9ca..00000000 --- a/examples/activeKey.js +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable no-console,react/button-has-type */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/SwipeableTabContent'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; - -const PanelContent = ({ id }) => ( -
- {[1, 2, 3, 4].map(item => ( -

{id}

- ))} -
-); - -class Demo extends React.Component { - state = { - activeKey: '', - start: 0, - }; - - onChange = activeKey => { - console.log(`onChange ${activeKey}`); - this.setState({ - activeKey, - }); - }; - - onTabClick = key => { - console.log(`onTabClick ${key}`); - if (key === this.state.activeKey) { - this.setState({ - activeKey: '', - }); - } - }; - - tick = () => { - this.setState({ - start: this.state.start + 10, - }); - }; - - handleNotExistKey = () => { - this.setState({ - activeKey: '-1', - }); - }; - - render() { - const { start } = this.state; - return ( -
-

Simple Tabs

- } - renderTabContent={() => } - activeKey={this.state.activeKey} - onChange={this.onChange} - > - - - - - - - - - - - - - - - -
- ); - } -} -export default Demo; diff --git a/examples/add.js b/examples/add.js deleted file mode 100755 index dfe321fa..00000000 --- a/examples/add.js +++ /dev/null @@ -1,136 +0,0 @@ -/* eslint-disable no-console,react/button-has-type,no-alert,no-plusplus */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/TabContent'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; - -let index = 1; - -class Demo extends React.Component { - state = { - tabs: [ - { - title: '初始', - content: '初始内容', - }, - ], - activeKey: '初始', - }; - - onTabChange = activeKey => { - this.setState({ - activeKey, - }); - }; - - construct() { - const disabled = true; - return this.state.tabs - .map(t => ( - - {t.title} - { - this.remove(t.title, e); - }} - > - x - - - } - key={t.title} - > -
{t.content}
-
- )) - .concat([ - - {' '} - + 添加 - - } - disabled={disabled} - key="__add" - />, - ]); - } - - remove = (title, e) => { - e.stopPropagation(); - if (this.state.tabs.length === 1) { - alert('只剩一个,不能删'); - return; - } - let foundIndex = 0; - const after = this.state.tabs.filter((t, i) => { - if (t.title !== title) { - return true; - } - foundIndex = i; - return false; - }); - - let { activeKey } = this.state; - if (activeKey === title) { - if (foundIndex) { - foundIndex--; - } - activeKey = after[foundIndex].title; - } - this.setState({ - tabs: after, - activeKey, - }); - }; - - add = e => { - e.stopPropagation(); - index++; - const newTab = { - title: `名称: ${index}`, - content: `内容: ${index}`, - }; - this.setState({ - tabs: this.state.tabs.concat(newTab), - activeKey: `名称: ${index}`, - }); - }; - - render() { - const tabStyle = { - width: 500, - }; - - return ( -
-

Addable Tabs

-
- ( - +添加} /> - )} - renderTabContent={() => } - activeKey={this.state.activeKey} - onChange={this.onTabChange} - > - {this.construct()} - -
-
- ); - } -} - -export default Demo; diff --git a/examples/antd.js b/examples/antd.js deleted file mode 100755 index b0e5d0d5..00000000 --- a/examples/antd.js +++ /dev/null @@ -1,245 +0,0 @@ -/* eslint-disable no-console,react/button-has-type,no-plusplus */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/TabContent'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; -import ScrollableTabBar from '../src/ScrollableTabBar'; -import InkTabBar from '../src/InkTabBar'; -import TabBar from '../src/TabBar'; - -const arrowPath = - 'M869 487.8L491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h' + - '-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v' + - '60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91' + - '.5c1.9 0 3.8-0.7 5.2-2L869 536.2c14.7-12.8 14.7-35.6 0-48.4z'; - -const getSvg = (path, style = {}, svgStyle = {}) => ( - - - - - -); - -const next = getSvg(arrowPath); -const prev = getSvg( - arrowPath, - {}, - { - transform: 'scaleX(-1)', - }, -); - -class PanelContent extends React.Component { - constructor(props) { - super(props); - console.log(this.props.id, 'constructor'); - } - - componentWillReceiveProps(nextProps) { - console.log(nextProps.id, 'componentWillReceiveProps'); - } - - render() { - const length = Math.round(10 * Math.random() + 4); - const count = new Array(length); // new Array(4) skip forEach .... - for (let i = 0; i < length; i++) { - count[i] = 1; - } - const content = new Array(Math.round(100 * Math.random()) + 4).join(` ${this.props.id}`); - const els = count.map((c, i) =>

{content}

); - return
{els}
; - } -} - -function construct(start, num) { - const ends = []; - let index = 1; - for (let i = start; i < start + num; i++) { - ends.push( - - - , - ); - index++; - } - return ends; -} - -class Demo extends React.Component { - state = { - tabBarPosition: 'top', - activeKey: '3', - start: 0, - useIcon: false, - }; - - onChange = key => { - console.log(`onChange ${key}`); - }; - - onChange2 = activeKey => { - this.setState({ activeKey }); - }; - - onTabClick = key => { - console.log(`onTabClick ${key}`); - }; - - tick = () => { - this.setState({ - start: this.state.start + 10, - }); - }; - - toggleCustomIcon = () => { - this.setState({ - useIcon: !this.state.useIcon, - }); - }; - - changeTabPosition = e => { - this.setState({ - tabBarPosition: e.target.value, - }); - }; - - scrollToActive = () => { - this.bar.scrollToActiveTab(); - }; - - switchToLast = ends => { - if (this.state.activeKey !== ends[ends.length - 1].key) { - this.setState({ activeKey: ends[ends.length - 1].key }, this.scrollToActive); - } else { - this.scrollToActive(); - } - }; - - saveBar = bar => { - this.bar = bar; - }; - - render() { - const { start, tabBarPosition } = this.state; - const ends = construct(start, 9); - const ends2 = construct(start, 3); - let style; - const contentStyle = { - height: 400, - }; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - style = contentStyle; - } else { - style = { - width: 500, - }; - } - - const cls = (this.state.useIcon && 'rc-tabs-custom-icon') || undefined; - - const iconProps = this.state.useIcon - ? { - nextIcon: next, - prevIcon: prev, - } - : {}; - - return ( -
-

Basic Tabs

-

- tabBarPosition: - -

-
- } - renderTabContent={() => } - onChange={this.onChange} - > - {ends2} - -
-

Basic Tabs With Ink Bar and tabBarGutter

-

- tabBarPosition: - -

-
- } - renderTabContent={() => } - onChange={this.onChange} - > - {ends2} - -
-

Scroll Tabs

-
- -
- - is using icon: {(this.state.useIcon && 'true') || 'false'} - ( - - )} - renderTabContent={() => } - onChange={this.onChange2} - > - {ends} - -
- -

Scroll Tabs with inkBar

-
- - ( - - )} - renderTabContent={() => } - onChange={this.onChange2} - > - {ends} - -
- -
- ); - } -} - -export default Demo; diff --git a/examples/basic.tsx b/examples/basic.tsx new file mode 100644 index 00000000..db4710f0 --- /dev/null +++ b/examples/basic.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Tabs, { TabPane } from '../src'; +import '../assets/index.less'; + +export default () => { + const [destroy, setDestroy] = React.useState(false); + const [children, setChildren] = React.useState([ + + Light + , + + Bamboo + , + + Cute + , + ]); + + if (destroy) { + return null; + } + + return ( + + {children} + + + + ); +}; diff --git a/examples/defaultActiveKey.js b/examples/defaultActiveKey.js deleted file mode 100755 index 2d70b9e2..00000000 --- a/examples/defaultActiveKey.js +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint-disable no-console,react/button-has-type */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/TabContent'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; - -class PanelContent extends React.Component { - constructor(props) { - super(props); - console.log(this.props.id, 'constructor'); - } - - componentWillReceiveProps() { - console.log(this.props.id, 'componentWillReceiveProps'); - } - - render() { - const count = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; // new Array(4) skip forEach .... - const els = count.map((c, i) => ( -

- -

- )); - return
{els}
; - } -} - -const defaultTabKey = '2'; - -function onChange(key) { - console.log(`onChange ${key}`); -} - -class Component extends React.Component { - state = { - start: 0, - tabKey: defaultTabKey, - }; - - onTabClick = key => { - console.log(`onTabClick ${key}`); - this.setState({ - tabKey: key, - }); - }; - - tick = () => { - this.setState({ - start: this.state.start + 10, - }); - }; - - render() { - const { start } = this.state; - return ( -
-

Simple Tabs

-

current: {this.state.tabKey}

- } - renderTabContent={() => } - onChange={onChange} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); - } -} - -export default Component; diff --git a/examples/destroyInactiveTabpanel.js b/examples/destroyInactiveTabpanel.js deleted file mode 100755 index 7ed510c9..00000000 --- a/examples/destroyInactiveTabpanel.js +++ /dev/null @@ -1,76 +0,0 @@ -/* eslint-disable no-console,react/button-has-type */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/TabContent'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; - -class PanelContent extends React.Component { - constructor(props) { - super(props); - console.log(this.props.id, 'constructor'); - } - - componentWillReceiveProps() { - console.log(this.props.id, 'componentWillReceiveProps'); - } - - render() { - const count = [1, 1, 1, 1]; // new Array(4) skip forEach .... - const els = count.map((c, i) =>

{this.props.id}

); - return
{els}
; - } -} - -class Demo extends React.Component { - state = { - start: 0, - }; - - onChange = key => { - console.log(`onChange ${key}`); - }; - - onTabClick = key => { - console.log(`onTabClick ${key}`); - }; - - tick = () => { - this.setState({ - start: this.state.start + 10, - }); - }; - - render() { - const { start } = this.state; - const disabled = true; - return ( -
-

Simple Tabs

- } - renderTabContent={() => } - onChange={this.onChange} - > - - - - - - - - - - - - - - -
- ); - } -} - -export default Demo; diff --git a/examples/dnd.js b/examples/dnd.js deleted file mode 100755 index a62889a6..00000000 --- a/examples/dnd.js +++ /dev/null @@ -1,122 +0,0 @@ -/* eslint-disable no-console,react/button-has-type */ -import '../assets/index.less'; -import React from 'react'; -import HTML5Backend from 'react-dnd-html5-backend'; -import { DragSource, DropTarget, DragDropContextProvider } from 'react-dnd'; -import update from 'immutability-helper'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; -import TabContent from '../src/SwipeableTabContent'; -import Tabs, { TabPane } from '../src'; - -// Drag & Drop node -class TabNode extends React.Component { - render() { - const { connectDragSource, connectDropTarget, children } = this.props; - - return connectDragSource(connectDropTarget(children)); - } -} - -const cardTarget = { - drop(props, monitor) { - const dragKey = monitor.getItem().index; - const hoverKey = props.index; - - if (dragKey === hoverKey) { - return; - } - - props.moveTabNode(dragKey, hoverKey); - monitor.getItem().index = hoverKey; - }, -}; - -const cardSource = { - beginDrag(props) { - return { - id: props.id, - index: props.index, - }; - }, -}; - -const WrapTabNode = DropTarget('DND_NODE', cardTarget, connect => ({ - connectDropTarget: connect.dropTarget(), -}))( - DragSource('DND_NODE', cardSource, (connect, monitor) => ({ - connectDragSource: connect.dragSource(), - isDragging: monitor.isDragging(), - }))(TabNode), -); - -// Demo -class Demo extends React.Component { - state = { - activeKey: '1', - tabs: ['1', '2', '3'], - }; - - onChange = activeKey => { - console.log(`onChange ${activeKey}`); - this.setState({ - activeKey, - }); - }; - - onTabClick = key => { - console.log(`onTabClick ${key}`); - if (key === this.state.activeKey) { - this.setState({ - activeKey: '', - }); - } - }; - - moveTabNode = (dragKey, hoverKey) => { - const { tabs } = this.state; - const dragIndex = tabs.indexOf(dragKey); - const hoverIndex = tabs.indexOf(hoverKey); - const dragTab = this.state.tabs[dragIndex]; - this.setState( - update(this.state, { - tabs: { - $splice: [[dragIndex, 1], [hoverIndex, 0, dragTab]], - }, - }), - ); - }; - - renderTabBarNode = node => ( - - {node} - - ); - - render() { - return ( - -
-

Simple Tabs

- ( - - {this.renderTabBarNode} - - )} - renderTabContent={() => } - activeKey={this.state.activeKey} - onChange={this.onChange} - > - {this.state.tabs.map(id => ( - - {id} - - ))} - -
-
- ); - } -} - -export default Demo; diff --git a/examples/mix.tsx b/examples/mix.tsx new file mode 100644 index 00000000..07e4e56b --- /dev/null +++ b/examples/mix.tsx @@ -0,0 +1,168 @@ +/* eslint-disable jsx-a11y/label-has-for, jsx-a11y/label-has-associated-control */ +import React from 'react'; +import Tabs, { TabPane } from '../src'; +import '../assets/index.less'; + +function getTabPanes(count = 50) { + const tabs: React.ReactElement[] = []; + for (let i = 0; i < count; i += 1) { + tabs.push( + + Content of {i} + , + ); + } + return tabs; +} + +export default () => { + const [activeKey, setActiveKey] = React.useState(undefined); + const [position, setPosition] = React.useState('top'); + const [gutter, setGutter] = React.useState(false); + const [fixHeight, setFixHeight] = React.useState(true); + const [rtl, setRTL] = React.useState(false); + const [editable, setEditable] = React.useState(true); + const [destroyInactiveTabPane, setDestroyInactiveTabPane] = React.useState(false); + const [destroy, setDestroy] = React.useState(false); + const [animated, setAnimated] = React.useState(true); + const [tabPanes, setTabPanes] = React.useState(getTabPanes(14)); + + const editableConfig = editable + ? { + onEdit: ( + type: string, + info: { key?: string; event: React.MouseEvent | React.KeyboardEvent }, + ) => { + if (type === 'remove') { + setTabPanes(tabs => tabs.filter(tab => tab.key !== info.key)); + } else { + setTabPanes(tabs => { + const lastTab = tabs[tabs.length - 1]; + const num = Number(lastTab.key) + 1; + return [ + ...tabs, + + Content of {num} + , + ]; + }); + } + }, + } + : null; + + return ( +
+
+ {/* tabBarGutter */} + + + {/* animated */} + + + {/* fixHeight */} + + + {/* editable */} + + + {/* editable */} + + + {/* direction */} + + + {/* Change children */} + + + {/* Active random */} + + + {/* Position */} + + + {/* destroy */} + +
+ + {!destroy && ( + + { + if (activeKey !== undefined) { + setActiveKey(key); + } + }} + destroyInactiveTabPane={destroyInactiveTabPane} + animated={{ tabPane: animated }} + editable={editableConfig} + direction={rtl ? 'rtl' : null} + tabPosition={position} + tabBarGutter={gutter ? 32 : null} + tabBarExtraContent="extra" + defaultActiveKey="30" + moreIcon="..." + style={{ height: fixHeight ? 300 : null }} + > + {tabPanes} + + + )} +
+ ); +}; diff --git a/examples/overflow.tsx b/examples/overflow.tsx new file mode 100644 index 00000000..815a9ad7 --- /dev/null +++ b/examples/overflow.tsx @@ -0,0 +1,53 @@ +/* eslint-disable jsx-a11y/label-has-for, jsx-a11y/label-has-associated-control */ +import React from 'react'; +import Tabs, { TabPane } from '../src'; +import '../assets/index.less'; + +const tabs: React.ReactElement[] = []; + +for (let i = 0; i < 50; i += 1) { + tabs.push( + + Content of {i} + , + ); +} + +export default () => { + const [gutter, setGutter] = React.useState(true); + const [destroy, setDestroy] = React.useState(false); + + if (destroy) { + return null; + } + + return ( +
+ +
+ + {tabs} + +
+
+ + + +
+ ); +}; diff --git a/examples/position.tsx b/examples/position.tsx new file mode 100644 index 00000000..fa7f6381 --- /dev/null +++ b/examples/position.tsx @@ -0,0 +1,58 @@ +/* eslint-disable jsx-a11y/label-has-for, jsx-a11y/label-has-associated-control */ +import React from 'react'; +import Tabs, { TabPane } from '../src'; +import '../assets/index.less'; + +export default () => { + const [position, setPosition] = React.useState('left'); + const [gutter, setGutter] = React.useState(false); + const [fixedHeight, setFixedHeight] = React.useState(false); + + return ( + + + + + + + Light + + + Bamboo + + + Cat + + + Miu + + + 3333 + + + 4444 + + + + ); +}; diff --git a/examples/renderTabBar-dragable.tsx b/examples/renderTabBar-dragable.tsx new file mode 100644 index 00000000..a4cb071d --- /dev/null +++ b/examples/renderTabBar-dragable.tsx @@ -0,0 +1,140 @@ +/* eslint-disable import/no-named-as-default-member */ +import React from 'react'; +import { DndProvider, DragSource, DropTarget } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import Tabs from '../src'; +import '../assets/index.less'; + +const { TabPane } = Tabs; + +// Drag & Drop node +class TabNode extends React.Component { + render() { + const { connectDragSource, connectDropTarget, children } = this.props; + + return connectDragSource(connectDropTarget(children)); + } +} + +const cardTarget = { + drop(props, monitor) { + const dragKey = monitor.getItem().index; + const hoverKey = props.index; + + if (dragKey === hoverKey) { + return; + } + + props.moveTabNode(dragKey, hoverKey); + monitor.getItem().index = hoverKey; + }, +}; + +const cardSource = { + beginDrag(props) { + return { + id: props.id, + index: props.index, + }; + }, +}; + +const WrapTabNode = DropTarget('DND_NODE', cardTarget, connect => ({ + connectDropTarget: connect.dropTarget(), +}))( + DragSource('DND_NODE', cardSource, (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging(), + }))(TabNode), +); + +class DraggableTabs extends React.Component { + state = { + order: [], + }; + + moveTabNode = (dragKey, hoverKey) => { + const newOrder = this.state.order.slice(); + const { children } = this.props; + + React.Children.forEach(children, (c: React.ReactElement) => { + if (newOrder.indexOf(c.key) === -1) { + newOrder.push(c.key); + } + }); + + const dragIndex = newOrder.indexOf(dragKey); + const hoverIndex = newOrder.indexOf(hoverKey); + + newOrder.splice(dragIndex, 1); + newOrder.splice(hoverIndex, 0, dragKey); + + this.setState({ + order: newOrder, + }); + }; + + renderTabBar = (props, DefaultTabBar) => ( + + {node => { + return ( + + {node} + + ); + }} + + ); + + render() { + const { order } = this.state; + const { children } = this.props; + + const tabs = []; + React.Children.forEach(children, c => { + tabs.push(c); + }); + + const orderTabs = tabs.slice().sort((a, b) => { + const orderA = order.indexOf(a.key); + const orderB = order.indexOf(b.key); + + if (orderA !== -1 && orderB !== -1) { + return orderA - orderB; + } + if (orderA !== -1) { + return -1; + } + if (orderB !== -1) { + return 1; + } + + const ia = tabs.indexOf(a); + const ib = tabs.indexOf(b); + + return ia - ib; + }); + + return ( + + + {orderTabs} + + + ); + } +} + +export default () => ( + + + Content of Tab Pane 1 + + + Content of Tab Pane 2 + + + Content of Tab Pane 3 + + +); diff --git a/examples/renderTabBar-sticky.tsx b/examples/renderTabBar-sticky.tsx new file mode 100644 index 00000000..7f34d335 --- /dev/null +++ b/examples/renderTabBar-sticky.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { StickyContainer, Sticky } from 'react-sticky'; +import Tabs, { TabPane } from '../src'; +import '../assets/index.less'; + +const renderTabBar = (props, DefaultTabBar) => ( + + {({ style }) => ( + + )} + +); + +export default () => { + return ( +
+ + + + Content of Tab Pane 1 + + + Content of Tab Pane 2 + + + Content of Tab Pane 3 + + + +
+ ); +}; diff --git a/examples/router.js b/examples/router.js deleted file mode 100644 index 8817f0b7..00000000 --- a/examples/router.js +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable no-console,react/button-has-type */ -import { Router, Route, IndexRoute, hashHistory } from 'react-router'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import '../assets/index.less'; -import TabContent from '../src/TabContent'; -import ScrollableInkTabBar from '../src/ScrollableInkTabBar'; - -const Tab1 = () =>
tab1
; -const Tab2 = () =>
tab2
; - -class App extends React.Component { - componentWillMount() { - this.data = [ - { - key: 'tab1', - component: , - }, - { - key: 'tab2', - component: , - }, - ]; - } - - onChange = key => { - // for demo, better use router api - window.location.hash = key; - }; - - render() { - let activeKey = 'tab1'; - const { children } = this.props; - if (children) { - this.data.forEach(d => { - if (d.component.type === children.type) { - // for demo, better immutable - d.component = children; - activeKey = d.key; - } - }); - } - const tabs = this.data.map(d => ( - - {d.component} - - )); - return ( - } - renderTabContent={() => } - > - {tabs} - - ); - } -} - -function Demo() { - return ( - - - - - - - - ); -} - -export default Demo; diff --git a/examples/rtl.js b/examples/rtl.js deleted file mode 100644 index 49d8ca08..00000000 --- a/examples/rtl.js +++ /dev/null @@ -1,242 +0,0 @@ -/* eslint-disable no-console,react/button-has-type,no-plusplus */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/TabContent'; -import SwipeableInkTabBar from '../src/SwipeableInkTabBar'; -import ScrollableTabBar from '../src/ScrollableTabBar'; -import InkTabBar from '../src/InkTabBar'; - -const arrowPath = - 'M869 487.8L491.2 159.9c-2.9-2.5-6.6-3.9-10.5-3.9h' + - '-88.5c-7.4 0-10.8 9.2-5.2 14l350.2 304H152c-4.4 0-8 3.6-8 8v' + - '60c0 4.4 3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2 14h91' + - '.5c1.9 0 3.8-0.7 5.2-2L869 536.2c14.7-12.8 14.7-35.6 0-48.4z'; - -const getSvg = (path, style = {}, svgStyle = {}) => ( - - - - - -); - -const next = getSvg( - arrowPath, - {}, - { - transform: 'scaleX(-1)', - }, -); -const prev = getSvg(arrowPath); - -const contentStyle = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100px', - backgroundColor: '#fff', -}; -const tabTitle = key =>
{`تب ${key}`}
; -const makeTabPane = key => ( - -
{`مطالب داخل تب ${key}`}
-
-); - -const makeMultiTabPane = count => { - const result = []; - for (let i = 0; i < count; i++) { - result.push(makeTabPane(i)); - } - return result; -}; -class PanelContent extends React.Component { - constructor(props) { - super(props); - console.log(this.props.id, 'constructor'); - } - - componentWillReceiveProps(nextProps) { - console.log(nextProps.id, 'componentWillReceiveProps'); - } - - render() { - const length = Math.round(10 * Math.random() + 4); - const count = new Array(length); // new Array(4) skip forEach .... - for (let i = 0; i < length; i++) { - count[i] = 1; - } - const content = new Array(Math.round(100 * Math.random()) + 4).join(` ${this.props.id}`); - const els = count.map((c, i) =>

{content}

); - return
{els}
; - } -} - -function construct(start, num) { - const ends = []; - let index = 1; - for (let i = start; i < start + num; i++) { - ends.push( - - - , - ); - index++; - } - return ends; -} - -class Demo extends React.Component { - state = { - tabBarPosition: 'top', - activeKey: '3', - start: 0, - useIcon: false, - }; - - onChange = key => { - console.log(`onChange ${key}`); - }; - - onChange2 = activeKey => { - this.setState({ activeKey }); - }; - - onTabClick = key => { - console.log(`onTabClick ${key}`); - }; - - tick = () => { - this.setState({ - start: this.state.start + 10, - }); - }; - - toggleCustomIcon = () => { - this.setState({ - useIcon: !this.state.useIcon, - }); - }; - - changeTabPosition = e => { - this.setState({ - tabBarPosition: e.target.value, - }); - }; - - scrollToActive = () => { - this.bar.scrollToActiveTab(); - }; - - switchToLast = ends => { - if (this.state.activeKey !== ends[ends.length - 1].key) { - this.setState({ activeKey: ends[ends.length - 1].key }, this.scrollToActive); - } else { - this.scrollToActive(); - } - }; - - saveBar = bar => { - this.bar = bar; - }; - - render() { - const { start, tabBarPosition } = this.state; - const ends = construct(start, 9); - const ends2 = construct(start, 3); - let style; - const contentStyleSwipeable = { - height: 400, - }; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - style = { - height: 400, - }; - } else { - style = { - width: 500, - }; - } - - const cls = (this.state.useIcon && 'rc-tabs-custom-icon') || undefined; - - const iconProps = this.state.useIcon - ? { - nextIcon: next, - prevIcon: prev, - } - : {}; - - return ( -
-

Basic Tabs With Ink Bar and tabBarGutter

-

- tabBarPosition: - -

-
- } - renderTabContent={() => } - onChange={this.onChange} - direction="rtl" - > - {ends2} - -
-

Scroll Tabs

-
- -
- - is using icon: {(this.state.useIcon && 'true') || 'false'} - ( - - )} - renderTabContent={() => } - onChange={this.onChange2} - direction="rtl" - > - {ends} - -
- -

Swipeable Tabs with inkBar

-
- } - renderTabContent={() => } - defaultActiveKey="2" - > - {makeMultiTabPane(11)} - -
- -
- ); - } -} - -export default Demo; diff --git a/examples/swipeInkTabBar.js b/examples/swipeInkTabBar.js deleted file mode 100644 index 7b6bf1b8..00000000 --- a/examples/swipeInkTabBar.js +++ /dev/null @@ -1,136 +0,0 @@ -/* eslint-disable no-console,react/button-has-type,no-plusplus,global-require,react/no-unescaped-entities,max-len */ -import '../assets/index.less'; -import React from 'react'; -import Tabs, { TabPane } from '../src'; -import TabContent from '../src/SwipeableTabContent'; -import SwipeableInkTabBar from '../src/SwipeableInkTabBar'; - -if (process.env.DEMO_ENV === 'preact') { - require('preact/devtools'); -} - -const contentStyle = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100px', - backgroundColor: '#fff', -}; - -const tabTitle = key =>
{`选项${key}`}
; -const makeTabPane = key => ( - -
{`选项${key}内容`}
-
-); - -const makeMultiTabPane = count => { - const result = []; - for (let i = 0; i < count; i++) { - result.push(makeTabPane(i)); - } - return result; -}; - -const Component = () => ( -
-

pageSize = 5, speed = 5

-
- } - renderTabContent={() => } - defaultActiveKey="8" - > - {makeMultiTabPane(11)} - -
-

pageSize = 3, speed = 10

-
- } - renderTabContent={() => } - defaultActiveKey="2" - > - {makeMultiTabPane(7)} - -
-

pageSize = 3, speed = 10, tabBarPosition='bottom'

-
- } - renderTabContent={() => } - defaultActiveKey="2" - > - {makeMultiTabPane(7)} - -
-

tabBarPosition='left'

-
- } - renderTabContent={() => } - defaultActiveKey="2" - > - {makeMultiTabPane(11)} - -
-

tabBarPosition='right'

-
- } - renderTabContent={() => } - defaultActiveKey="2" - > - {makeMultiTabPane(11)} - -
-

custom inkBar style

-
- ( - - )} - renderTabContent={() => } - defaultActiveKey="8" - > - {makeMultiTabPane(11)} - -
-

custom inkBar style, tabBarPosition='left'

-
- ( - - )} - renderTabContent={() => } - defaultActiveKey="2" - > - {makeMultiTabPane(11)} - -
-
-); - -export default Component; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..86627c33 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + setupFiles: ['./tests/setup.js'], + snapshotSerializers: [require.resolve('enzyme-to-json/serializer')], +}; diff --git a/package.json b/package.json index 01f268b5..7f53d98d 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,10 @@ }, "license": "MIT", "scripts": { - "start": "father doc dev --storybook", + "start": "cross-env NODE_ENV=development father doc dev --storybook", "build": "father doc build --storybook", "compile": "father build && npm run compile:style", "test": "father test", - "chrome-test": "rc-test run chrome-test", "coverage": "father test --coverage", "now-build": "npm run build", "lint": "eslint src/ examples/ --ext .tsx,.ts,.jsx,.js", @@ -41,13 +40,19 @@ "prepublishOnly": "npm run lint && npm run test && npm run compile && np --no-cleanup --yolo --no-publish" }, "devDependencies": { + "@types/classnames": "^2.2.10", + "@types/enzyme": "^3.10.5", + "@types/jest": "^25.2.3", + "@types/raf": "^3.4.0", + "@types/react": "^16.9.35", + "@types/react-dom": "^16.9.8", "@umijs/fabric": "^2.0.4", "coveralls": "^3.0.6", "cross-env": "^7.0.2", "enzyme": "^3.7.0", "enzyme-adapter-react-16": "^1.7.0", "enzyme-to-json": "^3.3.4", - "eslint": "^6.8.0", + "eslint": "^7.0.0", "fastclick": "~1.0.6", "father": "^2.29.2", "history": "^1.17.0", @@ -58,18 +63,22 @@ "preact": "^8.2.1", "preact-compat": "^3.16.0", "react": "^16.0.0", - "react-dnd": "^7.0.2", - "react-dnd-html5-backend": "^7.0.2", + "react-dnd": "^10.0.0", + "react-dnd-html5-backend": "^10.0.0", "react-dom": "^16.0.0", "react-router": "^3.0.0", + "react-sticky": "^6.0.3", "react-test-renderer": "^16.0.0", - "sortablejs": "^1.7.0" + "sortablejs": "^1.7.0", + "typescript": "^3.9.2" }, "dependencies": { "classnames": "2.x", - "lodash": "^4.17.5", - "rc-hammerjs": "~0.6.0", - "resize-observer-polyfill": "^1.5.1", - "warning": "^4.0.3" + "raf": "^3.4.1", + "rc-dropdown": "^3.1.0", + "rc-menu": "^8.2.1", + "rc-resize-observer": "^0.2.1", + "rc-trigger": "^4.2.1", + "rc-util": "^4.20.5" } } diff --git a/src/InkTabBar.js b/src/InkTabBar.js deleted file mode 100755 index 73325961..00000000 --- a/src/InkTabBar.js +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable react/prefer-stateless-function */ -import React from 'react'; -import InkTabBarNode from './InkTabBarNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import TabBarRootNode from './TabBarRootNode'; -import SaveRef from './SaveRef'; - -export default class InkTabBar extends React.Component { - render() { - return ( - - {(saveRef, getRef) => ( - - - - - )} - - ); - } -} - -InkTabBar.defaultProps = { - onTabClick: () => {}, -}; diff --git a/src/InkTabBarNode.js b/src/InkTabBarNode.js deleted file mode 100644 index f60eddd6..00000000 --- a/src/InkTabBarNode.js +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import { - setTransform, - isTransform3dSupported, - getLeft, - getStyle, - getTop, - getActiveIndex, -} from './utils'; - -function componentDidUpdate(component, init) { - const { styles, panels, activeKey, direction } = component.props; - const rootNode = component.props.getRef('root'); - const wrapNode = component.props.getRef('nav') || rootNode; - const inkBarNode = component.props.getRef('inkBar'); - const activeTab = component.props.getRef('activeTab'); - const inkBarNodeStyle = inkBarNode.style; - const { tabBarPosition } = component.props; - const activeIndex = getActiveIndex(panels, activeKey); - if (init) { - // prevent mount animation - inkBarNodeStyle.display = 'none'; - } - if (activeTab) { - const tabNode = activeTab; - const transformSupported = isTransform3dSupported(inkBarNodeStyle); - - // Reset current style - setTransform(inkBarNodeStyle, ''); - inkBarNodeStyle.width = ''; - inkBarNodeStyle.height = ''; - inkBarNodeStyle.left = ''; - inkBarNodeStyle.top = ''; - inkBarNodeStyle.bottom = ''; - inkBarNodeStyle.right = ''; - - if (tabBarPosition === 'top' || tabBarPosition === 'bottom') { - let left = getLeft(tabNode, wrapNode); - let width = tabNode.offsetWidth; - - // If tabNode'width width equal to wrapNode'width when tabBarPosition is top or bottom - // It means no css working, then ink bar should not have width until css is loaded - // Fix https://github.com/ant-design/ant-design/issues/7564 - if (width === rootNode.offsetWidth) { - width = 0; - } else if (styles.inkBar && styles.inkBar.width !== undefined) { - width = parseFloat(styles.inkBar.width, 10); - if (width) { - left += (tabNode.offsetWidth - width) / 2; - } - } - if (direction === 'rtl') { - left = getStyle(tabNode, 'margin-left') - left; - } - // use 3d gpu to optimize render - if (transformSupported) { - setTransform(inkBarNodeStyle, `translate3d(${left}px,0,0)`); - } else { - inkBarNodeStyle.left = `${left}px`; - } - inkBarNodeStyle.width = `${width}px`; - } else { - let top = getTop(tabNode, wrapNode, true); - let height = tabNode.offsetHeight; - if (styles.inkBar && styles.inkBar.height !== undefined) { - height = parseFloat(styles.inkBar.height, 10); - if (height) { - top += (tabNode.offsetHeight - height) / 2; - } - } - if (transformSupported) { - setTransform(inkBarNodeStyle, `translate3d(0,${top}px,0)`); - inkBarNodeStyle.top = '0'; - } else { - inkBarNodeStyle.top = `${top}px`; - } - inkBarNodeStyle.height = `${height}px`; - } - } - inkBarNodeStyle.display = activeIndex !== -1 ? 'block' : 'none'; -} - -export default class InkTabBarNode extends React.Component { - componentDidMount() { - // ref https://github.com/ant-design/ant-design/issues/8678 - // ref https://github.com/react-component/tabs/issues/135 - // InkTabBarNode need parent/root ref for calculating position - // since parent componentDidMount triggered after child componentDidMount - // we're doing a quick fix here to use setTimeout to calculate position - // after parent/root component mounted - this.timeout = setTimeout(() => { - componentDidUpdate(this, true); - }, 0); - } - - componentDidUpdate() { - componentDidUpdate(this); - } - - componentWillUnmount() { - clearTimeout(this.timeout); - } - - render() { - const { prefixCls, styles, inkBarAnimated } = this.props; - const className = `${prefixCls}-ink-bar`; - const classes = classnames({ - [className]: true, - [inkBarAnimated ? `${className}-animated` : `${className}-no-animated`]: true, - }); - return ( -
- ); - } -} - -InkTabBarNode.defaultProps = { - prefixCls: '', - inkBarAnimated: true, - styles: {}, - saveRef: () => {}, -}; diff --git a/src/KeyCode.js b/src/KeyCode.js deleted file mode 100755 index 096935d6..00000000 --- a/src/KeyCode.js +++ /dev/null @@ -1,18 +0,0 @@ -export default { - /** - * LEFT - */ - LEFT: 37, // also NUM_WEST - /** - * UP - */ - UP: 38, // also NUM_NORTH - /** - * RIGHT - */ - RIGHT: 39, // also NUM_EAST - /** - * DOWN - */ - DOWN: 40, // also NUM_SOUTH -}; diff --git a/src/SaveRef.js b/src/SaveRef.js deleted file mode 100644 index 2ba0e7a3..00000000 --- a/src/SaveRef.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -export default class SaveRef extends React.Component { - getRef = name => this[name]; - - saveRef = name => node => { - if (node) { - this[name] = node; - } - }; - - render() { - return this.props.children(this.saveRef, this.getRef); - } -} - -SaveRef.defaultProps = { - children: () => null, -}; diff --git a/src/ScrollableInkTabBar.js b/src/ScrollableInkTabBar.js deleted file mode 100755 index c5c55d03..00000000 --- a/src/ScrollableInkTabBar.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable react/prefer-stateless-function */ -import React from 'react'; -import InkTabBarNode from './InkTabBarNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import TabBarRootNode from './TabBarRootNode'; -import ScrollableTabBarNode from './ScrollableTabBarNode'; -import SaveRef from './SaveRef'; - -export default class ScrollableInkTabBar extends React.Component { - render() { - const { children: renderTabBarNode, ...restProps } = this.props; - - return ( - - {(saveRef, getRef) => ( - - - - - - - )} - - ); - } -} \ No newline at end of file diff --git a/src/ScrollableTabBar.js b/src/ScrollableTabBar.js deleted file mode 100755 index fd5e1372..00000000 --- a/src/ScrollableTabBar.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable react/prefer-stateless-function */ -import React from 'react'; -import ScrollableTabBarNode from './ScrollableTabBarNode'; -import TabBarRootNode from './TabBarRootNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import SaveRef from './SaveRef'; - -export default class ScrollableTabBar extends React.Component { - render() { - return ( - - {(saveRef, getRef) => ( - - - - - - )} - - ); - } -} diff --git a/src/ScrollableTabBarNode.js b/src/ScrollableTabBarNode.js deleted file mode 100755 index 1481f307..00000000 --- a/src/ScrollableTabBarNode.js +++ /dev/null @@ -1,313 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import debounce from 'lodash/debounce'; -import ResizeObserver from 'resize-observer-polyfill'; -import { setTransform, isTransform3dSupported } from './utils'; - -export default class ScrollableTabBarNode extends React.Component { - constructor(props) { - super(props); - this.offset = 0; - - this.state = { - next: false, - prev: false, - }; - } - - componentDidMount() { - this.componentDidUpdate(); - this.debouncedResize = debounce(() => { - this.setNextPrev(); - this.scrollToActiveTab(); - }, 200); - this.resizeObserver = new ResizeObserver(this.debouncedResize); - this.resizeObserver.observe(this.props.getRef('container')); - } - - componentDidUpdate(prevProps) { - const { props } = this; - if (prevProps && prevProps.tabBarPosition !== props.tabBarPosition) { - this.setOffset(0); - return; - } - const nextPrev = this.setNextPrev(); - // wait next, prev show hide - /* eslint react/no-did-update-set-state:0 */ - if (this.isNextPrevShown(this.state) !== this.isNextPrevShown(nextPrev)) { - this.setState({}, this.scrollToActiveTab); - } else if (!prevProps || props.activeKey !== prevProps.activeKey) { - // can not use props.activeKey - this.scrollToActiveTab(); - } - } - - componentWillUnmount() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - } - if (this.debouncedResize && this.debouncedResize.cancel) { - this.debouncedResize.cancel(); - } - } - - setNextPrev() { - const navNode = this.props.getRef('nav'); - const navTabsContainer = this.props.getRef('navTabsContainer'); - const navNodeWH = this.getScrollWH(navTabsContainer || navNode); - // Add 1px to fix `offsetWidth` with decimal in Chrome not correct handle - // https://github.com/ant-design/ant-design/issues/13423 - const containerWH = this.getOffsetWH(this.props.getRef('container')) + 1; - const navWrapNodeWH = this.getOffsetWH(this.props.getRef('navWrap')); - let { offset } = this; - const minOffset = containerWH - navNodeWH; - let { next, prev } = this.state; - if (minOffset >= 0) { - next = false; - this.setOffset(0, false); - offset = 0; - } else if (minOffset < offset) { - next = true; - } else { - next = false; - // Fix https://github.com/ant-design/ant-design/issues/8861 - // Test with container offset which is stable - // and set the offset of the nav wrap node - const realOffset = navWrapNodeWH - navNodeWH; - this.setOffset(realOffset, false); - offset = realOffset; - } - - if (offset < 0) { - prev = true; - } else { - prev = false; - } - - this.setNext(next); - this.setPrev(prev); - return { - next, - prev, - }; - } - - getOffsetWH(node) { - const { tabBarPosition } = this.props; - let prop = 'offsetWidth'; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - prop = 'offsetHeight'; - } - return node[prop]; - } - - getScrollWH(node) { - const { tabBarPosition } = this.props; - let prop = 'scrollWidth'; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - prop = 'scrollHeight'; - } - return node[prop]; - } - - getOffsetLT(node) { - const { tabBarPosition } = this.props; - let prop = 'left'; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - prop = 'top'; - } - return node.getBoundingClientRect()[prop]; - } - - setOffset(offset, checkNextPrev = true) { - let target = Math.min(0, offset); - if (this.offset !== target) { - this.offset = target; - let navOffset = {}; - const { tabBarPosition } = this.props; - const navStyle = this.props.getRef('nav').style; - const transformSupported = isTransform3dSupported(navStyle); - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - if (transformSupported) { - navOffset = { - value: `translate3d(0,${target}px,0)`, - }; - } else { - navOffset = { - name: 'top', - value: `${target}px`, - }; - } - } else if (transformSupported) { - if (this.props.direction === 'rtl') { - target = -target; - } - navOffset = { - value: `translate3d(${target}px,0,0)`, - }; - } else { - navOffset = { - name: 'left', - value: `${target}px`, - }; - } - if (transformSupported) { - setTransform(navStyle, navOffset.value); - } else { - navStyle[navOffset.name] = navOffset.value; - } - if (checkNextPrev) { - this.setNextPrev(); - } - } - } - - setPrev(v) { - if (this.state.prev !== v) { - this.setState({ - prev: v, - }); - } - } - - setNext(v) { - if (this.state.next !== v) { - this.setState({ - next: v, - }); - } - } - - isNextPrevShown(state) { - if (state) { - return state.next || state.prev; - } - return this.state.next || this.state.prev; - } - - prevTransitionEnd = e => { - if (e.propertyName !== 'opacity') { - return; - } - const container = this.props.getRef('container'); - this.scrollToActiveTab({ - target: container, - currentTarget: container, - }); - }; - - scrollToActiveTab = e => { - const activeTab = this.props.getRef('activeTab'); - const navWrap = this.props.getRef('navWrap'); - if ((e && e.target !== e.currentTarget) || !activeTab) { - return; - } - - // when not scrollable or enter scrollable first time, don't emit scrolling - const needToSroll = this.isNextPrevShown() && this.lastNextPrevShown; - this.lastNextPrevShown = this.isNextPrevShown(); - if (!needToSroll) { - return; - } - - const activeTabWH = this.getScrollWH(activeTab); - const navWrapNodeWH = this.getOffsetWH(navWrap); - let { offset } = this; - const wrapOffset = this.getOffsetLT(navWrap); - const activeTabOffset = this.getOffsetLT(activeTab); - if (wrapOffset > activeTabOffset) { - offset += wrapOffset - activeTabOffset; - this.setOffset(offset); - } else if (wrapOffset + navWrapNodeWH < activeTabOffset + activeTabWH) { - offset -= activeTabOffset + activeTabWH - (wrapOffset + navWrapNodeWH); - this.setOffset(offset); - } - }; - - prev = e => { - this.props.onPrevClick(e); - const navWrapNode = this.props.getRef('navWrap'); - const navWrapNodeWH = this.getOffsetWH(navWrapNode); - const { offset } = this; - this.setOffset(offset + navWrapNodeWH); - }; - - next = e => { - this.props.onNextClick(e); - const navWrapNode = this.props.getRef('navWrap'); - const navWrapNodeWH = this.getOffsetWH(navWrapNode); - const { offset } = this; - this.setOffset(offset - navWrapNodeWH); - }; - - render() { - const { next, prev } = this.state; - const { prefixCls, scrollAnimated, navWrapper, prevIcon, nextIcon } = this.props; - const showNextPrev = prev || next; - - const prevButton = ( - - {prevIcon || } - - ); - - const nextButton = ( - - {nextIcon || } - - ); - - const navClassName = `${prefixCls}-nav`; - const navClasses = classnames({ - [navClassName]: true, - [scrollAnimated ? `${navClassName}-animated` : `${navClassName}-no-animated`]: true, - }); - - return ( -
- {prevButton} - {nextButton} -
-
-
- {navWrapper(this.props.children)} -
-
-
-
- ); - } -} - -ScrollableTabBarNode.defaultProps = { - tabBarPosition: 'left', - prefixCls: '', - scrollAnimated: true, - onPrevClick: () => {}, - onNextClick: () => {}, - navWrapper: ele => ele, -}; diff --git a/src/SwipeableInkTabBar.js b/src/SwipeableInkTabBar.js deleted file mode 100755 index 0b7f53eb..00000000 --- a/src/SwipeableInkTabBar.js +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable react/prefer-stateless-function */ -import React from 'react'; -import SwipeableTabBarNode from './SwipeableTabBarNode'; -import TabBarSwipeableTabs from './TabBarSwipeableTabs'; -import TabBarRootNode from './TabBarRootNode'; -import InkTabBarNode from './InkTabBarNode'; -import SaveRef from './SaveRef'; - -export default class SwipeableInkTabBar extends React.Component { - render() { - return ( - - {(saveRef, getRef) => ( - - - - - - - )} - - ); - } -} diff --git a/src/SwipeableTabBarNode.js b/src/SwipeableTabBarNode.js deleted file mode 100755 index 0361d746..00000000 --- a/src/SwipeableTabBarNode.js +++ /dev/null @@ -1,218 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import Hammer from 'rc-hammerjs'; -import ReactDOM from 'react-dom'; -import { isVertical, getStyle, setPxStyle } from './utils'; - -export default class SwipeableTabBarNode extends React.Component { - constructor(props) { - super(props); - - const { hasPrevPage, hasNextPage } = this.checkPaginationByKey(this.props.activeKey); - this.state = { - hasPrevPage, - hasNextPage, - }; - } - - componentDidMount() { - const swipe = this.props.getRef('swipe'); - const nav = this.props.getRef('nav'); - const { activeKey } = this.props; - this.swipeNode = ReactDOM.findDOMNode(swipe); // dom which scroll (9999px) - this.realNode = ReactDOM.findDOMNode(nav); // dom which visiable in screen (viewport) - this.setCache(); - this.setSwipePositionByKey(activeKey); - } - - componentDidUpdate(prevProps) { - this.setCache(); - if ( - (this.props.activeKey && this.props.activeKey !== prevProps.activeKey) || - this.props.panels.length !== prevProps.panels.length || - this.props.pageSize !== prevProps.pageSize - ) { - this.setSwipePositionByKey(this.props.activeKey); - } - } - - onPan = e => { - const { vertical, totalAvaliableDelta, totalDelta } = this.cache; - const { speed } = this.props; - // calculate touch distance - let nowDelta = vertical ? e.deltaY : e.deltaX; - nowDelta *= speed / 10; - - // calculate distance dom need transform - let _nextDelta = nowDelta + totalDelta; - - if (this.isRtl()) { - // calculate distance from right when direction is right-to-left - if (_nextDelta <= 0) { - _nextDelta = 0; - } else if (_nextDelta >= totalAvaliableDelta) { - _nextDelta = totalAvaliableDelta; - } - } - // calculate distance from left when direction is left-to-right - else if (_nextDelta >= 0) { - _nextDelta = 0; - } else if (_nextDelta <= -totalAvaliableDelta) { - _nextDelta = -totalAvaliableDelta; - } - - this.cache.totalDelta = _nextDelta; - this.setSwipePosition(); - - // calculate pagination display - const { hasPrevPage, hasNextPage } = this.checkPaginationByDelta(this.cache.totalDelta); - if (hasPrevPage !== this.state.hasPrevPage || hasNextPage !== this.state.hasNextPage) { - this.setState({ - hasPrevPage, - hasNextPage, - }); - } - }; - - setCache() { - const { tabBarPosition, pageSize, panels } = this.props; - const _isVertical = isVertical(tabBarPosition); - const _viewSize = getStyle(this.realNode, _isVertical ? 'height' : 'width'); - const _tabWidth = _viewSize / pageSize; - this.cache = { - ...this.cache, - vertical: _isVertical, - totalAvaliableDelta: _tabWidth * panels.length - _viewSize, - tabWidth: _tabWidth, - }; - } - - /** - * used for props.activeKey setting, not for swipe callback - */ - getDeltaByKey(activeKey) { - const { pageSize } = this.props; - const index = this.getIndexByKey(activeKey); - const centerTabCount = Math.floor(pageSize / 2); - const { tabWidth } = this.cache; - let delta = (index - centerTabCount) * tabWidth; - // in rtl direction tabs are ordered from right to left, so delta should be positive in order to - // push swiped element to righ side (start of view) - if (!this.isRtl()) { - delta *= -1; - } - return delta; - } - - getIndexByKey(activeKey) { - const { panels } = this.props; - const { length } = panels; - for (let i = 0; i < length; i++) { - if (panels[i].key === activeKey) { - return i; - } - } - return -1; - } - - setSwipePositionByKey(activeKey) { - const { hasPrevPage, hasNextPage } = this.checkPaginationByKey(activeKey); - const { totalAvaliableDelta } = this.cache; - this.setState({ - hasPrevPage, - hasNextPage, - }); - let delta; - if (!hasPrevPage) { - // the first page - delta = 0; - } else if (!hasNextPage) { - // the last page - delta = this.isRtl() ? totalAvaliableDelta : -totalAvaliableDelta; - } else if (hasNextPage) { - // the middle page - delta = this.getDeltaByKey(activeKey); - } - this.cache.totalDelta = delta; - this.setSwipePosition(); - } - - setSwipePosition() { - const { totalDelta, vertical } = this.cache; - setPxStyle(this.swipeNode, totalDelta, vertical); - } - - checkPaginationByKey(activeKey) { - const { panels, pageSize } = this.props; - const index = this.getIndexByKey(activeKey); - const centerTabCount = Math.floor(pageSize / 2); - // the basic rule is to make activeTab be shown in the center of TabBar viewport - return { - hasPrevPage: index - centerTabCount > 0, - hasNextPage: index + centerTabCount < panels.length, - }; - } - - checkPaginationByDelta(delta) { - const { totalAvaliableDelta } = this.cache; - return { - hasPrevPage: delta < 0, - hasNextPage: -delta < totalAvaliableDelta, - }; - } - - isRtl() { - return this.props.direction === 'rtl'; - } - - render() { - const { prefixCls, hammerOptions, tabBarPosition } = this.props; - const { hasPrevPage, hasNextPage } = this.state; - const navClassName = `${prefixCls}-nav`; - const navClasses = classnames({ - [navClassName]: true, - }); - const events = { - onPan: this.onPan, - }; - return ( -
-
- -
-
- {this.props.children} -
-
-
-
-
- ); - } -} - -SwipeableTabBarNode.defaultProps = { - panels: null, - tabBarPosition: 'top', - prefixCls: '', - children: null, - hammerOptions: {}, - pageSize: 5, // per page show how many tabs - speed: 7, // swipe speed, 1 to 10, more bigger more faster - saveRef: () => {}, - getRef: () => {}, -}; diff --git a/src/SwipeableTabContent.js b/src/SwipeableTabContent.js deleted file mode 100755 index c5a3ed62..00000000 --- a/src/SwipeableTabContent.js +++ /dev/null @@ -1,163 +0,0 @@ -import React from 'react'; -import Hammer from 'rc-hammerjs'; -import ReactDOM from 'react-dom'; -import TabContent from './TabContent'; -import { - isVertical, - getActiveIndex, - getTransformByIndex, - setTransform, - getActiveKey, - toArray, - setTransition, -} from './utils'; - -const RESISTANCE_COEF = 0.6; - -function computeIndex({ maxIndex, startIndex, delta, viewSize }) { - let index = startIndex + -delta / viewSize; - if (index < 0) { - index = Math.exp(index * RESISTANCE_COEF) - 1; - } else if (index > maxIndex) { - index = maxIndex + 1 - Math.exp((maxIndex - index) * RESISTANCE_COEF); - } - return index; -} - -function getIndexByDelta(e) { - const delta = isVertical(this.props.tabBarPosition) ? e.deltaY : e.deltaX; - const otherDelta = isVertical(this.props.tabBarPosition) ? e.deltaX : e.deltaY; - if (Math.abs(delta) < Math.abs(otherDelta)) { - return undefined; - } - const currentIndex = computeIndex({ - maxIndex: this.maxIndex, - viewSize: this.viewSize, - startIndex: this.startIndex, - delta, - }); - let showIndex = delta < 0 ? Math.floor(currentIndex + 1) : Math.floor(currentIndex); - if (showIndex < 0) { - showIndex = 0; - } else if (showIndex > this.maxIndex) { - showIndex = this.maxIndex; - } - if (this.children[showIndex].props.disabled) { - return undefined; - } - return currentIndex; -} - -export default class SwipeableTabContent extends React.Component { - componentDidMount() { - this.rootNode = ReactDOM.findDOMNode(this); - } - - onPanStart = () => { - const { tabBarPosition, children, activeKey, animated } = this.props; - this.startIndex = getActiveIndex(children, activeKey); - const { startIndex } = this; - if (startIndex === -1) { - return; - } - if (animated) { - setTransition(this.rootNode.style, 'none'); - } - this.startDrag = true; - this.children = toArray(children); - this.maxIndex = this.children.length - 1; - this.viewSize = isVertical(tabBarPosition) - ? this.rootNode.offsetHeight - : this.rootNode.offsetWidth; - }; - - onPan = e => { - if (!this.startDrag) { - return; - } - const { tabBarPosition } = this.props; - const currentIndex = getIndexByDelta.call(this, e); - if (currentIndex !== undefined) { - setTransform(this.rootNode.style, getTransformByIndex(currentIndex, tabBarPosition)); - } - }; - - onPanEnd = e => { - if (!this.startDrag) { - return; - } - this.end(e); - }; - - onSwipe = e => { - this.end(e, true); - }; - - end = (e, swipe) => { - const { tabBarPosition, animated } = this.props; - this.startDrag = false; - if (animated) { - setTransition(this.rootNode.style, ''); - } - const currentIndex = getIndexByDelta.call(this, e); - let finalIndex = this.startIndex; - if (currentIndex !== undefined) { - if (currentIndex < 0) { - finalIndex = 0; - } else if (currentIndex > this.maxIndex) { - finalIndex = this.maxIndex; - } else if (swipe) { - const delta = isVertical(tabBarPosition) ? e.deltaY : e.deltaX; - finalIndex = delta < 0 ? Math.ceil(currentIndex) : Math.floor(currentIndex); - } else { - const floorIndex = Math.floor(currentIndex); - if (currentIndex - floorIndex > 0.6) { - finalIndex = floorIndex + 1; - } else { - finalIndex = floorIndex; - } - } - } - if (this.children[finalIndex].props.disabled) { - return; - } - if (this.startIndex === finalIndex) { - if (animated) { - setTransform( - this.rootNode.style, - getTransformByIndex(finalIndex, this.props.tabBarPosition), - ); - } - } else { - this.props.onChange(getActiveKey(this.props.children, finalIndex)); - } - }; - - render() { - const { tabBarPosition, hammerOptions, animated } = this.props; - let events = { - onSwipe: this.onSwipe, - onPanStart: this.onPanStart, - }; - if (animated !== false) { - events = { - ...events, - onPan: this.onPan, - onPanEnd: this.onPanEnd, - }; - } - return ( - - - - ); - } -} - -SwipeableTabContent.defaultProps = { - animated: true, -}; diff --git a/src/TabBar.js b/src/TabBar.js deleted file mode 100644 index 1bb65335..00000000 --- a/src/TabBar.js +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable react/prefer-stateless-function */ -import React from 'react'; -import TabBarRootNode from './TabBarRootNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import SaveRef from './SaveRef'; - -export default class TabBar extends React.Component { - render() { - return ( - - {(saveRef, getRef) => ( - - - - )} - - ); - } -} diff --git a/src/TabBarRootNode.js b/src/TabBarRootNode.js deleted file mode 100644 index ba2adb2f..00000000 --- a/src/TabBarRootNode.js +++ /dev/null @@ -1,73 +0,0 @@ -import React, { cloneElement } from 'react'; -import classnames from 'classnames'; -import { getDataAttr } from './utils'; - -export default class TabBarRootNode extends React.Component { - getExtraContentStyle = () => { - const { tabBarPosition, direction } = this.props; - const topOrBottom = tabBarPosition === 'top' || tabBarPosition === 'bottom'; - if (direction === 'rtl') { - return topOrBottom ? { float: 'left' } : {}; - } - return topOrBottom ? { float: 'right' } : {}; - }; - - render() { - const { - prefixCls, - onKeyDown, - className, - extraContent, - style, - tabBarPosition, - children, - direction, - ...restProps - } = this.props; - const cls = classnames(`${prefixCls}-bar`, { - [className]: !!className, - }); - const topOrBottom = tabBarPosition === 'top' || tabBarPosition === 'bottom'; - const extraContentStyle = extraContent && extraContent.props ? extraContent.props.style : {}; - let newChildren = children; - if (extraContent) { - newChildren = [ - cloneElement(extraContent, { - key: 'extra', - onKeyDown: e => e.stopPropagation(), - style: { - ...this.getExtraContentStyle(topOrBottom, direction), - ...extraContentStyle, - }, - }), - cloneElement(children, { key: 'content' }), - ]; - newChildren = topOrBottom ? newChildren : newChildren.reverse(); - } - return ( -
- {newChildren} -
- ); - } -} - -TabBarRootNode.defaultProps = { - prefixCls: '', - className: '', - style: {}, - tabBarPosition: 'top', - extraContent: null, - children: null, - onKeyDown: () => {}, - saveRef: () => {}, - getRef: () => {}, -}; diff --git a/src/TabBarSwipeableTabs.js b/src/TabBarSwipeableTabs.js deleted file mode 100644 index cb93938d..00000000 --- a/src/TabBarSwipeableTabs.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; - -export default class TabBarSwipeableTabs extends React.Component { - render() { - const { props } = this; - const children = props.panels; - const { activeKey } = props; - const rst = []; - const { prefixCls } = props; - - const _flexWidth = `${(1 / props.pageSize) * 100}%`; - const tabStyle = { - WebkitFlexBasis: _flexWidth, - flexBasis: _flexWidth, - }; - - React.Children.forEach(children, child => { - if (!child) { - return; - } - const { key } = child; - const cls = classnames(`${prefixCls}-tab`, { - [`${prefixCls}-tab-active`]: activeKey === key, - [`${prefixCls}-tab-disabled`]: child.props.disabled, - }); - let events = {}; - if (!child.props.disabled) { - events = { - onClick: this.props.onTabClick.bind(this, key), - }; - } - const refProps = {}; - if (activeKey === key) { - refProps.ref = this.props.saveRef('activeTab'); - } - const id = this.props.id ? `${key}-${this.props.id}` : key; - rst.push( - , - ); - }); - - return rst; - } -} - -TabBarSwipeableTabs.defaultProps = { - pageSize: 5, - onTabClick: () => {}, - saveRef: () => {}, -}; diff --git a/src/TabBarTabsNode.js b/src/TabBarTabsNode.js deleted file mode 100644 index 7685d1b6..00000000 --- a/src/TabBarTabsNode.js +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import warning from 'warning'; -import { isVertical } from './utils'; - -export default class TabBarTabsNode extends React.Component { - render() { - const { - panels: children, - activeKey, - prefixCls, - tabBarGutter, - saveRef, - tabBarPosition, - renderTabBarNode, - direction, - } = this.props; - const rst = []; - - React.Children.forEach(children, (child, index) => { - if (!child) { - return; - } - const { key } = child; - let cls = activeKey === key ? `${prefixCls}-tab-active` : ''; - cls += ` ${prefixCls}-tab`; - let events = {}; - if (child.props.disabled) { - cls += ` ${prefixCls}-tab-disabled`; - } else { - events = { - onClick: this.props.onTabClick.bind(this, key), - }; - } - const ref = {}; - if (activeKey === key) { - ref.ref = saveRef('activeTab'); - } - - const gutter = tabBarGutter && index === children.length - 1 ? 0 : tabBarGutter; - - const marginProperty = direction === 'rtl' ? 'marginLeft' : 'marginRight'; - const style = { - [isVertical(tabBarPosition) ? 'marginBottom' : marginProperty]: gutter, - }; - warning('tab' in child.props, 'There must be `tab` property on children of Tabs.'); - - const id = this.props.id ? `${key}-${this.props.id}` : key; - - let node = ( - - ); - - if (renderTabBarNode) { - node = renderTabBarNode(node); - } - - rst.push(node); - }); - - return
{rst}
; - } -} - -TabBarTabsNode.defaultProps = { - panels: [], - prefixCls: [], - tabBarGutter: null, - onTabClick: () => {}, - saveRef: () => {}, -}; diff --git a/src/TabContent.js b/src/TabContent.js deleted file mode 100755 index 60fc9397..00000000 --- a/src/TabContent.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import { - getTransformByIndex, - getActiveIndex, - getTransformPropValue, - getMarginStyle, -} from './utils'; - -export default class TabContent extends React.Component { - getTabPanes() { - const { props } = this; - const { activeKey } = props; - const { children } = props; - const newChildren = []; - - React.Children.forEach(children, child => { - if (!child) { - return; - } - const { key } = child; - const active = activeKey === key; - newChildren.push( - React.cloneElement(child, { - active, - destroyInactiveTabPane: props.destroyInactiveTabPane, - rootPrefixCls: props.prefixCls, - id: props.id, - }), - ); - }); - - return newChildren; - } - - render() { - const { props } = this; - const { - prefixCls, - children, - activeKey, - className, - tabBarPosition, - animated, - animatedWithMargin, - direction, - } = props; - let { style } = props; - const classes = classnames( - { - [`${prefixCls}-content`]: true, - [animated ? `${prefixCls}-content-animated` : `${prefixCls}-content-no-animated`]: true, - }, - className, - ); - if (animated) { - const activeIndex = getActiveIndex(children, activeKey); - if (activeIndex !== -1) { - const animatedStyle = animatedWithMargin - ? getMarginStyle(activeIndex, tabBarPosition, direction) - : getTransformPropValue(getTransformByIndex(activeIndex, tabBarPosition, direction)); - style = { - ...style, - ...animatedStyle, - }; - } else { - style = { - ...style, - display: 'none', - }; - } - } - return ( -
- {this.getTabPanes()} -
- ); - } -} - -TabContent.defaultProps = { - animated: true, -}; diff --git a/src/TabContext.ts b/src/TabContext.ts new file mode 100644 index 00000000..7a5eee8c --- /dev/null +++ b/src/TabContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import { Tab } from './interface'; + +export interface TabContextProps { + tabs: Tab[]; + prefixCls: string; +} + +export default createContext(null); diff --git a/src/TabNavList/AddButton.tsx b/src/TabNavList/AddButton.tsx new file mode 100644 index 00000000..69b67a97 --- /dev/null +++ b/src/TabNavList/AddButton.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { EditableConfig, TabsLocale } from '../interface'; + +export interface AddButtonProps { + prefixCls: string; + editable?: EditableConfig; + locale?: TabsLocale; + style?: React.CSSProperties; +} + +function AddButton( + { prefixCls, editable, locale, style }: AddButtonProps, + ref: React.Ref, +) { + if (!editable || editable.showAdd === false) { + return null; + } + + return ( + + ); +} + +export default React.forwardRef(AddButton); diff --git a/src/TabNavList/OperationNode.tsx b/src/TabNavList/OperationNode.tsx new file mode 100644 index 00000000..c19aad6b --- /dev/null +++ b/src/TabNavList/OperationNode.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { useState, useEffect } from 'react'; +import KeyCode from 'rc-util/lib/KeyCode'; +import Menu, { MenuItem } from 'rc-menu'; +import Dropdown from 'rc-dropdown'; +import { Tab, TabsLocale, EditableConfig } from '../interface'; +import AddButton from './AddButton'; + +export interface OperationNodeProps { + prefixCls: string; + className?: string; + style?: React.CSSProperties; + id: string; + tabs: Tab[]; + rtl: boolean; + tabBarGutter?: number; + activeKey: string; + mobile: boolean; + moreIcon?: React.ReactNode; + editable?: EditableConfig; + locale?: TabsLocale; + onTabClick: (key: React.Key, e: React.MouseEvent | React.KeyboardEvent) => void; +} + +function OperationNode( + { + prefixCls, + id, + tabs, + locale, + mobile, + moreIcon = 'More', + style, + className, + editable, + tabBarGutter, + rtl, + onTabClick, + }: OperationNodeProps, + ref: React.Ref, +) { + // ======================== Dropdown ======================== + const [open, setOpen] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + + const popupId = `${id}-more-popup`; + const dropdownPrefix = `${prefixCls}-dropdown`; + const selectedItemId = selectedKey !== null ? `${popupId}-${selectedKey}` : null; + + const dropdownAriaLabel = locale?.dropdownAriaLabel; + + const menu = ( + { + onTabClick(key, domEvent); + setOpen(false); + }} + id={popupId} + tabIndex={-1} + role="listbox" + aria-activedescendant={selectedItemId} + selectedKeys={[selectedKey]} + aria-label={dropdownAriaLabel !== undefined ? dropdownAriaLabel : 'expanded dropdown'} + > + {tabs.map(tab => ( + + {tab.tab} + + ))} + + ); + + function selectOffset(offset: -1 | 1) { + const enabledTabs = tabs.filter(tab => !tab.disabled); + let selectedIndex = enabledTabs.findIndex(tab => tab.key === selectedKey) || 0; + const len = enabledTabs.length; + + for (let i = 0; i < len; i += 1) { + selectedIndex = (selectedIndex + offset + len) % len; + const tab = enabledTabs[selectedIndex]; + if (!tab.disabled) { + setSelectedKey(tab.key); + return; + } + } + } + + function onKeyDown(e: React.KeyboardEvent) { + const { which } = e; + + if (!open) { + if ([KeyCode.DOWN, KeyCode.SPACE, KeyCode.ENTER].includes(which)) { + setOpen(true); + e.preventDefault(); + } + return; + } + + switch (which) { + case KeyCode.UP: + selectOffset(-1); + e.preventDefault(); + break; + case KeyCode.DOWN: + selectOffset(1); + e.preventDefault(); + break; + case KeyCode.ESC: + setOpen(false); + break; + case KeyCode.SPACE: + case KeyCode.ENTER: + if (selectedKey !== null) onTabClick(selectedKey, e); + break; + } + } + + // ========================= Effect ========================= + useEffect(() => { + // We use query element here to avoid React strict warning + const ele = document.getElementById(selectedItemId); + if (ele && ele.scrollIntoView) { + ele.scrollIntoView(false); + } + }, [selectedKey]); + + useEffect(() => { + if (!open) { + setSelectedKey(null); + } + }, [open]); + + // ========================= Render ========================= + const moreStyle: React.CSSProperties = { + [rtl ? 'marginLeft' : 'marginRight']: tabBarGutter, + }; + if (!tabs.length) { + moreStyle.visibility = 'hidden'; + moreStyle.order = 1; + } + + const moreNode: React.ReactElement = mobile ? null : ( + + + + ); + + return ( +
+ {moreNode} + +
+ ); +} + +export default React.forwardRef(OperationNode); diff --git a/src/TabNavList/TabNode.tsx b/src/TabNavList/TabNode.tsx new file mode 100644 index 00000000..be835a28 --- /dev/null +++ b/src/TabNavList/TabNode.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import KeyCode from 'rc-util/lib/KeyCode'; +import { Tab, TabPosition, EditableConfig } from '../interface'; + +export interface TabNodeProps { + id: string; + prefixCls: string; + tab: Tab; + active: boolean; + rtl: boolean; + closable?: boolean; + editable?: EditableConfig; + onClick?: React.MouseEventHandler; + onResize?: (width: number, height: number, left: number, top: number) => void; + tabBarGutter?: number; + tabPosition: TabPosition; + renderWrapper?: (node: React.ReactElement) => React.ReactElement; + removeAriaLabel?: string; + removeIcon?: React.ReactNode; + onRemove: () => void; + onFocus: React.FocusEventHandler; +} + +function TabNode( + { + prefixCls, + id, + active, + rtl, + tab: { key, tab, disabled }, + tabBarGutter, + tabPosition, + closable, + renderWrapper, + removeAriaLabel, + editable, + onClick, + onRemove, + onFocus, + }: TabNodeProps, + ref: React.Ref, +) { + const tabPrefix = `${prefixCls}-tab`; + + React.useEffect(() => onRemove, []); + + const nodeStyle: React.CSSProperties = {}; + if (tabPosition === 'top' || tabPosition === 'bottom') { + nodeStyle[rtl ? 'marginLeft' : 'marginRight'] = tabBarGutter; + } else { + nodeStyle.marginBottom = tabBarGutter; + } + + const removable = editable && closable !== false && !disabled; + + function onRemoveTab(event: React.MouseEvent | React.KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + editable.onEdit('remove', { + key, + event, + }); + } + + let node: React.ReactElement = ( + + ); + + if (renderWrapper) { + node = renderWrapper(node); + } + + return node; +} + +export default React.forwardRef(TabNode); diff --git a/src/TabNavList/index.tsx b/src/TabNavList/index.tsx new file mode 100644 index 00000000..945d037a --- /dev/null +++ b/src/TabNavList/index.tsx @@ -0,0 +1,405 @@ +import * as React from 'react'; +import { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import raf from 'raf'; +import ResizeObserver from 'rc-resize-observer'; +import useRaf, { useRafState } from '../hooks/useRaf'; +import TabNode from './TabNode'; +import { + TabSizeMap, + TabPosition, + RenderTabBar, + TabsLocale, + EditableConfig, + AnimatedConfig, +} from '../interface'; +import useOffsets from '../hooks/useOffsets'; +import useVisibleRange from '../hooks/useVisibleRange'; +import OperationNode from './OperationNode'; +import TabContext from '../TabContext'; +import useTouchMove from '../hooks/useTouchMove'; +import useRefs from '../hooks/useRefs'; +import AddButton from './AddButton'; +import useSyncState from '../hooks/useSyncState'; + +export interface TabNavListProps { + id: string; + tabPosition: TabPosition; + activeKey: string; + rtl: boolean; + animated?: AnimatedConfig; + extra?: React.ReactNode; + editable?: EditableConfig; + moreIcon?: React.ReactNode; + mobile: boolean; + tabBarGutter?: number; + renderTabBar?: RenderTabBar; + className?: string; + style?: React.CSSProperties; + locale?: TabsLocale; + onTabClick: (activeKey: React.Key, e: React.MouseEvent | React.KeyboardEvent) => void; + children?: (node: React.ReactElement) => React.ReactElement; +} + +function TabNavList(props: TabNavListProps, ref: React.Ref) { + const { prefixCls, tabs } = React.useContext(TabContext); + const { + className, + style, + id, + animated, + activeKey, + rtl, + extra, + editable, + locale, + tabPosition, + tabBarGutter, + children, + onTabClick, + } = props; + const tabsWrapperRef = useRef(); + const tabListRef = useRef(); + const operationsRef = useRef(); + const innerAddButtonRef = useRef(); + const [getBtnRef, removeBtnRef] = useRefs(); + + const [transformLeft, setTransformLeft] = useSyncState(0); + const [transformTop, setTransformTop] = useSyncState(0); + + const [wrapperScrollWidth, setWrapperScrollWidth] = useState(0); + const [wrapperScrollHeight, setWrapperScrollHeight] = useState(0); + const [wrapperWidth, setWrapperWidth] = useState(null); + const [wrapperHeight, setWrapperHeight] = useState(null); + + const tabPositionTopOrBottom = tabPosition === 'top' || tabPosition === 'bottom'; + + const [tabSizes, setTabSizes] = useRafState(new Map()); + const tabOffsets = useOffsets(tabs, tabSizes, wrapperScrollWidth); + + // ========================== Util ========================= + const operationsHiddenClassName = `${prefixCls}-nav-operations-hidden`; + + let transformMin = 0; + let transformMax = 0; + + if (!tabPositionTopOrBottom) { + transformMin = Math.min(0, wrapperHeight - wrapperScrollHeight); + transformMax = 0; + } else if (rtl) { + transformMin = 0; + transformMax = Math.max(0, wrapperScrollWidth - wrapperWidth); + } else { + transformMin = Math.min(0, wrapperWidth - wrapperScrollWidth); + transformMax = 0; + } + + function alignInRange(value: number): [number, boolean] { + if (value < transformMin) { + return [transformMin, false]; + } + if (value > transformMax) { + return [transformMax, false]; + } + return [value, true]; + } + + // ========================= Mobile ======================== + const touchMovingRef = useRef(); + const [lockAnimation, setLockAnimation] = useState(); + + function doLockAnimation() { + setLockAnimation(Date.now()); + } + + function clearTouchMoving() { + window.clearTimeout(touchMovingRef.current); + } + + useTouchMove(tabsWrapperRef, (offsetX, offsetY) => { + let preventDefault = false; + + function doMove(setState: React.Dispatch>, offset: number) { + setState(value => { + const [newValue, needPrevent] = alignInRange(value + offset); + + preventDefault = needPrevent; + return newValue; + }); + } + + if (tabPositionTopOrBottom) { + // Skip scroll if place is enough + if (wrapperWidth >= wrapperScrollWidth) { + return preventDefault; + } + + doMove(setTransformLeft, offsetX); + } else { + if (wrapperHeight >= wrapperScrollHeight) { + return preventDefault; + } + + doMove(setTransformTop, offsetY); + } + + clearTouchMoving(); + doLockAnimation(); + + return preventDefault; + }); + + useEffect(() => { + clearTouchMoving(); + if (lockAnimation) { + touchMovingRef.current = window.setTimeout(() => { + setLockAnimation(0); + }, 100); + } + + return clearTouchMoving; + }, [lockAnimation]); + + // ========================= Scroll ======================== + function scrollToTab(key = activeKey) { + const tabOffset = tabOffsets.get(key); + + if (!tabOffset) return; + + if (tabPositionTopOrBottom) { + // ============ Align with top & bottom ============ + let newTransform = transformLeft; + + // RTL + if (rtl) { + if (tabOffset.right < transformLeft) { + newTransform = tabOffset.right; + } else if (tabOffset.right + tabOffset.width > transformLeft + wrapperWidth) { + newTransform = tabOffset.right + tabOffset.width - wrapperWidth; + } + } + // LTR + else if (tabOffset.left < -transformLeft) { + newTransform = -tabOffset.left; + } else if (tabOffset.left + tabOffset.width > -transformLeft + wrapperWidth) { + newTransform = -(tabOffset.left + tabOffset.width - wrapperWidth); + } + + setTransformTop(0); + setTransformLeft(alignInRange(newTransform)[0]); + } else { + // ============ Align with left & right ============ + let newTransform = transformTop; + + if (tabOffset.top < -transformTop) { + newTransform = -tabOffset.top; + } else if (tabOffset.top + tabOffset.height > -transformTop + wrapperHeight) { + newTransform = -(tabOffset.top + tabOffset.height - wrapperHeight); + } + + setTransformLeft(0); + setTransformTop(alignInRange(newTransform)[0]); + } + } + + // ========================== Tab ========================== + // Render tab node & collect tab offset + + const [visibleStart, visibleEnd] = useVisibleRange( + tabOffsets, + { + width: wrapperWidth, + height: wrapperHeight, + left: transformLeft, + top: transformTop, + }, + { ...props, tabs }, + ); + + function getAdditionalSpaceSize(type: 'offsetWidth' | 'offsetHeight') { + const addBtnSize = innerAddButtonRef.current?.[type] || 0; + let optionsSize = 0; + if (operationsRef.current?.className.includes(operationsHiddenClassName)) { + optionsSize = operationsRef.current[type]; + } + + return addBtnSize + optionsSize; + } + + const tabNodes: React.ReactElement[] = tabs.map(tab => { + const { key } = tab; + return ( + { + onTabClick(key, e); + }} + onRemove={() => { + removeBtnRef(key); + }} + onFocus={() => { + scrollToTab(key); + doLockAnimation(); + + // Focus element will make scrollLeft change which we should reset back + if (!rtl) { + tabsWrapperRef.current.scrollLeft = 0; + } + tabsWrapperRef.current.scrollTop = 0; + }} + /> + ); + }); + + const onListHolderResize = useRaf(() => { + // Update wrapper records + const { offsetWidth, offsetHeight } = tabsWrapperRef.current; + setWrapperWidth(offsetWidth); + setWrapperHeight(offsetHeight); + setWrapperScrollWidth(tabListRef.current.offsetWidth - getAdditionalSpaceSize('offsetWidth')); + setWrapperScrollHeight( + tabListRef.current.offsetHeight - getAdditionalSpaceSize('offsetHeight'), + ); + + // Update buttons records + setTabSizes(() => { + const newSizes: TabSizeMap = new Map(); + tabs.forEach(({ key }) => { + const btnNode = getBtnRef(key).current; + newSizes.set(key, { + width: btnNode.offsetWidth, + height: btnNode.offsetHeight, + left: btnNode.offsetLeft, + top: btnNode.offsetTop, + }); + }); + return newSizes; + }); + }); + + // ======================== Dropdown ======================= + const startHiddenTabs = tabs.slice(0, visibleStart); + const endHiddenTabs = tabs.slice(visibleEnd + 1); + const hiddenTabs = [...startHiddenTabs, ...endHiddenTabs]; + + // =================== Link & Operations =================== + const [inkStyle, setInkStyle] = useState(); + + const activeTabOffset = tabOffsets.get(activeKey); + + // Delay set ink style to avoid remove tab blink + const inkBarRafRef = useRef(); + function cleanInkBarRaf() { + raf.cancel(inkBarRafRef.current); + } + + useEffect(() => { + const newInkStyle: React.CSSProperties = {}; + + if (activeTabOffset) { + if (tabPositionTopOrBottom) { + if (rtl) { + newInkStyle.right = activeTabOffset.right; + } else { + newInkStyle.left = activeTabOffset.left; + } + + newInkStyle.width = activeTabOffset.width; + } else { + newInkStyle.top = activeTabOffset.top; + newInkStyle.height = activeTabOffset.height; + } + } + + cleanInkBarRaf(); + inkBarRafRef.current = raf(() => { + setInkStyle(newInkStyle); + }); + + return cleanInkBarRaf; + }, [activeTabOffset, tabPositionTopOrBottom, rtl]); + + // ========================= Effect ======================== + useEffect(() => { + scrollToTab(); + }, [activeKey, activeTabOffset, tabOffsets, tabPositionTopOrBottom]); + + // Should recalculate when rtl changed + useEffect(() => { + onListHolderResize(); + }, [rtl, tabBarGutter]); + + // ========================= Render ======================== + const hasDropdown = !!hiddenTabs.length; + + /* eslint-disable jsx-a11y/interactive-supports-focus */ + return ( +
{ + // No need animation when use keyboard + doLockAnimation(); + }} + > + +
+ +
+ {tabNodes} + {!hasDropdown && ( + + )} + +
+
+ +
+
+ + + + {extra &&
{extra}
} +
+ ); + /* eslint-enable */ +} + +export default React.forwardRef(TabNavList); diff --git a/src/TabPane.js b/src/TabPane.js deleted file mode 100755 index 01ad2d91..00000000 --- a/src/TabPane.js +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import { getDataAttr } from './utils'; - -export default class TabPane extends React.Component { - render() { - const { - id, - className, - destroyInactiveTabPane, - active, - forceRender, - rootPrefixCls, - style, - children, - placeholder, - tabKey, - ...restProps - } = this.props; - this._isActived = this._isActived || active; - const prefixCls = `${rootPrefixCls}-tabpane`; - const cls = classnames({ - [prefixCls]: 1, - [`${prefixCls}-inactive`]: !active, - [`${prefixCls}-active`]: active, - [className]: className, - }); - const isRender = destroyInactiveTabPane ? active : this._isActived; - const shouldRender = isRender || forceRender; - - const tabKeyExists = tabKey && String(tabKey).length > 0; - const uuid = tabKeyExists && (id ? `${tabKey}-${id}` : `${tabKey}`); - return ( -
- {shouldRender ? children : placeholder} -
- ); - } -} - -TabPane.defaultProps = { - placeholder: null, -}; diff --git a/src/TabPanelList/TabPane.tsx b/src/TabPanelList/TabPane.tsx new file mode 100644 index 00000000..4687fd9b --- /dev/null +++ b/src/TabPanelList/TabPane.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { Tab } from '../interface'; + +export interface TabPaneProps { + prefixCls: string; + id: string; + tab: Tab; + animated: boolean; + active: boolean; + destroyInactiveTabPane?: boolean; +} + +export default function TabPane({ + prefixCls, + id, + active, + animated, + destroyInactiveTabPane, + tab: { key, children, forceRender, className, style }, +}: TabPaneProps) { + const [visited, setVisited] = React.useState(forceRender); + + React.useEffect(() => { + if (active) { + setVisited(true); + } else if (destroyInactiveTabPane) { + setVisited(false); + } + }, [active, destroyInactiveTabPane]); + + const mergedStyle: React.CSSProperties = {}; + if (!active) { + if (animated) { + mergedStyle.visibility = 'hidden'; + } else { + mergedStyle.display = 'none'; + } + } + + return ( +
+ {(active || visited || forceRender) && children} +
+ ); +} diff --git a/src/TabPanelList/index.tsx b/src/TabPanelList/index.tsx new file mode 100644 index 00000000..e3200d27 --- /dev/null +++ b/src/TabPanelList/index.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import TabPane from './TabPane'; +import TabContext from '../TabContext'; +import { TabPosition, AnimatedConfig } from '../interface'; + +export interface TabPanelListProps { + activeKey: React.Key; + id: string; + rtl: boolean; + animated?: AnimatedConfig; + tabPosition?: TabPosition; + destroyInactiveTabPane?: boolean; +} + +export default function TabPanelList({ + id, + activeKey, + animated, + tabPosition, + rtl, + destroyInactiveTabPane, +}: TabPanelListProps) { + const { prefixCls, tabs } = React.useContext(TabContext); + const tabPaneAnimated = animated.tabPane; + + const activeIndex = tabs.findIndex(tab => tab.key === activeKey); + + return ( +
+
+ {tabs.map(tab => { + return ( + + ); + })} +
+
+ ); +} diff --git a/src/Tabs.js b/src/Tabs.js deleted file mode 100755 index 10240c39..00000000 --- a/src/Tabs.js +++ /dev/null @@ -1,209 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import KeyCode from './KeyCode'; -import TabPane from './TabPane'; -import { getDataAttr } from './utils'; - -function noop() {} - -function getDefaultActiveKey(props) { - let activeKey; - React.Children.forEach(props.children, child => { - if (child && !activeKey && !child.props.disabled) { - activeKey = child.key; - } - }); - return activeKey; -} - -function activeKeyIsValid(props, key) { - const keys = React.Children.map(props.children, child => child && child.key); - return keys.indexOf(key) >= 0; -} - -class Tabs extends React.Component { - constructor(props) { - super(props); - - let activeKey; - if ('activeKey' in props) { - activeKey = props.activeKey; - } else if ('defaultActiveKey' in props) { - activeKey = props.defaultActiveKey; - } else { - activeKey = getDefaultActiveKey(props); - } - - this.state = { - activeKey, - }; - } - - static getDerivedStateFromProps(props, state) { - const newState = {}; - if ('activeKey' in props) { - newState.activeKey = props.activeKey; - } else if (!activeKeyIsValid(props, state.activeKey)) { - newState.activeKey = getDefaultActiveKey(props); - } - if (Object.keys(newState).length > 0) { - return newState; - } - return null; - } - - componentWillUnmount() { - this.destroy = true; - } - - onTabClick = (activeKey, e) => { - if (this.tabBar.props.onTabClick) { - this.tabBar.props.onTabClick(activeKey, e); - } - this.setActiveKey(activeKey); - }; - - onNavKeyDown = e => { - const { keyboard } = this.props; - if (!keyboard) { - return; - } - const eventKeyCode = e.keyCode; - if (eventKeyCode === KeyCode.RIGHT || eventKeyCode === KeyCode.DOWN) { - e.preventDefault(); - const nextKey = this.getNextActiveKey(true); - this.onTabClick(nextKey); - } else if (eventKeyCode === KeyCode.LEFT || eventKeyCode === KeyCode.UP) { - e.preventDefault(); - const previousKey = this.getNextActiveKey(false); - this.onTabClick(previousKey); - } - }; - - onScroll = ({ target, currentTarget }) => { - if (target === currentTarget && target.scrollLeft > 0) { - target.scrollLeft = 0; - } - }; - - setActiveKey = activeKey => { - if (this.state.activeKey !== activeKey) { - if (!('activeKey' in this.props)) { - this.setState({ - activeKey, - }); - } - this.props.onChange(activeKey); - } - }; - - getNextActiveKey = next => { - const { activeKey } = this.state; - const children = []; - React.Children.forEach(this.props.children, c => { - if (c && !c.props.disabled) { - if (next) { - children.push(c); - } else { - children.unshift(c); - } - } - }); - const { length } = children; - let ret = length && children[0].key; - children.forEach((child, i) => { - if (child.key === activeKey) { - if (i === length - 1) { - ret = children[0].key; - } else { - ret = children[i + 1].key; - } - } - }); - return ret; - }; - - render() { - const { props } = this; - const { - prefixCls, - navWrapper, - tabBarPosition, - className, - renderTabContent, - renderTabBar, - destroyInactiveTabPane, - direction, - id, - ...restProps - } = props; - const cls = classnames({ - [prefixCls]: 1, - [`${prefixCls}-${tabBarPosition}`]: 1, - [className]: !!className, - [`${prefixCls}-rtl`]: direction === 'rtl', - }); - - this.tabBar = renderTabBar(); - - const tabBar = React.cloneElement(this.tabBar, { - prefixCls, - navWrapper, - key: 'tabBar', - onKeyDown: this.onNavKeyDown, - tabBarPosition, - onTabClick: this.onTabClick, - panels: props.children, - activeKey: this.state.activeKey, - direction: this.props.direction, - id, - }); - - const tabContent = React.cloneElement(renderTabContent(), { - prefixCls, - tabBarPosition, - activeKey: this.state.activeKey, - destroyInactiveTabPane, - children: props.children, - onChange: this.setActiveKey, - key: 'tabContent', - direction: this.props.direction, - id, - }); - - const contents = []; - if (tabBarPosition === 'bottom') { - contents.push(tabContent, tabBar); - } else { - contents.push(tabBar, tabContent); - } - - return ( -
- {contents} -
- ); - } -} - -Tabs.defaultProps = { - prefixCls: 'rc-tabs', - destroyInactiveTabPane: false, - onChange: noop, - keyboard: true, - navWrapper: arg => arg, - tabBarPosition: 'top', - children: null, - style: {}, - direction: 'ltr', -}; - -Tabs.TabPane = TabPane; - -export default Tabs; diff --git a/src/Tabs.tsx b/src/Tabs.tsx new file mode 100644 index 00000000..26d600cd --- /dev/null +++ b/src/Tabs.tsx @@ -0,0 +1,227 @@ +// Accessibility https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import toArray from 'rc-util/lib/Children/toArray'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import TabPane, { TabPaneProps } from './sugar/TabPane'; +import TabNavList from './TabNavList'; +import TabPanelList from './TabPanelList'; +import { + Tab, + TabPosition, + RenderTabBar, + TabsLocale, + EditableConfig, + AnimatedConfig, +} from './interface'; +import TabContext from './TabContext'; +import { isMobile } from './hooks/useTouchMove'; + +/** + * Should added antd: + * - type + * + * Removed: + * - onNextClick + * - onPrevClick + * - keyboard + */ + +// Used for accessibility +let uuid = 0; + +export interface TabsProps extends Omit, 'onChange'> { + prefixCls?: string; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; + id?: string; + + activeKey?: string; + defaultActiveKey?: string; + direction?: 'ltr' | 'rtl'; + animated?: boolean | AnimatedConfig; + renderTabBar?: RenderTabBar; + tabBarExtraContent?: React.ReactNode; + tabBarGutter?: number; + tabBarStyle?: React.CSSProperties; + tabPosition?: TabPosition; + destroyInactiveTabPane?: boolean; + + onChange?: (activeKey: string) => void; + onTabClick?: (activeKey: string, e: React.KeyboardEvent | React.MouseEvent) => void; + + editable?: EditableConfig; + + // Accessibility + locale?: TabsLocale; + + // Icons + moreIcon?: React.ReactNode; +} + +function parseTabList(children: React.ReactNode): Tab[] { + return toArray(children).map((node: React.ReactElement) => + React.isValidElement(node) + ? { + key: node.key !== undefined ? String(node.key) : undefined, + ...node.props, + } + : null, + ); +} + +function Tabs( + { + id, + prefixCls = 'rc-tabs', + className, + children, + direction, + activeKey, + defaultActiveKey, + editable, + animated, + tabPosition = 'top', + tabBarGutter, + tabBarExtraContent, + locale, + moreIcon, + destroyInactiveTabPane, + renderTabBar, + onChange, + onTabClick, + ...restProps + }: TabsProps, + ref: React.Ref, +) { + const tabs = parseTabList(children); + const rtl = direction === 'rtl'; + + let mergedAnimated: AnimatedConfig | false; + if (animated === false) { + mergedAnimated = { + inkBar: false, + tabPane: false, + }; + } else { + mergedAnimated = { + inkBar: true, + tabPane: false, + ...(animated !== true ? animated : null), + }; + } + + // ======================== Mobile ======================== + const [mobile, setMobile] = useState(false); + useEffect(() => { + // Only update on the client side + setMobile(isMobile()); + }, []); + + // ====================== Active Key ====================== + const [mergedActiveKey, setMergedActiveKey] = useMergedState(undefined, { + value: activeKey, + defaultValue: defaultActiveKey, + }); + const [activeIndex, setActiveIndex] = useState(() => + tabs.findIndex(tab => tab.key === mergedActiveKey), + ); + + // Reset active key if not exist anymore + useEffect(() => { + let newActiveIndex = tabs.findIndex(tab => tab.key === mergedActiveKey); + if (newActiveIndex === -1) { + newActiveIndex = Math.max(0, Math.min(activeIndex, tabs.length - 1)); + setMergedActiveKey(tabs[newActiveIndex]?.key); + } + setActiveIndex(newActiveIndex); + }, [tabs.map(tab => tab.key).join('_'), mergedActiveKey, activeIndex]); + + // ===================== Accessibility ==================== + const [mergedId, setMergedId] = useMergedState(null, { + value: id, + }); + + const mergedTabPosition = mobile ? 'top' : tabPosition; + + // Async generate id to avoid ssr mapping failed + useEffect(() => { + if (!id) { + setMergedId(`rc-tabs-${uuid}`); + uuid += 1; + } + }, []); + + // ======================== Events ======================== + function onInternalTabClick(key: string, e: React.MouseEvent | React.KeyboardEvent) { + onTabClick?.(key, e); + + setMergedActiveKey(key); + onChange?.(key); + } + + // ======================== Render ======================== + const sharedProps = { + id: mergedId, + activeKey: mergedActiveKey, + animated: mergedAnimated, + tabPosition: mergedTabPosition, + rtl, + mobile, + }; + + let tabNavBar: React.ReactElement; + + const tabNavBarProps = { + ...sharedProps, + editable, + locale, + moreIcon, + tabBarGutter, + onTabClick: onInternalTabClick, + extra: tabBarExtraContent, + }; + + if (renderTabBar) { + tabNavBar = renderTabBar(tabNavBarProps, TabNavList); + } else { + tabNavBar = ; + } + + return ( + +
+ {tabNavBar} + +
+
+ ); +} + +const ForwardTabs = React.forwardRef(Tabs); + +export type ForwardTabsType = typeof ForwardTabs & { TabPane: typeof TabPane }; + +(ForwardTabs as ForwardTabsType).TabPane = TabPane; + +export default ForwardTabs as ForwardTabsType; diff --git a/src/hooks/useOffsets.ts b/src/hooks/useOffsets.ts new file mode 100644 index 00000000..7a22f87c --- /dev/null +++ b/src/hooks/useOffsets.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { TabSizeMap, TabOffsetMap, Tab, TabOffset } from '../interface'; + +const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0 }; + +export default function useOffsets(tabs: Tab[], tabSizes: TabSizeMap, holderScrollWidth: number) { + return useMemo(() => { + const map: TabOffsetMap = new Map(); + + const lastOffset = tabSizes.get(tabs[0]?.key) || DEFAULT_SIZE; + const rightOffset = lastOffset.left + lastOffset.width; + + for (let i = 0; i < tabs.length; i += 1) { + const { key } = tabs[i]; + let data = tabSizes.get(key); + + // Reuse last one when not exist yet + if (!data) { + data = tabSizes.get(tabs[i - 1]?.key) || DEFAULT_SIZE; + } + + const entity = (map.get(key) || { ...data }) as TabOffset; + + // Right + entity.right = rightOffset - entity.left - entity.width; + + // Update entity + map.set(key, entity); + } + + return map; + }, [tabs.map(tab => tab.key).join('_'), tabSizes, holderScrollWidth]); +} diff --git a/src/hooks/useRaf.ts b/src/hooks/useRaf.ts new file mode 100644 index 00000000..7dedf7b4 --- /dev/null +++ b/src/hooks/useRaf.ts @@ -0,0 +1,53 @@ +import { useRef, useState, useEffect } from 'react'; +import raf from 'raf'; + +export default function useRaf(callback: Callback) { + const rafRef = useRef(); + const removedRef = useRef(false); + + function trigger(...args: any[]) { + if (!removedRef.current) { + raf.cancel(rafRef.current); + rafRef.current = raf(() => { + callback(...args); + }); + } + } + + useEffect(() => { + return () => { + removedRef.current = true; + raf.cancel(rafRef.current); + }; + }, []); + + return trigger; +} + +type Callback = (ori: T) => T; + +export function useRafState(defaultState: T | (() => T)): [T, (updater: Callback) => void] { + const batchRef = useRef[]>([]); + const [, forceUpdate] = useState({}); + const state = useRef( + typeof defaultState === 'function' ? (defaultState as any)() : defaultState, + ); + + const flushUpdate = useRaf(() => { + let current = state.current; + batchRef.current.forEach(callback => { + current = callback(current); + }); + batchRef.current = []; + + state.current = current; + forceUpdate({}); + }); + + function updater(callback: Callback) { + batchRef.current.push(callback); + flushUpdate(); + } + + return [state.current, updater]; +} diff --git a/src/hooks/useRefs.ts b/src/hooks/useRefs.ts new file mode 100644 index 00000000..ec573300 --- /dev/null +++ b/src/hooks/useRefs.ts @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { useRef } from 'react'; + +export default function useRefs(): [ + (key: React.Key) => React.RefObject, + (key: React.Key) => void, +] { + const cacheRefs = useRef(new Map>()); + + function getRef(key: React.Key) { + if (!cacheRefs.current.has(key)) { + cacheRefs.current.set(key, React.createRef()); + } + return cacheRefs.current.get(key); + } + + function removeRef(key: React.Key) { + cacheRefs.current.delete(key); + } + + return [getRef, removeRef]; +} diff --git a/src/hooks/useSyncState.ts b/src/hooks/useSyncState.ts new file mode 100644 index 00000000..4a7036a6 --- /dev/null +++ b/src/hooks/useSyncState.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +type Updater = (prev: T) => T; + +export default function useSyncState(defaultState: T): [T, (updater: T | Updater) => void] { + const stateRef = React.useRef(defaultState); + const [, forceUpdate] = React.useState({}); + + function setState(updater: any) { + stateRef.current = typeof updater === 'function' ? updater(stateRef.current) : updater; + forceUpdate({}); + } + + return [stateRef.current, setState]; +} diff --git a/src/hooks/useTouchMove.ts b/src/hooks/useTouchMove.ts new file mode 100644 index 00000000..47df6159 --- /dev/null +++ b/src/hooks/useTouchMove.ts @@ -0,0 +1,168 @@ +import * as React from 'react'; +import { useState, useRef } from 'react'; + +type TouchEventHandler = (e: TouchEvent) => void; +type WheelEventHandler = (e: WheelEvent) => void; + +const MIN_SWIPE_DISTANCE = 0.1; +const STOP_SWIPE_DISTANCE = 0.01; +const REFRESH_INTERVAL = 20; +const SPEED_OFF_MULTIPLE = 0.995 ** REFRESH_INTERVAL; + +// ========================= Check if is a mobile ========================= +export function isMobile() { + const agent = navigator.userAgent || navigator.vendor || (window as any).opera; + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test( + agent, + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test( + agent.substr(0, 4), + ) + ) { + return true; + } + return false; +} + +// ================================= Hook ================================= +export default function useTouchMove( + ref: React.RefObject, + onOffset: (offsetX: number, offsetY: number) => boolean, +) { + const [touchPosition, setTouchPosition] = useState<{ x: number; y: number }>(); + const [lastTimestamp, setLastTimestamp] = useState(0); + const [lastTimeDiff, setLastTimeDiff] = useState(0); + const [lastOffset, setLastOffset] = useState<{ x: number; y: number }>(); + const motionRef = useRef(); + + // ========================= Events ========================= + // >>> Touch events + function onTouchStart(e: TouchEvent) { + const { screenX, screenY } = e.touches[0]; + setTouchPosition({ x: screenX, y: screenY }); + window.clearInterval(motionRef.current); + } + + function onTouchMove(e: TouchEvent) { + if (!touchPosition) return; + + e.preventDefault(); + const { screenX, screenY } = e.touches[0]; + setTouchPosition({ x: screenX, y: screenY }); + const offsetX = screenX - touchPosition.x; + const offsetY = screenY - touchPosition.y; + onOffset(offsetX, offsetY); + const now = Date.now(); + setLastTimestamp(now); + setLastTimeDiff(now - lastTimestamp); + setLastOffset({ x: offsetX, y: offsetY }); + } + + function onTouchEnd() { + if (!touchPosition) return; + + setTouchPosition(null); + setLastOffset(null); + + // Swipe if needed + if (lastOffset) { + const distanceX = lastOffset.x / lastTimeDiff; + const distanceY = lastOffset.y / lastTimeDiff; + const absX = Math.abs(distanceX); + const absY = Math.abs(distanceY); + + // Skip swipe if low distance + if (Math.max(absX, absY) < MIN_SWIPE_DISTANCE) return; + + let currentX = distanceX; + let currentY = distanceY; + + motionRef.current = window.setInterval(() => { + if (Math.abs(currentX) < STOP_SWIPE_DISTANCE && Math.abs(currentY) < STOP_SWIPE_DISTANCE) { + window.clearInterval(motionRef.current); + return; + } + + currentX *= SPEED_OFF_MULTIPLE; + currentY *= SPEED_OFF_MULTIPLE; + onOffset(currentX * REFRESH_INTERVAL, currentY * REFRESH_INTERVAL); + }, REFRESH_INTERVAL); + } + } + + // >>> Wheel event + const lastMixedWheelRef = useRef(0); + const lastWheelTimestampRef = useRef(0); + const lastWheelPreventRef = useRef(false); + const lastWheelDirectionRef = useRef<'x' | 'y'>(); + + function onWheel(e: WheelEvent) { + const { deltaX, deltaY } = e; + // Convert both to x & y since wheel only happened on PC + let mixed: number = 0; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + if (absX === absY) { + mixed = lastWheelDirectionRef.current === 'x' ? deltaX : deltaY; + } else if (absX > absY) { + mixed = deltaX; + lastWheelDirectionRef.current = 'x'; + } else { + mixed = deltaY; + lastWheelDirectionRef.current = 'y'; + } + + // Optimize mac touch scroll + const now = Date.now(); + const absMixed = Math.abs(mixed); + + if (now - lastWheelTimestampRef.current > 100 || absMixed - lastMixedWheelRef.current > 10) { + lastWheelPreventRef.current = false; + } + + if (onOffset(-mixed, -mixed) || lastWheelPreventRef.current) { + e.preventDefault(); + lastWheelPreventRef.current = true; + } + + lastWheelTimestampRef.current = now; + lastMixedWheelRef.current = absMixed; + } + + // ========================= Effect ========================= + const touchEventsRef = useRef<{ + onTouchStart: TouchEventHandler; + onTouchMove: TouchEventHandler; + onTouchEnd: TouchEventHandler; + onWheel: WheelEventHandler; + }>(null); + touchEventsRef.current = { onTouchStart, onTouchMove, onTouchEnd, onWheel }; + + React.useEffect(() => { + function onProxyTouchStart(e: TouchEvent) { + touchEventsRef.current.onTouchStart(e); + } + function onProxyTouchMove(e: TouchEvent) { + touchEventsRef.current.onTouchMove(e); + } + function onProxyTouchEnd(e: TouchEvent) { + touchEventsRef.current.onTouchEnd(e); + } + function onProxyWheel(e: WheelEvent) { + touchEventsRef.current.onWheel(e); + } + + document.addEventListener('touchmove', onProxyTouchMove, { passive: false }); + document.addEventListener('touchend', onProxyTouchEnd, { passive: false }); + + // No need to clean up since element removed + ref.current.addEventListener('touchstart', onProxyTouchStart, { passive: false }); + ref.current.addEventListener('wheel', onProxyWheel); + + return () => { + document.removeEventListener('touchmove', onProxyTouchMove); + document.removeEventListener('touchend', onProxyTouchEnd); + }; + }, []); +} diff --git a/src/hooks/useVisibleRange.ts b/src/hooks/useVisibleRange.ts new file mode 100644 index 00000000..67476920 --- /dev/null +++ b/src/hooks/useVisibleRange.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { Tab, TabOffsetMap } from '../interface'; +import { TabNavListProps } from '../TabNavList'; + +const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0, right: 0 }; + +export default function useVisibleRange( + tabOffsets: TabOffsetMap, + containerSize: { width: number; height: number; left: number; top: number }, + { tabs, tabPosition, rtl }: { tabs: Tab[] } & TabNavListProps, +): [number, number] { + let unit: 'width' | 'height'; + let position: 'left' | 'top' | 'right'; + let transformSize: number; + + if (['top', 'bottom'].includes(tabPosition)) { + unit = 'width'; + position = rtl ? 'right' : 'left'; + transformSize = Math.abs(containerSize.left); + } else { + unit = 'height'; + position = 'top'; + transformSize = -containerSize.top; + } + + const basicSize = containerSize[unit]; + + return useMemo(() => { + if (!tabs.length) { + return [0, 0]; + } + + const len = tabs.length; + let endIndex = len; + for (let i = 0; i < len; i += 1) { + const offset = tabOffsets.get(tabs[i].key) || DEFAULT_SIZE; + if (offset[position] + offset[unit] > transformSize + basicSize) { + endIndex = i - 1; + break; + } + } + + let startIndex = 0; + for (let i = len - 1; i >= 0; i -= 1) { + const offset = tabOffsets.get(tabs[i].key) || DEFAULT_SIZE; + if (offset[position] < transformSize) { + startIndex = i + 1; + break; + } + } + + return [startIndex, endIndex]; + }, [tabOffsets, transformSize, basicSize, tabPosition, tabs.map(tab => tab.key).join('_'), rtl]); +} diff --git a/src/index.js b/src/index.js deleted file mode 100755 index 4f125f3f..00000000 --- a/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import Tabs from './Tabs'; -import TabPane from './TabPane'; -import TabContent from './TabContent'; - -export default Tabs; -export { TabPane, TabContent }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..d4ec41f7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +import Tabs from './Tabs'; +import TabPane from './sugar/TabPane'; + +export { TabPane }; + +export default Tabs; diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 00000000..ec61b621 --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,47 @@ +import { TabPaneProps } from './sugar/TabPane'; + +export type TabSizeMap = Map< + React.Key, + { width: number; height: number; left: number; top: number } +>; + +export interface TabOffset { + width: number; + height: number; + left: number; + right: number; + top: number; +} +export type TabOffsetMap = Map; + +export type TabPosition = 'left' | 'right' | 'top' | 'bottom'; + +export interface Tab extends TabPaneProps { + tab?: React.ReactNode; + children?: React.ReactNode; + forceRender?: boolean; + key: string; +} + +export type RenderTabBar = (props: any, DefaultTabBar: React.ComponentType) => React.ReactElement; + +export interface TabsLocale { + dropdownAriaLabel?: string; + removeAriaLabel?: string; + addAriaLabel?: string; +} + +export interface EditableConfig { + onEdit: ( + type: 'add' | 'remove', + info: { key?: string; event: React.MouseEvent | React.KeyboardEvent }, + ) => void; + showAdd?: boolean; + removeIcon?: React.ReactNode; + addIcon?: React.ReactNode; +} + +export interface AnimatedConfig { + inkBar?: boolean; + tabPane?: boolean; +} diff --git a/src/sugar/TabPane.tsx b/src/sugar/TabPane.tsx new file mode 100644 index 00000000..035e33cd --- /dev/null +++ b/src/sugar/TabPane.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +export interface TabPaneProps { + tab?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + children?: React.ReactNode; + forceRender?: boolean; + closable?: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function TabPane(_: TabPaneProps) { + return null; +} diff --git a/src/utils.js b/src/utils.js deleted file mode 100755 index 4ae4c94f..00000000 --- a/src/utils.js +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; - -export function toArray(children) { - // allow [c,[a,b]] - const c = []; - React.Children.forEach(children, child => { - if (child) { - c.push(child); - } - }); - return c; -} - -export function getActiveIndex(children, activeKey) { - const c = toArray(children); - for (let i = 0; i < c.length; i++) { - if (c[i].key === activeKey) { - return i; - } - } - return -1; -} - -export function getActiveKey(children, index) { - const c = toArray(children); - return c[index].key; -} - -export function setTransform(style, v) { - style.transform = v; - style.webkitTransform = v; - style.mozTransform = v; -} - -export function isTransform3dSupported(style) { - return ( - ('transform' in style || 'webkitTransform' in style || 'MozTransform' in style) && window.atob - ); -} - -export function setTransition(style, v) { - style.transition = v; - style.webkitTransition = v; - style.MozTransition = v; -} - -export function getTransformPropValue(v) { - return { - transform: v, - WebkitTransform: v, - MozTransform: v, - }; -} - -export function isVertical(tabBarPosition) { - return tabBarPosition === 'left' || tabBarPosition === 'right'; -} - -export function getTransformByIndex(index, tabBarPosition, direction = 'ltr') { - const translate = isVertical(tabBarPosition) ? 'translateY' : 'translateX'; - - if (!isVertical(tabBarPosition) && direction === 'rtl') { - return `${translate}(${index * 100}%) translateZ(0)`; - } - return `${translate}(${-index * 100}%) translateZ(0)`; -} - -export function getMarginStyle(index, tabBarPosition, direction = 'ltr') { - const marginDirection = isVertical(tabBarPosition) ? 'marginTop' : 'marginLeft'; - - if (!isVertical(tabBarPosition) && direction === 'rtl') { - return { - [marginDirection]: `${(index + 1) * 100}%`, - }; - } - return { - [marginDirection]: `${-index * 100}%`, - }; -} - -export function getStyle(el, property) { - return +window - .getComputedStyle(el) - .getPropertyValue(property) - .replace('px', ''); -} - -export function setPxStyle(el, value, vertical) { - value = vertical ? `0px, ${value}px, 0px` : `${value}px, 0px, 0px`; - setTransform(el.style, `translate3d(${value})`); -} - -export function getDataAttr(props) { - return Object.keys(props).reduce((prev, key) => { - if (key.substr(0, 5) === 'aria-' || key.substr(0, 5) === 'data-' || key === 'role') { - prev[key] = props[key]; - } - return prev; - }, {}); -} - -function toNum(style, property) { - return +style.getPropertyValue(property).replace('px', ''); -} - -function getTypeValue(start, current, end, tabNode, wrapperNode) { - let total = getStyle(wrapperNode, `padding-${start}`); - if (!tabNode || !tabNode.parentNode) { - return total; - } - - const { childNodes } = tabNode.parentNode; - Array.prototype.some.call(childNodes, node => { - const style = window.getComputedStyle(node); - - if (node !== tabNode) { - total += toNum(style, `margin-${start}`); - total += node[current]; - total += toNum(style, `margin-${end}`); - - if (style.boxSizing === 'content-box') { - total += toNum(style, `border-${start}-width`) + toNum(style, `border-${end}-width`); - } - return false; - } - - // We need count current node margin - // ref: https://github.com/react-component/tabs/pull/139#issuecomment-431005262 - total += toNum(style, `margin-${start}`); - - return true; - }); - - return total; -} - -export function getLeft(tabNode, wrapperNode) { - return getTypeValue('left', 'offsetWidth', 'right', tabNode, wrapperNode); -} - -export function getTop(tabNode, wrapperNode) { - return getTypeValue('top', 'offsetHeight', 'bottom', tabNode, wrapperNode); -} diff --git a/tests/__snapshots__/a11y.spec.js.snap b/tests/__snapshots__/a11y.spec.js.snap deleted file mode 100644 index d1273d05..00000000 --- a/tests/__snapshots__/a11y.spec.js.snap +++ /dev/null @@ -1,514 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should render correctly 1`] = ` -
-
-
- -
- - - - - - -
-
-
-
- - - - - -
-
-
-
-
-
-
-
-
-
- - -
-
-