From 882e3ef5e787e587909bde1064f5dabe3d66ad72 Mon Sep 17 00:00:00 2001 From: chenjiahan Date: Tue, 22 Sep 2020 20:03:23 +0800 Subject: [PATCH] refactor(Tabs): refactor with composition api --- src/mixins/touch.js | 61 ----- src/tab/index.js | 111 ++++++---- src/tabs/index.js | 529 ++++++++++++++++++++++---------------------- 3 files changed, 330 insertions(+), 371 deletions(-) delete mode 100644 src/mixins/touch.js diff --git a/src/mixins/touch.js b/src/mixins/touch.js deleted file mode 100644 index 45f9190da44..00000000000 --- a/src/mixins/touch.js +++ /dev/null @@ -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); - } - }, - }, -}; diff --git a/src/tab/index.js b/src/tab/index.js index 8da68ff2711..bf70d76f541 100644 --- a/src/tab/index.js +++ b/src/tab/index.js @@ -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; - }, + if (!parent) { + throw new Error('[Vant] Tabs: must be used inside '); + } + + const getName = () => props.name ?? index.value; - isActive() { - const active = this.computedName === this.parent.currentName; + const init = () => { + inited.value = true; - if (active) { - this.inited = true; + if (parent.props.lazyRender) { + nextTick(() => { + parent.emit('rendered', getName(), props.title); + }); } - return active; - }, - }, + }; - watch: { - title() { - this.parent.setLine(); - }, + const isActive = () => { + const active = getName() === parent.currentName.value; - inited(val) { - if (this.parent.lazyRender && val) { - this.$nextTick(() => { - this.parent.$emit('rendered', this.computedName, this.title); - }); + if (active && !inited.value) { + init(); } - }, - }, - 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; + 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 ( +
+
{Content}
+
+ ); + } - if (parent.animated) { return ( -
-
{Content}
+
+ {Content}
); - } - - return ( -
- {Content} -
- ); + }; }, }); diff --git a/src/tabs/index.js b/src/tabs/index.js index 714536605fb..f427f673320 100644 --- a/src/tabs/index.js +++ b/src/tabs/index.js @@ -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 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); - } - }), - ], +export const TABS_KEY = 'vanTabs'; +export default createComponent({ 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; + + const root = ref(); + const navRef = ref(); + const wrapRef = ref(); - return { + 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, + })); + + const getTabName = (tab, index) => tab.props.name ?? index; - currentName() { - const activeTab = this.children[this.currentIndex]; + 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', - - active(name) { - if (name !== this.currentName) { - this.setCurrentIndexByName(name); - } - }, - - children() { - this.setCurrentIndexByName(this.currentName || this.active); - this.setLine(); - - this.$nextTick(() => { - this.scrollIntoView(true); - }); - }, - - currentIndex() { - this.scrollIntoView(); - this.setLine(); + }); - // scroll to correct position - if (this.stickyFixed && !this.scrollspy) { - setRootScrollTop(Math.ceil(getElementTop(this.$el) - this.offsetTopPx)); - } - }, + // scroll active tab into view + const scrollIntoView = (immediate) => { + const nav = navRef.value; + const titles = titleRefs.value; - scrollspy(val) { - if (val) { - on(this.scroller, 'scroll', this.onScroll, true); - } else { - off(this.scroller, 'scroll', this.onScroll); + if (!scrollable.value || !titles || !titles[state.currentIndex]) { + return; } - }, - }, - mounted() { - this.init(); - }, - - activated() { - this.init(); - this.setLine(); - }, + const title = titles[state.currentIndex].$el; + const to = title.offsetLeft - (nav.offsetWidth - title.offsetWidth) / 2; - methods: { - // @exposed-api - resize() { - this.setLine(); - }, + scrollLeftTo(nav, to, immediate ? 0 : +props.duration); + }; - init() { - this.$nextTick(() => { - this.inited = true; - this.tabHeight = getVisibleHeight(this.$refs.wrap); - this.scrollIntoView(true); + const init = () => { + nextTick(() => { + inited = true; + tabHeight = getVisibleHeight(wrapRef.value); + 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); + while (index >= 0 && index < children.length) { + if (!children[index].props.disabled) { + return index; + } - if (isDef(currentIndex) && currentIndex !== this.currentIndex) { - const shouldEmitChange = this.currentIndex !== null; - this.currentIndex = currentIndex; - this.$emit('update:active', this.currentName); + 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) { - this.$emit( - 'change', - this.currentName, - this.children[currentIndex].title - ); + emit('change', currentName.value, children[currentIndex].props.title); } } - }, + }; - findAvailableTab(index) { - const diff = index < this.currentIndex ? -1 : 1; + // correct the index of active tab + const setCurrentIndexByName = (name) => { + const matched = children.filter( + (tab, index) => getTabName(tab, index) === name + ); - while (index >= 0 && index < this.children.length) { - if (!this.children[index].disabled) { - return index; - } + const index = matched[0] ? children.indexOf(matched[0]) : 0; + setCurrentIndex(index); + }; - index += diff; + 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; - - 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); - }, + const onStickyScroll = (params) => { + stickyFixed = params.isFixed; + 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) => ( { - 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 = ( -
+ const renderHeader = () => { + const { type, border } = props; + return (
- {this.$slots['nav-left']?.()} - {Nav} - {type === 'line' && ( -
- )} - {this.$slots['nav-right']?.()} +
+ {slots['nav-left']?.()} + {renderNav()} + {type === 'line' && ( +
+ )} + {slots['nav-right']?.()} +
-
+ ); + }; + + watch([() => props.color, windowSize.width], setLine); + + watch( + () => props.active, + (value) => { + if (value !== currentName.value) { + setCurrentIndexByName(name); + } + } ); - return ( -
- {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 () => ( +
+ {props.sticky ? ( - {Wrap} + {renderHeader()} ) : ( - Wrap + renderHeader() )} - {this.$slots.default?.()} + {slots.default?.()}
);