refactor: reorganize all components (#8303)
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
computed,
|
||||
reactive,
|
||||
nextTick,
|
||||
PropType,
|
||||
ComputedRef,
|
||||
onActivated,
|
||||
CSSProperties,
|
||||
defineComponent,
|
||||
ComponentPublicInstance,
|
||||
} from 'vue';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
isDef,
|
||||
addUnit,
|
||||
isHidden,
|
||||
unitToPx,
|
||||
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';
|
||||
|
||||
// Components
|
||||
import Sticky from '../sticky';
|
||||
import TabsTitle from './TabsTitle';
|
||||
import TabsContent from './TabsContent';
|
||||
|
||||
const [name, bem] = createNamespace('tabs');
|
||||
|
||||
export const TABS_KEY = Symbol(name);
|
||||
|
||||
export type TabType = 'line' | 'card';
|
||||
|
||||
export type TabsProvide = {
|
||||
props: {
|
||||
animated?: boolean;
|
||||
swipeable?: boolean;
|
||||
scrollspy?: boolean;
|
||||
lazyRender: boolean;
|
||||
};
|
||||
setLine: () => void;
|
||||
onRendered: (name: string | number, title?: string) => void;
|
||||
scrollIntoView: (immediate?: boolean) => void;
|
||||
currentName: ComputedRef<number | string | undefined>;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name,
|
||||
|
||||
props: {
|
||||
color: String,
|
||||
border: Boolean,
|
||||
sticky: Boolean,
|
||||
animated: Boolean,
|
||||
swipeable: Boolean,
|
||||
scrollspy: Boolean,
|
||||
background: String,
|
||||
lineWidth: [Number, String],
|
||||
lineHeight: [Number, String],
|
||||
beforeChange: Function as PropType<Interceptor>,
|
||||
titleActiveColor: String,
|
||||
titleInactiveColor: String,
|
||||
type: {
|
||||
type: String as PropType<TabType>,
|
||||
default: 'line',
|
||||
},
|
||||
active: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
ellipsis: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
duration: {
|
||||
type: [Number, String],
|
||||
default: 0.3,
|
||||
},
|
||||
offsetTop: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
lazyRender: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
swipeThreshold: {
|
||||
type: [Number, String],
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['click', 'change', 'scroll', 'disabled', 'rendered', 'update:active'],
|
||||
|
||||
setup(props, { emit, slots }) {
|
||||
let tabHeight: number;
|
||||
let lockScroll: boolean;
|
||||
let stickyFixed: boolean;
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
const navRef = ref<HTMLElement>();
|
||||
const wrapRef = ref<HTMLElement>();
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const scroller = useScrollParent(root);
|
||||
const [titleRefs, setTitleRefs] = useRefs<ComponentInstance>();
|
||||
const { children, linkChildren } = useChildren<ComponentInstance>(TABS_KEY);
|
||||
|
||||
const state = reactive({
|
||||
inited: false,
|
||||
position: '',
|
||||
currentIndex: -1,
|
||||
lineStyle: {
|
||||
backgroundColor: props.color,
|
||||
} as CSSProperties,
|
||||
});
|
||||
|
||||
// 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 onClick = (item: ComponentInstance, index: number) => {
|
||||
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', name, title);
|
||||
route(item as ComponentPublicInstance<RouteProps>);
|
||||
}
|
||||
};
|
||||
|
||||
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) => (
|
||||
<TabsTitle
|
||||
ref={setTitleRefs(index)}
|
||||
dot={item.dot}
|
||||
type={props.type}
|
||||
badge={item.badge}
|
||||
title={item.title}
|
||||
color={props.color}
|
||||
style={item.titleStyle}
|
||||
class={item.titleClass}
|
||||
isActive={index === state.currentIndex}
|
||||
disabled={item.disabled}
|
||||
scrollable={scrollable.value}
|
||||
renderTitle={item.$slots.title}
|
||||
activeColor={props.titleActiveColor}
|
||||
inactiveColor={props.titleInactiveColor}
|
||||
onClick={() => {
|
||||
onClick(item, index);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
const renderHeader = () => {
|
||||
const { type, border } = props;
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
class={[
|
||||
bem('wrap', { scrollable: scrollable.value }),
|
||||
{ [BORDER_TOP_BOTTOM]: type === 'line' && border },
|
||||
]}
|
||||
>
|
||||
<div
|
||||
ref={navRef}
|
||||
role="tablist"
|
||||
class={bem('nav', [type, { complete: scrollable.value }])}
|
||||
style={navStyle.value}
|
||||
>
|
||||
{slots['nav-left']?.()}
|
||||
{renderNav()}
|
||||
{type === 'line' && (
|
||||
<div class={bem('line')} style={state.lineStyle} />
|
||||
)}
|
||||
{slots['nav-right']?.()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
onMountedOrActivated(init);
|
||||
useEventListener('scroll', onScroll, { target: scroller.value });
|
||||
|
||||
linkChildren({
|
||||
props,
|
||||
setLine,
|
||||
onRendered,
|
||||
currentName,
|
||||
scrollIntoView,
|
||||
});
|
||||
|
||||
return () => (
|
||||
<div ref={root} class={bem([props.type])}>
|
||||
{props.sticky ? (
|
||||
<Sticky
|
||||
container={root.value}
|
||||
offsetTop={offsetTopPx.value}
|
||||
onScroll={onStickyScroll}
|
||||
>
|
||||
{renderHeader()}
|
||||
</Sticky>
|
||||
) : (
|
||||
renderHeader()
|
||||
)}
|
||||
<TabsContent
|
||||
count={children.length}
|
||||
inited={state.inited}
|
||||
animated={props.animated}
|
||||
duration={props.duration}
|
||||
swipeable={props.swipeable}
|
||||
lazyRender={props.lazyRender}
|
||||
currentIndex={state.currentIndex}
|
||||
onChange={setCurrentIndex}
|
||||
>
|
||||
{slots.default?.()}
|
||||
</TabsContent>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user