import { ref, watch, computed, reactive, nextTick, PropType, ComputedRef, onActivated, InjectionKey, CSSProperties, defineComponent, ExtractPropTypes, ComponentPublicInstance, } from 'vue'; // Utils import { isDef, addUnit, isHidden, unitToPx, truthProp, getVisibleTop, getElementTop, createNamespace, getVisibleHeight, setRootScrollTop, ComponentInstance, } from '../utils'; import { scrollLeftTo, scrollTopTo } from './utils'; import { BORDER_TOP_BOTTOM } from '../utils/constant'; import { callInterceptor, Interceptor } from '../utils/interceptor'; // Composables import { useChildren, useWindowSize, useScrollParent, useEventListener, onMountedOrActivated, } from '@vant/use'; import { route, RouteProps } from '../composables/use-route'; import { useRefs } from '../composables/use-refs'; import { useExpose } from '../composables/use-expose'; import { onPopupReopen } from '../composables/on-popup-reopen'; // Components import { Sticky } from '../sticky'; import TabsTitle from './TabsTitle'; import TabsContent from './TabsContent'; const [name, bem] = createNamespace('tabs'); export type TabsType = 'line' | 'card'; const props = { color: String, border: Boolean, sticky: Boolean, animated: Boolean, ellipsis: truthProp, swipeable: Boolean, scrollspy: Boolean, background: String, lazyRender: truthProp, lineWidth: [Number, String], lineHeight: [Number, String], beforeChange: Function as PropType, titleActiveColor: String, titleInactiveColor: String, type: { type: String as PropType, default: 'line', }, active: { type: [Number, String], default: 0, }, duration: { type: [Number, String], default: 0.3, }, offsetTop: { type: [Number, String], default: 0, }, swipeThreshold: { type: [Number, String], default: 5, }, }; export type TabsProvide = { props: ExtractPropTypes; setLine: () => void; onRendered: (name: string | number, title?: string) => void; scrollIntoView: (immediate?: boolean) => void; currentName: ComputedRef; }; export const TABS_KEY: InjectionKey = Symbol(name); export default defineComponent({ name, props, emits: [ 'click', 'change', 'scroll', 'disabled', 'rendered', 'click-tab', 'update:active', ], setup(props, { emit, slots }) { let tabHeight: number; let lockScroll: boolean; let stickyFixed: boolean; const root = ref(); const navRef = ref(); const wrapRef = ref(); const windowSize = useWindowSize(); const scroller = useScrollParent(root); const [titleRefs, setTitleRefs] = useRefs(); const { children, linkChildren } = useChildren(TABS_KEY); const state = reactive({ inited: false, position: '', lineStyle: {} as CSSProperties, currentIndex: -1, }); // whether the nav is scrollable const scrollable = computed( () => children.length > props.swipeThreshold || !props.ellipsis ); const navStyle = computed(() => ({ borderColor: props.color, background: props.background, })); const getTabName = ( tab: ComponentInstance, index: number ): number | string => tab.name ?? index; const currentName = computed(() => { const activeTab = children[state.currentIndex]; if (activeTab) { return getTabName(activeTab, state.currentIndex); } }); const offsetTopPx = computed(() => unitToPx(props.offsetTop)); const scrollOffset = computed(() => { if (props.sticky) { return offsetTopPx.value + tabHeight; } return 0; }); // scroll active tab into view const scrollIntoView = (immediate?: boolean) => { const nav = navRef.value; const titles = titleRefs.value; if (!scrollable.value || !nav || !titles || !titles[state.currentIndex]) { return; } const title = titles[state.currentIndex].$el; const to = title.offsetLeft - (nav.offsetWidth - title.offsetWidth) / 2; scrollLeftTo(nav, to, immediate ? 0 : +props.duration); }; // update nav bar style const setLine = () => { const shouldAnimate = state.inited; nextTick(() => { const titles = titleRefs.value; if ( !titles || !titles[state.currentIndex] || props.type !== 'line' || isHidden(root.value!) ) { return; } const title = titles[state.currentIndex].$el; const { lineWidth, lineHeight } = props; const left = title.offsetLeft + title.offsetWidth / 2; const lineStyle: CSSProperties = { width: addUnit(lineWidth), backgroundColor: props.color, transform: `translateX(${left}px) translateX(-50%)`, }; if (shouldAnimate) { lineStyle.transitionDuration = `${props.duration}s`; } if (isDef(lineHeight)) { const height = addUnit(lineHeight); lineStyle.height = height; lineStyle.borderRadius = height; } state.lineStyle = lineStyle; }); }; const findAvailableTab = (index: number) => { const diff = index < state.currentIndex ? -1 : 1; while (index >= 0 && index < children.length) { if (!children[index].disabled) { return index; } index += diff; } }; const setCurrentIndex = (currentIndex: number) => { const newIndex = findAvailableTab(currentIndex); if (!isDef(newIndex)) { return; } const newTab = children[newIndex]; const newName = getTabName(newTab, newIndex); const shouldEmitChange = state.currentIndex !== null; state.currentIndex = newIndex; if (newName !== props.active) { emit('update:active', newName); if (shouldEmitChange) { emit('change', newName, newTab.title); } } }; // correct the index of active tab const setCurrentIndexByName = (name: number | string) => { const matched = children.find( (tab, index) => getTabName(tab, index) === name ); const index = matched ? children.indexOf(matched) : 0; setCurrentIndex(index); }; const scrollToCurrentContent = (immediate = false) => { if (props.scrollspy) { const target = children[state.currentIndex].$el; if (target && scroller.value) { const to = getElementTop(target, scroller.value) - scrollOffset.value; lockScroll = true; scrollTopTo( scroller.value, to, immediate ? 0 : +props.duration, () => { lockScroll = false; } ); } } }; // emit event when clicked const onClickTab = ( item: ComponentInstance, index: number, event: MouseEvent ) => { const { title, disabled } = children[index]; const name = getTabName(children[index], index); if (disabled) { emit('disabled', name, title); } else { callInterceptor({ interceptor: props.beforeChange, args: [name], done: () => { setCurrentIndex(index); scrollToCurrentContent(); }, }); emit('click-tab', { name, title, event, }); // @deprecated // should be removed in next major version emit('click', name, title); route(item as ComponentPublicInstance); } }; const onStickyScroll = (params: { isFixed: boolean; scrollTop: number; }) => { stickyFixed = params.isFixed; emit('scroll', params); }; const scrollTo = (name: number | string) => { nextTick(() => { setCurrentIndexByName(name); scrollToCurrentContent(true); }); }; const getCurrentIndexOnScroll = () => { for (let index = 0; index < children.length; index++) { const top = getVisibleTop(children[index].$el); if (top > scrollOffset.value) { return index === 0 ? 0 : index - 1; } } return children.length - 1; }; const onScroll = () => { if (props.scrollspy && !lockScroll) { const index = getCurrentIndexOnScroll(); setCurrentIndex(index); } }; const renderNav = () => children.map((item, index) => ( { onClickTab(item, index, event); }} /> )); const renderHeader = () => { const { type, border } = props; return (
{slots['nav-left']?.()} {renderNav()} {type === 'line' && (
)} {slots['nav-right']?.()}
); }; watch([() => props.color, windowSize.width], setLine); watch( () => props.active, (value) => { if (value !== currentName.value) { setCurrentIndexByName(value); } } ); watch( () => children.length, () => { if (state.inited) { setCurrentIndexByName(props.active); setLine(); nextTick(() => { scrollIntoView(true); }); } } ); watch( () => state.currentIndex, () => { scrollIntoView(); setLine(); // scroll to correct position if (stickyFixed && !props.scrollspy) { setRootScrollTop( Math.ceil(getElementTop(root.value!) - offsetTopPx.value) ); } } ); const init = () => { setCurrentIndexByName(props.active); nextTick(() => { state.inited = true; tabHeight = getVisibleHeight(wrapRef.value!); scrollIntoView(true); }); }; const onRendered = (name: string | number, title?: string) => emit('rendered', name, title); useExpose({ resize: setLine, scrollTo, }); onActivated(setLine); onPopupReopen(setLine); onMountedOrActivated(init); useEventListener('scroll', onScroll, { target: scroller }); linkChildren({ props, setLine, onRendered, currentName, scrollIntoView, }); return () => (
{props.sticky ? ( {renderHeader()} {slots['nav-bottom']?.()} ) : ( [renderHeader(), slots['nav-bottom']?.()] )} {slots.default?.()}
); }, });