From 53625976b87af92b29dc1c0443ac695e663e61d1 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Sun, 3 Oct 2021 21:56:47 +0800 Subject: [PATCH 01/11] refactor: tabs --- components/_util/hooks/useState.ts | 2 +- components/components.ts | 3 +- components/menu/src/Menu.tsx | 2 + components/menu/src/MenuItem.tsx | 2 + components/style/themes/default.less | 21 +- components/tabs/TabBar.tsx | 94 --- components/tabs/index.ts | 10 +- components/tabs/src/TabContext.ts | 33 ++ components/tabs/src/TabNavList/AddButton.tsx | 48 ++ .../tabs/src/TabNavList/OperationNode.tsx | 217 +++++++ components/tabs/src/TabNavList/TabNode.tsx | 138 +++++ components/tabs/src/TabNavList/index.tsx | 559 ++++++++++++++++++ components/tabs/src/TabPanelList/TabPane.tsx | 85 +++ components/tabs/src/TabPanelList/index.tsx | 68 +++ components/tabs/src/Tabs.tsx | 334 +++++++++++ components/tabs/src/hooks/useOffsets.ts | 40 ++ components/tabs/src/hooks/useRaf.ts | 52 ++ components/tabs/src/hooks/useRefs.ts | 23 + components/tabs/src/hooks/useSyncState.ts | 21 + components/tabs/src/hooks/useTouchMove.ts | 141 +++++ components/tabs/src/index.ts | 11 + components/tabs/src/interface.ts | 51 ++ components/tabs/style/card-style.less | 186 ------ components/tabs/style/card.less | 97 +++ components/tabs/style/dropdown.less | 60 ++ components/tabs/style/index.less | 517 +++++----------- components/tabs/style/{index.ts => index.tsx} | 0 components/tabs/style/position.less | 195 ++++++ components/tabs/style/rtl.less | 79 +++ components/tabs/style/size.less | 41 ++ components/tabs/tabs.tsx | 181 ------ components/vc-overflow/Overflow.tsx | 3 + components/vc-overflow/RawItem.tsx | 1 + components/vc-tabs/src/InkTabBar.jsx | 22 - components/vc-tabs/src/InkTabBarNode.jsx | 121 ---- components/vc-tabs/src/KeyCode.js | 18 - components/vc-tabs/src/SaveRef.jsx | 27 - .../vc-tabs/src/ScrollableInkTabBar.jsx | 26 - components/vc-tabs/src/ScrollableTabBar.jsx | 20 - .../vc-tabs/src/ScrollableTabBarNode.jsx | 335 ----------- components/vc-tabs/src/Sentinel.jsx | 44 -- components/vc-tabs/src/TabBar.jsx | 19 - components/vc-tabs/src/TabBarRootNode.jsx | 61 -- components/vc-tabs/src/TabBarTabsNode.jsx | 83 --- components/vc-tabs/src/TabContent.jsx | 92 --- components/vc-tabs/src/TabPane.jsx | 53 -- components/vc-tabs/src/Tabs.jsx | 254 -------- components/vc-tabs/src/index.js | 7 - components/vc-tabs/src/utils.js | 132 ----- site/debugger/index.tsx | 2 +- 50 files changed, 2469 insertions(+), 2162 deletions(-) delete mode 100644 components/tabs/TabBar.tsx create mode 100644 components/tabs/src/TabContext.ts create mode 100644 components/tabs/src/TabNavList/AddButton.tsx create mode 100644 components/tabs/src/TabNavList/OperationNode.tsx create mode 100644 components/tabs/src/TabNavList/TabNode.tsx create mode 100644 components/tabs/src/TabNavList/index.tsx create mode 100644 components/tabs/src/TabPanelList/TabPane.tsx create mode 100644 components/tabs/src/TabPanelList/index.tsx create mode 100644 components/tabs/src/Tabs.tsx create mode 100644 components/tabs/src/hooks/useOffsets.ts create mode 100644 components/tabs/src/hooks/useRaf.ts create mode 100644 components/tabs/src/hooks/useRefs.ts create mode 100644 components/tabs/src/hooks/useSyncState.ts create mode 100644 components/tabs/src/hooks/useTouchMove.ts create mode 100644 components/tabs/src/index.ts create mode 100644 components/tabs/src/interface.ts delete mode 100644 components/tabs/style/card-style.less create mode 100644 components/tabs/style/card.less create mode 100644 components/tabs/style/dropdown.less rename components/tabs/style/{index.ts => index.tsx} (100%) create mode 100644 components/tabs/style/position.less create mode 100644 components/tabs/style/rtl.less create mode 100644 components/tabs/style/size.less delete mode 100644 components/tabs/tabs.tsx delete mode 100644 components/vc-tabs/src/InkTabBar.jsx delete mode 100644 components/vc-tabs/src/InkTabBarNode.jsx delete mode 100644 components/vc-tabs/src/KeyCode.js delete mode 100644 components/vc-tabs/src/SaveRef.jsx delete mode 100644 components/vc-tabs/src/ScrollableInkTabBar.jsx delete mode 100644 components/vc-tabs/src/ScrollableTabBar.jsx delete mode 100644 components/vc-tabs/src/ScrollableTabBarNode.jsx delete mode 100644 components/vc-tabs/src/Sentinel.jsx delete mode 100644 components/vc-tabs/src/TabBar.jsx delete mode 100644 components/vc-tabs/src/TabBarRootNode.jsx delete mode 100644 components/vc-tabs/src/TabBarTabsNode.jsx delete mode 100644 components/vc-tabs/src/TabContent.jsx delete mode 100644 components/vc-tabs/src/TabPane.jsx delete mode 100644 components/vc-tabs/src/Tabs.jsx delete mode 100755 components/vc-tabs/src/index.js delete mode 100644 components/vc-tabs/src/utils.js diff --git a/components/_util/hooks/useState.ts b/components/_util/hooks/useState.ts index a74476a9e2..5276ab44a2 100644 --- a/components/_util/hooks/useState.ts +++ b/components/_util/hooks/useState.ts @@ -2,7 +2,7 @@ import type { Ref } from 'vue'; import { ref } from 'vue'; export default function useState>( - defaultStateValue: T | (() => T), + defaultStateValue?: T | (() => T), ): [R, (val: T) => void] { const initValue: T = typeof defaultStateValue === 'function' ? (defaultStateValue as any)() : defaultStateValue; diff --git a/components/components.ts b/components/components.ts index bf1ccd2d92..b733f1a488 100644 --- a/components/components.ts +++ b/components/components.ts @@ -190,7 +190,8 @@ export { default as Tree, TreeNode, DirectoryTree } from './tree'; export type { TreeSelectProps } from './tree-select'; export { default as TreeSelect, TreeSelectNode } from './tree-select'; -export { default as Tabs, TabPane, TabContent } from './tabs'; +export type { TabsProps, TabPaneProps } from './tabs'; +export { default as Tabs, TabPane } from './tabs'; export type { TagProps } from './tag'; export { default as Tag, CheckableTag } from './tag'; diff --git a/components/menu/src/Menu.tsx b/components/menu/src/Menu.tsx index 4c7d1a1a99..d5343b4213 100644 --- a/components/menu/src/Menu.tsx +++ b/components/menu/src/Menu.tsx @@ -26,6 +26,7 @@ import EllipsisOutlined from '@ant-design/icons-vue/EllipsisOutlined'; import { cloneElement } from '../../_util/vnode'; export const menuProps = { + id: String, prefixCls: String, disabled: Boolean, inlineCollapsed: Boolean, @@ -420,6 +421,7 @@ export default defineComponent({ itemComponent={MenuItem} class={className.value} role="menu" + id={props.id} data={wrappedChildList} renderRawItem={node => node} renderRawRest={omitItems => { diff --git a/components/menu/src/MenuItem.tsx b/components/menu/src/MenuItem.tsx index df483b93d2..39dd707432 100644 --- a/components/menu/src/MenuItem.tsx +++ b/components/menu/src/MenuItem.tsx @@ -14,6 +14,7 @@ import devWarning from '../../vc-util/devWarning'; let indexGuid = 0; const menuItemProps = { + id: String, role: String, disabled: Boolean, danger: Boolean, @@ -210,6 +211,7 @@ export default defineComponent({ , - default: undefined, - }, - renderTabBar: PropTypes.func, - panels: PropTypes.array.def([]), - activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - tabBarGutter: PropTypes.number, - }, - render() { - const { - centered, - tabBarStyle, - animated = true, - renderTabBar, - tabBarExtraContent, - tabPosition, - prefixCls, - type = 'line', - size, - } = this.$props; - const inkBarAnimated = typeof animated === 'object' ? animated.inkBar : animated; - - const isVertical = tabPosition === 'left' || tabPosition === 'right'; - const prevIcon = ( - - {isVertical ? ( - - ) : ( - - )} - - ); - const nextIcon = ( - - {isVertical ? ( - - ) : ( - - )} - - ); - // Additional className for style usage - const cls = { - [this.$attrs.class as string]: this.$attrs.class, - [`${prefixCls}-centered-bar`]: centered, - [`${prefixCls}-${tabPosition}-bar`]: true, - [`${prefixCls}-${size}-bar`]: !!size, - [`${prefixCls}-card-bar`]: type && type.indexOf('card') >= 0, - }; - - const renderProps = { - ...this.$props, - ...this.$attrs, - children: null, - inkBarAnimated, - extraContent: tabBarExtraContent, - prevIcon, - nextIcon, - style: tabBarStyle, - class: cls, - }; - - if (renderTabBar) { - return renderTabBar({ ...renderProps, DefaultTabBar: ScrollableInkTabBar }); - } else { - return ; - } - }, -}); - -export default TabBar; diff --git a/components/tabs/index.ts b/components/tabs/index.ts index 14b04d33ec..9c34b615fd 100644 --- a/components/tabs/index.ts +++ b/components/tabs/index.ts @@ -1,23 +1,19 @@ import type { App, Plugin } from 'vue'; -import Tabs from './tabs'; -import TabPane from '../vc-tabs/src/TabPane'; -import TabContent from '../vc-tabs/src/TabContent'; +import Tabs, { TabPane } from './src'; +export type { TabsProps, TabPaneProps } from './src'; Tabs.TabPane = { ...TabPane, name: 'ATabPane', __ANT_TAB_PANE: true }; -Tabs.TabContent = { ...TabContent, name: 'ATabContent' }; /* istanbul ignore next */ Tabs.install = function (app: App) { app.component(Tabs.name, Tabs); app.component(Tabs.TabPane.name, Tabs.TabPane); - app.component(Tabs.TabContent.name, Tabs.TabContent); return app; }; export default Tabs as typeof Tabs & Plugin & { readonly TabPane: typeof TabPane; - readonly TabContent: typeof TabContent; }; -export { TabPane, TabContent }; +export { TabPane }; diff --git a/components/tabs/src/TabContext.ts b/components/tabs/src/TabContext.ts new file mode 100644 index 0000000000..75a2a4d426 --- /dev/null +++ b/components/tabs/src/TabContext.ts @@ -0,0 +1,33 @@ +import type { Tab } from './interface'; +import type { PropType, InjectionKey } from 'vue'; +import { provide, inject, defineComponent } from 'vue'; + +export interface TabContextProps { + tabs: Tab[]; + prefixCls: string; +} + +const TabsContextKey: InjectionKey = Symbol('tabsContextKey'); + +export const useProvideTabs = (props: TabContextProps) => { + provide(TabsContextKey, props); +}; + +export const useInjectTabs = () => { + return inject(TabsContextKey, { tabs: [], prefixCls: undefined }); +}; + +const TabsContextProvider = defineComponent({ + name: 'TabsContextProvider', + inheritAttrs: false, + props: { + tabs: { type: Object as PropType, default: undefined }, + prefixCls: { type: String, default: undefined }, + }, + setup(props, { slots }) { + useProvideTabs(props); + return () => slots.default?.(); + }, +}); + +export default TabsContextProvider; diff --git a/components/tabs/src/TabNavList/AddButton.tsx b/components/tabs/src/TabNavList/AddButton.tsx new file mode 100644 index 0000000000..3502b8d1ca --- /dev/null +++ b/components/tabs/src/TabNavList/AddButton.tsx @@ -0,0 +1,48 @@ +import type { PropType } from 'vue'; +import { defineComponent, ref } from 'vue'; +import type { EditableConfig, TabsLocale } from '../interface'; + +export interface AddButtonProps { + prefixCls: string; + editable?: EditableConfig; + locale?: TabsLocale; +} + +export default defineComponent({ + name: 'AddButton', + inheritAttrs: false, + props: { + prefixCls: String, + editable: { type: Object as PropType }, + locale: { type: Object as PropType, default: undefined as TabsLocale }, + }, + setup(props, { expose, attrs }) { + const domRef = ref(); + expose({ + domRef, + }); + return () => { + const { prefixCls, editable, locale } = props; + if (!editable || editable.showAdd === false) { + return null; + } + + return ( + + ); + }; + }, +}); diff --git a/components/tabs/src/TabNavList/OperationNode.tsx b/components/tabs/src/TabNavList/OperationNode.tsx new file mode 100644 index 0000000000..bb279497f5 --- /dev/null +++ b/components/tabs/src/TabNavList/OperationNode.tsx @@ -0,0 +1,217 @@ +import Menu, { MenuItem } from '../../../menu'; +import Dropdown from '../../../vc-dropdown'; +import type { Tab, TabsLocale, EditableConfig } from '../interface'; +import AddButton from './AddButton'; +import type { Key, VueNode } from '../../../_util/type'; +import KeyCode from '../../../_util/KeyCode'; +import type { CSSProperties, PropType } from 'vue'; +import classNames from '../../../_util/classNames'; +import { defineComponent, watch, computed, onMounted } from 'vue'; +import PropTypes from '../../../_util/vue-types'; +import useState from '../../../_util/hooks/useState'; + +export interface OperationNodeProps { + prefixCls: string; + id: string; + tabs: Tab[]; + rtl: boolean; + tabBarGutter?: number; + activeKey: string; + mobile: boolean; + moreIcon?: VueNode; + moreTransitionName?: string; + editable?: EditableConfig; + locale?: TabsLocale; + onTabClick: (key: Key, e: MouseEvent | KeyboardEvent) => void; +} + +export default defineComponent({ + name: 'OperationNode', + inheritAttrs: false, + props: { + prefixCls: { type: String }, + id: { type: String }, + tabs: { type: Object as PropType }, + rtl: { type: Boolean }, + tabBarGutter: { type: Number }, + activeKey: { type: String }, + mobile: { type: Boolean }, + moreIcon: PropTypes.any, + moreTransitionName: { type: String }, + editable: { type: Object as PropType }, + locale: { type: Object as PropType, default: undefined as TabsLocale }, + onTabClick: { type: Function as PropType<(key: Key, e: MouseEvent | KeyboardEvent) => void> }, + }, + emits: ['tabClick'], + slots: ['moreIcon'], + setup(props, { attrs, slots }) { + // ======================== Dropdown ======================== + const [open, setOpen] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + const selectOffset = (offset: -1 | 1) => { + const enabledTabs = props.tabs.filter(tab => !tab.disabled); + let selectedIndex = enabledTabs.findIndex(tab => tab.key === selectedKey.value) || 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; + } + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + const { which } = e; + + if (!open.value) { + 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.value !== null) props.onTabClick(selectedKey.value, e); + break; + } + }; + const popupId = computed(() => `${props.id}-more-popup`); + + const selectedItemId = computed(() => + selectedKey.value !== null ? `${popupId.value}-${selectedKey.value}` : null, + ); + + onMounted(() => { + watch( + selectedKey, + () => { + const ele = document.getElementById(selectedItemId.value); + if (ele && ele.scrollIntoView) { + ele.scrollIntoView(false); + } + }, + { flush: 'post', immediate: true }, + ); + }); + + watch(open, () => { + if (!open.value) { + setSelectedKey(null); + } + }); + + return () => { + const { + prefixCls, + id, + tabs, + locale, + mobile, + moreIcon = slots.moreIcon?.() || 'More', + moreTransitionName, + editable, + tabBarGutter, + rtl, + onTabClick, + } = props; + const dropdownPrefix = `${prefixCls}-dropdown`; + + const dropdownAriaLabel = locale?.dropdownAriaLabel; + + // ========================= Render ========================= + const moreStyle: CSSProperties = { + [rtl ? 'marginRight' : 'marginLeft']: tabBarGutter, + }; + if (!tabs.length) { + moreStyle.visibility = 'hidden'; + moreStyle.order = 1; + } + + const overlayClassName = classNames({ + [`${dropdownPrefix}-rtl`]: rtl, + }); + const moreNode = mobile ? null : ( + ( + { + onTabClick(key, domEvent); + setOpen(false); + }} + id={popupId.value} + tabindex={-1} + role="listbox" + aria-activedescendant={selectedItemId.value} + selectedKeys={[selectedKey.value]} + aria-label={ + dropdownAriaLabel !== undefined ? dropdownAriaLabel : 'expanded dropdown' + } + > + {tabs.map(tab => ( + + {typeof tab.tab === 'function' ? tab.tab() : tab.tab} + + ))} + + ), + default: () => ( + + ), + }} + > + ); + + return ( +
+ {moreNode} + +
+ ); + }; + }, +}); diff --git a/components/tabs/src/TabNavList/TabNode.tsx b/components/tabs/src/TabNavList/TabNode.tsx new file mode 100644 index 0000000000..49a5fc7dbd --- /dev/null +++ b/components/tabs/src/TabNavList/TabNode.tsx @@ -0,0 +1,138 @@ +import type { Tab, EditableConfig } from '../interface'; +import type { PropType } from 'vue'; +import { onBeforeUnmount, defineComponent, computed, ref } from 'vue'; +import type { FocusEventHandler } from '../../../_util/EventInterface'; +import KeyCode from '../../../_util/KeyCode'; +import classNames from '../../../_util/classNames'; + +export interface TabNodeProps { + id: string; + prefixCls: string; + tab: Tab; + active: boolean; + closable?: boolean; + editable?: EditableConfig; + onClick?: (e: MouseEvent | KeyboardEvent) => void; + onResize?: (width: number, height: number, left: number, top: number) => void; + renderWrapper?: (node: any) => any; + removeAriaLabel?: string; + onRemove: () => void; + onFocus: FocusEventHandler; +} + +export default defineComponent({ + name: 'TabNode', + props: { + id: { type: String as PropType }, + prefixCls: { type: String as PropType }, + tab: { type: Object as PropType }, + active: { type: Boolean }, + closable: { type: Boolean }, + editable: { type: Object as PropType }, + onClick: { type: Function as PropType<(e: MouseEvent | KeyboardEvent) => void> }, + onResize: { + type: Function as PropType< + (width: number, height: number, left: number, top: number) => void + >, + }, + renderWrapper: { type: Function as PropType<(node: any) => any> }, + removeAriaLabel: { type: String }, + onRemove: { type: Function as PropType<() => void> }, + onFocus: { type: Function as PropType }, + }, + emits: ['click', 'resize', 'remove', 'focus'], + setup(props, { expose, attrs }) { + const domRef = ref(); + function onInternalClick(e: MouseEvent | KeyboardEvent) { + if (props.tab?.disabled) { + return; + } + props.onClick(e); + } + expose({ + domRef, + }); + onBeforeUnmount(() => { + props.onRemove(); + }); + function onRemoveTab(event: MouseEvent | KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + props.editable.onEdit('remove', { + key: props.tab?.key, + event, + }); + } + + const removable = computed( + () => props.editable && props.closable !== false && !props.tab?.disabled, + ); + return () => { + const { + prefixCls, + id, + active, + tab: { key, tab, disabled, closeIcon }, + renderWrapper, + removeAriaLabel, + editable, + onFocus, + } = props; + const tabPrefix = `${prefixCls}-tab`; + const node = ( +
+ {/* Primary Tab Button */} + + + {/* Remove Button */} + {removable.value && ( + + )} +
+ ); + return renderWrapper ? renderWrapper(node) : node; + }; + }, +}); diff --git a/components/tabs/src/TabNavList/index.tsx b/components/tabs/src/TabNavList/index.tsx new file mode 100644 index 0000000000..90f055aa79 --- /dev/null +++ b/components/tabs/src/TabNavList/index.tsx @@ -0,0 +1,559 @@ +import { useRafState } from '../hooks/useRaf'; +import TabNode from './TabNode'; +import type { + TabSizeMap, + TabPosition, + RenderTabBar, + TabsLocale, + EditableConfig, + AnimatedConfig, + OnTabScroll, + TabBarExtraPosition, + TabBarExtraContent, +} from '../interface'; +import useOffsets from '../hooks/useOffsets'; +import OperationNode from './OperationNode'; +import { useInjectTabs } from '../TabContext'; +import useTouchMove from '../hooks/useTouchMove'; +import useRefs from '../hooks/useRefs'; +import AddButton from './AddButton'; +import type { Key } from '../../../_util/type'; +import type { ExtractPropTypes, PropType, CSSProperties } from 'vue'; +import { onBeforeUnmount, defineComponent, ref, watch, watchEffect, computed } from 'vue'; +import PropTypes from '../../../_util/vue-types'; +import useSyncState from '../hooks/useSyncState'; +import useState from '../../../_util/hooks/useState'; +import wrapperRaf from '../../../_util/raf'; +import classNames from '../../../_util/classNames'; +import ResizeObserver from '../../../vc-resize-observer'; +import { toPx } from '../../../_util/util'; +const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0, right: 0 }; +const tabNavListProps = () => { + return { + id: { type: String }, + tabPosition: { type: String as PropType }, + activeKey: { type: String }, + rtl: { type: Boolean }, + panes: PropTypes.any, + animated: { type: Object as PropType, default: undefined as AnimatedConfig }, + extra: PropTypes.any, + editable: { type: Object as PropType }, + moreIcon: PropTypes.any, + moreTransitionName: { type: String }, + mobile: { type: Boolean }, + tabBarGutter: { type: Number }, + renderTabBar: { type: Function as PropType }, + locale: { type: Object as PropType, default: undefined as TabsLocale }, + onTabClick: { + type: Function as PropType<(activeKey: Key, e: MouseEvent | KeyboardEvent) => void>, + }, + onTabScroll: { type: Function as PropType }, + }; +}; + +export type TabNavListProps = Partial>>; + +interface ExtraContentProps { + position: TabBarExtraPosition; + prefixCls: string; + extra?: TabBarExtraContent; +} + +export default defineComponent({ + name: 'TabNavList', + inheritAttrs: false, + props: tabNavListProps(), + slots: ['panes', 'moreIcon', 'extra'], + emits: ['tabClick', 'tabScroll'], + setup(props, { attrs, slots }) { + const tabsContext = useInjectTabs(); + const tabsWrapperRef = ref(); + const tabListRef = ref(); + const operationsRef = ref<{ $el: HTMLDivElement }>(); + const innerAddButtonRef = ref(); + const [getBtnRef, removeBtnRef] = useRefs(); + + const tabPositionTopOrBottom = computed( + () => props.tabPosition === 'top' || props.tabPosition === 'bottom', + ); + + const [transformLeft, setTransformLeft] = useSyncState(0, (next, prev) => { + if (tabPositionTopOrBottom.value && props.onTabScroll) { + props.onTabScroll({ direction: next > prev ? 'left' : 'right' }); + } + }); + const [transformTop, setTransformTop] = useSyncState(0, (next, prev) => { + if (!tabPositionTopOrBottom.value && props.onTabScroll) { + props.onTabScroll({ direction: next > prev ? 'top' : 'bottom' }); + } + }); + + const [wrapperScrollWidth, setWrapperScrollWidth] = useState(0); + const [wrapperScrollHeight, setWrapperScrollHeight] = useState(0); + const [wrapperContentWidth, setWrapperContentWidth] = useState(0); + const [wrapperContentHeight, setWrapperContentHeight] = useState(0); + const [wrapperWidth, setWrapperWidth] = useState(null); + const [wrapperHeight, setWrapperHeight] = useState(null); + const [addWidth, setAddWidth] = useState(0); + const [addHeight, setAddHeight] = useState(0); + + const [tabSizes, setTabSizes] = useRafState(new Map()); + const tabOffsets = useOffsets( + computed(() => tabsContext.tabs), + tabSizes, + ); + + // ========================== Util ========================= + const operationsHiddenClassName = computed( + () => `${tabsContext.prefixCls}-nav-operations-hidden`, + ); + + const transformMin = ref(0); + const transformMax = ref(0); + + watchEffect(() => { + if (!tabPositionTopOrBottom.value) { + transformMin.value = Math.min(0, wrapperHeight.value - wrapperScrollHeight.value); + transformMax.value = 0; + } else if (props.rtl) { + transformMin.value = 0; + transformMax.value = Math.max(0, wrapperScrollWidth.value - wrapperWidth.value); + } else { + transformMin.value = Math.min(0, wrapperWidth.value - wrapperScrollWidth.value); + transformMax.value = 0; + } + }); + + const alignInRange = (value: number): number => { + if (value < transformMin.value) { + return transformMin.value; + } + if (value > transformMax.value) { + return transformMax.value; + } + return value; + }; + + // ========================= Mobile ======================== + const touchMovingRef = ref(); + const [lockAnimation, setLockAnimation] = useState(); + + const doLockAnimation = () => { + setLockAnimation(Date.now()); + }; + + const clearTouchMoving = () => { + window.clearTimeout(touchMovingRef.value); + }; + const doMove = (setState: (fn: (val: number) => number) => void, offset: number) => { + setState((value: number) => { + const newValue = alignInRange(value + offset); + + return newValue; + }); + }; + useTouchMove(tabsWrapperRef, (offsetX, offsetY) => { + if (tabPositionTopOrBottom.value) { + // Skip scroll if place is enough + if (wrapperWidth.value >= wrapperScrollWidth.value) { + return false; + } + + doMove(setTransformLeft, offsetX); + } else { + if (wrapperHeight.value >= wrapperScrollHeight.value) { + return false; + } + + doMove(setTransformTop, offsetY); + } + + clearTouchMoving(); + doLockAnimation(); + + return true; + }); + + watch(lockAnimation, () => { + clearTouchMoving(); + if (lockAnimation.value) { + touchMovingRef.value = window.setTimeout(() => { + setLockAnimation(0); + }, 100); + } + }); + + // ========================= Scroll ======================== + const scrollToTab = (key = props.activeKey) => { + const tabOffset = tabOffsets.value.get(key) || { + width: 0, + height: 0, + left: 0, + right: 0, + top: 0, + }; + + if (tabPositionTopOrBottom.value) { + // ============ Align with top & bottom ============ + let newTransform = transformLeft.value; + + // RTL + if (props.rtl) { + if (tabOffset.right < transformLeft.value) { + newTransform = tabOffset.right; + } else if (tabOffset.right + tabOffset.width > transformLeft.value + wrapperWidth.value) { + newTransform = tabOffset.right + tabOffset.width - wrapperWidth.value; + } + } + // LTR + else if (tabOffset.left < -transformLeft.value) { + newTransform = -tabOffset.left; + } else if (tabOffset.left + tabOffset.width > -transformLeft.value + wrapperWidth.value) { + newTransform = -(tabOffset.left + tabOffset.width - wrapperWidth.value); + } + + setTransformTop(0); + setTransformLeft(alignInRange(newTransform)); + } else { + // ============ Align with left & right ============ + let newTransform = transformTop.value; + + if (tabOffset.top < -transformTop.value) { + newTransform = -tabOffset.top; + } else if (tabOffset.top + tabOffset.height > -transformTop.value + wrapperHeight.value) { + newTransform = -(tabOffset.top + tabOffset.height - wrapperHeight.value); + } + + setTransformLeft(0); + setTransformTop(alignInRange(newTransform)); + } + }; + + const visibleStart = ref(0); + const visibleEnd = ref(0); + + watchEffect(() => { + let unit: 'width' | 'height'; + let position: 'left' | 'top' | 'right'; + let transformSize: number; + let basicSize: number; + let tabContentSize: number; + let addSize: number; + + if (['top', 'bottom'].includes(props.tabPosition)) { + unit = 'width'; + basicSize = wrapperContentWidth.value; + tabContentSize = wrapperContentWidth.value; + addSize = addWidth.value; + position = props.rtl ? 'right' : 'left'; + transformSize = Math.abs(transformLeft.value); + } else { + unit = 'height'; + basicSize = wrapperContentHeight.value; + tabContentSize = wrapperContentHeight.value; + addSize = addHeight.value; + position = 'top'; + transformSize = -transformTop.value; + } + + let mergedBasicSize = basicSize; + if (tabContentSize + addSize > basicSize) { + mergedBasicSize = basicSize - addSize; + } + + const { tabs } = tabsContext; + if (!tabs.length) { + [visibleStart.value, visibleEnd.value] = [0, 0]; + } + + const len = tabs.length; + let endIndex = len; + for (let i = 0; i < len; i += 1) { + const offset = tabOffsets.value.get(tabs[i].key) || DEFAULT_SIZE; + if (offset[position] + offset[unit] > transformSize + mergedBasicSize) { + endIndex = i - 1; + break; + } + } + + let startIndex = 0; + for (let i = len - 1; i >= 0; i -= 1) { + const offset = tabOffsets.value.get(tabs[i].key) || DEFAULT_SIZE; + if (offset[position] < transformSize) { + startIndex = i + 1; + break; + } + } + + [visibleStart.value, visibleEnd.value] = [startIndex, endIndex]; + }); + + const onListHolderResize = () => { + // Update wrapper records + const offsetWidth = tabsWrapperRef.value?.offsetWidth || 0; + const offsetHeight = tabsWrapperRef.value?.offsetHeight || 0; + const newAddWidth = innerAddButtonRef.value?.offsetWidth || 0; + const newAddHeight = innerAddButtonRef.value?.offsetHeight || 0; + const newOperationWidth = operationsRef.value?.$el.offsetWidth || 0; + const newOperationHeight = operationsRef.value?.$el.offsetHeight || 0; + + setWrapperWidth(offsetWidth); + setWrapperHeight(offsetHeight); + setAddWidth(newAddWidth); + setAddHeight(newAddHeight); + + const newWrapperScrollWidth = (tabListRef.value?.offsetWidth || 0) - newAddWidth; + const newWrapperScrollHeight = (tabListRef.value?.offsetHeight || 0) - newAddHeight; + + setWrapperScrollWidth(newWrapperScrollWidth); + setWrapperScrollHeight(newWrapperScrollHeight); + + const isOperationHidden = operationsRef.value?.$el.className.includes( + operationsHiddenClassName.value, + ); + setWrapperContentWidth(newWrapperScrollWidth - (isOperationHidden ? 0 : newOperationWidth)); + setWrapperContentHeight( + newWrapperScrollHeight - (isOperationHidden ? 0 : newOperationHeight), + ); + + // Update buttons records + setTabSizes(() => { + const newSizes: TabSizeMap = new Map(); + tabsContext.tabs.forEach(({ key }) => { + const btnRef = getBtnRef(key).value; + const btnNode = (btnRef as any).$el || btnRef; + if (btnNode) { + newSizes.set(key, { + width: btnNode.offsetWidth, + height: btnNode.offsetHeight, + left: btnNode.offsetLeft, + top: btnNode.offsetTop, + }); + } + }); + return newSizes; + }); + }; + + // ======================== Dropdown ======================= + const hiddenTabs = computed(() => [ + ...tabsContext.tabs.slice(0, visibleStart.value), + ...tabsContext.tabs.slice(visibleEnd.value + 1), + ]); + + // =================== Link & Operations =================== + const [inkStyle, setInkStyle] = useState(); + + const activeTabOffset = computed(() => tabOffsets.value.get(props.activeKey)); + + // Delay set ink style to avoid remove tab blink + const inkBarRafRef = ref(); + const cleanInkBarRaf = () => { + wrapperRaf.cancel(inkBarRafRef.value); + }; + + watch([activeTabOffset, tabPositionTopOrBottom, () => props.rtl], () => { + const newInkStyle: CSSProperties = {}; + + if (activeTabOffset.value) { + if (tabPositionTopOrBottom.value) { + if (props.rtl) { + newInkStyle.right = toPx(activeTabOffset.value.right); + } else { + newInkStyle.left = toPx(activeTabOffset.value.left); + } + + newInkStyle.width = toPx(activeTabOffset.value.width); + } else { + newInkStyle.top = toPx(activeTabOffset.value.top); + newInkStyle.height = toPx(activeTabOffset.value.height); + } + } + + cleanInkBarRaf(); + inkBarRafRef.value = wrapperRaf(() => { + setInkStyle(newInkStyle); + }); + }); + + watch( + [() => props.activeKey, activeTabOffset, tabOffsets, tabPositionTopOrBottom], + () => { + scrollToTab(); + }, + { flush: 'post' }, + ); + + watch( + [() => props.rtl, () => props.tabBarGutter, () => props.activeKey, () => tabsContext.tabs], + () => { + onListHolderResize(); + }, + { flush: 'post' }, + ); + + const ExtraContent = ({ position, prefixCls, extra }: ExtraContentProps) => { + if (!extra) return null; + + const content = slots.extra?.({ position }); + + return content ?
{content}
: null; + }; + + onBeforeUnmount(() => { + clearTouchMoving(); + cleanInkBarRaf(); + }); + + return () => { + const { prefixCls, tabs } = tabsContext; + const { + id, + animated, + activeKey, + rtl, + extra, + editable, + locale, + tabPosition, + tabBarGutter, + onTabClick, + } = props; + const { class: className, style } = attrs; + // ========================= Render ======================== + const hasDropdown = !!hiddenTabs.value.length; + const wrapPrefix = `${prefixCls}-nav-wrap`; + let pingLeft: boolean; + let pingRight: boolean; + let pingTop: boolean; + let pingBottom: boolean; + + if (tabPositionTopOrBottom.value) { + if (rtl) { + pingRight = transformLeft.value > 0; + pingLeft = transformLeft.value + wrapperWidth.value < wrapperScrollWidth.value; + } else { + pingLeft = transformLeft.value < 0; + pingRight = -transformLeft.value + wrapperWidth.value < wrapperScrollWidth.value; + } + } else { + pingTop = transformTop.value < 0; + pingBottom = -transformTop.value + wrapperHeight.value < wrapperScrollHeight.value; + } + + const tabNodeStyle: CSSProperties = {}; + if (tabPosition === 'top' || tabPosition === 'bottom') { + tabNodeStyle[rtl ? 'marginRight' : 'marginLeft'] = + typeof tabBarGutter === 'number' ? `${tabBarGutter}px` : tabBarGutter; + } else { + tabNodeStyle.marginTop = + typeof tabBarGutter === 'number' ? `${tabBarGutter}px` : tabBarGutter; + } + + const tabNodes = tabs.map((tab, i) => { + const { key } = tab; + return ( + { + onTabClick(key, e); + }} + onRemove={() => { + removeBtnRef(key); + }} + onFocus={() => { + scrollToTab(key); + doLockAnimation(); + if (!tabsWrapperRef.value) { + return; + } + // Focus element will make scrollLeft change which we should reset back + if (!rtl) { + tabsWrapperRef.value.scrollLeft = 0; + } + tabsWrapperRef.value.scrollTop = 0; + }} + v-slots={slots} + > + ); + }); + + return ( +
{ + // No need animation when use keyboard + doLockAnimation(); + }} + > + + + +
+ +
+ {tabNodes} + + +
+
+ +
+
+ + + + +
+ ); + }; + }, +}); diff --git a/components/tabs/src/TabPanelList/TabPane.tsx b/components/tabs/src/TabPanelList/TabPane.tsx new file mode 100644 index 0000000000..fc86de7228 --- /dev/null +++ b/components/tabs/src/TabPanelList/TabPane.tsx @@ -0,0 +1,85 @@ +import { defineComponent, ref, watch, computed } from 'vue'; +import type { CSSProperties } from 'vue'; +import type { VueNode } from '../../../_util/type'; +import PropTypes from '../../../_util/vue-types'; + +export interface TabPaneProps { + tab?: VueNode | (() => VueNode); + disabled?: boolean; + forceRender?: boolean; + closable?: boolean; + closeIcon?: VueNode; + + // Pass by TabPaneList + prefixCls?: string; + tabKey?: string; + id?: string; + animated?: boolean; + active?: boolean; + destroyInactiveTabPane?: boolean; +} + +export default defineComponent({ + name: 'TabPane', + inheritAttrs: false, + props: { + tab: PropTypes.any, + disabled: { type: Boolean }, + forceRender: { type: Boolean }, + closable: { type: Boolean }, + animated: { type: Boolean }, + active: { type: Boolean }, + destroyInactiveTabPane: { type: Boolean }, + + // Pass by TabPaneList + prefixCls: { type: String }, + tabKey: { type: String }, + id: { type: String }, + }, + slots: ['closeIcon', 'tab'], + setup(props, { attrs, slots }) { + const visited = ref(props.forceRender); + watch( + [() => props.active, () => props.destroyInactiveTabPane], + () => { + if (props.active) { + visited.value = true; + } else if (props.destroyInactiveTabPane) { + visited.value = false; + } + }, + { immediate: true }, + ); + const mergedStyle = computed(() => { + if (!props.active) { + if (props.animated) { + return { + visibility: 'hidden', + height: 0, + overflowY: 'hidden', + }; + } else { + return { display: 'none' }; + } + } + return {}; + }); + + return () => { + const { prefixCls, forceRender, id, active, tabKey } = props; + return ( +
+ {(active || visited.value || forceRender) && slots.default?.()} +
+ ); + }; + }, +}); diff --git a/components/tabs/src/TabPanelList/index.tsx b/components/tabs/src/TabPanelList/index.tsx new file mode 100644 index 0000000000..16f30ab928 --- /dev/null +++ b/components/tabs/src/TabPanelList/index.tsx @@ -0,0 +1,68 @@ +import { useInjectTabs } from '../TabContext'; +import type { TabPosition, AnimatedConfig } from '../interface'; +import type { Key } from '../../../_util/type'; +import type { PropType } from 'vue'; +import { defineComponent } from 'vue'; +import { cloneElement } from '../../../_util/vnode'; + +export interface TabPanelListProps { + activeKey: Key; + id: string; + rtl: boolean; + animated?: AnimatedConfig; + tabPosition?: TabPosition; + destroyInactiveTabPane?: boolean; +} +export default defineComponent({ + name: 'TabPanelList', + inheritAttrs: false, + props: { + activeKey: { type: [String, Number] as PropType }, + id: { type: String }, + rtl: { type: Boolean }, + animated: { type: Object as PropType, default: undefined as AnimatedConfig }, + tabPosition: { type: String as PropType }, + destroyInactiveTabPane: { type: Boolean }, + }, + setup(props) { + const tabsContext = useInjectTabs(); + return () => { + const { id, activeKey, animated, tabPosition, rtl, destroyInactiveTabPane } = props; + const { prefixCls, tabs } = tabsContext; + const tabPaneAnimated = animated.tabPane; + + const activeIndex = tabs.findIndex(tab => tab.key === activeKey); + + return ( +
+
+ {tabs.map(tab => { + return cloneElement(tab.node, { + key: tab.key, + prefixCls, + tabKey: tab.key, + id, + animated: tabPaneAnimated, + active: tab.key === activeKey, + destroyInactiveTabPane, + }); + })} +
+
+ ); + }; + }, +}); diff --git a/components/tabs/src/Tabs.tsx b/components/tabs/src/Tabs.tsx new file mode 100644 index 0000000000..842f5a2895 --- /dev/null +++ b/components/tabs/src/Tabs.tsx @@ -0,0 +1,334 @@ +// Accessibility https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role +import TabNavList from './TabNavList'; +import TabPanelList from './TabPanelList'; +import type { + TabPosition, + RenderTabBar, + TabsLocale, + EditableConfig, + AnimatedConfig, + OnTabScroll, + Tab, +} from './interface'; +import type { CSSProperties, PropType, ExtractPropTypes } from 'vue'; +import { defineComponent, computed, onMounted, watchEffect, camelize } from 'vue'; +import { flattenChildren, initDefaultProps, isValidElement } from '../../_util/props-util'; +import useConfigInject from '../../_util/hooks/useConfigInject'; +import useState from '../../_util/hooks/useState'; +import isMobile from '../../vc-util/isMobile'; +import useMergedState from '../../_util/hooks/useMergedState'; +import classNames from '../../_util/classNames'; +import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue'; +import devWarning from '../../vc-util/devWarning'; +import type { SizeType } from '../../config-provider'; +import TabsContextProvider from './TabContext'; + +export type TabsType = 'line' | 'card' | 'editable-card'; +export type TabsPosition = 'top' | 'right' | 'bottom' | 'left'; + +// Used for accessibility +let uuid = 0; + +export const tabsProps = () => { + return { + prefixCls: { type: String }, + id: { type: String }, + + activeKey: { type: String }, + defaultActiveKey: { type: String }, + direction: { type: String as PropType<'ltr' | 'rtl'> }, + animated: { type: [Boolean, Object] as PropType }, + renderTabBar: { type: Function as PropType }, + tabBarGutter: { type: Number }, + tabBarStyle: { type: Object as PropType }, + tabPosition: { type: String as PropType }, + destroyInactiveTabPane: { type: Boolean }, + + hideAdd: Boolean, + type: { type: String as PropType }, + size: { type: String as PropType }, + centered: Boolean, + onEdit: { + type: Function as PropType< + (e: MouseEvent | KeyboardEvent | string, action: 'add' | 'remove') => void + >, + }, + onChange: { type: Function as PropType<(activeKey: string) => void> }, + onTabClick: { + type: Function as PropType<(activeKey: string, e: KeyboardEvent | MouseEvent) => void>, + }, + onTabScroll: { type: Function as PropType }, + + // Accessibility + locale: { type: Object as PropType, default: undefined as TabsLocale }, + onPrevClick: Function, + onNextClick: Function, + }; +}; + +export type TabsProps = Partial>>; + +function parseTabList(children: any[]): Tab[] { + return children + .map(node => { + if (isValidElement(node)) { + const props = { ...(node.props || {}) }; + for (const [k, v] of Object.entries(props)) { + delete props[k]; + props[camelize(k)] = v; + } + const slots = node.children || {}; + const key = node.key !== undefined ? String(node.key) : undefined; + const { + tab = slots.tab, + disabled, + forceRender, + closable, + animated, + active, + destroyInactiveTabPane, + } = props; + return { + key, + ...props, + node, + closeIcon: slots.closeIcon, + tab, + disabled: disabled === '' || disabled, + forceRender: forceRender === '' || forceRender, + closable: closable === '' || closable, + animated: animated === '' || animated, + active: active === '' || active, + destroyInactiveTabPane: destroyInactiveTabPane === '' || destroyInactiveTabPane, + }; + } + + return null; + }) + .filter(tab => tab); +} +const InternalTabs = defineComponent({ + name: 'InternalTabs', + inheritAttrs: false, + props: { + ...initDefaultProps(tabsProps(), { + tabPosition: 'top', + animated: { + inkBar: true, + tabPane: false, + }, + }), + tabs: { type: Array as PropType }, + }, + slots: ['tabBarExtraContent', 'moreIcon', 'addIcon', 'removeIcon'], + emits: ['tabClick', 'tabScroll', 'change', 'update:activeKey'], + setup(props, { attrs, slots }) { + devWarning( + !(props.onPrevClick !== undefined) && !(props.onNextClick !== undefined), + 'Tabs', + '`onPrevClick / @prevClick` and `onNextClick / @nextClick` has been removed. Please use `onTabScroll / @tabScroll` instead.', + ); + const { prefixCls, direction, size, rootPrefixCls } = useConfigInject('tabs', props); + const rtl = computed(() => direction.value === 'rtl'); + const mergedAnimated = computed(() => { + const { animated } = props; + if (animated === false) { + return { + inkBar: false, + tabPane: false, + }; + } else if (animated === true) { + return { + inkBar: true, + tabPane: true, + }; + } else { + return { + inkBar: true, + tabPane: false, + ...(typeof animated === 'object' ? animated : {}), + }; + } + }); + + // ======================== Mobile ======================== + const [mobile, setMobile] = useState(false); + onMounted(() => { + // Only update on the client side + setMobile(isMobile()); + }); + + // ====================== Active Key ====================== + const [mergedActiveKey, setMergedActiveKey] = useMergedState(() => props.tabs[0]?.key, { + value: computed(() => props.activeKey), + defaultValue: props.defaultActiveKey, + }); + const [activeIndex, setActiveIndex] = useState(() => + props.tabs.findIndex(tab => tab.key === mergedActiveKey.value), + ); + + watchEffect(() => { + let newActiveIndex = props.tabs.findIndex(tab => tab.key === mergedActiveKey.value); + if (newActiveIndex === -1) { + newActiveIndex = Math.max(0, Math.min(activeIndex.value, props.tabs.length - 1)); + setMergedActiveKey(props.tabs[newActiveIndex]?.key); + } + setActiveIndex(newActiveIndex); + }); + + // ===================== Accessibility ==================== + const [mergedId, setMergedId] = useMergedState(null, { + value: computed(() => props.id), + }); + + const mergedTabPosition = computed(() => { + if (mobile.value && !['left', 'right'].includes(props.tabPosition)) { + return 'top'; + } else { + return props.tabPosition; + } + }); + + onMounted(() => { + if (!props.id) { + setMergedId(`rc-tabs-${process.env.NODE_ENV === 'test' ? 'test' : uuid}`); + uuid += 1; + } + }); + + // ======================== Events ======================== + const onInternalTabClick = (key: string, e: MouseEvent | KeyboardEvent) => { + props.onTabClick?.(key, e); + + setMergedActiveKey(key); + props.onChange?.(key); + }; + + return () => { + const { + id, + type, + activeKey, + defaultActiveKey, + tabBarGutter, + tabBarStyle, + locale, + destroyInactiveTabPane, + renderTabBar, + onChange, + onTabClick, + onTabScroll, + hideAdd, + centered, + ...restProps + } = props; + // ======================== Render ======================== + const sharedProps = { + id: mergedId.value, + activeKey: mergedActiveKey.value, + animated: mergedAnimated.value, + tabPosition: mergedTabPosition.value, + rtl: rtl.value, + mobile: mobile.value, + }; + + let editable: EditableConfig | undefined; + if (type === 'editable-card') { + editable = { + onEdit: (editType, { key, event }) => { + props.onEdit?.(editType === 'add' ? event : key!, editType); + }, + removeIcon: () => , + addIcon: slots.addIcon ? slots.addIcon : () => , + showAdd: hideAdd !== true, + }; + } + + let tabNavBar; + + const tabNavBarProps = { + ...sharedProps, + moreTransitionName: `${rootPrefixCls.value}-slide-up`, + editable, + locale, + tabBarGutter, + onTabClick: onInternalTabClick, + onTabScroll, + style: tabBarStyle, + panes: flattenChildren(slots.default?.()), + }; + + if (renderTabBar) { + tabNavBar = renderTabBar(tabNavBarProps, TabNavList); + } else { + tabNavBar = ( + + ); + } + const pre = prefixCls.value; + + return ( + +
+ {tabNavBar} + +
+
+ ); + }; + }, +}); + +export default defineComponent({ + name: 'ATabs', + inheritAttrs: false, + props: initDefaultProps(tabsProps(), { + tabPosition: 'top', + animated: { + inkBar: true, + tabPane: false, + }, + }), + slots: ['tabBarExtraContent', 'moreIcon', 'addIcon', 'removeIcon'], + emits: ['tabClick', 'tabScroll', 'change', 'update:activeKey'], + setup(props, { attrs, slots, emit }) { + const handleChange = (key: string) => { + emit('update:activeKey', key); + emit('change', key); + }; + return () => { + const tabs = parseTabList(flattenChildren(slots.default?.())); + return ( + + ); + }; + }, +}); diff --git a/components/tabs/src/hooks/useOffsets.ts b/components/tabs/src/hooks/useOffsets.ts new file mode 100644 index 0000000000..fca176d209 --- /dev/null +++ b/components/tabs/src/hooks/useOffsets.ts @@ -0,0 +1,40 @@ +import type { Ref } from 'vue'; +import { ref, watchEffect } from 'vue'; +import type { TabSizeMap, TabOffsetMap, Tab, TabOffset } from '../interface'; + +const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0 }; + +export default function useOffsets( + tabs: Ref, + tabSizes: Ref, + // holderScrollWidth: Ref, +): Ref { + const offsetMap = ref(new Map()); + watchEffect(() => { + const map: TabOffsetMap = new Map(); + const tabsValue = tabs.value; + const lastOffset = tabSizes.value.get(tabsValue[0]?.key) || DEFAULT_SIZE; + const rightOffset = lastOffset.left + lastOffset.width; + + for (let i = 0; i < tabsValue.length; i += 1) { + const { key } = tabsValue[i]; + let data = tabSizes.value.get(key); + + // Reuse last one when not exist yet + if (!data) { + data = tabSizes.value.get(tabsValue[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); + } + offsetMap.value = new Map(map); + }); + + return offsetMap; +} diff --git a/components/tabs/src/hooks/useRaf.ts b/components/tabs/src/hooks/useRaf.ts new file mode 100644 index 0000000000..cbb39d7160 --- /dev/null +++ b/components/tabs/src/hooks/useRaf.ts @@ -0,0 +1,52 @@ +import type { Ref } from 'vue'; +import { ref, onBeforeUnmount } from 'vue'; +import wrapperRaf from '../../../_util/raf'; + +export default function useRaf(callback: Callback) { + const rafRef = ref(); + const removedRef = ref(false); + + function trigger(...args: any[]) { + if (!removedRef.value) { + wrapperRaf.cancel(rafRef.value); + rafRef.value = wrapperRaf(() => { + callback(...args); + }); + } + } + + onBeforeUnmount(() => { + removedRef.value = true; + wrapperRaf.cancel(rafRef.value); + }); + + return trigger; +} + +type Callback = (ori: T) => T; + +export function useRafState( + defaultState: T | (() => T), +): [Ref, (updater: Callback) => void] { + const batchRef = ref[]>([]); + const state: Ref = ref( + typeof defaultState === 'function' ? (defaultState as any)() : defaultState, + ); + + const flushUpdate = useRaf(() => { + let value = state.value; + batchRef.value.forEach(callback => { + value = callback(value); + }); + batchRef.value = []; + + state.value = value; + }); + + function updater(callback: Callback) { + batchRef.value.push(callback); + flushUpdate(); + } + + return [state, updater]; +} diff --git a/components/tabs/src/hooks/useRefs.ts b/components/tabs/src/hooks/useRefs.ts new file mode 100644 index 0000000000..73bacbae5d --- /dev/null +++ b/components/tabs/src/hooks/useRefs.ts @@ -0,0 +1,23 @@ +import type { Ref, ComponentPublicInstance } from 'vue'; +import { ref } from 'vue'; +import type { Key } from '../../../_util/type'; + +export default function useRefs(): [ + (key: Key) => Ref, + (key: Key) => void, +] { + const cacheRefs = ref(new Map>()); + + function getRef(key: Key) { + if (!cacheRefs.value.has(key)) { + cacheRefs.value.set(key, ref()); + } + return cacheRefs.value.get(key); + } + + function removeRef(key: Key) { + cacheRefs.value.delete(key); + } + + return [getRef, removeRef]; +} diff --git a/components/tabs/src/hooks/useSyncState.ts b/components/tabs/src/hooks/useSyncState.ts new file mode 100644 index 0000000000..7d529d77a8 --- /dev/null +++ b/components/tabs/src/hooks/useSyncState.ts @@ -0,0 +1,21 @@ +import type { Ref } from 'vue'; +import { ref } from 'vue'; + +type Updater = (prev: T) => T; + +export default function useSyncState( + defaultState: T, + onChange: (newValue: T, prevValue: T) => void, +): [Ref, (updater: T | Updater) => void] { + const stateRef = ref(defaultState); + + function setState(updater: any) { + const newValue = typeof updater === 'function' ? updater(stateRef.value) : updater; + if (newValue !== stateRef.value) { + onChange(newValue, stateRef.value as T); + } + stateRef.value = newValue; + } + + return [stateRef as Ref, setState]; +} diff --git a/components/tabs/src/hooks/useTouchMove.ts b/components/tabs/src/hooks/useTouchMove.ts new file mode 100644 index 0000000000..a00258e8a8 --- /dev/null +++ b/components/tabs/src/hooks/useTouchMove.ts @@ -0,0 +1,141 @@ +import type { Ref } from 'vue'; +import { ref, onBeforeUnmount, onMounted } from 'vue'; +import useState from '../../../_util/hooks/useState'; + +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; + +// ================================= Hook ================================= +export default function useTouchMove( + domRef: Ref, + 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 = ref(); + + // ========================= Events ========================= + // >>> Touch events + function onTouchStart(e: TouchEvent) { + const { screenX, screenY } = e.touches[0]; + setTouchPosition({ x: screenX, y: screenY }); + window.clearInterval(motionRef.value); + } + + function onTouchMove(e: TouchEvent) { + if (!touchPosition.value) return; + + e.preventDefault(); + const { screenX, screenY } = e.touches[0]; + setTouchPosition({ x: screenX, y: screenY }); + const offsetX = screenX - touchPosition.value.x; + const offsetY = screenY - touchPosition.value.y; + onOffset(offsetX, offsetY); + const now = Date.now(); + setLastTimestamp(now); + setLastTimeDiff(now - lastTimestamp.value); + setLastOffset({ x: offsetX, y: offsetY }); + } + + function onTouchEnd() { + if (!touchPosition.value) return; + + setTouchPosition(null); + setLastOffset(null); + + // Swipe if needed + if (lastOffset.value) { + const distanceX = lastOffset.value.x / lastTimeDiff.value; + const distanceY = lastOffset.value.y / lastTimeDiff.value; + 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.value = window.setInterval(() => { + if (Math.abs(currentX) < STOP_SWIPE_DISTANCE && Math.abs(currentY) < STOP_SWIPE_DISTANCE) { + window.clearInterval(motionRef.value); + return; + } + + currentX *= SPEED_OFF_MULTIPLE; + currentY *= SPEED_OFF_MULTIPLE; + onOffset(currentX * REFRESH_INTERVAL, currentY * REFRESH_INTERVAL); + }, REFRESH_INTERVAL); + } + } + + // >>> Wheel event + const lastWheelDirectionRef = ref<'x' | 'y'>(); + + function onWheel(e: WheelEvent) { + const { deltaX, deltaY } = e; + + // Convert both to x & y since wheel only happened on PC + let mixed = 0; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + if (absX === absY) { + mixed = lastWheelDirectionRef.value === 'x' ? deltaX : deltaY; + } else if (absX > absY) { + mixed = deltaX; + lastWheelDirectionRef.value = 'x'; + } else { + mixed = deltaY; + lastWheelDirectionRef.value = 'y'; + } + + if (onOffset(-mixed, -mixed)) { + e.preventDefault(); + } + } + + // ========================= Effect ========================= + const touchEventsRef = ref<{ + onTouchStart: TouchEventHandler; + onTouchMove: TouchEventHandler; + onTouchEnd: TouchEventHandler; + onWheel: WheelEventHandler; + }>({ + onTouchStart, + onTouchMove, + onTouchEnd, + onWheel, + }); + function onProxyTouchStart(e: TouchEvent) { + touchEventsRef.value.onTouchStart(e); + } + function onProxyTouchMove(e: TouchEvent) { + touchEventsRef.value.onTouchMove(e); + } + function onProxyTouchEnd(e: TouchEvent) { + touchEventsRef.value.onTouchEnd(e); + } + function onProxyWheel(e: WheelEvent) { + touchEventsRef.value.onWheel(e); + } + onMounted(() => { + document.addEventListener('touchmove', onProxyTouchMove, { passive: false }); + document.addEventListener('touchend', onProxyTouchEnd, { passive: false }); + + // No need to clean up since element removed + domRef.value.addEventListener('touchstart', onProxyTouchStart, { passive: false }); + domRef.value.addEventListener('wheel', onProxyWheel); + }); + + onBeforeUnmount(() => { + document.removeEventListener('touchmove', onProxyTouchMove); + document.removeEventListener('touchend', onProxyTouchEnd); + }); +} diff --git a/components/tabs/src/index.ts b/components/tabs/src/index.ts new file mode 100644 index 0000000000..78d332d623 --- /dev/null +++ b/components/tabs/src/index.ts @@ -0,0 +1,11 @@ +// base rc-tabs 4.16.6 +import Tabs from './Tabs'; +import type { TabsProps } from './Tabs'; +import TabPane from './TabPanelList/TabPane'; +import type { TabPaneProps } from './TabPanelList/TabPane'; + +export type { TabsProps, TabPaneProps }; + +export { TabPane }; + +export default Tabs; diff --git a/components/tabs/src/interface.ts b/components/tabs/src/interface.ts new file mode 100644 index 0000000000..6cd9ac98ec --- /dev/null +++ b/components/tabs/src/interface.ts @@ -0,0 +1,51 @@ +import type { Key, VueNode } from '../../_util/type'; +import type { TabPaneProps } from './TabPanelList/TabPane'; + +export type TabSizeMap = Map; + +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 { + key: string; + node: VueNode; +} + +export type RenderTabBar = (props: any, DefaultTabBar: any) => VueNode; + +export interface TabsLocale { + dropdownAriaLabel?: string; + removeAriaLabel?: string; + addAriaLabel?: string; +} + +export interface EditableConfig { + onEdit: ( + type: 'add' | 'remove', + info: { key?: string; event: MouseEvent | KeyboardEvent }, + ) => void; + showAdd?: boolean; + removeIcon?: () => VueNode; + addIcon?: () => VueNode; +} + +export interface AnimatedConfig { + inkBar?: boolean; + tabPane?: boolean; +} + +export type OnTabScroll = (info: { direction: 'left' | 'right' | 'top' | 'bottom' }) => void; + +export type TabBarExtraPosition = 'left' | 'right'; + +export type TabBarExtraMap = Partial>; + +export type TabBarExtraContent = VueNode; diff --git a/components/tabs/style/card-style.less b/components/tabs/style/card-style.less deleted file mode 100644 index 7e3d886a1f..0000000000 --- a/components/tabs/style/card-style.less +++ /dev/null @@ -1,186 +0,0 @@ -@import '../../style/themes/index'; -@import '../../style/mixins/index'; - -@tab-prefix-cls: ~'@{ant-prefix}-tabs'; - -// card style -.@{tab-prefix-cls} { - &&-card &-card-bar &-nav-container { - height: @tabs-card-height; - } - &&-card &-card-bar &-ink-bar { - visibility: hidden; - } - &&-card &-card-bar &-tab { - height: @tabs-card-height; - margin: 0; - margin-right: @tabs-card-gutter; - padding: 0 16px; - line-height: @tabs-card-height - 2px; - background: @tabs-card-head-background; - border: @border-width-base @border-style-base @border-color-split; - border-radius: @border-radius-base @border-radius-base 0 0; - transition: all 0.3s @ease-in-out; - } - &&-card &-card-bar &-tab-active { - height: @tabs-card-height; - color: @tabs-card-active-color; - background: @component-background; - border-color: @border-color-split; - border-bottom: @border-width-base solid @component-background; - - &::before { - border-top: @tabs-card-tab-active-border-top; - } - } - &&-card &-card-bar &-tab-disabled { - color: @tabs-card-active-color; - color: @disabled-color; - } - &&-card &-card-bar &-tab-inactive { - padding: 0; - } - &&-card &-card-bar &-nav-wrap { - margin-bottom: 0; - } - &&-card &-card-bar &-tab &-close-x { - width: 16px; - height: 16px; - height: @font-size-base; - margin-right: -5px; - margin-left: 3px; - overflow: hidden; - color: @text-color-secondary; - font-size: @font-size-sm; - vertical-align: middle; - transition: all 0.3s; - &:hover { - color: @heading-color; - } - } - - &&-card &-card-content > &-tabpane, - &&-editable-card &-card-content > &-tabpane { - transition: none !important; - &-inactive { - overflow: hidden; - } - } - - &&-card &-card-bar &-tab:hover .@{iconfont-css-prefix}-close { - opacity: 1; - } - - &-extra-content { - line-height: @tabs-title-font-size * @line-height-base + extract(@tabs-horizontal-padding, 1) * - 2; - - .@{tab-prefix-cls}-new-tab { - position: relative; - width: 20px; - height: 20px; - color: @text-color; - font-size: 12px; - line-height: 20px; - text-align: center; - border: @border-width-base @border-style-base @border-color-split; - border-radius: @border-radius-sm; - cursor: pointer; - transition: all 0.3s; - &:hover { - color: @tabs-card-active-color; - border-color: @tabs-card-active-color; - } - svg { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - margin: auto; - } - } - } - - // https://github.com/ant-design/ant-design/issues/17865 - &&-large &-extra-content { - line-height: @tabs-title-font-size-lg * @line-height-base + - extract(@tabs-horizontal-padding-lg, 1) * 2; - } - - // https://github.com/ant-design/ant-design/issues/17865 - &&-small &-extra-content { - line-height: @tabs-title-font-size-sm * @line-height-base + - extract(@tabs-horizontal-padding-sm, 1) * 2; - } - - // https://github.com/ant-design/ant-design/issues/17865 - &&-card &-extra-content { - line-height: @tabs-card-height; - } - - // https://github.com/ant-design/ant-design/issues/4669 - &-vertical&-card &-card-bar&-left-bar, - &-vertical&-card &-card-bar&-right-bar { - .@{tab-prefix-cls}-nav-container { - height: 100%; - } - .@{tab-prefix-cls}-tab { - margin-bottom: 8px; - border-bottom: @border-width-base @border-style-base @border-color-split; - &-active { - padding-bottom: 4px; - } - &:last-child { - margin-bottom: 8px; - } - } - .@{tab-prefix-cls}-new-tab { - width: 90%; - } - } - - &-vertical&-card&-left &-card-bar&-left-bar { - .@{tab-prefix-cls}-nav-wrap { - margin-right: 0; - } - .@{tab-prefix-cls}-tab { - margin-right: 1px; - border-right: 0; - border-radius: @border-radius-base 0 0 @border-radius-base; - &-active { - margin-right: -1px; - padding-right: 18px; - } - } - } - - &-vertical&-card&-right &-card-bar&-right-bar { - .@{tab-prefix-cls}-nav-wrap { - margin-left: 0; - } - .@{tab-prefix-cls}-tab { - margin-left: 1px; - border-left: 0; - border-radius: 0 @border-radius-base @border-radius-base 0; - &-active { - margin-left: -1px; - padding-left: 18px; - } - } - } - - // https://github.com/ant-design/ant-design/issues/9104 - & &-card-bar&-bottom-bar &-tab { - height: auto; - border-top: 0; - border-bottom: @border-width-base @border-style-base @border-color-split; - border-radius: 0 0 @border-radius-base @border-radius-base; - } - - & &-card-bar&-bottom-bar &-tab-active { - padding-top: 1px; - padding-bottom: 0; - color: @primary-color; - } -} diff --git a/components/tabs/style/card.less b/components/tabs/style/card.less new file mode 100644 index 0000000000..471da77069 --- /dev/null +++ b/components/tabs/style/card.less @@ -0,0 +1,97 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; +@import './index'; + +.@{tab-prefix-cls}-card { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + margin: 0; + padding: @tabs-card-horizontal-padding; + background: @tabs-card-head-background; + border: @border-width-base @border-style-base @border-color-split; + transition: all @animation-duration-slow @ease-in-out; + + &-active { + color: @tabs-card-active-color; + background: @component-background; + } + } + + .@{tab-prefix-cls}-ink-bar { + visibility: hidden; + } + } + + // ========================== Top & Bottom ========================== + &.@{tab-prefix-cls}-top, + &.@{tab-prefix-cls}-bottom { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab + .@{tab-prefix-cls}-tab { + margin-left: @tabs-card-gutter; + } + } + } + + &.@{tab-prefix-cls}-top { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + border-radius: @border-radius-base @border-radius-base 0 0; + + &-active { + border-bottom-color: @component-background; + } + } + } + } + &.@{tab-prefix-cls}-bottom { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + border-radius: 0 0 @border-radius-base @border-radius-base; + + &-active { + border-top-color: @component-background; + } + } + } + } + + // ========================== Left & Right ========================== + &.@{tab-prefix-cls}-left, + &.@{tab-prefix-cls}-right { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab + .@{tab-prefix-cls}-tab { + margin-top: @tabs-card-gutter; + } + } + } + + &.@{tab-prefix-cls}-left { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + border-radius: @border-radius-base 0 0 @border-radius-base; + + &-active { + border-right-color: @component-background; + } + } + } + } + &.@{tab-prefix-cls}-right { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + border-radius: 0 @border-radius-base @border-radius-base 0; + + &-active { + border-left-color: @component-background; + } + } + } + } +} diff --git a/components/tabs/style/dropdown.less b/components/tabs/style/dropdown.less new file mode 100644 index 0000000000..7cbd909415 --- /dev/null +++ b/components/tabs/style/dropdown.less @@ -0,0 +1,60 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; +@import './index'; + +.@{tab-prefix-cls}-dropdown { + .reset-component(); + + position: absolute; + top: -9999px; + left: -9999px; + z-index: @zindex-dropdown; + display: block; + + &-hidden { + display: none; + } + + &-menu { + max-height: 200px; + margin: 0; + padding: @dropdown-edge-child-vertical-padding 0; + overflow-x: hidden; + overflow-y: auto; + text-align: left; + list-style-type: none; + background-color: @dropdown-menu-bg; + background-clip: padding-box; + border-radius: @border-radius-base; + outline: none; + box-shadow: @box-shadow-base; + + &-item { + min-width: 120px; + margin: 0; + padding: @dropdown-vertical-padding @control-padding-horizontal; + overflow: hidden; + color: @text-color; + font-weight: normal; + font-size: @dropdown-font-size; + line-height: @dropdown-line-height; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background: @item-hover-bg; + } + + &-disabled { + &, + &:hover { + color: @disabled-color; + background: transparent; + cursor: not-allowed; + } + } + } + } +} diff --git a/components/tabs/style/index.less b/components/tabs/style/index.less index 2fe32e0dd6..44645b3304 100644 --- a/components/tabs/style/index.less +++ b/components/tabs/style/index.less @@ -1,446 +1,223 @@ @import '../../style/themes/index'; @import '../../style/mixins/index'; -@import './card-style'; +@import './size'; +@import './rtl'; +@import './position'; +@import './dropdown'; +@import './card'; @tab-prefix-cls: ~'@{ant-prefix}-tabs'; -// Hidden content -.tabs-hidden-content() { - height: 0; - padding: 0 !important; - overflow: hidden; - opacity: 0; - pointer-events: none; - input { - visibility: hidden; - } -} - .@{tab-prefix-cls} { .reset-component(); - position: relative; + display: flex; overflow: hidden; - .clearfix(); - - &-ink-bar { - position: absolute; - bottom: 1px; - left: 0; - z-index: 1; - box-sizing: border-box; - width: 0; - height: 2px; - background-color: @tabs-ink-bar-color; - transform-origin: 0 0; - } - - &-bar { - margin: @tabs-bar-margin; - border-bottom: @border-width-base @border-style-base @border-color-split; - outline: none; - transition: padding 0.3s @ease-in-out; - } - &-nav-container { + // ========================== Navigation ========================== + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { position: relative; - box-sizing: border-box; - margin-bottom: -1px; - overflow: hidden; - font-size: @tabs-title-font-size; - line-height: @line-height-base; - white-space: nowrap; - transition: padding 0.3s @ease-in-out; - .clearfix(); - - &-scrolling { - padding-right: @tabs-scrolling-size; - padding-left: @tabs-scrolling-size; - } - } - - // https://github.com/ant-design/ant-design/issues/9104 - &-bottom &-bottom-bar { - margin-top: 16px; - margin-bottom: 0; - border-top: @border-width-base @border-style-base @border-color-split; - border-bottom: none; - } - - &-bottom &-bottom-bar &-ink-bar { - top: 1px; - bottom: auto; - } + display: flex; + flex: none; + align-items: center; - &-bottom &-bottom-bar &-nav-container { - margin-top: -1px; - margin-bottom: 0; - } - - &-tab-prev, - &-tab-next { - position: absolute; - z-index: 2; - width: 0; - height: 100%; - color: @text-color-secondary; - text-align: center; - background-color: transparent; - border: 0; - cursor: pointer; - opacity: 0; - transition: width 0.3s @ease-in-out, opacity 0.3s @ease-in-out, color 0.3s @ease-in-out; - user-select: none; - pointer-events: none; - - &.@{tab-prefix-cls}-tab-arrow-show { - width: @tabs-scrolling-size; - height: 100%; - opacity: 1; - pointer-events: auto; - } - - &:hover { - color: @text-color; - } - - &-icon { - position: absolute; - top: 50%; - left: 50%; - font-weight: bold; - font-style: normal; - font-variant: normal; - line-height: inherit; - text-align: center; - text-transform: none; - transform: translate(-50%, -50%); - - &-target { - display: block; - .iconfont-size-under-12px(10px); + .@{tab-prefix-cls}-nav-wrap { + position: relative; + display: inline-block; + display: flex; + flex: auto; + align-self: stretch; + overflow: hidden; + white-space: nowrap; + transform: translate(0); // Fix chrome render bug + + // >>>>> Ping shadow + &::before, + &::after { + position: absolute; + z-index: 1; + opacity: 0; + transition: opacity @animation-duration-slow; + content: ''; + pointer-events: none; } } - } - - &-tab-btn-disabled { - cursor: not-allowed; - &, - &:hover { - color: @disabled-color; - } - } - - &-tab-next { - right: 2px; - } - &-tab-prev { - left: 0; - :root & { - filter: none; + .@{tab-prefix-cls}-nav-list { + position: relative; + display: flex; + transition: transform @animation-duration-slow; } - } - &-nav-wrap { - margin-bottom: -1px; - overflow: hidden; - } - - &-nav-scroll { - overflow: hidden; - white-space: nowrap; - } - - &-nav { - position: relative; - display: inline-block; - box-sizing: border-box; - margin: 0; - padding-left: 0; - list-style: none; - transition: transform 0.3s @ease-in-out; - - &::before, - &::after { - display: table; - content: ' '; - } + // >>>>>>>> Operations + .@{tab-prefix-cls}-nav-operations { + display: flex; + align-self: stretch; - &::after { - clear: both; + &-hidden { + position: absolute; + visibility: hidden; + pointer-events: none; + } } - .@{tab-prefix-cls}-tab { + .@{tab-prefix-cls}-nav-more { position: relative; - display: inline-block; - box-sizing: border-box; - height: 100%; - margin: @tabs-horizontal-margin; - padding: @tabs-horizontal-padding; - text-decoration: none; - cursor: pointer; - transition: color 0.3s @ease-in-out; + padding: @tabs-card-horizontal-padding; + background: transparent; + border: 0; - &::before { + &::after { position: absolute; - top: -1px; + right: 0; + bottom: 0; left: 0; - width: 100%; - border-top: 2px solid transparent; - border-radius: @border-radius-base @border-radius-base 0 0; - transition: all 0.3s; + height: 5px; + transform: translateY(100%); content: ''; - pointer-events: none; } + } - &:last-child { - margin-right: 0; - } + .@{tab-prefix-cls}-nav-add { + min-width: @tabs-card-height; + padding: 0 @padding-xs; + background: @tabs-card-head-background; + border: @border-width-base @border-style-base @border-color-split; + border-radius: @border-radius-base @border-radius-base 0 0; + outline: none; + cursor: pointer; + transition: all @animation-duration-slow @ease-in-out; &:hover { color: @tabs-hover-color; } - &:active { + &:active, + &:focus { color: @tabs-active-color; } - - .@{iconfont-css-prefix} { - margin-right: 8px; - } - - &-active { - color: @tabs-highlight-color; - // https://github.com/vueComponent/ant-design-vue/issues/4241 - // Remove font-weight to keep pace with antd (#4241) - text-shadow: 0 0 0.25px currentColor; - // font-weight: 500; - } - - &-disabled { - &, - &:hover { - color: @disabled-color; - cursor: not-allowed; - } - } } } - .@{tab-prefix-cls}-large-bar { - .@{tab-prefix-cls}-nav-container { - font-size: @tabs-title-font-size-lg; - } - .@{tab-prefix-cls}-tab { - padding: @tabs-horizontal-padding-lg; - } + &-extra-content { + flex: none; } - .@{tab-prefix-cls}-small-bar { - .@{tab-prefix-cls}-nav-container { - font-size: @tabs-title-font-size-sm; - } - .@{tab-prefix-cls}-tab { - padding: @tabs-horizontal-padding-sm; - } - } - - .@{tab-prefix-cls}-centered-bar { - .@{tab-prefix-cls}-nav-wrap { - text-align: center; + &-centered { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-nav-wrap { + &:not([class*='@{tab-prefix-cls}-nav-wrap-ping']) { + justify-content: center; + } + } } } - // Create an empty element to avoid margin collapsing - // https://github.com/ant-design/ant-design/issues/18103 - &-content::before { - display: block; - overflow: hidden; - content: ''; + // ============================ InkBar ============================ + &-ink-bar { + position: absolute; + background: @tabs-ink-bar-color; + pointer-events: none; } - // Horizontal Content - .@{tab-prefix-cls}-top-content, - .@{tab-prefix-cls}-bottom-content { - width: 100%; - - > .@{tab-prefix-cls}-tabpane { - flex-shrink: 0; - width: 100%; - -webkit-backface-visibility: hidden; - opacity: 1; - transition: opacity 0.45s; - } - - > .@{tab-prefix-cls}-tabpane-inactive { - .tabs-hidden-content(); - } + // ============================= Tabs ============================= + &-tab { + position: relative; + display: inline-flex; + align-items: center; + padding: @tabs-horizontal-padding; + font-size: @tabs-title-font-size; + background: transparent; + border: 0; + outline: none; + cursor: pointer; - &.@{tab-prefix-cls}-content-animated { - display: flex; - flex-direction: row; - transition: margin-left 0.3s @ease-in-out; - will-change: margin-left; + &-btn, + &-remove { + &:focus, + &:active { + color: @tabs-active-color; + } } - } - // Vertical Bar - .@{tab-prefix-cls}-left-bar, - .@{tab-prefix-cls}-right-bar { - height: 100%; - border-bottom: 0; - - .@{tab-prefix-cls}-tab-arrow-show { - width: 100%; - height: @tabs-scrolling-size; + &-btn { + outline: none; + transition: all 0.3s; } - .@{tab-prefix-cls}-tab { - display: block; - float: none; - margin: @tabs-vertical-margin; - padding: @tabs-vertical-padding; + &-remove { + flex: none; + margin-right: -@margin-xss; + margin-left: @margin-xs; + color: @text-color-secondary; + font-size: @font-size-sm; + background: transparent; + border: none; + outline: none; + cursor: pointer; + transition: all @animation-duration-slow; - &:last-child { - margin-bottom: 0; + &:hover { + color: @heading-color; } } - .@{tab-prefix-cls}-extra-content { - text-align: center; + &:hover { + color: @tabs-hover-color; } - .@{tab-prefix-cls}-nav-scroll { - width: auto; + &&-active &-btn { + color: @tabs-highlight-color; + text-shadow: 0 0 0.25px currentColor; } - .@{tab-prefix-cls}-nav-container, - .@{tab-prefix-cls}-nav-wrap { - height: 100%; + &&-disabled { + color: @disabled-color; + cursor: not-allowed; } - .@{tab-prefix-cls}-nav-container { - margin-bottom: 0; - - &.@{tab-prefix-cls}-nav-container-scrolling { - padding: @tabs-scrolling-size 0; + &&-disabled &-btn, + &&-disabled &-remove { + &:focus, + &:active { + color: @disabled-color; } } - .@{tab-prefix-cls}-nav-wrap { - margin-bottom: 0; - } - - .@{tab-prefix-cls}-nav { - width: 100%; - } - - .@{tab-prefix-cls}-ink-bar { - top: 0; - bottom: auto; - left: auto; - width: 2px; - height: 0; - } - - .@{tab-prefix-cls}-tab-next { - right: 0; - bottom: 0; - width: 100%; - height: @tabs-scrolling-size; + & &-remove .@{iconfont-css-prefix} { + margin: 0; } - .@{tab-prefix-cls}-tab-prev { - top: 0; - width: 100%; - height: @tabs-scrolling-size; + .@{iconfont-css-prefix} { + margin-right: @margin-sm; } } - // Vertical Content - .@{tab-prefix-cls}-left-content, - .@{tab-prefix-cls}-right-content { - width: auto; - margin-top: 0 !important; - overflow: hidden; - } - - // Vertical - Left - .@{tab-prefix-cls}-left-bar { - float: left; - margin-right: -1px; - margin-bottom: 0; - border-right: @border-width-base @border-style-base @border-color-split; - .@{tab-prefix-cls}-tab { - text-align: right; - } - .@{tab-prefix-cls}-nav-container { - margin-right: -1px; - } - .@{tab-prefix-cls}-nav-wrap { - margin-right: -1px; - } - .@{tab-prefix-cls}-ink-bar { - right: 1px; - } - } - .@{tab-prefix-cls}-left-content { - padding-left: 24px; - border-left: @border-width-base @border-style-base @border-color-split; + &-tab + &-tab { + margin: @tabs-horizontal-margin; } - // Vertical - Right - .@{tab-prefix-cls}-right-bar { - float: right; - margin-bottom: 0; - margin-left: -1px; - border-left: @border-width-base @border-style-base @border-color-split; - .@{tab-prefix-cls}-nav-container { - margin-left: -1px; + // =========================== TabPanes =========================== + &-content { + &-holder { + flex: auto; + min-width: 0; + min-height: 0; } - .@{tab-prefix-cls}-nav-wrap { - margin-left: -1px; - } - .@{tab-prefix-cls}-ink-bar { - left: 1px; - } - } - .@{tab-prefix-cls}-right-content { - padding-right: 24px; - border-right: @border-width-base @border-style-base @border-color-split; - } -} - -.@{tab-prefix-cls}-top .@{tab-prefix-cls}-ink-bar-animated, -.@{tab-prefix-cls}-bottom .@{tab-prefix-cls}-ink-bar-animated { - transition: transform 0.3s @ease-in-out, width 0.2s @ease-in-out, left 0.3s @ease-in-out; -} -.@{tab-prefix-cls}-left .@{tab-prefix-cls}-ink-bar-animated, -.@{tab-prefix-cls}-right .@{tab-prefix-cls}-ink-bar-animated { - transition: transform 0.3s @ease-in-out, height 0.2s @ease-in-out, top 0.3s @ease-in-out; -} + display: flex; + width: 100%; -// No animation -.tabs-no-animation() { - > .@{tab-prefix-cls}-content-animated { - margin-left: 0 !important; - transform: none !important; - } - > .@{tab-prefix-cls}-tabpane-inactive { - .tabs-hidden-content(); + &-animated { + transition: margin @animation-duration-slow; + } } -} -.no-flex, -.@{tab-prefix-cls}-no-animation { - > .@{tab-prefix-cls}-content { - .tabs-no-animation(); + &-tabpane { + flex: none; + width: 100%; + outline: none; } } - -.@{tab-prefix-cls}-left-content, -.@{tab-prefix-cls}-right-content { - .tabs-no-animation(); -} diff --git a/components/tabs/style/index.ts b/components/tabs/style/index.tsx similarity index 100% rename from components/tabs/style/index.ts rename to components/tabs/style/index.tsx diff --git a/components/tabs/style/position.less b/components/tabs/style/position.less new file mode 100644 index 0000000000..9da9bb8ad1 --- /dev/null +++ b/components/tabs/style/position.less @@ -0,0 +1,195 @@ +@import './index'; + +.@{tab-prefix-cls} { + // ========================== Top & Bottom ========================== + &-top, + &-bottom { + flex-direction: column; + + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + margin: @tabs-bar-margin; + + &::before { + position: absolute; + right: 0; + left: 0; + border-bottom: @border-width-base @border-style-base @border-color-split; + content: ''; + } + + .@{tab-prefix-cls}-ink-bar { + height: 2px; + + &-animated { + transition: width @animation-duration-slow, left @animation-duration-slow, + right @animation-duration-slow; + } + } + + .@{tab-prefix-cls}-nav-wrap { + &::before, + &::after { + top: 0; + bottom: 0; + width: 30px; + } + + &::before { + left: 0; + box-shadow: inset 10px 0 8px -8px fade(@shadow-color, 8%); + } + &::after { + right: 0; + box-shadow: inset -10px 0 8px -8px fade(@shadow-color, 8%); + } + + &.@{tab-prefix-cls}-nav-wrap-ping-left::before { + opacity: 1; + } + &.@{tab-prefix-cls}-nav-wrap-ping-right::after { + opacity: 1; + } + } + } + } + + &-top { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + &::before { + bottom: 0; + } + + .@{tab-prefix-cls}-ink-bar { + bottom: 0; + } + } + } + + &-bottom { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + order: 1; + margin-top: @margin-md; + margin-bottom: 0; + + &::before { + top: 0; + } + + .@{tab-prefix-cls}-ink-bar { + top: 0; + } + } + + > .@{tab-prefix-cls}-content-holder, + > div > .@{tab-prefix-cls}-content-holder { + order: 0; + } + } + + // ========================== Left & Right ========================== + &-left, + &-right { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + flex-direction: column; + min-width: 50px; + + // >>>>>>>>>>> Tab + .@{tab-prefix-cls}-tab { + padding: @tabs-vertical-padding; + text-align: center; + } + + .@{tab-prefix-cls}-tab + .@{tab-prefix-cls}-tab { + margin: @tabs-vertical-margin; + } + + // >>>>>>>>>>> Nav + .@{tab-prefix-cls}-nav-wrap { + flex-direction: column; + + &::before, + &::after { + right: 0; + left: 0; + height: 30px; + } + + &::before { + top: 0; + box-shadow: inset 0 10px 8px -8px fade(@shadow-color, 8%); + } + &::after { + bottom: 0; + box-shadow: inset 0 -10px 8px -8px fade(@shadow-color, 8%); + } + + &.@{tab-prefix-cls}-nav-wrap-ping-top::before { + opacity: 1; + } + &.@{tab-prefix-cls}-nav-wrap-ping-bottom::after { + opacity: 1; + } + } + + // >>>>>>>>>>> Ink Bar + .@{tab-prefix-cls}-ink-bar { + width: 2px; + + &-animated { + transition: height @animation-duration-slow, top @animation-duration-slow; + } + } + + .@{tab-prefix-cls}-nav-list, + .@{tab-prefix-cls}-nav-operations { + flex: 1 0 auto; // fix safari scroll problem + flex-direction: column; + } + } + } + + &-left { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-ink-bar { + right: 0; + } + } + + > .@{tab-prefix-cls}-content-holder, + > div > .@{tab-prefix-cls}-content-holder { + margin-left: -@border-width-base; + border-left: @border-width-base @border-style-base @border-color-split; + + > .@{tab-prefix-cls}-content > .@{tab-prefix-cls}-tabpane { + padding-left: @padding-lg; + } + } + } + + &-right { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + order: 1; + + .@{tab-prefix-cls}-ink-bar { + left: 0; + } + } + + > .@{tab-prefix-cls}-content-holder, + > div > .@{tab-prefix-cls}-content-holder { + order: 0; + margin-right: -@border-width-base; + border-right: @border-width-base @border-style-base @border-color-split; + + > .@{tab-prefix-cls}-content > .@{tab-prefix-cls}-tabpane { + padding-right: @padding-lg; + } + } + } +} diff --git a/components/tabs/style/rtl.less b/components/tabs/style/rtl.less new file mode 100644 index 0000000000..b49e77b579 --- /dev/null +++ b/components/tabs/style/rtl.less @@ -0,0 +1,79 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; + +@tab-prefix-cls: ~'@{ant-prefix}-tabs'; + +.@{tab-prefix-cls} { + &-rtl { + direction: rtl; + + .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + margin: @tabs-horizontal-margin-rtl; + + &:last-of-type { + margin-left: 0; + } + + .@{iconfont-css-prefix} { + margin-right: 0; + margin-left: @margin-sm; + } + + .@{tab-prefix-cls}-tab-remove { + margin-right: @margin-xs; + margin-left: -@margin-xss; + + .@{iconfont-css-prefix} { + margin: 0; + } + } + } + } + + &.@{tab-prefix-cls}-left { + > .@{tab-prefix-cls}-nav { + order: 1; + } + > .@{tab-prefix-cls}-content-holder { + order: 0; + } + } + + &.@{tab-prefix-cls}-right { + > .@{tab-prefix-cls}-nav { + order: 0; + } + > .@{tab-prefix-cls}-content-holder { + order: 1; + } + } + } + + // ====================== Card ====================== + &-card { + &.@{tab-prefix-cls}-top, + &.@{tab-prefix-cls}-bottom { + > .@{tab-prefix-cls}-nav, + > div > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab + .@{tab-prefix-cls}-tab { + .@{tab-prefix-cls}-rtl& { + margin-right: 0; + margin-left: @tabs-card-gutter; + } + } + } + } + } +} + +.@{tab-prefix-cls}-dropdown { + &-rtl { + direction: rtl; + } + &-menu-item { + .@{tab-prefix-cls}-dropdown-rtl & { + text-align: right; + } + } +} diff --git a/components/tabs/style/size.less b/components/tabs/style/size.less new file mode 100644 index 0000000000..43a75f1797 --- /dev/null +++ b/components/tabs/style/size.less @@ -0,0 +1,41 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; +@import './index'; + +.@{tab-prefix-cls} { + &-small { + > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + padding: @tabs-horizontal-padding-sm; + font-size: @tabs-title-font-size-sm; + } + } + } + + &-large { + > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + padding: @tabs-horizontal-padding-lg; + font-size: @tabs-title-font-size-lg; + } + } + } + + &-card { + &.@{tab-prefix-cls}-small { + > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + padding: @tabs-card-horizontal-padding-sm; + } + } + } + + &.@{tab-prefix-cls}-large { + > .@{tab-prefix-cls}-nav { + .@{tab-prefix-cls}-tab { + padding: @tabs-card-horizontal-padding-lg; + } + } + } + } +} diff --git a/components/tabs/tabs.tsx b/components/tabs/tabs.tsx deleted file mode 100644 index 2e9c371159..0000000000 --- a/components/tabs/tabs.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { PropType } from 'vue'; -import { defineComponent, inject } from 'vue'; -import { tuple } from '../_util/type'; -import CloseOutlined from '@ant-design/icons-vue/CloseOutlined'; -import PlusOutlined from '@ant-design/icons-vue/PlusOutlined'; -import VcTabs, { TabPane } from '../vc-tabs/src'; -import TabContent from '../vc-tabs/src/TabContent'; -import PropTypes, { withUndefined } from '../_util/vue-types'; -import { - getComponent, - getOptionProps, - filterEmpty, - getPropsData, - getSlot, -} from '../_util/props-util'; -import { cloneElement } from '../_util/vnode'; -import isValid from '../_util/isValid'; -import { defaultConfigProvider } from '../config-provider'; -import TabBar from './TabBar'; - -export default defineComponent({ - TabPane, - name: 'ATabs', - inheritAttrs: false, - props: { - prefixCls: PropTypes.string, - activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - defaultActiveKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - hideAdd: PropTypes.looseBool.def(false), - centered: PropTypes.looseBool.def(false), - tabBarStyle: PropTypes.object, - tabBarExtraContent: PropTypes.any, - destroyInactiveTabPane: PropTypes.looseBool.def(false), - type: PropTypes.oneOf(tuple('line', 'card', 'editable-card')), - tabPosition: PropTypes.oneOf(['top', 'right', 'bottom', 'left']).def('top'), - size: PropTypes.oneOf(['default', 'small', 'large']), - animated: withUndefined(PropTypes.oneOfType([PropTypes.looseBool, PropTypes.object])), - tabBarGutter: PropTypes.number, - renderTabBar: PropTypes.func, - onChange: { - type: Function as PropType<(activeKey: string) => void>, - }, - onTabClick: PropTypes.func, - onPrevClick: { - type: Function as PropType<(e: MouseEvent) => void>, - }, - onNextClick: { - type: Function as PropType<(e: MouseEvent) => void>, - }, - onEdit: { - type: Function as PropType< - (targetKey: string | MouseEvent, action: 'add' | 'remove') => void - >, - }, - }, - emits: ['update:activeKey', 'edit', 'change'], - setup() { - return { - configProvider: inject('configProvider', defaultConfigProvider), - }; - }, - methods: { - removeTab(targetKey: string, e: MouseEvent) { - e.stopPropagation(); - if (isValid(targetKey)) { - this.$emit('edit', targetKey, 'remove'); - } - }, - handleChange(activeKey: string) { - this.$emit('update:activeKey', activeKey); - this.$emit('change', activeKey); - }, - createNewTab(targetKey: MouseEvent) { - this.$emit('edit', targetKey, 'add'); - }, - }, - - render() { - const props = getOptionProps(this); - const { - prefixCls: customizePrefixCls, - size, - type = 'line', - tabPosition, - animated = true, - hideAdd, - renderTabBar, - } = props; - const { class: className, ...restProps } = this.$attrs; - const getPrefixCls = this.configProvider.getPrefixCls; - const prefixCls = getPrefixCls('tabs', customizePrefixCls); - const children = filterEmpty(getSlot(this)); - - let tabBarExtraContent = getComponent(this, 'tabBarExtraContent'); - let tabPaneAnimated = typeof animated === 'object' ? animated.tabPane : animated; - - // card tabs should not have animation - if (type !== 'line') { - tabPaneAnimated = 'animated' in props ? tabPaneAnimated : false; - } - const cls = { - [className as string]: className, - [`${prefixCls}-vertical`]: tabPosition === 'left' || tabPosition === 'right', - [`${prefixCls}-${size}`]: !!size, - [`${prefixCls}-card`]: type.indexOf('card') >= 0, - [`${prefixCls}-${type}`]: true, - [`${prefixCls}-no-animation`]: !tabPaneAnimated, - }; - // only card type tabs can be added and closed - let childrenWithClose = []; - if (type === 'editable-card') { - childrenWithClose = []; - children.forEach((child, index) => { - const props = getPropsData(child) as any; - let closable = props.closable; - closable = typeof closable === 'undefined' ? true : closable; - const closeIcon = closable ? ( - this.removeTab(child.key, e)} - /> - ) : null; - childrenWithClose.push( - cloneElement(child, { - tab: ( -
- {getComponent(child, 'tab')} - {closeIcon} -
- ), - key: child.key || index, - }), - ); - }); - // Add new tab handler - if (!hideAdd) { - tabBarExtraContent = ( - - - {tabBarExtraContent} - - ); - } - } - - tabBarExtraContent = tabBarExtraContent ? ( -
{tabBarExtraContent}
- ) : null; - - const renderTabBarSlot = renderTabBar || this.$slots.renderTabBar; - const tabBarProps = { - ...props, - prefixCls, - tabBarExtraContent, - renderTabBar: renderTabBarSlot, - ...restProps, - children, - }; - const contentCls = { - [`${prefixCls}-${tabPosition}-content`]: true, - [`${prefixCls}-card-content`]: type.indexOf('card') >= 0, - }; - const tabsProps = { - ...props, - prefixCls, - tabBarPosition: tabPosition, - // https://github.com/vueComponent/ant-design-vue/issues/2030 - // 如仅传递 tabBarProps 会导致,第二次执行 renderTabBar 时,丢失 on 属性, - // 添加key之后,会在babel jsx 插件中做一次merge,最终TabBar接收的是一个新的对象,而不是 tabBarProps - renderTabBar: () => , - renderTabContent: () => ( - - ), - children: childrenWithClose.length > 0 ? childrenWithClose : children, - ...restProps, - onChange: this.handleChange, - class: cls, - }; - return ; - }, -}); diff --git a/components/vc-overflow/Overflow.tsx b/components/vc-overflow/Overflow.tsx index dd615aa3cb..51ab2e966a 100644 --- a/components/vc-overflow/Overflow.tsx +++ b/components/vc-overflow/Overflow.tsx @@ -43,6 +43,7 @@ const Overflow = defineComponent({ name: 'Overflow', inheritAttrs: false, props: { + id: String, prefixCls: String, data: Array, itemKey: [String, Number, Function] as PropType Key)>, @@ -245,6 +246,7 @@ const Overflow = defineComponent({ prefixCls = 'rc-overflow', suffix, component: Component = 'div' as any, + id, } = props; const { class: className, style, ...restAttrs } = attrs; let suffixStyle: CSSProperties = {}; @@ -341,6 +343,7 @@ const Overflow = defineComponent({ const overflowNode = () => ( { - const { onTabClick = noop, ...props } = attrs; - return ( - ( - - - - - )} - /> - ); -}; - -InkTabBar.inheritAttrs = false; -export default InkTabBar; diff --git a/components/vc-tabs/src/InkTabBarNode.jsx b/components/vc-tabs/src/InkTabBarNode.jsx deleted file mode 100644 index 9dfdf18072..0000000000 --- a/components/vc-tabs/src/InkTabBarNode.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import PropTypes from '../../_util/vue-types'; -import { - setTransform, - isTransform3dSupported, - getLeft, - getStyle, - getTop, - getActiveIndex, -} from './utils'; -import BaseMixin from '../../_util/BaseMixin'; - -function componentDidUpdate(component, init) { - const { styles = {}, panels, activeKey, direction } = component.$props; - const rootNode = component.getRef('root'); - const wrapNode = component.getRef('nav') || rootNode; - const inkBarNode = component.getRef('inkBar'); - const activeTab = component.getRef('activeTab'); - const inkBarNodeStyle = inkBarNode.style; - const tabBarPosition = component.$props.tabBarPosition; - 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 { - name: 'InkTabBarNode', - mixins: [BaseMixin], - inheritAttrs: false, - props: { - inkBarAnimated: { - type: Boolean, - default: true, - }, - direction: PropTypes.string, - prefixCls: String, - styles: Object, - tabBarPosition: String, - saveRef: PropTypes.func.def(() => {}), - getRef: PropTypes.func.def(() => {}), - panels: PropTypes.array, - activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - }, - updated() { - this.$nextTick(() => { - componentDidUpdate(this); - }); - }, - - mounted() { - this.$nextTick(() => { - componentDidUpdate(this, true); - }); - }, - render() { - const { prefixCls, styles = {}, inkBarAnimated } = this; - const className = `${prefixCls}-ink-bar`; - const classes = { - [className]: true, - [inkBarAnimated ? `${className}-animated` : `${className}-no-animated`]: true, - }; - return
; - }, -}; diff --git a/components/vc-tabs/src/KeyCode.js b/components/vc-tabs/src/KeyCode.js deleted file mode 100644 index 096935d68a..0000000000 --- a/components/vc-tabs/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/components/vc-tabs/src/SaveRef.jsx b/components/vc-tabs/src/SaveRef.jsx deleted file mode 100644 index 8b47bc68f0..0000000000 --- a/components/vc-tabs/src/SaveRef.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from '../../_util/vue-types'; - -export default { - props: { - children: PropTypes.func.def(() => null), - }, - methods: { - getRef(name) { - return this[name]; - }, - - saveRef(name) { - return node => { - if (node) { - this[name] = node; - } - }; - }, - }, - - render() { - // 每次都new一个新的function,避免子节点不能重新渲染 - const saveRef = name => this.saveRef(name); - const getRef = name => this.getRef(name); - return this.children(saveRef, getRef); - }, -}; diff --git a/components/vc-tabs/src/ScrollableInkTabBar.jsx b/components/vc-tabs/src/ScrollableInkTabBar.jsx deleted file mode 100644 index 8c68e9941b..0000000000 --- a/components/vc-tabs/src/ScrollableInkTabBar.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { defineComponent } from 'vue'; -import InkTabBarNode from './InkTabBarNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import TabBarRootNode from './TabBarRootNode'; -import ScrollableTabBarNode from './ScrollableTabBarNode'; -import SaveRef from './SaveRef'; - -export default defineComponent({ - name: 'ScrollableInkTabBar', - inheritAttrs: false, - render() { - const { children: renderTabBarNode } = this.$attrs; - return ( - ( - - - - - - - )} - /> - ); - }, -}); diff --git a/components/vc-tabs/src/ScrollableTabBar.jsx b/components/vc-tabs/src/ScrollableTabBar.jsx deleted file mode 100644 index 564900fc81..0000000000 --- a/components/vc-tabs/src/ScrollableTabBar.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import ScrollableTabBarNode from './ScrollableTabBarNode'; -import TabBarRootNode from './TabBarRootNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import SaveRef from './SaveRef'; - -const ScrollableTabBar = (_, { attrs }) => { - return ( - ( - - - - - - )} - /> - ); -}; -ScrollableTabBar.inheritAttrs = false; -export default ScrollableTabBar; diff --git a/components/vc-tabs/src/ScrollableTabBarNode.jsx b/components/vc-tabs/src/ScrollableTabBarNode.jsx deleted file mode 100644 index 2e1650e478..0000000000 --- a/components/vc-tabs/src/ScrollableTabBarNode.jsx +++ /dev/null @@ -1,335 +0,0 @@ -import debounce from 'lodash-es/debounce'; -import ResizeObserver from 'resize-observer-polyfill'; -import PropTypes from '../../_util/vue-types'; -import BaseMixin from '../../_util/BaseMixin'; -import { getComponent, getSlot } from '../../_util/props-util'; -import { setTransform, isTransform3dSupported } from './utils'; - -export default { - name: 'ScrollableTabBarNode', - mixins: [BaseMixin], - inheritAttrs: false, - props: { - activeKey: PropTypes.any, - getRef: PropTypes.func.def(() => {}), - saveRef: PropTypes.func.def(() => {}), - tabBarPosition: PropTypes.oneOf(['left', 'right', 'top', 'bottom']).def('left'), - prefixCls: PropTypes.string.def(''), - scrollAnimated: PropTypes.looseBool.def(true), - navWrapper: PropTypes.func.def(arg => arg), - prevIcon: PropTypes.any, - nextIcon: PropTypes.any, - direction: PropTypes.string, - }, - - data() { - this.offset = 0; - this.prevProps = { ...this.$props }; - return { - next: false, - prev: false, - }; - }, - watch: { - tabBarPosition() { - this.tabBarPositionChange = true; - this.$nextTick(() => { - this.setOffset(0); - }); - }, - }, - - mounted() { - this.$nextTick(() => { - this.updatedCal(); - this.debouncedResize = debounce(() => { - this.setNextPrev(); - this.scrollToActiveTab(); - }, 200); - this.resizeObserver = new ResizeObserver(this.debouncedResize); - this.resizeObserver.observe(this.$props.getRef('container')); - }); - }, - - updated() { - this.$nextTick(() => { - this.updatedCal(this.prevProps); - this.prevProps = { ...this.$props }; - }); - }, - - beforeUnmount() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - } - if (this.debouncedResize && this.debouncedResize.cancel) { - this.debouncedResize.cancel(); - } - }, - methods: { - updatedCal(prevProps) { - const props = this.$props; - if (prevProps && prevProps.tabBarPosition !== props.tabBarPosition) { - this.setOffset(0); - return; - } - // wait next, prev show hide - if (this.isNextPrevShown(this.$data) !== this.isNextPrevShown(this.setNextPrev())) { - this.$forceUpdate(); - this.$nextTick(() => { - this.scrollToActiveTab(); - }); - } else if (!prevProps || props.activeKey !== prevProps.activeKey) { - // can not use props.activeKey - this.scrollToActiveTab(); - } - }, - 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; - 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.tabBarPosition; - let prop = 'offsetWidth'; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - prop = 'offsetHeight'; - } - return node[prop]; - }, - - getScrollWH(node) { - const tabBarPosition = this.tabBarPosition; - let prop = 'scrollWidth'; - if (tabBarPosition === 'left' || tabBarPosition === 'right') { - prop = 'scrollHeight'; - } - return node[prop]; - }, - - getOffsetLT(node) { - const tabBarPosition = this.$props.tabBarPosition; - 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.tabBarPosition; - 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.prev !== v) { - this.prev = v; - } - }, - - setNext(v) { - if (this.next !== v) { - this.next = v; - } - }, - - isNextPrevShown(state) { - if (state) { - return state.next || state.prev; - } - return this.next || this.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); - } - }, - - prevClick(e) { - this.__emit('prevClick', e); - const navWrapNode = this.$props.getRef('navWrap'); - const navWrapNodeWH = this.getOffsetWH(navWrapNode); - const { offset } = this; - this.setOffset(offset + navWrapNodeWH); - }, - - nextClick(e) { - this.__emit('nextClick', e); - const navWrapNode = this.$props.getRef('navWrap'); - const navWrapNodeWH = this.getOffsetWH(navWrapNode); - const { offset } = this; - this.setOffset(offset - navWrapNodeWH); - }, - }, - render() { - const { next, prev } = this; - const { prefixCls, scrollAnimated, navWrapper } = this.$props; - const prevIcon = getComponent(this, 'prevIcon'); - const nextIcon = getComponent(this, 'nextIcon'); - const showNextPrev = prev || next; - - const prevButton = ( - - {prevIcon || } - - ); - - const nextButton = ( - - {nextIcon || } - - ); - - const navClassName = `${prefixCls}-nav`; - const navClasses = { - [navClassName]: true, - [scrollAnimated ? `${navClassName}-animated` : `${navClassName}-no-animated`]: true, - }; - - return ( -
- {prevButton} - {nextButton} -
-
-
- {navWrapper(getSlot(this))} -
-
-
-
- ); - }, -}; diff --git a/components/vc-tabs/src/Sentinel.jsx b/components/vc-tabs/src/Sentinel.jsx deleted file mode 100644 index ba0eb52689..0000000000 --- a/components/vc-tabs/src/Sentinel.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from '../../_util/vue-types'; -import KeyCode from '../../_util/KeyCode'; -import { getSlot } from '../../_util/props-util'; - -const sentinelStyle = { width: 0, height: 0, overflow: 'hidden', position: 'absolute' }; -export default { - name: 'Sentinel', - props: { - setRef: PropTypes.func, - prevElement: PropTypes.any, - nextElement: PropTypes.any, - }, - methods: { - onKeyDown({ target, which, shiftKey }) { - const { nextElement, prevElement } = this.$props; - if (which !== KeyCode.TAB || document.activeElement !== target) return; - - // Tab next - if (!shiftKey && nextElement) { - nextElement.focus(); - } - - // Tab prev - if (shiftKey && prevElement) { - prevElement.focus(); - } - }, - }, - render() { - const { setRef } = this.$props; - - return ( -
- {getSlot(this)} -
- ); - }, -}; diff --git a/components/vc-tabs/src/TabBar.jsx b/components/vc-tabs/src/TabBar.jsx deleted file mode 100644 index 5073ff697f..0000000000 --- a/components/vc-tabs/src/TabBar.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import TabBarRootNode from './TabBarRootNode'; -import TabBarTabsNode from './TabBarTabsNode'; -import SaveRef from './SaveRef'; - -export default { - name: 'TabBar', - inheritAttrs: false, - render() { - return ( - ( - - - - )} - /> - ); - }, -}; diff --git a/components/vc-tabs/src/TabBarRootNode.jsx b/components/vc-tabs/src/TabBarRootNode.jsx deleted file mode 100644 index fd8a51cd52..0000000000 --- a/components/vc-tabs/src/TabBarRootNode.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { cloneElement } from '../../_util/vnode'; -import PropTypes from '../../_util/vue-types'; -import BaseMixin from '../../_util/BaseMixin'; -import { getSlot } from '../../_util/props-util'; -import { getDataAttr } from './utils'; -function noop() {} -export default { - name: 'TabBarRootNode', - mixins: [BaseMixin], - inheritAttrs: false, - props: { - saveRef: PropTypes.func.def(noop), - getRef: PropTypes.func.def(noop), - prefixCls: PropTypes.string.def(''), - tabBarPosition: PropTypes.string.def('top'), - extraContent: PropTypes.any, - }, - methods: { - onKeyDown(e) { - this.__emit('keydown', e); - }, - }, - render() { - const { prefixCls, onKeyDown, tabBarPosition, extraContent } = this; - const { class: className, style, onKeydown, ...restProps } = this.$attrs; - const cls = { - [`${prefixCls}-bar`]: true, - [className]: !!className, - }; - const topOrBottom = tabBarPosition === 'top' || tabBarPosition === 'bottom'; - const tabBarExtraContentStyle = topOrBottom ? { float: 'right' } : {}; - const children = getSlot(this); - let newChildren = children; - if (extraContent) { - newChildren = [ - cloneElement(extraContent, { - key: 'extra', - style: { - ...tabBarExtraContentStyle, - }, - }), - cloneElement(children, { key: 'content' }), - ]; - newChildren = topOrBottom ? newChildren : newChildren.reverse(); - } - - return ( -
- {newChildren} -
- ); - }, -}; diff --git a/components/vc-tabs/src/TabBarTabsNode.jsx b/components/vc-tabs/src/TabBarTabsNode.jsx deleted file mode 100644 index e79859a077..0000000000 --- a/components/vc-tabs/src/TabBarTabsNode.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import warning from 'warning'; -import PropTypes from '../../_util/vue-types'; -import BaseMixin from '../../_util/BaseMixin'; -import { getComponent, getPropsData } from '../../_util/props-util'; -import { isVertical } from './utils'; -function noop() {} -export default { - name: 'TabBarTabsNode', - mixins: [BaseMixin], - inheritAttrs: false, - props: { - activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - panels: PropTypes.any.def([]), - prefixCls: PropTypes.string.def(''), - tabBarGutter: PropTypes.any.def(null), - onTabClick: PropTypes.func, - saveRef: PropTypes.func.def(noop), - getRef: PropTypes.func.def(noop), - renderTabBarNode: PropTypes.func, - tabBarPosition: PropTypes.string, - direction: PropTypes.string, - }, - render() { - const { - panels: children, - activeKey, - prefixCls, - tabBarGutter, - saveRef, - tabBarPosition, - direction, - } = this.$props; - const rst = []; - const renderTabBarNode = this.renderTabBarNode || this.$slots.renderTabBarNode; - children.forEach((child, index) => { - if (!child) { - return; - } - const props = getPropsData(child); - const key = child.key; - let cls = activeKey === key ? `${prefixCls}-tab-active` : ''; - cls += ` ${prefixCls}-tab`; - const events = {}; - const disabled = props.disabled; - if (disabled) { - cls += ` ${prefixCls}-tab-disabled`; - } else { - events.onClick = () => { - this.__emit('tabClick', key); - }; - } - const tab = getComponent(child, 'tab'); - let gutter = tabBarGutter && index === children.length - 1 ? 0 : tabBarGutter; - gutter = typeof gutter === 'number' ? `${gutter}px` : gutter; - const marginProperty = direction === 'rtl' ? 'marginLeft' : 'marginRight'; - const style = { - [isVertical(tabBarPosition) ? 'marginBottom' : marginProperty]: gutter, - }; - warning(tab !== undefined, 'There must be `tab` property or slot on children of Tabs.'); - let node = ( - - ); - if (renderTabBarNode) { - node = renderTabBarNode(node); - } - - rst.push(node); - }); - - return
{rst}
; - }, -}; diff --git a/components/vc-tabs/src/TabContent.jsx b/components/vc-tabs/src/TabContent.jsx deleted file mode 100644 index 42e91695a6..0000000000 --- a/components/vc-tabs/src/TabContent.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import PropTypes from '../../_util/vue-types'; -import { cloneElement } from '../../_util/vnode'; -import { - getTransformByIndex, - getActiveIndex, - getTransformPropValue, - getMarginStyle, -} from './utils'; -import { defineComponent } from 'vue'; -export default defineComponent({ - name: 'TabContent', - inheritAttrs: false, - props: { - animated: PropTypes.looseBool.def(true), - animatedWithMargin: PropTypes.looseBool.def(true), - prefixCls: PropTypes.string.def('ant-tabs'), - activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - tabBarPosition: PropTypes.string, - direction: PropTypes.string, - destroyInactiveTabPane: PropTypes.looseBool, - children: PropTypes.any, - }, - computed: { - classes() { - const { animated, prefixCls } = this; - const { class: className } = this.$attrs; - return { - [className]: !!className, - [`${prefixCls}-content`]: true, - [animated ? `${prefixCls}-content-animated` : `${prefixCls}-content-no-animated`]: true, - }; - }, - }, - methods: { - getTabPanes(children) { - const props = this.$props; - const activeKey = props.activeKey; - const newChildren = []; - - children.forEach(child => { - if (!child) { - return; - } - const key = child.key; - const active = activeKey === key; - newChildren.push( - cloneElement(child, { - active, - destroyInactiveTabPane: props.destroyInactiveTabPane, - rootPrefixCls: props.prefixCls, - }), - ); - }); - - return newChildren; - }, - }, - render() { - const { - activeKey, - tabBarPosition, - animated, - animatedWithMargin, - direction, - classes, - children, - } = this; - let style = {}; - if (animated && children) { - const activeIndex = getActiveIndex(children, activeKey); - if (activeIndex !== -1) { - const animatedStyle = animatedWithMargin - ? getMarginStyle(activeIndex, tabBarPosition) - : getTransformPropValue(getTransformByIndex(activeIndex, tabBarPosition, direction)); - style = { - ...this.$attrs.style, - ...animatedStyle, - }; - } else { - style = { - ...this.$attrs.style, - display: 'none', - }; - } - } - return ( -
- {this.getTabPanes(children || [])} -
- ); - }, -}); diff --git a/components/vc-tabs/src/TabPane.jsx b/components/vc-tabs/src/TabPane.jsx deleted file mode 100644 index 3efab0fcd5..0000000000 --- a/components/vc-tabs/src/TabPane.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import { defineComponent, inject } from 'vue'; -import PropTypes from '../../_util/vue-types'; -import { getComponent, getSlot } from '../../_util/props-util'; -import Sentinel from './Sentinel'; - -export default defineComponent({ - name: 'TabPane', - props: { - active: PropTypes.looseBool, - destroyInactiveTabPane: PropTypes.looseBool, - forceRender: PropTypes.looseBool, - placeholder: PropTypes.any, - rootPrefixCls: PropTypes.string, - tab: PropTypes.any, - closable: PropTypes.looseBool, - disabled: PropTypes.looseBool, - }, - setup() { - return { - isActived: undefined, - sentinelContext: inject('sentinelContext', {}), - }; - }, - render() { - const { destroyInactiveTabPane, active, forceRender, rootPrefixCls } = this.$props; - const children = getSlot(this); - const placeholder = getComponent(this, 'placeholder'); - this.isActived = this.isActived || active; - const prefixCls = `${rootPrefixCls}-tabpane`; - const cls = { - [prefixCls]: 1, - [`${prefixCls}-inactive`]: !active, - [`${prefixCls}-active`]: active, - }; - const isRender = destroyInactiveTabPane ? active : this.isActived; - const shouldRender = isRender || forceRender; - const { sentinelStart, sentinelEnd, setPanelSentinelStart, setPanelSentinelEnd } = - this.sentinelContext; - let panelSentinelStart; - let panelSentinelEnd; - if (active && shouldRender) { - panelSentinelStart = ; - panelSentinelEnd = ; - } - return ( -
- {panelSentinelStart} - {shouldRender ? children : placeholder} - {panelSentinelEnd} -
- ); - }, -}); diff --git a/components/vc-tabs/src/Tabs.jsx b/components/vc-tabs/src/Tabs.jsx deleted file mode 100644 index 638e4c5ed1..0000000000 --- a/components/vc-tabs/src/Tabs.jsx +++ /dev/null @@ -1,254 +0,0 @@ -import { defineComponent, provide, reactive, watchEffect } from 'vue'; -import BaseMixin from '../../_util/BaseMixin'; -import PropTypes from '../../_util/vue-types'; -import KeyCode from './KeyCode'; -import { cloneElement } from '../../_util/vnode'; -import Sentinel from './Sentinel'; -import isValid from '../../_util/isValid'; -import { getDataAttr } from './utils'; - -function getDefaultActiveKey(props) { - let activeKey; - const children = props.children; - children.forEach(child => { - if (child && !isValid(activeKey) && !child.disabled) { - activeKey = child.key; - } - }); - return activeKey; -} - -function activeKeyIsValid(props, key) { - const children = props.children; - const keys = children.map(child => child && child.key); - return keys.indexOf(key) >= 0; -} - -export default defineComponent({ - name: 'Tabs', - mixins: [BaseMixin], - inheritAttrs: false, - props: { - destroyInactiveTabPane: PropTypes.looseBool, - renderTabBar: PropTypes.func.isRequired, - renderTabContent: PropTypes.func.isRequired, - navWrapper: PropTypes.func.def(arg => arg), - children: PropTypes.any.def([]), - prefixCls: PropTypes.string.def('ant-tabs'), - tabBarPosition: PropTypes.string.def('top'), - activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - defaultActiveKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - direction: PropTypes.string.def('ltr'), - tabBarGutter: PropTypes.number, - }, - setup(props) { - let activeKey; - if (props.activeKey !== undefined) { - // eslint-disable-next-line vue/no-setup-props-destructure - activeKey = props.activeKey; - } else if (props.defaultActiveKey !== undefined) { - // eslint-disable-next-line vue/no-setup-props-destructure - activeKey = props.defaultActiveKey; - } else { - activeKey = getDefaultActiveKey(props); - } - const state = reactive({ - _activeKey: activeKey, - }); - watchEffect( - () => { - if (props.activeKey !== undefined) { - state._activeKey = props.activeKey; - } else if (!activeKeyIsValid(props, state._activeKey)) { - // https://github.com/ant-design/ant-design/issues/7093 - state._activeKey = getDefaultActiveKey(props); - } - }, - { - flush: 'sync', - }, - ); - return { state }; - }, - created() { - this.panelSentinelStart = undefined; - this.panelSentinelEnd = undefined; - this.sentinelStart = undefined; - this.sentinelEnd = undefined; - provide('sentinelContext', this); - }, - beforeUnmount() { - this.destroy = true; - cancelAnimationFrame(this.sentinelId); - }, - methods: { - onTabClick(activeKey, e) { - if (this.tabBar.props && this.tabBar.props.onTabClick) { - this.tabBar.props.onTabClick(activeKey, e); - } - this.setActiveKey(activeKey); - }, - - onNavKeyDown(e) { - 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; - } - }, - - // Sentinel for tab index - setSentinelStart(node) { - this.sentinelStart = node; - }, - - setSentinelEnd(node) { - this.sentinelEnd = node; - }, - - setPanelSentinelStart(node) { - if (node !== this.panelSentinelStart) { - this.updateSentinelContext(); - } - this.panelSentinelStart = node; - }, - - setPanelSentinelEnd(node) { - if (node !== this.panelSentinelEnd) { - this.updateSentinelContext(); - } - this.panelSentinelEnd = node; - }, - - setActiveKey(activeKey) { - if (this.state._activeKey !== activeKey) { - const props = this.$props; - if (props.activeKey === undefined) { - this.state._activeKey = activeKey; - } - this.__emit('update:activeKey', activeKey); - this.__emit('change', activeKey); - } - }, - - getNextActiveKey(next) { - const activeKey = this.state._activeKey; - const children = []; - this.$props.children.forEach(c => { - if (c && !c.props?.disabled && c.props?.disabled !== '') { - if (next) { - children.push(c); - } else { - children.unshift(c); - } - } - }); - const length = children.length; - 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; - }, - updateSentinelContext() { - if (this.destroy) return; - - cancelAnimationFrame(this.sentinelId); - this.sentinelId = requestAnimationFrame(() => { - if (this.destroy) return; - this.$forceUpdate(); - }); - }, - }, - render() { - const props = this.$props; - const { - prefixCls, - navWrapper, - tabBarPosition, - renderTabContent, - renderTabBar, - destroyInactiveTabPane, - direction, - tabBarGutter, - } = props; - const { class: className, onChange, style, ...restProps } = this.$attrs; - const cls = { - [className]: className, - [prefixCls]: 1, - [`${prefixCls}-${tabBarPosition}`]: 1, - [`${prefixCls}-rtl`]: direction === 'rtl', - }; - - this.tabBar = renderTabBar(); - const tabBar = cloneElement(this.tabBar, { - prefixCls, - navWrapper, - tabBarPosition, - panels: props.children, - activeKey: this.state._activeKey, - direction, - tabBarGutter, - onKeydown: this.onNavKeyDown, - onTabClick: this.onTabClick, - key: 'tabBar', - }); - const tabContent = cloneElement(renderTabContent(), { - prefixCls, - tabBarPosition, - activeKey: this.state._activeKey, - destroyInactiveTabPane, - direction, - onChange: this.setActiveKey, - children: props.children, - key: 'tabContent', - }); - - const sentinelStart = ( - - ); - const sentinelEnd = ( - - ); - - const contents = []; - - if (tabBarPosition === 'bottom') { - contents.push(sentinelStart, tabContent, sentinelEnd, tabBar); - } else { - contents.push(tabBar, sentinelStart, tabContent, sentinelEnd); - } - const p = { - ...getDataAttr(restProps), - style, - onScroll: this.onScroll, - class: cls, - }; - return
{contents}
; - }, -}); diff --git a/components/vc-tabs/src/index.js b/components/vc-tabs/src/index.js deleted file mode 100755 index 57f2c19603..0000000000 --- a/components/vc-tabs/src/index.js +++ /dev/null @@ -1,7 +0,0 @@ -// based on rc-tabs 9.7.0 -import Tabs from './Tabs'; -import TabPane from './TabPane'; -import TabContent from './TabContent'; - -export default Tabs; -export { TabPane, TabContent }; diff --git a/components/vc-tabs/src/utils.js b/components/vc-tabs/src/utils.js deleted file mode 100644 index e8936acde5..0000000000 --- a/components/vc-tabs/src/utils.js +++ /dev/null @@ -1,132 +0,0 @@ -import { isVNode } from 'vue'; -export function toArray(children) { - const c = []; - children.forEach(child => { - if (isVNode(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) { - const marginDirection = isVertical(tabBarPosition) ? 'marginTop' : 'marginLeft'; - 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 => { - if (!node.tagName) { - return false; - } - 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/site/debugger/index.tsx b/site/debugger/index.tsx index b6e600792b..464ae8004b 100644 --- a/site/debugger/index.tsx +++ b/site/debugger/index.tsx @@ -1,5 +1,5 @@ // debugger tsx -import Demo from '../../components/auto-complete/demo/index.vue'; +import Demo from '../../components/tabs/demo/basic.vue'; export default { render() { From cd9f592c2e64c0e2fea9475e98a1363a2215ef5f Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Wed, 6 Oct 2021 10:18:00 +0800 Subject: [PATCH 02/11] refactor: tabs --- components/tabs/demo/card-top.vue | 55 ++++++++---- components/tabs/src/TabContext.ts | 12 +-- .../tabs/src/TabNavList/OperationNode.tsx | 6 +- components/tabs/src/TabNavList/index.tsx | 55 ++++++------ components/tabs/src/TabPanelList/TabPane.tsx | 6 +- components/tabs/src/TabPanelList/index.tsx | 20 ++--- components/tabs/src/Tabs.tsx | 84 +++++++++---------- components/tabs/src/hooks/useTouchMove.ts | 8 +- components/tabs/src/interface.ts | 7 +- site/debugger/index.tsx | 2 +- 10 files changed, 133 insertions(+), 122 deletions(-) diff --git a/components/tabs/demo/card-top.vue b/components/tabs/demo/card-top.vue index 7c81201259..a39966cf7a 100644 --- a/components/tabs/demo/card-top.vue +++ b/components/tabs/demo/card-top.vue @@ -42,38 +42,57 @@ import { defineComponent, ref } from 'vue'; export default defineComponent({ setup() { return { - activeKey: ref(1), + activeKey: ref('2'), }; }, }); diff --git a/components/tabs/src/TabContext.ts b/components/tabs/src/TabContext.ts index 75a2a4d426..d563aa7f16 100644 --- a/components/tabs/src/TabContext.ts +++ b/components/tabs/src/TabContext.ts @@ -1,10 +1,10 @@ import type { Tab } from './interface'; -import type { PropType, InjectionKey } from 'vue'; -import { provide, inject, defineComponent } from 'vue'; +import type { PropType, InjectionKey, Ref } from 'vue'; +import { provide, inject, defineComponent, toRefs, ref } from 'vue'; export interface TabContextProps { - tabs: Tab[]; - prefixCls: string; + tabs: Ref; + prefixCls: Ref; } const TabsContextKey: InjectionKey = Symbol('tabsContextKey'); @@ -14,7 +14,7 @@ export const useProvideTabs = (props: TabContextProps) => { }; export const useInjectTabs = () => { - return inject(TabsContextKey, { tabs: [], prefixCls: undefined }); + return inject(TabsContextKey, { tabs: ref([]), prefixCls: ref() }); }; const TabsContextProvider = defineComponent({ @@ -25,7 +25,7 @@ const TabsContextProvider = defineComponent({ prefixCls: { type: String, default: undefined }, }, setup(props, { slots }) { - useProvideTabs(props); + useProvideTabs(toRefs(props)); return () => slots.default?.(); }, }); diff --git a/components/tabs/src/TabNavList/OperationNode.tsx b/components/tabs/src/TabNavList/OperationNode.tsx index bb279497f5..ff5e426771 100644 --- a/components/tabs/src/TabNavList/OperationNode.tsx +++ b/components/tabs/src/TabNavList/OperationNode.tsx @@ -16,7 +16,7 @@ export interface OperationNodeProps { tabs: Tab[]; rtl: boolean; tabBarGutter?: number; - activeKey: string; + activeKey: Key; mobile: boolean; moreIcon?: VueNode; moreTransitionName?: string; @@ -34,7 +34,7 @@ export default defineComponent({ tabs: { type: Object as PropType }, rtl: { type: Boolean }, tabBarGutter: { type: Number }, - activeKey: { type: String }, + activeKey: { type: [String, Number] }, mobile: { type: Boolean }, moreIcon: PropTypes.any, moreTransitionName: { type: String }, @@ -47,7 +47,7 @@ export default defineComponent({ setup(props, { attrs, slots }) { // ======================== Dropdown ======================== const [open, setOpen] = useState(false); - const [selectedKey, setSelectedKey] = useState(null); + const [selectedKey, setSelectedKey] = useState(null); const selectOffset = (offset: -1 | 1) => { const enabledTabs = props.tabs.filter(tab => !tab.disabled); let selectedIndex = enabledTabs.findIndex(tab => tab.key === selectedKey.value) || 0; diff --git a/components/tabs/src/TabNavList/index.tsx b/components/tabs/src/TabNavList/index.tsx index 90f055aa79..2eb9c5f494 100644 --- a/components/tabs/src/TabNavList/index.tsx +++ b/components/tabs/src/TabNavList/index.tsx @@ -32,7 +32,7 @@ const tabNavListProps = () => { return { id: { type: String }, tabPosition: { type: String as PropType }, - activeKey: { type: String }, + activeKey: { type: [String, Number] }, rtl: { type: Boolean }, panes: PropTypes.any, animated: { type: Object as PropType, default: undefined as AnimatedConfig }, @@ -66,7 +66,7 @@ export default defineComponent({ slots: ['panes', 'moreIcon', 'extra'], emits: ['tabClick', 'tabScroll'], setup(props, { attrs, slots }) { - const tabsContext = useInjectTabs(); + const { tabs, prefixCls } = useInjectTabs(); const tabsWrapperRef = ref(); const tabListRef = ref(); const operationsRef = ref<{ $el: HTMLDivElement }>(); @@ -98,15 +98,10 @@ export default defineComponent({ const [addHeight, setAddHeight] = useState(0); const [tabSizes, setTabSizes] = useRafState(new Map()); - const tabOffsets = useOffsets( - computed(() => tabsContext.tabs), - tabSizes, - ); + const tabOffsets = useOffsets(tabs, tabSizes); // ========================== Util ========================= - const operationsHiddenClassName = computed( - () => `${tabsContext.prefixCls}-nav-operations-hidden`, - ); + const operationsHiddenClassName = computed(() => `${prefixCls.value}-nav-operations-hidden`); const transformMin = ref(0); const transformMax = ref(0); @@ -261,15 +256,15 @@ export default defineComponent({ mergedBasicSize = basicSize - addSize; } - const { tabs } = tabsContext; - if (!tabs.length) { + const tabsVal = tabs.value; + if (!tabsVal.length) { [visibleStart.value, visibleEnd.value] = [0, 0]; } - const len = tabs.length; + const len = tabsVal.length; let endIndex = len; for (let i = 0; i < len; i += 1) { - const offset = tabOffsets.value.get(tabs[i].key) || DEFAULT_SIZE; + const offset = tabOffsets.value.get(tabsVal[i].key) || DEFAULT_SIZE; if (offset[position] + offset[unit] > transformSize + mergedBasicSize) { endIndex = i - 1; break; @@ -278,7 +273,7 @@ export default defineComponent({ let startIndex = 0; for (let i = len - 1; i >= 0; i -= 1) { - const offset = tabOffsets.value.get(tabs[i].key) || DEFAULT_SIZE; + const offset = tabOffsets.value.get(tabsVal[i].key) || DEFAULT_SIZE; if (offset[position] < transformSize) { startIndex = i + 1; break; @@ -319,7 +314,7 @@ export default defineComponent({ // Update buttons records setTabSizes(() => { const newSizes: TabSizeMap = new Map(); - tabsContext.tabs.forEach(({ key }) => { + tabs.value.forEach(({ key }) => { const btnRef = getBtnRef(key).value; const btnNode = (btnRef as any).$el || btnRef; if (btnNode) { @@ -337,8 +332,8 @@ export default defineComponent({ // ======================== Dropdown ======================= const hiddenTabs = computed(() => [ - ...tabsContext.tabs.slice(0, visibleStart.value), - ...tabsContext.tabs.slice(visibleEnd.value + 1), + ...tabs.value.slice(0, visibleStart.value), + ...tabs.value.slice(visibleEnd.value + 1), ]); // =================== Link & Operations =================== @@ -385,7 +380,7 @@ export default defineComponent({ ); watch( - [() => props.rtl, () => props.tabBarGutter, () => props.activeKey, () => tabsContext.tabs], + [() => props.rtl, () => props.tabBarGutter, () => props.activeKey, () => tabs.value], () => { onListHolderResize(); }, @@ -406,7 +401,6 @@ export default defineComponent({ }); return () => { - const { prefixCls, tabs } = tabsContext; const { id, animated, @@ -420,9 +414,10 @@ export default defineComponent({ onTabClick, } = props; const { class: className, style } = attrs; + const pre = prefixCls.value; // ========================= Render ======================== const hasDropdown = !!hiddenTabs.value.length; - const wrapPrefix = `${prefixCls}-nav-wrap`; + const wrapPrefix = `${pre}-nav-wrap`; let pingLeft: boolean; let pingRight: boolean; let pingTop: boolean; @@ -450,12 +445,12 @@ export default defineComponent({ typeof tabBarGutter === 'number' ? `${tabBarGutter}px` : tabBarGutter; } - const tabNodes = tabs.map((tab, i) => { + const tabNodes = tabs.value.map((tab, i) => { const { key } = tab; return ( { // No need animation when use keyboard doLockAnimation(); }} > - +
@@ -546,12 +541,12 @@ export default defineComponent({ - +
); }; diff --git a/components/tabs/src/TabPanelList/TabPane.tsx b/components/tabs/src/TabPanelList/TabPane.tsx index fc86de7228..67359acc2c 100644 --- a/components/tabs/src/TabPanelList/TabPane.tsx +++ b/components/tabs/src/TabPanelList/TabPane.tsx @@ -1,6 +1,6 @@ import { defineComponent, ref, watch, computed } from 'vue'; import type { CSSProperties } from 'vue'; -import type { VueNode } from '../../../_util/type'; +import type { VueNode, Key } from '../../../_util/type'; import PropTypes from '../../../_util/vue-types'; export interface TabPaneProps { @@ -12,7 +12,7 @@ export interface TabPaneProps { // Pass by TabPaneList prefixCls?: string; - tabKey?: string; + tabKey?: Key; id?: string; animated?: boolean; active?: boolean; @@ -33,7 +33,7 @@ export default defineComponent({ // Pass by TabPaneList prefixCls: { type: String }, - tabKey: { type: String }, + tabKey: { type: [String, Number] }, id: { type: String }, }, slots: ['closeIcon', 'tab'], diff --git a/components/tabs/src/TabPanelList/index.tsx b/components/tabs/src/TabPanelList/index.tsx index 16f30ab928..82754ed777 100644 --- a/components/tabs/src/TabPanelList/index.tsx +++ b/components/tabs/src/TabPanelList/index.tsx @@ -25,22 +25,20 @@ export default defineComponent({ destroyInactiveTabPane: { type: Boolean }, }, setup(props) { - const tabsContext = useInjectTabs(); + const { tabs, prefixCls } = useInjectTabs(); return () => { const { id, activeKey, animated, tabPosition, rtl, destroyInactiveTabPane } = props; - const { prefixCls, tabs } = tabsContext; const tabPaneAnimated = animated.tabPane; - - const activeIndex = tabs.findIndex(tab => tab.key === activeKey); - + const pre = prefixCls.value; + const activeIndex = tabs.value.findIndex(tab => tab.key === activeKey); return ( -
+
- {tabs.map(tab => { + {tabs.value.map(tab => { return cloneElement(tab.node, { key: tab.key, - prefixCls, + prefixCls: pre, tabKey: tab.key, id, animated: tabPaneAnimated, diff --git a/components/tabs/src/Tabs.tsx b/components/tabs/src/Tabs.tsx index 842f5a2895..d41688ecf9 100644 --- a/components/tabs/src/Tabs.tsx +++ b/components/tabs/src/Tabs.tsx @@ -21,7 +21,8 @@ import classNames from '../../_util/classNames'; import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue'; import devWarning from '../../vc-util/devWarning'; import type { SizeType } from '../../config-provider'; -import TabsContextProvider from './TabContext'; +import { useProvideTabs } from './TabContext'; +import type { Key } from '../../_util/type'; export type TabsType = 'line' | 'card' | 'editable-card'; export type TabsPosition = 'top' | 'right' | 'bottom' | 'left'; @@ -34,8 +35,8 @@ export const tabsProps = () => { prefixCls: { type: String }, id: { type: String }, - activeKey: { type: String }, - defaultActiveKey: { type: String }, + activeKey: { type: [String, Number], required: true }, + defaultActiveKey: { type: [String, Number] }, direction: { type: String as PropType<'ltr' | 'rtl'> }, animated: { type: [Boolean, Object] as PropType }, renderTabBar: { type: Function as PropType }, @@ -50,12 +51,12 @@ export const tabsProps = () => { centered: Boolean, onEdit: { type: Function as PropType< - (e: MouseEvent | KeyboardEvent | string, action: 'add' | 'remove') => void + (e: MouseEvent | KeyboardEvent | Key, action: 'add' | 'remove') => void >, }, - onChange: { type: Function as PropType<(activeKey: string) => void> }, + onChange: { type: Function as PropType<(activeKey: Key) => void> }, onTabClick: { - type: Function as PropType<(activeKey: string, e: KeyboardEvent | MouseEvent) => void>, + type: Function as PropType<(activeKey: Key, e: KeyboardEvent | MouseEvent) => void>, }, onTabScroll: { type: Function as PropType }, @@ -78,7 +79,7 @@ function parseTabList(children: any[]): Tab[] { props[camelize(k)] = v; } const slots = node.children || {}; - const key = node.key !== undefined ? String(node.key) : undefined; + const key = node.key !== undefined ? node.key : undefined; const { tab = slots.tab, disabled, @@ -159,7 +160,7 @@ const InternalTabs = defineComponent({ }); // ====================== Active Key ====================== - const [mergedActiveKey, setMergedActiveKey] = useMergedState(() => props.tabs[0]?.key, { + const [mergedActiveKey, setMergedActiveKey] = useMergedState(() => props.tabs[0]?.key, { value: computed(() => props.activeKey), defaultValue: props.defaultActiveKey, }); @@ -171,7 +172,7 @@ const InternalTabs = defineComponent({ let newActiveIndex = props.tabs.findIndex(tab => tab.key === mergedActiveKey.value); if (newActiveIndex === -1) { newActiveIndex = Math.max(0, Math.min(activeIndex.value, props.tabs.length - 1)); - setMergedActiveKey(props.tabs[newActiveIndex]?.key); + mergedActiveKey.value = props.tabs[newActiveIndex]?.key; } setActiveIndex(newActiveIndex); }); @@ -197,30 +198,30 @@ const InternalTabs = defineComponent({ }); // ======================== Events ======================== - const onInternalTabClick = (key: string, e: MouseEvent | KeyboardEvent) => { + const onInternalTabClick = (key: Key, e: MouseEvent | KeyboardEvent) => { props.onTabClick?.(key, e); setMergedActiveKey(key); props.onChange?.(key); }; + useProvideTabs({ + tabs: computed(() => props.tabs), + prefixCls, + }); + return () => { const { id, type, - activeKey, - defaultActiveKey, tabBarGutter, tabBarStyle, locale, destroyInactiveTabPane, renderTabBar, - onChange, - onTabClick, onTabScroll, hideAdd, centered, - ...restProps } = props; // ======================== Render ======================== const sharedProps = { @@ -274,34 +275,31 @@ const InternalTabs = defineComponent({ const pre = prefixCls.value; return ( - -
- {tabNavBar} - -
-
+
+ {tabNavBar} + +
); }; }, diff --git a/components/tabs/src/hooks/useTouchMove.ts b/components/tabs/src/hooks/useTouchMove.ts index a00258e8a8..c66ed604b9 100644 --- a/components/tabs/src/hooks/useTouchMove.ts +++ b/components/tabs/src/hooks/useTouchMove.ts @@ -1,3 +1,4 @@ +import supportsPassive from 'ant-design-vue/es/_util/supportsPassive'; import type { Ref } from 'vue'; import { ref, onBeforeUnmount, onMounted } from 'vue'; import useState from '../../../_util/hooks/useState'; @@ -9,7 +10,6 @@ const MIN_SWIPE_DISTANCE = 0.1; const STOP_SWIPE_DISTANCE = 0.01; const REFRESH_INTERVAL = 20; const SPEED_OFF_MULTIPLE = 0.995 ** REFRESH_INTERVAL; - // ================================= Hook ================================= export default function useTouchMove( domRef: Ref, @@ -131,7 +131,11 @@ export default function useTouchMove( // No need to clean up since element removed domRef.value.addEventListener('touchstart', onProxyTouchStart, { passive: false }); - domRef.value.addEventListener('wheel', onProxyWheel); + domRef.value.addEventListener( + 'wheel', + onProxyWheel, + supportsPassive ? { passive: true } : false, + ); }); onBeforeUnmount(() => { diff --git a/components/tabs/src/interface.ts b/components/tabs/src/interface.ts index 6cd9ac98ec..a73d92c48e 100644 --- a/components/tabs/src/interface.ts +++ b/components/tabs/src/interface.ts @@ -15,7 +15,7 @@ export type TabOffsetMap = Map; export type TabPosition = 'left' | 'right' | 'top' | 'bottom'; export interface Tab extends TabPaneProps { - key: string; + key: Key; node: VueNode; } @@ -28,10 +28,7 @@ export interface TabsLocale { } export interface EditableConfig { - onEdit: ( - type: 'add' | 'remove', - info: { key?: string; event: MouseEvent | KeyboardEvent }, - ) => void; + onEdit: (type: 'add' | 'remove', info: { key?: Key; event: MouseEvent | KeyboardEvent }) => void; showAdd?: boolean; removeIcon?: () => VueNode; addIcon?: () => VueNode; diff --git a/site/debugger/index.tsx b/site/debugger/index.tsx index 464ae8004b..b9f30bd4da 100644 --- a/site/debugger/index.tsx +++ b/site/debugger/index.tsx @@ -1,5 +1,5 @@ // debugger tsx -import Demo from '../../components/tabs/demo/basic.vue'; +import Demo from '../../components/tabs/demo/card-top.vue'; export default { render() { From 8937d92cdc580c522b498487638108167afb0e13 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Wed, 6 Oct 2021 13:07:19 +0800 Subject: [PATCH 03/11] fix: tabs hotreload error --- components/_util/hooks/useRef.ts | 9 +++++---- components/tabs/src/TabNavList/TabNode.tsx | 10 +++++----- components/tabs/src/TabNavList/index.tsx | 18 ++++++------------ components/tabs/src/Tabs.tsx | 1 - components/tabs/src/hooks/useTouchMove.ts | 4 ++-- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/components/_util/hooks/useRef.ts b/components/_util/hooks/useRef.ts index 86f6512ecd..93b80d0f23 100644 --- a/components/_util/hooks/useRef.ts +++ b/components/_util/hooks/useRef.ts @@ -1,11 +1,12 @@ -import type { Ref } from 'vue'; +import type { Ref, ComponentPublicInstance } from 'vue'; import { onBeforeUpdate, ref } from 'vue'; -export type UseRef = [(el: any, key: string | number) => void, Ref]; -export type Refs = Record; +type RefType = HTMLElement | ComponentPublicInstance; +export type Refs = Record; +export type UseRef = [(el: RefType, key: string | number) => void, Ref]; export const useRef = (): UseRef => { const refs = ref({}); - const setRef = (el: any, key: string | number) => { + const setRef = (el: RefType, key: string | number) => { refs.value[key] = el; }; onBeforeUpdate(() => { diff --git a/components/tabs/src/TabNavList/TabNode.tsx b/components/tabs/src/TabNavList/TabNode.tsx index 49a5fc7dbd..3ee867749f 100644 --- a/components/tabs/src/TabNavList/TabNode.tsx +++ b/components/tabs/src/TabNavList/TabNode.tsx @@ -1,6 +1,6 @@ import type { Tab, EditableConfig } from '../interface'; import type { PropType } from 'vue'; -import { onBeforeUnmount, defineComponent, computed, ref } from 'vue'; +import { defineComponent, computed, ref } from 'vue'; import type { FocusEventHandler } from '../../../_util/EventInterface'; import KeyCode from '../../../_util/KeyCode'; import classNames from '../../../_util/classNames'; @@ -37,7 +37,7 @@ export default defineComponent({ }, renderWrapper: { type: Function as PropType<(node: any) => any> }, removeAriaLabel: { type: String }, - onRemove: { type: Function as PropType<() => void> }, + // onRemove: { type: Function as PropType<() => void> }, onFocus: { type: Function as PropType }, }, emits: ['click', 'resize', 'remove', 'focus'], @@ -52,9 +52,9 @@ export default defineComponent({ expose({ domRef, }); - onBeforeUnmount(() => { - props.onRemove(); - }); + // onBeforeUnmount(() => { + // props.onRemove(); + // }); function onRemoveTab(event: MouseEvent | KeyboardEvent) { event.preventDefault(); event.stopPropagation(); diff --git a/components/tabs/src/TabNavList/index.tsx b/components/tabs/src/TabNavList/index.tsx index 2eb9c5f494..04f809a55c 100644 --- a/components/tabs/src/TabNavList/index.tsx +++ b/components/tabs/src/TabNavList/index.tsx @@ -15,7 +15,6 @@ import useOffsets from '../hooks/useOffsets'; import OperationNode from './OperationNode'; import { useInjectTabs } from '../TabContext'; import useTouchMove from '../hooks/useTouchMove'; -import useRefs from '../hooks/useRefs'; import AddButton from './AddButton'; import type { Key } from '../../../_util/type'; import type { ExtractPropTypes, PropType, CSSProperties } from 'vue'; @@ -27,6 +26,7 @@ import wrapperRaf from '../../../_util/raf'; import classNames from '../../../_util/classNames'; import ResizeObserver from '../../../vc-resize-observer'; import { toPx } from '../../../_util/util'; +import useRef from '../../../_util/hooks/useRef'; const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0, right: 0 }; const tabNavListProps = () => { return { @@ -34,7 +34,6 @@ const tabNavListProps = () => { tabPosition: { type: String as PropType }, activeKey: { type: [String, Number] }, rtl: { type: Boolean }, - panes: PropTypes.any, animated: { type: Object as PropType, default: undefined as AnimatedConfig }, extra: PropTypes.any, editable: { type: Object as PropType }, @@ -63,7 +62,7 @@ export default defineComponent({ name: 'TabNavList', inheritAttrs: false, props: tabNavListProps(), - slots: ['panes', 'moreIcon', 'extra'], + slots: ['moreIcon', 'extra'], emits: ['tabClick', 'tabScroll'], setup(props, { attrs, slots }) { const { tabs, prefixCls } = useInjectTabs(); @@ -71,8 +70,7 @@ export default defineComponent({ const tabListRef = ref(); const operationsRef = ref<{ $el: HTMLDivElement }>(); const innerAddButtonRef = ref(); - const [getBtnRef, removeBtnRef] = useRefs(); - + const [setRef, btnRefs] = useRef(); const tabPositionTopOrBottom = computed( () => props.tabPosition === 'top' || props.tabPosition === 'bottom', ); @@ -315,8 +313,8 @@ export default defineComponent({ setTabSizes(() => { const newSizes: TabSizeMap = new Map(); tabs.value.forEach(({ key }) => { - const btnRef = getBtnRef(key).value; - const btnNode = (btnRef as any).$el || btnRef; + const btnRef = btnRefs.value[key]; + const btnNode = (btnRef as any)?.$el || btnRef; if (btnNode) { newSizes.set(key, { width: btnNode.offsetWidth, @@ -459,13 +457,10 @@ export default defineComponent({ editable={editable} active={key === activeKey} removeAriaLabel={locale?.removeAriaLabel} - ref={getBtnRef(key)} + ref={r => setRef(r, key)} onClick={e => { onTabClick(key, e); }} - onRemove={() => { - removeBtnRef(key); - }} onFocus={() => { scrollToTab(key); doLockAnimation(); @@ -537,7 +532,6 @@ export default defineComponent({
- Date: Wed, 6 Oct 2021 14:14:58 +0800 Subject: [PATCH 04/11] refactor: rename useRef --- components/_util/hooks/useRef.ts | 18 ------------------ components/rate/index.tsx | 8 ++++---- components/tabs/src/TabNavList/index.tsx | 8 ++++---- components/tabs/src/hooks/useRefs.ts | 23 ----------------------- components/vc-progress/src/Circle.tsx | 6 +++--- components/vc-progress/src/Line.tsx | 6 +++--- components/vc-progress/src/common.ts | 9 +++++---- 7 files changed, 19 insertions(+), 59 deletions(-) delete mode 100644 components/_util/hooks/useRef.ts delete mode 100644 components/tabs/src/hooks/useRefs.ts diff --git a/components/_util/hooks/useRef.ts b/components/_util/hooks/useRef.ts deleted file mode 100644 index 93b80d0f23..0000000000 --- a/components/_util/hooks/useRef.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Ref, ComponentPublicInstance } from 'vue'; -import { onBeforeUpdate, ref } from 'vue'; - -type RefType = HTMLElement | ComponentPublicInstance; -export type Refs = Record; -export type UseRef = [(el: RefType, key: string | number) => void, Ref]; -export const useRef = (): UseRef => { - const refs = ref({}); - const setRef = (el: RefType, key: string | number) => { - refs.value[key] = el; - }; - onBeforeUpdate(() => { - refs.value = {}; - }); - return [setRef, refs]; -}; - -export default useRef; diff --git a/components/rate/index.tsx b/components/rate/index.tsx index c3e1b3a107..86eec267a2 100644 --- a/components/rate/index.tsx +++ b/components/rate/index.tsx @@ -11,7 +11,7 @@ import Tooltip from '../tooltip'; import useConfigInject from '../_util/hooks/useConfigInject'; import Star from './Star'; -import { useRef } from '../_util/hooks/useRef'; +import useRefs from '../_util/hooks/useRefs'; import { useInjectFormItemContext } from '../form/FormItemContext'; export const rateProps = { @@ -48,7 +48,7 @@ const Rate = defineComponent({ const { prefixCls, direction } = useConfigInject('rate', props); const formItemContext = useInjectFormItemContext(); const rateRef = ref(); - const [setRef, starRefs] = useRef(); + const [setRef, starRefs] = useRefs(); const state = reactive({ value: props.value, focused: false, @@ -62,7 +62,7 @@ const Rate = defineComponent({ }, ); const getStarDOM = (index: number) => { - return findDOMNode(starRefs.value[index]); + return findDOMNode(starRefs.value.get(index)); }; const getStarValue = (index: number, x: number) => { const reverse = direction.value === 'rtl'; @@ -199,7 +199,7 @@ const Rate = defineComponent({ for (let index = 0; index < count; index++) { stars.push( setRef(r, index)} + ref={setRef(index)} key={index} index={index} count={count} diff --git a/components/tabs/src/TabNavList/index.tsx b/components/tabs/src/TabNavList/index.tsx index 04f809a55c..9644b13a94 100644 --- a/components/tabs/src/TabNavList/index.tsx +++ b/components/tabs/src/TabNavList/index.tsx @@ -26,7 +26,7 @@ import wrapperRaf from '../../../_util/raf'; import classNames from '../../../_util/classNames'; import ResizeObserver from '../../../vc-resize-observer'; import { toPx } from '../../../_util/util'; -import useRef from '../../../_util/hooks/useRef'; +import useRefs from '../../../_util/hooks/useRefs'; const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0, right: 0 }; const tabNavListProps = () => { return { @@ -70,7 +70,7 @@ export default defineComponent({ const tabListRef = ref(); const operationsRef = ref<{ $el: HTMLDivElement }>(); const innerAddButtonRef = ref(); - const [setRef, btnRefs] = useRef(); + const [setRef, btnRefs] = useRefs(); const tabPositionTopOrBottom = computed( () => props.tabPosition === 'top' || props.tabPosition === 'bottom', ); @@ -313,7 +313,7 @@ export default defineComponent({ setTabSizes(() => { const newSizes: TabSizeMap = new Map(); tabs.value.forEach(({ key }) => { - const btnRef = btnRefs.value[key]; + const btnRef = btnRefs.value.get(key); const btnNode = (btnRef as any)?.$el || btnRef; if (btnNode) { newSizes.set(key, { @@ -457,7 +457,7 @@ export default defineComponent({ editable={editable} active={key === activeKey} removeAriaLabel={locale?.removeAriaLabel} - ref={r => setRef(r, key)} + ref={setRef(key)} onClick={e => { onTabClick(key, e); }} diff --git a/components/tabs/src/hooks/useRefs.ts b/components/tabs/src/hooks/useRefs.ts deleted file mode 100644 index 73bacbae5d..0000000000 --- a/components/tabs/src/hooks/useRefs.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Ref, ComponentPublicInstance } from 'vue'; -import { ref } from 'vue'; -import type { Key } from '../../../_util/type'; - -export default function useRefs(): [ - (key: Key) => Ref, - (key: Key) => void, -] { - const cacheRefs = ref(new Map>()); - - function getRef(key: Key) { - if (!cacheRefs.value.has(key)) { - cacheRefs.value.set(key, ref()); - } - return cacheRefs.value.get(key); - } - - function removeRef(key: Key) { - cacheRefs.value.delete(key); - } - - return [getRef, removeRef]; -} diff --git a/components/vc-progress/src/Circle.tsx b/components/vc-progress/src/Circle.tsx index a4aa86ece8..938c74fd18 100644 --- a/components/vc-progress/src/Circle.tsx +++ b/components/vc-progress/src/Circle.tsx @@ -3,7 +3,7 @@ import type { GapPositionType } from './types'; import { propTypes } from './types'; import { computed, defineComponent, ref } from 'vue'; import initDefaultProps from '../../_util/props-util/initDefaultProps'; -import { useRef } from '../../_util/hooks/useRef'; +import useRefs from '../../_util/hooks/useRefs'; let gradientSeed = 0; @@ -75,7 +75,7 @@ export default defineComponent({ const percentList = computed(() => toArray(props.percent)); const strokeColorList = computed(() => toArray(props.strokeColor)); - const [setRef, paths] = useRef(); + const [setRef, paths] = useRefs(); useTransitionDuration(paths); const getStokeList = () => { @@ -111,7 +111,7 @@ export default defineComponent({ class: `${prefixCls}-circle-path`, style: pathStyle, }; - return setRef(c, index)} {...pathProps} />; + return ; }); }; diff --git a/components/vc-progress/src/Line.tsx b/components/vc-progress/src/Line.tsx index 578e859265..80402d4515 100644 --- a/components/vc-progress/src/Line.tsx +++ b/components/vc-progress/src/Line.tsx @@ -1,4 +1,4 @@ -import { useRef } from '../../_util/hooks/useRef'; +import useRefs from '../../_util/hooks/useRefs'; import { computed, defineComponent } from 'vue'; import initDefaultProps from '../../_util/props-util/initDefaultProps'; import { useTransitionDuration, defaultProps } from './common'; @@ -58,7 +58,7 @@ export default defineComponent({ const { strokeColor } = props; return Array.isArray(strokeColor) ? strokeColor : [strokeColor]; }); - const [setRef, paths] = useRef(); + const [setRef, paths] = useRefs(); useTransitionDuration(paths); const center = computed(() => props.strokeWidth / 2); const right = computed(() => 100 - props.strokeWidth / 2); @@ -103,7 +103,7 @@ export default defineComponent({ > {percentListProps.value.map((pathProps, index) => { - return setRef(c, index)} {...pathProps} />; + return ; })} ); diff --git a/components/vc-progress/src/common.ts b/components/vc-progress/src/common.ts index 25da82c350..89d05b4e15 100644 --- a/components/vc-progress/src/common.ts +++ b/components/vc-progress/src/common.ts @@ -1,4 +1,5 @@ -import type { Refs } from '../../_util/hooks/useRef'; +import type { RefsValue } from '../../_util/hooks/useRefs'; +import type { Ref } from 'vue'; import { ref, onUpdated } from 'vue'; import type { ProgressProps } from './types'; @@ -12,15 +13,15 @@ export const defaultProps: Partial = { trailWidth: 1, }; -export const useTransitionDuration = (paths: Refs) => { +export const useTransitionDuration = (paths: Ref) => { const prevTimeStamp = ref(null); onUpdated(() => { const now = Date.now(); let updated = false; - Object.keys(paths.value).forEach(key => { - const path = paths.value[key]; + paths.value.forEach(val => { + const path = (val as any)?.$el || val; if (!path) { return; } From ba8a128943e78f1089e779ac87a2e981ab159b57 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Wed, 6 Oct 2021 15:41:52 +0800 Subject: [PATCH 05/11] feat: add leftExtra rightExtra --- components/_util/hooks/useRefs.ts | 20 +++++++++++ components/tabs/demo/centered.vue | 36 +++++++++++++++++++ components/tabs/demo/custom-add-trigger.vue | 2 +- components/tabs/demo/custom-tab-bar.vue | 8 ++--- components/tabs/demo/extra.vue | 17 +++++++-- components/tabs/index.en-US.md | 25 ++++++------- components/tabs/index.zh-CN.md | 25 ++++++------- components/tabs/src/TabNavList/TabNode.tsx | 4 +-- components/tabs/src/TabNavList/index.tsx | 15 ++++---- components/tabs/src/TabPanelList/TabPane.tsx | 2 +- components/tabs/src/Tabs.tsx | 37 +++++++++++++++----- components/tabs/src/interface.ts | 2 +- 12 files changed, 138 insertions(+), 55 deletions(-) create mode 100644 components/_util/hooks/useRefs.ts create mode 100644 components/tabs/demo/centered.vue diff --git a/components/_util/hooks/useRefs.ts b/components/_util/hooks/useRefs.ts new file mode 100644 index 0000000000..965805e203 --- /dev/null +++ b/components/_util/hooks/useRefs.ts @@ -0,0 +1,20 @@ +import type { Ref, ComponentPublicInstance } from 'vue'; +import { onBeforeUpdate, ref } from 'vue'; +import type { Key } from '../type'; + +type RefType = HTMLElement | ComponentPublicInstance; +export type RefsValue = Map; +type UseRef = [(key: Key) => (el: RefType) => void, Ref]; +const useRefs = (): UseRef => { + const refs = ref(new Map()); + + const setRef = (key: Key) => (el: RefType) => { + refs.value.set(key, el); + }; + onBeforeUpdate(() => { + refs.value = new Map(); + }); + return [setRef, refs]; +}; + +export default useRefs; diff --git a/components/tabs/demo/centered.vue b/components/tabs/demo/centered.vue new file mode 100644 index 0000000000..ffc58a1716 --- /dev/null +++ b/components/tabs/demo/centered.vue @@ -0,0 +1,36 @@ + +--- +order: 2 +title: + zh-CN: 居中 + en-US: Centered +--- + +## zh-CN + +标签居中展示。 + +## en-US + +Centered tabs. + + + + + diff --git a/components/tabs/demo/custom-add-trigger.vue b/components/tabs/demo/custom-add-trigger.vue index a204d922ce..de131cbf95 100644 --- a/components/tabs/demo/custom-add-trigger.vue +++ b/components/tabs/demo/custom-add-trigger.vue @@ -33,7 +33,7 @@ import { defineComponent, ref } from 'vue'; export default defineComponent({ setup() { - const panes = ref([ + const panes = ref<{ title: string; content: string; key: string; closable?: boolean }[]>([ { title: 'Tab 1', content: 'Content of Tab 1', key: '1' }, { title: 'Tab 2', content: 'Content of Tab 2', key: '2' }, ]); diff --git a/components/tabs/demo/custom-tab-bar.vue b/components/tabs/demo/custom-tab-bar.vue index c4a4cf6378..3ed2fefb6f 100644 --- a/components/tabs/demo/custom-tab-bar.vue +++ b/components/tabs/demo/custom-tab-bar.vue @@ -16,17 +16,13 @@ Customized bar of tab.