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 AddButton from './AddButton'; import { objectType, functionType } from '../../../_util/type'; import type { CustomSlotsType, Key } from '../../../_util/type'; import type { ExtractPropTypes, PropType, CSSProperties } from 'vue'; import { shallowRef, onBeforeUnmount, defineComponent, watch, watchEffect, computed } from 'vue'; import PropTypes from '../../../_util/vue-types'; import useSyncState from '../hooks/useSyncState'; import useState from '../../../_util/hooks/useState'; import raf from '../../../_util/raf'; import classNames from '../../../_util/classNames'; import ResizeObserver from '../../../vc-resize-observer'; import { toPx } from '../../../_util/util'; import useRefs from '../../../_util/hooks/useRefs'; import pick from 'lodash-es/pick'; const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0, right: 0 }; export const tabNavListProps = () => { return { id: { type: String }, tabPosition: { type: String as PropType }, activeKey: { type: [String, Number] }, rtl: { type: Boolean }, animated: objectType(), editable: objectType(), moreIcon: PropTypes.any, moreTransitionName: { type: String }, mobile: { type: Boolean }, tabBarGutter: { type: Number }, renderTabBar: { type: Function as PropType }, locale: objectType(), popupClassName: String, getPopupContainer: functionType< ((triggerNode?: HTMLElement | undefined) => HTMLElement) | undefined >(), 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?: (info?: { position: 'left' | 'right' }) => TabBarExtraContent; } const getTabSize = (tab: HTMLElement, containerRect: { x: number; y: number }) => { // tabListRef const { offsetWidth, offsetHeight, offsetTop, offsetLeft } = tab; const { width, height, x, y } = tab.getBoundingClientRect(); // Use getBoundingClientRect to avoid decimal inaccuracy if (Math.abs(width - offsetWidth) < 1) { return [width, height, x - containerRect.x, y - containerRect.y]; } return [offsetWidth, offsetHeight, offsetLeft, offsetTop]; }; // const getSize = (refObj: ShallowRef) => { // const { offsetWidth = 0, offsetHeight = 0 } = refObj.value || {}; // // Use getBoundingClientRect to avoid decimal inaccuracy // if (refObj.value) { // const { width, height } = refObj.value.getBoundingClientRect(); // if (Math.abs(width - offsetWidth) < 1) { // return [width, height]; // } // } // return [offsetWidth, offsetHeight]; // }; export default defineComponent({ compatConfig: { MODE: 3 }, name: 'TabNavList', inheritAttrs: false, props: tabNavListProps(), slots: Object as CustomSlotsType<{ moreIcon?: any; leftExtra?: any; rightExtra?: any; tabBarExtraContent?: any; default?: any; }>, emits: ['tabClick', 'tabScroll'], setup(props, { attrs, slots }) { const { tabs, prefixCls } = useInjectTabs(); const tabsWrapperRef = shallowRef(); const tabListRef = shallowRef(); const operationsRef = shallowRef<{ $el: HTMLDivElement }>(); const innerAddButtonRef = shallowRef(); const [setRef, btnRefs] = 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 [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(tabs, tabSizes); // ========================== Util ========================= const operationsHiddenClassName = computed(() => `${prefixCls.value}-nav-operations-hidden`); const transformMin = shallowRef(0); const transformMax = shallowRef(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 = shallowRef(); const [lockAnimation, setLockAnimation] = useState(); const doLockAnimation = () => { setLockAnimation(Date.now()); }; const clearTouchMoving = () => { 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 = 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 = shallowRef(0); const visibleEnd = shallowRef(0); watchEffect(() => { let unit: 'width' | 'height'; let position: 'left' | 'top' | 'right'; let transformSize: number; let basicSize: number; let tabContentSize: number; let addSize: number; const tabOffsetsValue = tabOffsets.value; if (['top', 'bottom'].includes(props.tabPosition)) { unit = 'width'; basicSize = wrapperWidth.value; tabContentSize = wrapperScrollWidth.value; addSize = addWidth.value; position = props.rtl ? 'right' : 'left'; transformSize = Math.abs(transformLeft.value); } else { unit = 'height'; basicSize = wrapperHeight.value; tabContentSize = wrapperScrollWidth.value; addSize = addHeight.value; position = 'top'; transformSize = -transformTop.value; } let mergedBasicSize = basicSize; if (tabContentSize + addSize > basicSize && tabContentSize < basicSize) { mergedBasicSize = basicSize - addSize; } const tabsVal = tabs.value; if (!tabsVal.length) { return ([visibleStart.value, visibleEnd.value] = [0, 0]); } const len = tabsVal.length; let endIndex = len; for (let i = 0; i < len; i += 1) { const offset = tabOffsetsValue.get(tabsVal[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 = tabOffsetsValue.get(tabsVal[i].key) || DEFAULT_SIZE; if (offset[position] < transformSize) { startIndex = i + 1; break; } } return ([visibleStart.value, visibleEnd.value] = [startIndex, endIndex]); }); const updateTabSizes = () => { setTabSizes(() => { const newSizes: TabSizeMap = new Map(); const listRect = tabListRef.value?.getBoundingClientRect(); tabs.value.forEach(({ key }) => { const btnRef = btnRefs.value.get(key); const btnNode = (btnRef as any)?.$el || btnRef; if (btnNode) { const [width, height, left, top] = getTabSize(btnNode, listRect); newSizes.set(key, { width, height, left, top }); } }); return newSizes; }); }; watch( () => tabs.value.map(tab => tab.key).join('%%'), () => { updateTabSizes(); }, { flush: 'post' }, ); const onListHolderResize = () => { // Update wrapper records const offsetWidth = tabsWrapperRef.value?.offsetWidth || 0; const offsetHeight = tabsWrapperRef.value?.offsetHeight || 0; const addDom = innerAddButtonRef.value?.$el || {}; const newAddWidth = addDom.offsetWidth || 0; const newAddHeight = addDom.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); // Update buttons records updateTabSizes(); }; // ======================== Dropdown ======================= const hiddenTabs = computed(() => [ ...tabs.value.slice(0, visibleStart.value), ...tabs.value.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 = shallowRef(); const cleanInkBarRaf = () => { raf.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 = raf(() => { setInkStyle(newInkStyle); }); }); watch( [() => props.activeKey, activeTabOffset, tabOffsets, tabPositionTopOrBottom], () => { scrollToTab(); }, { flush: 'post' }, ); watch( [() => props.rtl, () => props.tabBarGutter, () => props.activeKey, () => tabs.value], () => { onListHolderResize(); }, { flush: 'post' }, ); const ExtraContent = ({ position, prefixCls, extra }: ExtraContentProps) => { if (!extra) return null; const content = extra?.({ position }); return content ?
{content}
: null; }; onBeforeUnmount(() => { clearTouchMoving(); cleanInkBarRaf(); }); return () => { const { id, animated, activeKey, rtl, editable, locale, tabPosition, tabBarGutter, onTabClick, } = props; const { class: className, style } = attrs; const pre = prefixCls.value; // ========================= Render ======================== const hasDropdown = !!hiddenTabs.value.length; const wrapPrefix = `${pre}-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.value.map((tab, i) => { const { key } = tab; return ( { onTabClick(key, e); }} 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}
); }; }, });