Files
2026-01-05 12:47:14 +08:00

349 lines
11 KiB
Vue

<script setup lang="ts">
import type { ComponentInternalInstance, CSSProperties, Ref, VNode } from 'vue'
import { computed, defineComponent, getCurrentInstance, nextTick, onActivated, onMounted, ref, watch } from 'vue'
import { CHANGE_EVENT, CLICK_EVENT, PREFIX, UPDATE_MODEL_EVENT } from '../_constants'
import { useProvide, useRect, useSelectorQuery } from '../_hooks'
import { getMainClass, getRandomId, pxCheck, TypeOfFun } from '../_utils'
import raf from '../_utils/raf'
import NutIcon from '../icon/icon.vue'
import { useTabContentTouch } from './hooks'
import { TAB_KEY, tabsEmits, tabsProps, Title } from './tabs'
const props = defineProps(tabsProps)
const emit = defineEmits(tabsEmits)
const instance = getCurrentInstance() as ComponentInternalInstance
const { getSelectorNodeInfo, getSelectorNodeInfos } = useSelectorQuery(instance)
const refRandomId = getRandomId()
const container = ref(null)
const { internalChildren } = useProvide(TAB_KEY, `${PREFIX}-tabs`)({
activeKey: computed(() => props.modelValue || 0),
autoHeight: computed(() => props.autoHeight),
animatedTime: computed(() => props.animatedTime),
})
const titles: Ref<Title[]> = ref([])
function renderTitles(vnodes: VNode[]) {
vnodes.forEach((vnode: VNode, index: number) => {
let type = vnode.type
type = (type as any).name || type
if (type === 'nut-tab-pane') {
const title = new Title()
if (vnode.props?.title || vnode.props?.['pane-key'] || vnode.props?.paneKey) {
const paneKeyType = TypeOfFun(vnode.props?.['pane-key'])
const paneIndex
= paneKeyType === 'number' || paneKeyType === 'string' ? String(vnode.props?.['pane-key']) : null
const camelPaneKeyType = TypeOfFun(vnode.props?.paneKey)
const camelPaneIndex
= camelPaneKeyType === 'number' || camelPaneKeyType === 'string' ? String(vnode.props?.paneKey) : null
title.title = vnode.props?.title
title.paneKey = paneIndex || camelPaneIndex || String(index)
title.disabled = vnode.props?.disabled
}
titles.value.push(title)
}
else {
if (vnode.children === ' ')
return
renderTitles(vnode.children as VNode[])
}
})
}
const currentIndex = ref((props.modelValue as number) || 0)
function findTabsIndex(value: string | number) {
const index = titles.value.findIndex(item => item.paneKey === String(value))
// if (titles.value.length === 0)
// console.warn('[NutUI] <Tabs> 当前未找到 TabPane 组件元素 , 请检查 .')
// else if (index === -1)
// console.warn('[NutUI] <Tabs> 请检查 v-model 值是否为 paneKey ,如 paneKey 未设置,请采用下标控制 .')
// else
currentIndex.value = index
}
const getScrollX = computed(() => {
return props.titleScroll && props.direction === 'horizontal'
})
const getScrollY = computed(() => {
return props.titleScroll && props.direction === 'vertical'
})
const titleRef = ref([]) as Ref<HTMLElement[]>
const scrollLeft = ref(0)
const scrollTop = ref(0)
const scrollWithAnimation = ref(false)
const navRectRef = ref()
const titleRectRef = ref<UniApp.NodeInfo[]>([])
const canShowLabel = ref(false)
function scrollIntoView() {
if (!props.titleScroll)
return
raf(() => {
Promise.all([
getSelectorNodeInfo(`#nut-tabs__titles_${refRandomId}`),
getSelectorNodeInfos(`#nut-tabs__titles_${refRandomId} .nut-tabs__titles-item`),
]).then(([navRect, titleRects]) => {
navRectRef.value = navRect
titleRectRef.value = titleRects
if (navRectRef.value) {
if (props.direction === 'vertical') {
const titlesTotalHeight = titleRects.reduce((prev: number, curr: UniApp.NodeInfo) => prev + curr.height!, 0)
if (titlesTotalHeight > navRectRef.value?.height)
canShowLabel.value = true
else
canShowLabel.value = false
}
else {
const titlesTotalWidth = titleRects.reduce((prev: number, curr: UniApp.NodeInfo) => prev + curr.width!, 0)
if (titlesTotalWidth > navRectRef.value?.width)
canShowLabel.value = true
else
canShowLabel.value = false
}
}
const titleRect: UniApp.NodeInfo = titleRectRef.value[currentIndex.value]
let to = 0
if (props.direction === 'vertical') {
const top = titleRects
.slice(0, currentIndex.value)
.reduce((prev: number, curr) => prev + curr.height!, 0)
to = top - (navRectRef.value?.height - titleRect.height!) / 2
}
else {
const left = titleRects
.slice(0, currentIndex.value)
.reduce((prev: number, curr) => prev + curr.width!, 0)
// eslint-disable-next-line ts/no-non-null-asserted-optional-chain
to = left - (navRectRef.value?.width - titleRect?.width!) / 2
}
nextTick(() => {
scrollWithAnimation.value = true
})
scrollDirection(to, props.direction)
})
})
}
function scrollDirection(to: number, direction: 'horizontal' | 'vertical') {
let count = 0
const from = direction === 'horizontal' ? scrollLeft.value : scrollTop.value
const frames = 1
function animate() {
if (direction === 'horizontal')
scrollLeft.value += (to - from) / frames
else
scrollTop.value += (to - from) / frames
if (++count < frames)
raf(animate)
}
animate()
}
function init(vnodes: VNode[] = internalChildren.map(item => item.vnode)) {
titles.value = []
vnodes = vnodes?.filter(item => typeof item.children !== 'string')
if (vnodes && vnodes.length)
renderTitles(vnodes)
findTabsIndex(props.modelValue)
setTimeout(() => {
scrollIntoView()
}, 500)
}
watch(
() => internalChildren.map(item => item.props),
(vnodes: any[]) => {
init(internalChildren as any)
},
{ deep: true, immediate: true },
)
watch(
() => props.modelValue,
(value: string | number) => {
findTabsIndex(value)
scrollIntoView()
},
)
onMounted(init)
onActivated(init)
const tabMethods = {
isBegin: () => {
return currentIndex.value === 0
},
isEnd: () => {
return currentIndex.value === titles.value.length - 1
},
next: () => {
currentIndex.value += 1
const nextDisabled = titles.value[currentIndex.value].disabled
if (tabMethods.isEnd() && nextDisabled) {
tabMethods.prev()
return
}
if (nextDisabled && currentIndex.value < titles.value.length - 1) {
tabMethods.next()
return
}
tabMethods.updateValue(titles.value[currentIndex.value])
},
prev: () => {
currentIndex.value -= 1
const prevDisabled = titles.value[currentIndex.value].disabled
if (tabMethods.isBegin() && prevDisabled) {
tabMethods.next()
return
}
if (prevDisabled && currentIndex.value > 0) {
tabMethods.prev()
return
}
tabMethods.updateValue(titles.value[currentIndex.value])
},
updateValue: (item: Title) => {
emit(UPDATE_MODEL_EVENT, item.paneKey)
emit(CHANGE_EVENT, item)
},
tabChange: (item: Title, index: number) => {
emit(CLICK_EVENT, item)
if (item.disabled || currentIndex.value === index)
return
currentIndex.value = index
tabMethods.updateValue(item)
},
setTabItemRef: (el: HTMLElement, index: number) => {
titleRef.value[index] = el
},
}
const { tabChange } = tabMethods
const { touchState, touchMethods, tabsContentID, tabsContentRef } = useTabContentTouch(props, tabMethods, instance, useRect)
const contentStyle = computed(() => {
let offsetPercent = currentIndex.value * 100
if (touchState.moving)
offsetPercent += touchState.offset
let style: CSSProperties = {
transform:
props.direction === 'horizontal'
? `translate3d(-${offsetPercent}%, 0, 0)`
: `translate3d( 0,-${offsetPercent}%, 0)`,
transitionDuration: touchState.moving ? undefined : `${props.animatedTime}ms`,
}
if (props.animatedTime === 0)
style = {}
return style
})
const tabsNavStyle = computed(() => {
return {
background: props.background,
}
})
const tabsActiveStyle = computed(() => {
return {
color: props.type === 'smile' ? props.customColor : '',
background: props.type === 'line' ? props.customColor : '',
}
})
const titleStyle = computed(() => {
if (!props.titleGutter)
return {}
const px = pxCheck(props.titleGutter)
if (props.direction === 'vertical')
return { paddingTop: px, paddingBottom: px }
return { paddingLeft: px, paddingRight: px }
})
const classes = computed(() => {
return getMainClass(props, componentName, {
[props.direction]: true,
})
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-tabs`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view ref="container" :style="customStyle" :class="classes">
<scroll-view
:id="`nut-tabs__titles_${refRandomId}`"
:scroll-x="getScrollX"
:scroll-y="getScrollY"
:scroll-with-animation="scrollWithAnimation"
:scroll-left="scrollLeft"
:scroll-top="scrollTop"
:enable-flex="true"
class="nut-tabs__titles"
:class="{ [type]: type, scrollable: titleScroll, [size]: size }"
:style="tabsNavStyle"
>
<view class="nut-tabs__list" :class="{ 'nut-tabs__titles-left': align === 'left' }">
<slot v-if="$slots.titles" name="titles" />
<template v-else>
<view
v-for="(item, index) in titles"
:key="item.paneKey"
class="nut-tabs__titles-item uni"
:style="titleStyle"
:class="{
'nut-tabs-active': item.paneKey === String(modelValue),
'disabled': item.disabled,
'nut-tabs__titles-item-left': align === 'left',
}"
@click="tabChange(item, index)"
>
<view v-if="type === 'line'" class="nut-tabs__titles-item__line" :style="tabsActiveStyle" />
<view v-if="type === 'smile'" class="nut-tabs__titles-item__smile" :style="tabsActiveStyle">
<NutIcon name="joy-smile" :custom-color="customColor" />
</view>
<view class="nut-tabs__titles-item__text" :class="{ ellipsis }">
{{ item.title }}
</view>
</view>
<view v-if="canShowLabel && titleScroll" class="nut-tabs__titles-placeholder" />
</template>
</view>
</scroll-view>
<view
:id="tabsContentID"
ref="tabsContentRef"
class="nut-tabs__content"
:style="contentStyle"
@touchstart="touchMethods.onTouchStart"
@touchmove="touchMethods.onTouchMove"
@touchend="touchMethods.onTouchEnd"
@touchcancel="touchMethods.onTouchEnd"
>
<slot name="default" />
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>