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

View File

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