diff --git a/src/mixins/touch.js b/src/mixins/touch.js
deleted file mode 100644
index 45f9190da..000000000
--- 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 8da68ff27..bf70d76f5 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;
- },
-
- 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 (
-
- );
+ if (!parent) {
+ throw new Error('[Vant] Tabs: must be used inside ');
}
- return (
-
- {Content}
-
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {Content}
+
+ );
+ };
},
});
diff --git a/src/tabs/index.js b/src/tabs/index.js
index 714536605..f427f6733 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 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) => (
{
- 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?.()}
);