refactor(Tabs): refactor with composition api

This commit is contained in:
chenjiahan 2020-09-22 20:03:23 +08:00
parent 8ef46764c9
commit 882e3ef5e7
3 changed files with 343 additions and 384 deletions

View File

@ -1,61 +0,0 @@
import { on } from '../utils/dom/event';
const MIN_DISTANCE = 10;
function getDirection(x, y) {
if (x > y && x > MIN_DISTANCE) {
return 'horizontal';
}
if (y > x && y > MIN_DISTANCE) {
return 'vertical';
}
return '';
}
export const TouchMixin = {
data() {
return { direction: '' };
},
methods: {
touchStart(event) {
this.resetTouchStatus();
this.startX = event.touches[0].clientX;
this.startY = event.touches[0].clientY;
},
touchMove(event) {
const touch = event.touches[0];
this.deltaX = touch.clientX - this.startX;
this.deltaY = touch.clientY - this.startY;
this.offsetX = Math.abs(this.deltaX);
this.offsetY = Math.abs(this.deltaY);
this.direction =
this.direction || getDirection(this.offsetX, this.offsetY);
},
resetTouchStatus() {
this.direction = '';
this.deltaX = 0;
this.deltaY = 0;
this.offsetX = 0;
this.offsetY = 0;
},
// avoid Vue 2.6 event bubble issues by manually binding events
// https://github.com/youzan/vant/issues/3015
bindTouchEvent(el) {
const { onTouchStart, onTouchMove, onTouchEnd } = this;
on(el, 'touchstart', onTouchStart);
on(el, 'touchmove', onTouchMove);
if (onTouchEnd) {
on(el, 'touchend', onTouchEnd);
on(el, 'touchcancel', onTouchEnd);
}
},
},
};

View File

@ -1,12 +1,14 @@
import { ref, watch, nextTick } from 'vue';
import { createNamespace } from '../utils';
import { ChildrenMixin } from '../mixins/relation';
import { TABS_KEY } from '../tabs';
// Composition
import { routeProps } from '../composition/use-route';
import { useParent } from '../composition/use-relation';
const [createComponent, bem] = createNamespace('tab');
export default createComponent({
mixins: [ChildrenMixin('vanTabs')],
props: {
...routeProps,
dot: Boolean,
@ -17,63 +19,74 @@ export default createComponent({
disabled: Boolean,
},
data() {
return {
inited: false,
};
},
setup(props, { slots }) {
const root = ref();
const inited = ref(false);
const { parent, index } = useParent(TABS_KEY, {
getRoot: () => root.value,
props,
slots,
});
computed: {
computedName() {
return this.name ?? this.index;
},
isActive() {
const active = this.computedName === this.parent.currentName;
if (active) {
this.inited = true;
}
return active;
},
},
watch: {
title() {
this.parent.setLine();
},
inited(val) {
if (this.parent.lazyRender && val) {
this.$nextTick(() => {
this.parent.$emit('rendered', this.computedName, this.title);
});
}
},
},
render() {
const { parent, isActive } = this;
const shouldRender = this.inited || parent.scrollspy || !parent.lazyRender;
const show = parent.scrollspy || isActive;
const Content = shouldRender ? this.$slots.default?.() : null;
if (parent.animated) {
return (
<div
role="tabpanel"
aria-hidden={!isActive}
class={bem('pane-wrapper', { inactive: !isActive })}
>
<div class={bem('pane')}>{Content}</div>
</div>
);
if (!parent) {
throw new Error('[Vant] Tabs: <van-tab> must be used inside <van-tabs>');
}
return (
<div vShow={show} role="tabpanel" class={bem('pane')}>
{Content}
</div>
const getName = () => props.name ?? index.value;
const init = () => {
inited.value = true;
if (parent.props.lazyRender) {
nextTick(() => {
parent.emit('rendered', getName(), props.title);
});
}
};
const isActive = () => {
const active = getName() === parent.currentName.value;
if (active && !inited.value) {
init();
}
return active;
};
watch(
() => props.title,
() => {
parent.setLine();
}
);
return () => {
const { animated, scrollspy, lazyRender } = parent.props;
const active = isActive();
const show = scrollspy || active;
const shouldRender = inited.value || scrollspy || !lazyRender;
const Content = shouldRender ? slots.default?.() : null;
if (animated) {
return (
<div
ref={root}
role="tabpanel"
aria-hidden={!active}
class={bem('pane-wrapper', { inactive: !active })}
>
<div class={bem('pane')}>{Content}</div>
</div>
);
}
return (
<div v-show={show} ref={root} role="tabpanel" class={bem('pane')}>
{Content}
</div>
);
};
},
});

View File

@ -1,23 +1,33 @@
import {
ref,
watch,
provide,
computed,
reactive,
nextTick,
onMounted,
onActivated,
} from 'vue';
// Utils
import { createNamespace, isDef, addUnit } from '../utils';
import { scrollLeftTo, scrollTopTo } from './utils';
import { route } from '../composition/use-route';
import { isHidden } from '../utils/dom/style';
import { on, off } from '../utils/dom/event';
import { unitToPx } from '../utils/format/unit';
import { BORDER_TOP_BOTTOM } from '../utils/constant';
import { callInterceptor } from '../utils/interceptor';
import {
getScroller,
getVisibleTop,
getElementTop,
getVisibleHeight,
setRootScrollTop,
} from '../utils/dom/scroll';
// Mixins
import { ParentMixin } from '../mixins/relation';
import { BindEventMixin } from '../mixins/bind-event';
// Composition
import { useRefs } from '../composition/use-refs';
import { useExpose } from '../composition/use-expose';
import { useWindowSize, useScrollParent, useEventListener } from '@vant/use';
// Components
import Sticky from '../sticky';
@ -26,22 +36,9 @@ import TabsContent from './TabsContent';
const [createComponent, bem] = createNamespace('tabs');
export const TABS_KEY = 'vanTabs';
export default createComponent({
mixins: [
ParentMixin('vanTabs'),
BindEventMixin(function (bind) {
if (!this.scroller) {
this.scroller = getScroller(this.$el);
}
bind(window, 'resize', this.resize, true);
if (this.scrollspy) {
bind(this.scroller, 'scroll', this.onScroll, true);
}
}),
],
props: {
color: String,
border: Boolean,
@ -87,139 +84,109 @@ export default createComponent({
emits: ['click', 'change', 'scroll', 'disabled', 'rendered', 'update:active'],
data() {
this.titleRefs = [];
setup(props, { emit, slots }) {
let inited;
let tabHeight;
let lockScroll;
let stickyFixed;
return {
const root = ref();
const navRef = ref();
const wrapRef = ref();
const windowSize = useWindowSize();
const scroller = useScrollParent(root);
const [titleRefs, setTitleRefs] = useRefs();
const children = reactive([]);
const state = reactive({
position: '',
currentIndex: -1,
lineStyle: {
backgroundColor: this.color,
backgroundColor: props.color,
},
};
},
});
computed: {
// whether the nav is scrollable
scrollable() {
return this.children.length > this.swipeThreshold || !this.ellipsis;
},
const scrollable = computed(
() => children.length > props.swipeThreshold || !props.ellipsis
);
navStyle() {
return {
borderColor: this.color,
background: this.background,
};
},
const navStyle = computed(() => ({
borderColor: props.color,
background: props.background,
}));
currentName() {
const activeTab = this.children[this.currentIndex];
const getTabName = (tab, index) => tab.props.name ?? index;
const currentName = computed(() => {
const activeTab = children[state.currentIndex];
if (activeTab) {
return activeTab.computedName;
return getTabName(activeTab, state.currentIndex);
}
},
});
offsetTopPx() {
return unitToPx(this.offsetTop);
},
const offsetTopPx = computed(() => unitToPx(props.offsetTop));
scrollOffset() {
if (this.sticky) {
return this.offsetTopPx + this.tabHeight;
const scrollOffset = computed(() => {
if (props.sticky) {
return offsetTopPx.value + tabHeight;
}
return 0;
},
},
});
watch: {
color: 'setLine',
// scroll active tab into view
const scrollIntoView = (immediate) => {
const nav = navRef.value;
const titles = titleRefs.value;
active(name) {
if (name !== this.currentName) {
this.setCurrentIndexByName(name);
if (!scrollable.value || !titles || !titles[state.currentIndex]) {
return;
}
},
children() {
this.setCurrentIndexByName(this.currentName || this.active);
this.setLine();
const title = titles[state.currentIndex].$el;
const to = title.offsetLeft - (nav.offsetWidth - title.offsetWidth) / 2;
this.$nextTick(() => {
this.scrollIntoView(true);
scrollLeftTo(nav, to, immediate ? 0 : +props.duration);
};
const init = () => {
nextTick(() => {
inited = true;
tabHeight = getVisibleHeight(wrapRef.value);
scrollIntoView(true);
});
},
currentIndex() {
this.scrollIntoView();
this.setLine();
// scroll to correct position
if (this.stickyFixed && !this.scrollspy) {
setRootScrollTop(Math.ceil(getElementTop(this.$el) - this.offsetTopPx));
}
},
scrollspy(val) {
if (val) {
on(this.scroller, 'scroll', this.onScroll, true);
} else {
off(this.scroller, 'scroll', this.onScroll);
}
},
},
mounted() {
this.init();
},
activated() {
this.init();
this.setLine();
},
methods: {
// @exposed-api
resize() {
this.setLine();
},
init() {
this.$nextTick(() => {
this.inited = true;
this.tabHeight = getVisibleHeight(this.$refs.wrap);
this.scrollIntoView(true);
});
},
};
// update nav bar style
setLine() {
const shouldAnimate = this.inited;
const setLine = () => {
const shouldAnimate = inited;
this.$nextTick(() => {
const { titleRefs } = this;
nextTick(() => {
const titles = titleRefs.value;
if (
!titleRefs ||
!titleRefs[this.currentIndex] ||
this.type !== 'line' ||
isHidden(this.$el)
!titles ||
!titles[state.currentIndex] ||
props.type !== 'line' ||
isHidden(root.value)
) {
return;
}
const title = titleRefs[this.currentIndex].$el;
const { lineWidth, lineHeight } = this;
const title = titles[state.currentIndex].$el;
const { lineWidth, lineHeight } = props;
const left = title.offsetLeft + title.offsetWidth / 2;
const lineStyle = {
width: addUnit(lineWidth),
backgroundColor: this.color,
backgroundColor: props.color,
transform: `translateX(${left}px) translateX(-50%)`,
};
if (shouldAnimate) {
lineStyle.transitionDuration = `${this.duration}s`;
lineStyle.transitionDuration = `${props.duration}s`;
}
if (isDef(lineHeight)) {
@ -228,207 +195,247 @@ export default createComponent({
lineStyle.borderRadius = height;
}
this.lineStyle = lineStyle;
state.lineStyle = lineStyle;
});
},
};
// correct the index of active tab
setCurrentIndexByName(name) {
const matched = this.children.filter((tab) => tab.computedName === name);
const defaultIndex = (this.children[0] || {}).index || 0;
this.setCurrentIndex(matched.length ? matched[0].index : defaultIndex);
},
const findAvailableTab = (index) => {
const diff = index < state.currentIndex ? -1 : 1;
setCurrentIndex(currentIndex) {
currentIndex = this.findAvailableTab(currentIndex);
if (isDef(currentIndex) && currentIndex !== this.currentIndex) {
const shouldEmitChange = this.currentIndex !== null;
this.currentIndex = currentIndex;
this.$emit('update:active', this.currentName);
if (shouldEmitChange) {
this.$emit(
'change',
this.currentName,
this.children[currentIndex].title
);
}
}
},
findAvailableTab(index) {
const diff = index < this.currentIndex ? -1 : 1;
while (index >= 0 && index < this.children.length) {
if (!this.children[index].disabled) {
while (index >= 0 && index < children.length) {
if (!children[index].props.disabled) {
return index;
}
index += diff;
}
},
};
const setCurrentIndex = (currentIndex) => {
currentIndex = findAvailableTab(currentIndex);
if (isDef(currentIndex) && currentIndex !== state.currentIndex) {
const shouldEmitChange = state.currentIndex !== null;
state.currentIndex = currentIndex;
emit('update:active', currentName.value);
if (shouldEmitChange) {
emit('change', currentName.value, children[currentIndex].props.title);
}
}
};
// correct the index of active tab
const setCurrentIndexByName = (name) => {
const matched = children.filter(
(tab, index) => getTabName(tab, index) === name
);
const index = matched[0] ? children.indexOf(matched[0]) : 0;
setCurrentIndex(index);
};
const scrollToCurrentContent = (immediate = false) => {
if (props.scrollspy) {
const target = children[state.currentIndex];
const el = target?.getRoot();
if (el) {
const to = getElementTop(el, scroller.value) - scrollOffset.value;
lockScroll = true;
scrollTopTo(
scroller.value,
to,
immediate ? 0 : +props.duration,
() => {
lockScroll = false;
}
);
}
}
};
// emit event when clicked
onClick(item, index) {
const { title, disabled, computedName } = this.children[index];
const onClick = (item, index) => {
const { title, disabled } = children[index].props;
const name = getTabName(children[index], index);
if (disabled) {
this.$emit('disabled', computedName, title);
emit('disabled', name, title);
} else {
callInterceptor({
interceptor: this.beforeChange,
args: [computedName],
interceptor: props.beforeChange,
args: [name],
done: () => {
this.setCurrentIndex(index);
this.scrollToCurrentContent();
setCurrentIndex(index);
scrollToCurrentContent();
},
});
this.$emit('click', computedName, title);
emit('click', name, title);
route(item.$router, item);
}
},
};
// scroll active tab into view
scrollIntoView(immediate) {
const { titleRefs } = this;
const onStickyScroll = (params) => {
stickyFixed = params.isFixed;
emit('scroll', params);
};
if (!this.scrollable || !titleRefs || !titleRefs[this.currentIndex]) {
return;
}
const { nav } = this.$refs;
const title = titleRefs[this.currentIndex].$el;
const to = title.offsetLeft - (nav.offsetWidth - title.offsetWidth) / 2;
scrollLeftTo(nav, to, immediate ? 0 : +this.duration);
},
onSticktScroll(params) {
this.stickyFixed = params.isFixed;
this.$emit('scroll', params);
},
// @exposed-api
scrollTo(name) {
this.$nextTick(() => {
this.setCurrentIndexByName(name);
this.scrollToCurrentContent(true);
const scrollTo = (name) => {
nextTick(() => {
setCurrentIndexByName(name);
scrollToCurrentContent(true);
});
},
scrollToCurrentContent(immediate = false) {
if (this.scrollspy) {
const target = this.children[this.currentIndex];
const el = target?.$el;
if (el) {
const to = getElementTop(el, this.scroller) - this.scrollOffset;
this.lockScroll = true;
scrollTopTo(this.scroller, to, immediate ? 0 : +this.duration, () => {
this.lockScroll = false;
});
}
}
},
onScroll() {
if (this.scrollspy && !this.lockScroll) {
const index = this.getCurrentIndexOnScroll();
this.setCurrentIndex(index);
}
},
getCurrentIndexOnScroll() {
const { children } = this;
};
const getCurrentIndexOnScroll = () => {
for (let index = 0; index < children.length; index++) {
const top = getVisibleTop(children[index].$el);
const top = getVisibleTop(children[index].getRoot());
if (top > this.scrollOffset) {
if (top > scrollOffset.value) {
return index === 0 ? 0 : index - 1;
}
}
return children.length - 1;
},
},
};
render() {
const { type, animated, scrollable } = this;
const onScroll = () => {
if (props.scrollspy && !lockScroll) {
const index = getCurrentIndexOnScroll();
setCurrentIndex(index);
}
};
const Nav = this.children.map((item, index) => {
return (
const renderNav = () =>
children.map((item, index) => (
<TabsTitle
ref={(val) => {
this.titleRefs[index] = val;
}}
dot={item.dot}
type={type}
badge={item.badge}
title={item.title}
color={this.color}
style={item.titleStyle}
isActive={index === this.currentIndex}
disabled={item.disabled}
scrollable={scrollable}
renderTitle={item.$slots.title}
activeColor={this.titleActiveColor}
inactiveColor={this.titleInactiveColor}
ref={setTitleRefs(index)}
dot={item.props.dot}
type={props.type}
badge={item.props.badge}
title={item.props.title}
color={props.color}
style={item.props.titleStyle}
isActive={index === state.currentIndex}
disabled={item.props.disabled}
scrollable={scrollable.value}
renderTitle={item.slots.title}
activeColor={props.titleActiveColor}
inactiveColor={props.titleInactiveColor}
onClick={() => {
this.onClick(item, index);
onClick(item, index);
}}
/>
);
});
));
const Wrap = (
<div
ref="wrap"
class={[
bem('wrap', { scrollable }),
{ [BORDER_TOP_BOTTOM]: type === 'line' && this.border },
]}
>
const renderHeader = () => {
const { type, border } = props;
return (
<div
ref="nav"
role="tablist"
class={bem('nav', [type, { complete: this.scrollable }])}
style={this.navStyle}
ref={wrapRef}
class={[
bem('wrap', { scrollable: scrollable.value }),
{ [BORDER_TOP_BOTTOM]: type === 'line' && border },
]}
>
{this.$slots['nav-left']?.()}
{Nav}
{type === 'line' && (
<div class={bem('line')} style={this.lineStyle} />
)}
{this.$slots['nav-right']?.()}
<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>
</div>
);
};
watch([() => props.color, windowSize.width], setLine);
watch(
() => props.active,
(value) => {
if (value !== currentName.value) {
setCurrentIndexByName(name);
}
}
);
return (
<div class={bem([type])}>
{this.sticky ? (
watch(children, () => {
setCurrentIndexByName(currentName.value || 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)
);
}
}
);
onMounted(init);
onActivated(() => {
init();
setLine();
});
useExpose({
resize: setLine,
scrollTo,
});
useEventListener('scroll', onScroll, { target: scroller.value });
provide(TABS_KEY, {
emit,
props,
setLine,
children,
currentName,
});
return () => (
<div ref={root} class={bem([props.type])}>
{props.sticky ? (
<Sticky
container={this.$el}
offsetTop={this.offsetTop}
onScroll={this.onSticktScroll}
container={root.value}
offsetTop={offsetTopPx.value}
onScroll={onStickyScroll}
>
{Wrap}
{renderHeader()}
</Sticky>
) : (
Wrap
renderHeader()
)}
<TabsContent
count={this.children.length}
animated={animated}
duration={this.duration}
swipeable={this.swipeable}
currentIndex={this.currentIndex}
onChange={this.setCurrentIndex}
count={children.length}
animated={props.animated}
duration={props.duration}
swipeable={props.swipeable}
currentIndex={state.currentIndex}
onChange={setCurrentIndex}
>
{this.$slots.default?.()}
{slots.default?.()}
</TabsContent>
</div>
);