UNPKG

fun-tab

Version:

A mobile touch-swappable tabs component for Vue3

491 lines (482 loc) 15.1 kB
import { defineComponent, ref, watch, provide, toRef, openBlock, createElementBlock, createElementVNode, renderSlot, inject, computed, onMounted, onUnmounted, normalizeStyle, createTextVNode, toDisplayString, createCommentVNode, nextTick, onBeforeUnmount } from 'vue'; const tabsInjectionKey = Symbol(); var _export_sfc = (sfc, props) => { const target = sfc.__vccOpts || sfc; for (const [key, val] of props) { target[key] = val; } return target; }; const _sfc_main$2 = defineComponent({ name: "FunTabBar", props: { modelValue: { type: [String, Number], default: "" }, activeColor: { type: String, default: "#1677ff" } }, emits: ["update:modelValue", "change"], setup(props, { emit }) { const activeValue = ref(props.modelValue); watch(() => props.modelValue, (v) => { activeValue.value = v; }); const setActiveValue = (value) => { activeValue.value = value; emit("update:modelValue", value); emit("change", value); }; provide(tabsInjectionKey, { activeValue, activeColor: toRef(props, "activeColor"), setActiveValue }); } }); const _hoisted_1$2 = { class: "fun-tab-bar" }; const _hoisted_2$1 = { class: "fun-tab-bar-wrap" }; function _sfc_render$2(_ctx, _cache, $props, $setup, $data, $options) { return openBlock(), createElementBlock("div", _hoisted_1$2, [ createElementVNode("div", _hoisted_2$1, [ renderSlot(_ctx.$slots, "default") ]) ]); } var FunTabBar = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["render", _sfc_render$2], ["__file", "tab-bar.vue"]]); const _sfc_main$1 = defineComponent({ name: "FunTabItem", props: { title: String, name: [String, Number], badge: [String, Number] }, setup(props) { const parent = inject(tabsInjectionKey); const el = ref(); const style = computed(() => { return parent?.activeValue.value === props.name ? { color: parent?.activeColor.value } : {}; }); const handleClick = () => { parent?.setActiveValue(props.name); }; const instance = { name: toRef(props, "name"), el }; onMounted(() => { parent.addItem?.(instance); }); onUnmounted(() => { parent.removeItem?.(instance); }); return { el, style, handleClick }; } }); const _hoisted_1$1 = { class: "fun-tab-item__wrap" }; const _hoisted_2 = { class: "fun-tab-item__label" }; const _hoisted_3 = { key: 0, class: "fun-tab-item__badge" }; function _sfc_render$1(_ctx, _cache, $props, $setup, $data, $options) { return openBlock(), createElementBlock("div", { ref: "el", style: normalizeStyle(_ctx.style), class: "fun-tab-item", onClick: _cache[0] || (_cache[0] = (...args) => _ctx.handleClick && _ctx.handleClick(...args)) }, [ createElementVNode("div", _hoisted_1$1, [ renderSlot(_ctx.$slots, "icon"), createElementVNode("div", _hoisted_2, [ renderSlot(_ctx.$slots, "default", {}, () => [ createTextVNode(toDisplayString(_ctx.title), 1) ]) ]), _ctx.badge ? (openBlock(), createElementBlock("div", _hoisted_3, toDisplayString(_ctx.badge), 1)) : createCommentVNode("v-if", true) ]) ], 4); } var FunTabItem = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["render", _sfc_render$1], ["__file", "tab-item.vue"]]); const win = window; function windowInit() { let lastTime = 0; const vendors = ["webkit", "moz"]; for (let x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { window.requestAnimationFrame = win[vendors[x] + "RequestAnimationFrame"]; window.cancelAnimationFrame = win[vendors[x] + "CancelAnimationFrame"] || win[vendors[x] + "CancelRequestAnimationFrame"]; } if (!window.requestAnimationFrame) { window.requestAnimationFrame = function(callback) { const currTime = Date.now(); const interval = currTime - lastTime; const timeToCall = Math.max(0, 16.7 - interval); const id = window.setTimeout(function() { callback(interval); }, timeToCall); lastTime = currTime + timeToCall; return id; }; } if (!window.cancelAnimationFrame) { window.cancelAnimationFrame = function(id) { clearTimeout(id); }; } } const _sfc_main = defineComponent({ name: "FunTabs", props: { modelValue: { type: [String, Number], default: "" }, lineWidth: { type: [Number, String], default: 30 }, lineHeight: { type: Number, default: 3 }, activeColor: { type: String, default: "#1677ff" }, additionalX: { type: Number, default: 50 }, reBoundExponent: { type: Number, default: 10, validator(v) { return v > 0; } }, inertialDuration: { type: Number, default: 1e3, validator(v) { return v > 0; } }, reBoundingDuration: { type: Number, default: 360 } }, emits: ["update:modelValue", "change"], setup(props, { emit, expose }) { let refreshTask = null; const children = []; const viewAreaRef = ref(); const listRef = ref(); const activeValue = ref(props.modelValue); const lineOffset = ref(0); const activeLineWidth = ref(0); const viewAreaWidth = ref(0); const offsetX = ref(0); const speed = ref(0); const touching = ref(false); const reBounding = ref(false); const translateX = ref(0); const startX = ref(0); const lastX = ref(0); const currentX = ref(0); const startMoveTime = ref(0); const endMoveTime = ref(0); const frameTime = ref(16.7); const frameStartTime = ref(0); const frameEndTime = ref(0); const inertiaFrame = ref(0); const zeroSpeed = ref(1e-3); const acceleration = ref(1e-3); const listStyle = computed(() => { const duration = reBounding.value && !touching.value ? props.reBoundingDuration : 0; return { transitionTimingFunction: reBounding.value ? "cubic-bezier(0.25, 0.46, 0.45, 0.94)" : "cubic-bezier(0.1, 0.57, 0.1, 1)", transitionDuration: `${duration}ms`, transform: `translate3d(${translateX.value}px, 0px, 0px)` }; }); const activeBarStyle = computed(() => { return { transition: `all 300ms`, width: `${activeLineWidth.value}px`, height: `${props.lineHeight}px`, transform: `translate3d(${lineOffset.value}px, 0, 0)`, backgroundColor: props.activeColor }; }); const isMoveLeft = computed(() => currentX.value <= startX.value); watch(() => props.modelValue, (v) => { activeValue.value = v; refreshState(); }); const refreshState = () => { if (refreshTask) { return; } refreshTask = new Promise((resolve) => { nextTick(() => { resize(); resolve(); refreshTask = null; }); }); }; const setActiveValue = (value) => { activeValue.value = value; emit("update:modelValue", value); emit("change", value); }; const addItem = (tabItem) => { children.push(tabItem); refreshState(); }; const removeItem = (tabItem) => { const index = children.findIndex((item) => item.name === tabItem.name); if (index === -1) return; children.splice(index, 1); refreshState(); }; const injection = { activeValue, activeColor: toRef(props, "activeColor"), addItem, removeItem, setActiveValue }; provide(tabsInjectionKey, injection); const resize = () => { viewAreaWidth.value = viewAreaRef.value.offsetWidth; offsetX.value = listRef.value.offsetWidth - viewAreaWidth.value; checkPosition(); calcLineOffset(); }; const reboundIfNeeded = () => { reBounding.value = false; if (translateX.value > 0) { reBounding.value = true; translateX.value = 0; } else if (translateX.value < -offsetX.value) { reBounding.value = true; translateX.value = -offsetX.value; } return reBounding.value; }; const moveFollowTouch = () => { if (isMoveLeft.value) { if (translateX.value <= 0 && translateX.value + offsetX.value > 0 || translateX.value > 0) { translateX.value += currentX.value - lastX.value; } else if (translateX.value + offsetX.value <= 0) { translateX.value += props.additionalX * (currentX.value - lastX.value) / (viewAreaWidth.value + Math.abs(translateX.value + offsetX.value)); } } else { if (translateX.value >= 0) { translateX.value += props.additionalX * (currentX.value - lastX.value) / (viewAreaWidth.value + translateX.value); } else if (translateX.value <= 0 && translateX.value + offsetX.value >= 0 || translateX.value + offsetX.value <= 0) { translateX.value += currentX.value - lastX.value; } } lastX.value = currentX.value; }; const moveByInertia = () => { frameEndTime.value = Date.now(); frameTime.value = frameEndTime.value - frameStartTime.value; if (isMoveLeft.value) { if (translateX.value <= -offsetX.value) { acceleration.value *= (props.reBoundExponent + Math.abs(translateX.value + offsetX.value)) / props.reBoundExponent; speed.value = Math.min(speed.value - acceleration.value, 0); } else { speed.value = Math.min(speed.value - acceleration.value * frameTime.value, 0); } } else { if (translateX.value >= 0) { acceleration.value *= (props.reBoundExponent + translateX.value) / props.reBoundExponent; speed.value = Math.max(speed.value - acceleration.value, 0); } else { speed.value = Math.max(speed.value - acceleration.value * frameTime.value, 0); } } translateX.value += speed.value * frameTime.value / 2; if (Math.abs(speed.value) <= zeroSpeed.value) { reboundIfNeeded(); return; } frameStartTime.value = frameEndTime.value; inertiaFrame.value = requestAnimationFrame(moveByInertia); }; const getActiveItemEl = () => { if (!children.length) { return; } const target = children.find((child) => child.name.value === activeValue.value); return target && target.el.value; }; const calcLineOffset = () => { const itemEl = getActiveItemEl(); if (!itemEl) { return; } const itemWidth = itemEl.offsetWidth; const itemLeft = itemEl.offsetLeft; const { lineWidth } = props; if (lineWidth === "auto") { activeLineWidth.value = itemWidth; } else if (lineWidth < 1) { activeLineWidth.value = itemWidth * lineWidth; } else { activeLineWidth.value = lineWidth; } lineOffset.value = itemLeft + (itemWidth - activeLineWidth.value) / 2; }; const checkPosition = () => { const activeItemEl = getActiveItemEl(); if (!activeItemEl) { return; } const offsetLeft = activeItemEl.offsetLeft; const half = (viewAreaWidth.value - activeItemEl.offsetWidth) / 2; let changeX = 0; const absTransX = Math.abs(translateX.value); if (offsetLeft <= absTransX + half) { changeX = half - (offsetLeft + translateX.value); } else { changeX = -(offsetLeft - absTransX - half); } let targetX = changeX + translateX.value; if (targetX > 0) { targetX = 0; } if (targetX < -offsetX.value) { targetX = -offsetX.value; } reBounding.value = true; translateX.value = targetX; }; const handleTouchStart = (event) => { event.stopPropagation(); cancelAnimationFrame(inertiaFrame.value); lastX.value = event.touches[0].clientX; }; const handleTouchMove = (event) => { if (offsetX.value <= 0) { return; } event.preventDefault(); event.stopPropagation(); touching.value = true; startMoveTime.value = endMoveTime.value; startX.value = lastX.value; currentX.value = event.touches[0].clientX; moveFollowTouch(); endMoveTime.value = event.timeStamp; }; const handleTouchEnd = (event) => { touching.value = false; if (reboundIfNeeded()) { cancelAnimationFrame(inertiaFrame.value); } else { let silenceTime = event.timeStamp - endMoveTime.value; let timeStamp = endMoveTime.value - startMoveTime.value; timeStamp = timeStamp > 0 ? timeStamp : 8; if (silenceTime > 100) { return; } speed.value = (lastX.value - startX.value) / timeStamp; acceleration.value = speed.value / props.inertialDuration; frameStartTime.value = Date.now(); inertiaFrame.value = requestAnimationFrame(moveByInertia); } }; const bindEvents = () => { const el = viewAreaRef.value; el.addEventListener("touchstart", handleTouchStart, false); el.addEventListener("touchmove", handleTouchMove, false); el.addEventListener("touchend", handleTouchEnd, false); }; const removeEvents = () => { const el = viewAreaRef.value; el.removeEventListener("touchstart", handleTouchStart); el.removeEventListener("touchmove", handleTouchMove); el.removeEventListener("touchend", handleTouchEnd); }; onMounted(() => { windowInit(); bindEvents(); refreshState(); }); onBeforeUnmount(() => { removeEvents(); }); expose({ resize }); return { viewAreaRef, listRef, activeValue, lineOffset, activeLineWidth, viewAreaWidth, offsetX, speed, touching, reBounding, translateX, startX, lastX, currentX, startMoveTime, endMoveTime, frameTime, frameStartTime, frameEndTime, inertiaFrame, zeroSpeed, acceleration, listStyle, activeBarStyle, isMoveLeft }; } }); const _hoisted_1 = { ref: "viewAreaRef", class: "fun-tabs" }; function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { return openBlock(), createElementBlock("div", _hoisted_1, [ createElementVNode("div", { ref: "listRef", style: normalizeStyle(_ctx.listStyle), class: "fun-tabs__tab-list" }, [ renderSlot(_ctx.$slots, "default"), createElementVNode("div", { style: normalizeStyle(_ctx.activeBarStyle), class: "fun-tabs__active-line" }, null, 4) ], 4) ], 512); } var FunTabs = /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render], ["__file", "tabs.vue"]]); const install = (app) => { app.component(FunTabs.name, FunTabs); app.component(FunTabItem.name, FunTabItem); app.component(FunTabBar.name, FunTabBar); }; var index = { install }; export { FunTabBar, FunTabItem, FunTabs, index as default };