440 lines
15 KiB
Vue
440 lines
15 KiB
Vue
<template>
|
||
<template v-if="sticky">
|
||
<wd-sticky-box>
|
||
<view
|
||
:class="`wd-tabs ${customClass} ${innerSlidable ? 'is-slide' : ''} ${mapNum < children.length && mapNum !== 0 ? 'is-map' : ''}`"
|
||
:style="customStyle"
|
||
>
|
||
<wd-sticky :offset-top="offsetTop">
|
||
<view class="wd-tabs__nav wd-tabs__nav--sticky">
|
||
<view class="wd-tabs__nav--wrap">
|
||
<scroll-view :scroll-x="innerSlidable" scroll-with-animation :scroll-left="state.scrollLeft">
|
||
<view class="wd-tabs__nav-container">
|
||
<view
|
||
@click="handleSelect(index)"
|
||
v-for="(item, index) in children"
|
||
:key="index"
|
||
:class="`wd-tabs__nav-item ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`"
|
||
:style="state.activeIndex === index ? (color ? 'color:' + color : '') : inactiveColor ? 'color:' + inactiveColor : ''"
|
||
>
|
||
<wd-badge v-if="item.badgeProps" v-bind="item.badgeProps">
|
||
<text class="wd-tabs__nav-item-text">{{ item.title }}</text>
|
||
</wd-badge>
|
||
<text v-else class="wd-tabs__nav-item-text">{{ item.title }}</text>
|
||
|
||
<view class="wd-tabs__line wd-tabs__line--inner" v-if="state.activeIndex === index && state.useInnerLine"></view>
|
||
</view>
|
||
<view class="wd-tabs__line" :style="state.lineStyle"></view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
<view class="wd-tabs__map" v-if="mapNum < children.length && mapNum !== 0">
|
||
<view :class="`wd-tabs__map-btn ${state.animating ? 'is-open' : ''}`" @click="toggleMap">
|
||
<view :class="`wd-tabs__map-arrow ${state.animating ? 'is-open' : ''}`">
|
||
<wd-icon name="arrow-down" />
|
||
</view>
|
||
</view>
|
||
<view class="wd-tabs__map-header" :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1;' : ''}`">
|
||
{{ mapTitle || translate('all') }}
|
||
</view>
|
||
<view :class="`wd-tabs__map-body ${state.animating ? 'is-open' : ''}`" :style="state.mapShow ? '' : 'display:none'">
|
||
<view class="wd-tabs__map-nav-item" v-for="(item, index) in children" :key="index" @click="handleSelect(index)">
|
||
<view
|
||
:class="`wd-tabs__map-nav-btn ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`"
|
||
:style="
|
||
state.activeIndex === index
|
||
? color
|
||
? 'color:' + color + ';border-color:' + color
|
||
: ''
|
||
: inactiveColor
|
||
? 'color:' + inactiveColor
|
||
: ''
|
||
"
|
||
>
|
||
{{ item.title }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</wd-sticky>
|
||
|
||
<view class="wd-tabs__container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||
<view :class="['wd-tabs__body', animated ? 'is-animated' : '']" :style="bodyStyle">
|
||
<slot />
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
class="wd-tabs__mask"
|
||
:style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1;' : ''}`"
|
||
@click="toggleMap"
|
||
></view>
|
||
</view>
|
||
</wd-sticky-box>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<view :class="`wd-tabs ${customClass} ${innerSlidable ? 'is-slide' : ''} ${mapNum < children.length && mapNum !== 0 ? 'is-map' : ''}`">
|
||
<view class="wd-tabs__nav">
|
||
<view class="wd-tabs__nav--wrap">
|
||
<scroll-view :scroll-x="innerSlidable" scroll-with-animation :scroll-left="state.scrollLeft">
|
||
<view class="wd-tabs__nav-container">
|
||
<view
|
||
v-for="(item, index) in children"
|
||
@click="handleSelect(index)"
|
||
:key="index"
|
||
:class="`wd-tabs__nav-item ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`"
|
||
:style="state.activeIndex === index ? (color ? 'color:' + color : '') : inactiveColor ? 'color:' + inactiveColor : ''"
|
||
>
|
||
<wd-badge custom-class="wd-tabs__nav-item-badge" v-if="item.badgeProps" v-bind="item.badgeProps">
|
||
<text class="wd-tabs__nav-item-text">{{ item.title }}</text>
|
||
</wd-badge>
|
||
<text v-else class="wd-tabs__nav-item-text">{{ item.title }}</text>
|
||
<view class="wd-tabs__line wd-tabs__line--inner" v-if="state.activeIndex === index && state.useInnerLine"></view>
|
||
</view>
|
||
<view class="wd-tabs__line" :style="state.lineStyle"></view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
<view class="wd-tabs__map" v-if="mapNum < children.length && mapNum !== 0">
|
||
<view class="wd-tabs__map-btn" @click="toggleMap">
|
||
<view :class="`wd-tabs__map-arrow ${state.animating ? 'is-open' : ''}`">
|
||
<wd-icon name="arrow-down" />
|
||
</view>
|
||
</view>
|
||
<view class="wd-tabs__map-header" :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1;' : ''}`">
|
||
{{ translate('all') }}
|
||
</view>
|
||
<view :class="`wd-tabs__map-body ${state.animating ? 'is-open' : ''}`" :style="state.mapShow ? '' : 'display:none'">
|
||
<view class="wd-tabs__map-nav-item" v-for="(item, index) in children" :key="index" @click="handleSelect(index)">
|
||
<view :class="`wd-tabs__map-nav-btn ${state.activeIndex === index ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`">
|
||
{{ item.title }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="wd-tabs__container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||
<view :class="['wd-tabs__body', animated ? 'is-animated' : '']" :style="bodyStyle">
|
||
<slot />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="wd-tabs__mask" :style="`${state.mapShow ? '' : 'display:none;'} ${state.animating ? 'opacity:1' : ''}`" @click="toggleMap"></view>
|
||
</view>
|
||
</template>
|
||
</template>
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'wd-tabs',
|
||
options: {
|
||
addGlobalClass: true,
|
||
virtualHost: true,
|
||
styleIsolation: 'shared'
|
||
}
|
||
}
|
||
</script>
|
||
<script lang="ts" setup>
|
||
import wdIcon from '../wd-icon/wd-icon.vue'
|
||
import wdSticky from '../wd-sticky/wd-sticky.vue'
|
||
import wdStickyBox from '../wd-sticky-box/wd-sticky-box.vue'
|
||
import { computed, getCurrentInstance, onMounted, watch, nextTick, reactive, type CSSProperties, type ComponentInstance } from 'vue'
|
||
import { addUnit, checkNumRange, debounce, getRect, isDef, isNumber, isString, objToStyle } from '../common/util'
|
||
import { useTouch } from '../composables/useTouch'
|
||
import { TABS_KEY, tabsProps, type TabsExpose } from './types'
|
||
import { useChildren } from '../composables/useChildren'
|
||
import { useTranslate } from '../composables/useTranslate'
|
||
|
||
const $item = '.wd-tabs__nav-item'
|
||
const $itemText = '.wd-tabs__nav-item-text'
|
||
const $container = '.wd-tabs__nav-container'
|
||
|
||
const props = defineProps(tabsProps)
|
||
const emit = defineEmits(['change', 'disabled', 'click', 'update:modelValue'])
|
||
|
||
const { translate } = useTranslate('tabs')
|
||
|
||
const state = reactive({
|
||
activeIndex: 0, // 选中值的索引,默认第一个
|
||
lineStyle: 'display:none;', // 激活项边框线样式
|
||
useInnerLine: false, // 是否使用内部激活项边框线,当外部激活下划线未成功渲染时显示内部定位的
|
||
inited: false, // 是否初始化
|
||
animating: false, // 是否动画中
|
||
mapShow: false, // map的开关
|
||
scrollLeft: 0 // scroll-view偏移量
|
||
})
|
||
|
||
const { children, linkChildren } = useChildren(TABS_KEY)
|
||
linkChildren({ state, props })
|
||
|
||
const { proxy } = getCurrentInstance() as any
|
||
|
||
const touch = useTouch()
|
||
|
||
const innerSlidable = computed(() => {
|
||
return props.slidable === 'always' || children.length > props.slidableNum
|
||
})
|
||
|
||
const bodyStyle = computed(() => {
|
||
if (!props.animated) {
|
||
return ''
|
||
}
|
||
|
||
return objToStyle({
|
||
left: -100 * state.activeIndex + '%',
|
||
'transition-duration': props.duration + 'ms',
|
||
'-webkit-transition-duration': props.duration + 'ms'
|
||
})
|
||
})
|
||
|
||
const getTabName = (tab: ComponentInstance<any>, index: number) => {
|
||
return isDef(tab.name) ? tab.name : index
|
||
}
|
||
|
||
/**
|
||
* 更新激活项
|
||
* @param value 激活值
|
||
* @param init 是否已初始化
|
||
* @param setScroll // 是否设置scroll-view滚动
|
||
*/
|
||
const updateActive = (value: number | string = 0, init: boolean = false, setScroll: boolean = true) => {
|
||
// 没有tab子元素,不执行任何操作
|
||
if (children.length === 0) return
|
||
|
||
value = getActiveIndex(value)
|
||
// 被禁用,不执行任何操作
|
||
if (children[value].disabled) return
|
||
state.activeIndex = value
|
||
if (setScroll) {
|
||
updateLineStyle(init === false)
|
||
scrollIntoView()
|
||
}
|
||
setActiveTab()
|
||
}
|
||
|
||
/**
|
||
* @description 修改选中的tab Index
|
||
* @param {String |Number } value - radio绑定的value或者tab索引,默认值0
|
||
* @param {Boolean } init - 是否伴随初始化操作
|
||
*/
|
||
const setActive = debounce(updateActive, 100, { leading: true })
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(newValue) => {
|
||
if (!isNumber(newValue) && !isString(newValue)) {
|
||
console.error('[wot design] error(wd-tabs): the type of value should be number or string')
|
||
}
|
||
// 保证不为非空字符串,小于0的数字
|
||
if (newValue === '' || !isDef(newValue)) {
|
||
// eslint-disable-next-line quotes
|
||
console.error("[wot design] error(wd-tabs): tabs's value cannot be '' null or undefined")
|
||
}
|
||
if (typeof newValue === 'number' && newValue < 0) {
|
||
// eslint-disable-next-line quotes
|
||
console.error("[wot design] error(wd-tabs): tabs's value cannot be less than zero")
|
||
}
|
||
},
|
||
{
|
||
immediate: true,
|
||
deep: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(newValue) => {
|
||
const index = getActiveIndex(newValue)
|
||
setActive(newValue, false, index !== state.activeIndex)
|
||
},
|
||
{
|
||
immediate: false,
|
||
deep: true
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => children.length,
|
||
() => {
|
||
if (state.inited) {
|
||
nextTick(() => {
|
||
setActive(props.modelValue)
|
||
})
|
||
}
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.slidableNum,
|
||
(newValue) => {
|
||
checkNumRange(newValue, 'slidableNum')
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.mapNum,
|
||
(newValue) => {
|
||
checkNumRange(newValue, 'mapNum')
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
state.inited = true
|
||
nextTick(() => {
|
||
updateActive(props.modelValue, true)
|
||
state.useInnerLine = true
|
||
})
|
||
})
|
||
|
||
function toggleMap() {
|
||
if (state.mapShow) {
|
||
state.animating = false
|
||
setTimeout(() => {
|
||
state.mapShow = false
|
||
}, 300)
|
||
} else {
|
||
state.mapShow = true
|
||
setTimeout(() => {
|
||
state.animating = true
|
||
}, 100)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新 underline的偏移量
|
||
* @param animation 是否开启动画
|
||
*/
|
||
async function updateLineStyle(animation: boolean = true) {
|
||
if (!state.inited) return
|
||
const { autoLineWidth, lineWidth, lineHeight } = props
|
||
try {
|
||
const lineStyle: CSSProperties = {}
|
||
if (isDef(lineWidth)) {
|
||
lineStyle.width = addUnit(lineWidth)
|
||
} else {
|
||
if (autoLineWidth) {
|
||
const textRects = await getRect($itemText, true, proxy)
|
||
const textWidth = Number(textRects[state.activeIndex].width)
|
||
lineStyle.width = addUnit(textWidth)
|
||
}
|
||
}
|
||
if (isDef(lineHeight)) {
|
||
lineStyle.height = addUnit(lineHeight)
|
||
lineStyle.borderRadius = `calc(${addUnit(lineHeight)} / 2)`
|
||
}
|
||
const rects = await getRect($item, true, proxy)
|
||
const rect = rects[state.activeIndex]
|
||
let left = rects.slice(0, state.activeIndex).reduce((prev, curr) => prev + Number(curr.width), 0) + Number(rect.width) / 2
|
||
if (left) {
|
||
lineStyle.transform = `translateX(${left}px) translateX(-50%)`
|
||
if (animation) {
|
||
lineStyle.transition = 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);'
|
||
}
|
||
state.useInnerLine = false
|
||
state.lineStyle = objToStyle(lineStyle)
|
||
}
|
||
} catch (error) {
|
||
console.error('[wot design] error(wd-tabs): update line style failed', error)
|
||
}
|
||
}
|
||
|
||
function setActiveTab() {
|
||
if (!state.inited) return
|
||
const name = getTabName(children[state.activeIndex], state.activeIndex)
|
||
if (name !== props.modelValue) {
|
||
emit('change', {
|
||
index: state.activeIndex,
|
||
name: name
|
||
})
|
||
emit('update:modelValue', name)
|
||
}
|
||
}
|
||
|
||
function scrollIntoView() {
|
||
if (!state.inited) return
|
||
Promise.all([getRect($item, true, proxy), getRect($container, false, proxy)]).then(([navItemsRects, navRect]) => {
|
||
// 选中元素
|
||
const selectItem = navItemsRects[state.activeIndex]
|
||
// 选中元素之前的节点的宽度总和
|
||
const offsetLeft = (navItemsRects as any).slice(0, state.activeIndex).reduce((prev: any, curr: any) => prev + curr.width, 0)
|
||
// scroll-view滑动到selectItem的偏移量
|
||
const left = offsetLeft - ((navRect as any).width - Number(selectItem.width)) / 2
|
||
if (left === state.scrollLeft) {
|
||
state.scrollLeft = left + Math.random() / 10000
|
||
} else {
|
||
state.scrollLeft = left
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* @description 单击tab的处理
|
||
* @param index
|
||
*/
|
||
function handleSelect(index: number) {
|
||
if (index === undefined) return
|
||
const { disabled } = children[index]
|
||
const name = getTabName(children[index], index)
|
||
|
||
if (disabled) {
|
||
emit('disabled', {
|
||
index,
|
||
name
|
||
})
|
||
return
|
||
}
|
||
state.mapShow && toggleMap()
|
||
setActive(index)
|
||
emit('click', {
|
||
index,
|
||
name
|
||
})
|
||
}
|
||
function onTouchStart(event: any) {
|
||
if (!props.swipeable) return
|
||
touch.touchStart(event)
|
||
}
|
||
function onTouchMove(event: any) {
|
||
if (!props.swipeable) return
|
||
touch.touchMove(event)
|
||
}
|
||
function onTouchEnd() {
|
||
if (!props.swipeable) return
|
||
const { direction, deltaX, offsetX } = touch
|
||
const minSwipeDistance = 50
|
||
if (direction.value === 'horizontal' && offsetX.value >= minSwipeDistance) {
|
||
if (deltaX.value > 0 && state.activeIndex !== 0) {
|
||
setActive(state.activeIndex - 1)
|
||
} else if (deltaX.value < 0 && state.activeIndex !== children.length - 1) {
|
||
setActive(state.activeIndex + 1)
|
||
}
|
||
}
|
||
}
|
||
function getActiveIndex(value: number | string) {
|
||
// name代表的索引超过了children长度的边界,自动用0兜底
|
||
if (isNumber(value) && value >= children.length) {
|
||
// eslint-disable-next-line prettier/prettier
|
||
console.error('[wot design] warning(wd-tabs): the type of tabs\' value is Number shouldn\'t be less than its children')
|
||
value = 0
|
||
}
|
||
// 如果是字符串直接匹配,匹配不到用0兜底
|
||
if (isString(value)) {
|
||
const index = children.findIndex((item) => item.name === value)
|
||
value = index === -1 ? 0 : index
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
defineExpose<TabsExpose>({
|
||
setActive,
|
||
scrollIntoView,
|
||
updateLineStyle
|
||
})
|
||
</script>
|
||
<style lang="scss" scoped>
|
||
@import './index.scss';
|
||
</style>
|