349 lines
11 KiB
Vue
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>
|