This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import type { ComponentInternalInstance } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useTouch } from '../_hooks'
import { getRandomId } from '../_utils'
type TouchPosition = 'left' | 'right' | 'top' | 'bottom' | ''
export function useTabContentTouch(props: any, tabMethods: any, uni?: ComponentInternalInstance, useRect?: any) {
const tabsContentRef = ref<HTMLElement>()
const tabsContentID = `tabsContentRef-${getRandomId()}`
const tabsContentRefRect = ref({ width: 0, height: 0 })
const initUniWidth = async () => {
if (uni) {
const rect = await useRect(tabsContentID, uni)
tabsContentRefRect.value.width = rect.width || 0
tabsContentRefRect.value.height = rect.height || 0
}
else {
tabsContentRefRect.value.width = tabsContentRef.value?.clientWidth || 0
tabsContentRefRect.value.height = tabsContentRef.value?.clientHeight || 0
}
}
onMounted(() => {
setTimeout(() => {
initUniWidth()
}, 100)
})
const touchState = reactive({
offset: 0,
moving: false,
})
const touch = useTouch()
let position: TouchPosition = ''
const setoffset = (deltaX: number, deltaY: number) => {
let offset = deltaX
if (props.direction === 'horizontal') {
position = deltaX > 0 ? 'right' : 'left'
// 计算拖拽 百分比
offset = (Math.abs(offset) / tabsContentRefRect.value.width) * 100
}
else {
position = deltaY > 0 ? 'bottom' : 'top'
offset = deltaY
// 计算拖拽 百分比
offset = (Math.abs(offset) / tabsContentRefRect.value?.height) * 100
}
// 拖拽阈值 85%
if (offset > 85)
offset = 85
switch (position) {
case 'left':
case 'top':
// 起始tab拖拽拦截
if (tabMethods.isEnd()) {
offset = 0
touchState.moving = false
}
break
case 'right':
case 'bottom':
offset = -offset
// 末位tab拖拽拦截
if (tabMethods.isBegin()) {
offset = 0
touchState.moving = false
}
break
}
touchState.offset = offset
}
const touchMethods = {
onTouchStart(event: Event) {
if (!props.swipeable)
return
touch.start(event)
},
onTouchMove(event: Event) {
if (!props.swipeable)
return
touch.move(event)
touchState.moving = true
setoffset(touch.deltaX.value, touch.deltaY.value)
if (props.direction === 'horizontal' && touch.isHorizontal()) {
event.preventDefault()
event.stopPropagation()
}
if (props.direction === 'vertical' && touch.isVertical()) {
event.preventDefault()
event.stopPropagation()
}
},
onTouchEnd() {
if (touchState.moving) {
touchState.moving = false
switch (position) {
case 'left':
case 'top':
// 大于 35%阈值 切换至下一 Tab
if (touchState.offset > 35)
tabMethods.next()
break
case 'right':
case 'bottom':
if (touchState.offset < -35)
tabMethods.prev()
break
}
}
},
}
return { touchMethods, touchState, tabsContentRef, tabsContentID }
}

View File

@@ -0,0 +1,282 @@
@import "../sticky/index";
@import "../cascader/index";
.nut-theme-dark {
.nut-tabs {
.nut-tabs__titles {
background: $dark-background3;
.nut-tabs__titles-item {
color: $dark-color-gray;
&.nut-tabs-active {
color: $dark-color;
}
}
}
&.vertical {
.nut-tabs__titles {
.nut-tabs__titles-item {
&.nut-tabs-active {
background-color: $dark-background2;
}
}
}
}
}
}
.nut-tabs {
display: flex;
overflow: hidden;
.nut-tabs__titles {
display: flex;
flex-shrink: 0;
white-space: nowrap;
user-select: none;
background: $tabs-titles-background-color;
border-radius: $tabs-titles-border-radius;
.nut-tabs__list {
display: flex;
flex-shrink: 0;
width: 100%;
}
.nut-tabs__titles-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-size: $tabs-titles-item-font-size;
color: $tabs-titles-item-color;
&__text {
text-align: center;
&.ellipsis {
@include oneline-ellipsis;
}
}
&__smile {
.nut-icon {
position: absolute;
left: 0;
width: 100%;
height: 100%;
font-size: 12px;
color: $tabs-tab-smile-color;
}
}
&__smile,
&__line {
position: absolute;
bottom: 15%;
left: 50%;
width: 0;
height: 0;
overflow: hidden;
content: "";
border-radius: $tabs-titles-item-line-border-radius;
opacity: $tabs-titles-item-line-opacity;
transition: width 0.3s ease;
transform: translate(-50%, 0);
}
&.nut-tabs-active {
font-weight: bold;
color: $tabs-titles-item-active-color;
.nut-tabs__titles-item__line {
width: $tabs-horizontal-titles-item-active-line-width;
height: 3px;
content: "";
background: $tabs-horizontal-tab-line-color;
}
}
&.disabled {
color: $disable-color;
}
}
&-left {
justify-content: flex-start;
}
}
&.horizontal {
flex-direction: column;
.nut-sticky__box > .nut-tabs__titles,
> .nut-tabs__titles {
flex-direction: row;
height: $tabs-horizontal-titles-height;
.nut-tabs__list {
height: 100%;
}
&.scrollable {
overflow: auto hidden;
}
.nut-tabs__titles-item {
flex: 1 0 auto;
width: 0;
min-width: $tabs-horizontal-titles-item-min-width;
&__smile {
.nut-icon {
position: absolute;
width: 100%;
height: 100%;
font-size: 12px;
color: $tabs-tab-smile-color;
}
}
&-left {
flex: 0 0 auto;
}
}
}
}
&.vertical {
flex-direction: row;
width: 100%;
> .nut-tabs__titles {
flex-direction: column;
width: $tabs-vertical-titles-width;
height: auto;
padding: 10px 0;
.nut-tabs__list {
flex-direction: column;
}
&.scrollable {
height: auto;
overflow: hidden auto;
.nut-tabs__titles-placeholder {
height: 22px;
}
}
.nut-tabs__titles-item {
flex: none;
width: 100%;
min-width: $tabs-horizontal-titles-item-min-width;
height: $tabs-vertical-titles-item-height;
&__line {
bottom: none;
width: 0;
height: 0;
transition: height 0.3s ease;
transform: translate(0, -50%);
}
&.nut-tabs-active {
background-color: #fff;
.nut-tabs__titles-item__line {
left: 10px;
width: 3px;
height: $tabs-vertical-titles-item-active-line-height;
background: $tabs-vertical-tab-line-color;
}
.nut-tabs__titles-item__smile {
position: absolute;
right: -8px;
bottom: 2px;
left: auto;
width: 36px;
height: $tabs-vertical-titles-item-active-line-height;
transform: rotate(320deg);
}
}
}
}
.nut-tabs__content {
flex: 1;
flex-direction: column;
.nut-tab-pane {
height: 100%;
}
}
}
&__titles {
&.large .nut-tabs__titles-item {
font-size: $tabs-titles-item-large-font-size;
}
&.normal .nut-tabs__titles-item {
font-size: $tabs-titles-item-font-size;
}
&.small .nut-tabs__titles-item {
font-size: $tabs-titles-item-small-font-size;
}
&::-webkit-scrollbar {
display: none;
width: 0;
background: transparent;
}
&.smile {
.nut-tabs__titles-item {
.nut-tabs__titles-item__smile {
display: none;
}
&.nut-tabs-active {
.nut-tabs__titles-item__smile {
display: block;
width: 36px;
height: 10px;
}
}
}
}
}
&__content {
box-sizing: border-box;
display: flex;
}
.nut-tabs__titles-item {
.uni {
height: 46px;
line-height: 46px;
}
}
.nut-tabs__titles-placeholder {
width: auto;
min-width: 10px;
}
}
scroll-view ::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
background: transparent;
}

View File

@@ -0,0 +1 @@
export * from './tabs'

View File

@@ -0,0 +1,79 @@
import type { ExtractPropTypes, RendererElement, RendererNode, VNode } from 'vue'
import { CHANGE_EVENT, CLICK_EVENT, UPDATE_MODEL_EVENT } from '../_constants'
import { commonProps, isString, makeNumericProp, makeStringProp, truthProp } from '../_utils'
export const TAB_KEY = Symbol('tabs')
export const tabsProps = {
...commonProps,
/**
* @description 绑定当前选中标签的标识符
*/
modelValue: makeNumericProp(0),
/**
* @description 标签选中色
*/
customColor: String,
/**
* @description 使用横纵方向,可选值`horizontal`、`vertical`
*/
direction: makeStringProp<'horizontal' | 'vertical'>('horizontal'),
/**
* @description 标签栏字体尺寸大小,可选值`large` `normal` `small`
*/
size: makeStringProp<'large' | 'normal' | 'small'>('normal'),
/**
* @description 选中底部展示样式,可选值 `line`、`smile`
*/
type: makeStringProp<'line' | 'card' | 'smile'>('line'),
/**
* @description 标签栏是否可以滚动
*/
titleScroll: Boolean,
/**
* @description 是否省略过长的标题文字
*/
ellipsis: truthProp,
/**
* @description 是否开启手势左右滑动切换
*/
swipeable: Boolean,
/**
* @description 自动高度。设置为 true 时nut-tabs 和 nut-tabs__content 会随着当前 nut-tab-pane 的高度而发生变化,使用此属性时必须设置nut-tabs的`pane-key`
*/
autoHeight: Boolean,
/**
* @description 标签栏背景颜色
*/
background: String,
/**
* @description 切换动画时长,单位 ms。0 代表无动画,此时必须设置 pane-key
*/
animatedTime: makeNumericProp(300),
/**
* @description 标签间隙
*/
titleGutter: makeNumericProp(0),
/**
* @description 横轴方向的标题对齐方式,可选值 left、center
*/
align: makeStringProp<'left' | 'center'>('center'),
}
export type TabsProps = ExtractPropTypes<typeof tabsProps>
export const tabsEmits = {
[CLICK_EVENT]: (val: Title) => val instanceof Object,
[CHANGE_EVENT]: (val: Title) => val instanceof Object,
[UPDATE_MODEL_EVENT]: (val: string) => isString(val),
}
export type TabsEmits = typeof tabsEmits
export class Title {
title = ''
titleSlot?: VNode<RendererNode, RendererElement, { [key: string]: any }>
paneKey = ''
disabled = false
constructor() { }
}

View File

@@ -0,0 +1,348 @@
<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>

View File

@@ -0,0 +1,9 @@
export interface provideData {
children: any[]
size: string
modelValue: string | number
unactiveColor: string
activeColor: string
dot: boolean
changeIndex: (index: number, active: number | string) => void
}