2025/2/8第一次更新

This commit is contained in:
爱吃咸鱼小猫咪
2025-02-08 18:50:38 +08:00
commit d7af560866
26519 changed files with 5046029 additions and 0 deletions
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import ActionSheet from './src/action-sheet.vue'
export const TnActionSheet = withNoopInstall(ActionSheet)
export default TnActionSheet
export * from './src/action-sheet'
export type { TnActionSheetInstance } from './src/instance'
@@ -0,0 +1,68 @@
import { ZIndex } from '../../../constants'
import { buildProps } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export interface ActionSheetAction {
/**
* @description 选项文字
*/
text: string
/**
* @description 选项备注
*/
desc?: string
/**
* @description 选项的值
*/
value?: string | number
}
/**
* @description ActionSheet options配置项
*/
export interface ActionSheetOptions {
/**
* @description 选项列表
*/
actions: ActionSheetAction[]
/**
* @description 标题
*/
title?: string
/**
* @description 取消按钮文字,如果为空则不显示取消按钮
*/
cancelText?: string
/**
* @description 是否显示遮罩
*/
mask?: boolean
/**
* @description 点击蒙层是否允许关闭
*/
maskClosable?: boolean
/**
* @description 点击取消按钮触发的回调函数,返回 false 或者返回 Promise 且被 reject 则取消关闭
*/
cancel?: () => (Promise<boolean> | void) | boolean
/**
* @description 点击选项触发的回调函数,返回 false 或者返回 Promise 且被 reject 则取消关闭
*/
select?: (
index: number,
value: string | number
) => (Promise<boolean> | void) | boolean
}
export const actionSheetProps = buildProps({
/**
* @description ZIndex
*/
zIndex: {
type: Number,
default: ZIndex.popup,
},
})
export type ActionSheetProps = ExtractPropTypes<typeof actionSheetProps>
@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { useNamespace } from '../../../hooks'
import TnPopup from '../../popup/src/popup.vue'
import { actionSheetProps } from './action-sheet'
import { useActionSheet } from './composables'
defineProps(actionSheetProps)
const ns = useNamespace('action-sheet')
const {
data,
showTitle,
title,
showCancel,
cancelText,
overlay,
overlayClosable,
openPopup,
showActionSheet,
popupCloseEvent,
actionClickEvent,
} = useActionSheet()
defineExpose({
/**
* @description: 打开/显示 actionSheet 操作菜单
*/
show: showActionSheet,
})
</script>
<template>
<TnPopup
:model-value="openPopup"
open-direction="bottom"
:overlay="overlay"
:z-index="zIndex"
bg-color="transparent"
:safe-area-inset-bottom="false"
:overlay-closeable="overlayClosable"
@overlay-click="popupCloseEvent"
>
<view class="tn-u-safe-area" :class="[ns.b(), ns.is('shadow', !overlay)]">
<!-- 标题 -->
<view v-if="showTitle" :class="[ns.e('title')]">
<slot name="title">
{{ title }}
</slot>
</view>
<!-- 选项 -->
<view :class="[ns.e('actions')]">
<view
v-for="(item, index) in data"
:key="index"
:class="[ns.e('action')]"
hover-class="tn-u-btn-hover"
:hover-stay-time="150"
@tap.stop="actionClickEvent(index)"
>
<!-- 选项显示内容 -->
<view class="text">{{ item.text }}</view>
<!-- 选项描述 -->
<view v-if="item.desc" class="desc">{{ item.desc }}</view>
</view>
</view>
<!-- 取消按钮 -->
<view
v-if="showCancel"
:class="[ns.e('cancel')]"
hover-class="tn-u-btn-hover"
:hover-stay-time="150"
@tap.stop="popupCloseEvent"
>
<slot name="cancel">
{{ cancelText }}
</slot>
</view>
</view>
</TnPopup>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/action-sheet.scss';
</style>
@@ -0,0 +1 @@
export * from './use-action-sheet'
@@ -0,0 +1,141 @@
import { computed, getCurrentInstance, reactive, ref } from 'vue'
import {
debugWarn,
isBoolean,
isEmptyVariableInDefault,
isPromise,
} from '../../../../utils'
import type { ActionSheetOptions } from '../action-sheet'
export const useActionSheet = () => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnActionSheet', '请在 setup 中使用 useActionSheet')
}
const { slots } = instance!
// 默认配置项
const defaultOptions: ActionSheetOptions = {
actions: [],
title: '',
cancelText: '取 消',
mask: true,
maskClosable: false,
cancel: undefined,
select: undefined,
}
// 配置项
const options = reactive<ActionSheetOptions>({
actions: [],
title: '',
cancelText: '取 消',
mask: true,
cancel: undefined,
select: undefined,
})
// 操作菜单数据
const data = computed(() => options.actions)
// 是否显示标题
const showTitle = computed(() => !!slots?.title || !!options.title)
const title = computed(() => options.title)
// 是否显示取消按钮
const showCancel = computed(() => !!slots?.cancel || !!options.cancelText)
const cancelText = computed(() => options.cancelText)
// 是否显示遮罩
const overlay = computed(() => isEmptyVariableInDefault(options.mask, true))
// 点击遮罩是否允许关闭
const overlayClosable = computed(() =>
isEmptyVariableInDefault(options.maskClosable, false)
)
// 弹出popup弹框
const openPopup = ref<boolean>(false)
// popup弹框关闭事件
const popupCloseEvent = () => {
if (!options.cancel || overlayClosable.value) {
openPopup.value = false
return
}
const shouldCancel = options.cancel()
const isPromiseOrBoolean = [
isPromise(shouldCancel),
isBoolean(shouldCancel),
].includes(true)
if (!isPromiseOrBoolean) {
debugWarn(
'TnActionSheet',
'cancel 函数返回值必须是 Promise 或者 Boolean 类型'
)
return
}
if (isPromise(shouldCancel)) {
shouldCancel.then((res) => {
if (res) {
openPopup.value = false
}
})
} else {
if (shouldCancel) {
openPopup.value = false
}
}
}
// 选项点击事件
const actionClickEvent = (index: number) => {
if (!options.select) {
openPopup.value = false
return
}
const shouldSelect = options.select(index, options.actions[index].value!)
const isPromiseOrBoolean = [
isPromise(shouldSelect),
isBoolean(shouldSelect),
].includes(true)
if (!isPromiseOrBoolean) {
debugWarn(
'TnActionSheet',
'select 函数返回值必须是 Promise 或者 Boolean 类型'
)
return
}
if (isPromise(shouldSelect)) {
shouldSelect.then((res) => {
if (res) {
openPopup.value = false
}
})
} else {
if (shouldSelect) {
openPopup.value = false
}
}
}
const showActionSheet = (_options: ActionSheetOptions) => {
Object.assign(options, defaultOptions, _options)
openPopup.value = true
}
return {
data,
showTitle,
title,
showCancel,
cancelText,
overlay,
overlayClosable,
openPopup,
showActionSheet,
popupCloseEvent,
actionClickEvent,
}
}
@@ -0,0 +1,3 @@
import type ActionSheet from './action-sheet.vue'
export type TnActionSheetInstance = InstanceType<typeof ActionSheet>
+14
View File
@@ -0,0 +1,14 @@
import { withInstall, withNoopInstall } from '../../utils'
import Avatar from './src/avatar.vue'
import AvatarGroup from './src/avatar-group.vue'
export const TnAvatar = withInstall(Avatar, {
AvatarGroup,
})
export const TnAvatarGroup = withNoopInstall(AvatarGroup)
export default TnAvatar
export * from './src/avatar'
export * from './src/avatar-group'
export type { AvatarInstance, AvatarGroupInstance } from './src/instance'
@@ -0,0 +1,77 @@
import { buildProps } from '../../../utils'
import { avatarProps } from './avatar'
import type { ExtractPropTypes } from 'vue'
export const avatarGroupProps = buildProps({
/**
* @description 头像图标配置
*/
iconConfig: avatarProps.iconConfig,
/**
* @description 头像颜色类型
*/
type: avatarProps.type,
/**
* @description 头像大小
*/
size: avatarProps.size,
/**
* @description 头像形状
*/
shape: avatarProps.shape,
/**
* @description 头像图片模式
*/
imgMode: avatarProps.imgMode,
/**
* @description 背景颜色
*/
bgColor: avatarProps.bgColor,
/**
* @description 显示边框
*/
border: {
type: Boolean,
default: true,
},
/**
* @description 边框颜色
*/
borderColor: {
type: String,
default: 'tn-white',
},
/**
* @description 是否加粗边框
*/
borderBold: avatarProps.borderBold,
/**
* @description 显示阴影
*/
shadow: avatarProps.shadow,
/**
* @description 阴影颜色
*/
shadowColor: avatarProps.shadowColor,
/**
* @description 头像角标配置
*/
badgeConfig: avatarProps.badgeConfig,
/**
* @description 头像之间遮挡比例
*/
gap: {
type: [String, Number],
default: 0.4,
},
})
export const avatarGroupEmits = {
/**
* @description 点击头像
*/
click: (index: number) => typeof index === 'number',
}
export type AvatarGroupProps = ExtractPropTypes<typeof avatarGroupProps>
export type AvatarGroupEmits = typeof avatarGroupEmits
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { useNamespace } from '../../../hooks'
import { avatarGroupEmits, avatarGroupProps } from './avatar-group'
import { useAvatarGroup } from './composables'
const props = defineProps(avatarGroupProps)
const emits = defineEmits(avatarGroupEmits)
const ns = useNamespace('avatar')
useAvatarGroup(props, emits)
</script>
<template>
<view :class="`${ns.b('group')}`">
<slot />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/avatar-group.scss';
</style>
+144
View File
@@ -0,0 +1,144 @@
import { useComponentBoolean } from '../../base/composables/use-component-common-props'
import { buildProps, definePropType } from '../../../utils'
import { componentImgModes, componentTypes } from '../../../constants'
import type { ExtractPropTypes } from 'vue'
import type { BadgeProps } from '../../badge'
import type { IconProps } from '../../icon'
/**
* @description 头像形状
*/
export const avatarShape = ['circle', 'square'] as const
/**
* @description 图标参数配置
*/
export interface AvatarIconProps {
/**
* @description 图标颜色
*/
color?: IconProps['color']
/**
* @description 图标大小
*/
size?: IconProps['size']
/**
* @description 图标加粗
*/
bold?: IconProps['bold']
}
/**
* @description 徽标参数属性
*/
export type AvatarBadgeProps = Partial<
Pick<
BadgeProps,
| 'max'
| 'type'
| 'size'
| 'bgColor'
| 'textColor'
| 'fontSize'
| 'bold'
| 'dot'
| 'absolute'
| 'absolutePosition'
| 'absoluteCenter'
>
>
export const avatarProps = buildProps({
/**
* @description 头像地址(url地址和绝对地址)
*/
url: String,
/**
* @descripttion 头像图标
*/
icon: String,
/**
* @description 头像图标配置
*/
iconConfig: {
type: definePropType<AvatarIconProps>(Object),
default: () => ({}),
},
/**
* @description 头像颜色类型
*/
type: {
type: String,
values: componentTypes,
default: '',
},
/**
* @description 头像大小
*/
size: {
type: [String, Number],
},
/**
* @description 头像形状
*/
shape: {
type: String,
values: avatarShape,
default: 'circle',
},
/**
* @description 头像图片模式
*/
imgMode: {
type: String,
values: componentImgModes,
default: 'aspectFill',
},
/**
* @description 背景颜色
*/
bgColor: String,
/**
* @description 显示边框
*/
border: useComponentBoolean,
/**
* @description 边框颜色
*/
borderColor: String,
/**
* @description 是否加粗边框
*/
borderBold: useComponentBoolean,
/**
* @description 显示阴影
*/
shadow: useComponentBoolean,
/**
* @description 阴影颜色
*/
shadowColor: String,
/**
* @description 角标内容
*/
badge: {
type: [String, Number],
},
/**
* @description 角标配置
*/
badgeConfig: {
type: definePropType<AvatarBadgeProps>(Object),
default: () => ({}),
},
})
export const avatarEmits = {
/**
* @description 点击事件
*/
click: () => true,
}
export type AvatarProps = ExtractPropTypes<typeof avatarProps>
export type AvatarEmits = typeof avatarEmits
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import TnBadge from '../../badge/src/badge.vue'
import { avatarEmits, avatarProps } from './avatar'
import {
useAvatar,
useAvatarBadgeProps,
useAvatarCustomStyle,
useAvatarIconConfig,
useAvatarProps,
} from './composables'
const props = defineProps(avatarProps)
const emits = defineEmits(avatarEmits)
const { componentId, avatarGroupIndex, avatarWidth, avatarClick } = useAvatar(
props,
emits
)
const { ns, avatarClass, avatarStyle } = useAvatarCustomStyle(
props,
avatarGroupIndex,
avatarWidth
)
const { imgMode } = useAvatarProps(props)
const { iconSize, iconColor, iconBold } = useAvatarIconConfig(props.iconConfig)
const { badgeConfig } = useAvatarBadgeProps(props)
</script>
<template>
<view
:id="componentId"
:class="[avatarClass]"
:style="avatarStyle"
@tap="avatarClick"
>
<!-- 图片头像 -->
<view v-if="url" :class="[ns.e('image')]">
<image class="image" :src="url" :mode="imgMode" />
</view>
<!-- 图标头像 -->
<view v-else-if="icon" :class="[ns.e('icon')]">
<TnIcon
:name="icon"
:color="iconColor"
:size="iconSize"
:bold="iconBold"
/>
</view>
<!-- 自定义 -->
<view v-else :class="[ns.e('custom')]">
<slot />
</view>
<!-- 角标 -->
<TnBadge v-bind="badgeConfig" />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/avatar.scss';
</style>
@@ -0,0 +1,119 @@
import { computed } from 'vue'
import {
useComponentColor,
useComponentSize,
useNamespace,
} from '../../../../hooks'
import { formatDomSizeValue } from '../../../../utils'
import { useAvatarProps } from './use-avatar-props'
import type { CSSProperties, Ref } from 'vue'
import type { AvatarProps } from '../avatar'
export const useAvatarCustomStyle = (
props: AvatarProps,
groupIndex: Ref<number>,
avatarWidth: Ref<number>
) => {
const ns = useNamespace('avatar')
const {
type,
size,
shape,
bgColor,
border,
borderColor,
shadow,
shadowColor,
avatarGap,
} = useAvatarProps(props)
// 解析背景颜色
const [bgColorClass, bgColorStyle] = useComponentColor(bgColor, 'bg')
// 解析边框颜色
const [borderColorClass, borderColorStyle] = useComponentColor(
borderColor,
'border'
)
// 解析阴影颜色
const [shadowColorClass] = useComponentColor(shadowColor, 'shadow')
// 解析头像尺寸
const { sizeType } = useComponentSize(size.value)
// 头像动态类
const avatarClass = computed<string>(() => {
const cls: string[] = []
cls.push(ns.b())
// 设置头像颜色类型
if (type.value) cls.push(`tn-type-${type.value}_bg`)
// 设置背景颜色
if (!type.value && bgColorClass.value) cls.push(bgColorClass.value)
// 设置头像尺寸
if (sizeType.value === 'inner') cls.push(ns.m(size.value as string))
// 设置头像形状
if (shape.value) cls.push(ns.m(shape.value))
// 设置边框
if (border.value) {
cls.push('tn-border')
// 设置边框颜色
if (borderColorClass.value) cls.push(borderColorClass.value)
}
// 设置阴影
if (shadow.value) {
cls.push('tn-shadow')
// 设置阴影颜色
if (shadowColorClass.value) cls.push(shadowColorClass.value)
}
return cls.join(' ')
})
// 头像动态样式
const avatarStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 设置头像尺寸
if (sizeType.value === 'custom') {
style.width = formatDomSizeValue(size.value)
style.height = style.width
}
// 设置背景颜色
if (bgColorStyle.value) style.backgroundColor = bgColorStyle.value
// 设置边框颜色
if (border.value && borderColorStyle.value)
style.borderColor = borderColorStyle.value
// 如果是头像组,设置头像间距
if (groupIndex.value != -1) {
style.zIndex = groupIndex.value + 1
// style.transform = `translateX(calc(${
// (groupAvatarCount.value - groupIndex.value - 1) * 100
// }% * ${avatarGap.value}))`
if (groupIndex.value > 0) {
style.marginLeft = `calc(-${avatarWidth.value * avatarGap.value}px)`
} else {
style.marginLeft = '0px'
}
}
return style
})
return {
ns,
avatarClass,
avatarStyle,
}
}
@@ -0,0 +1,6 @@
export * from './use-avatar-icon-props'
export * from './use-avatar-props'
export * from './avatar-custom'
export * from './use-avatar'
export * from './use-avatar-group'
export * from './use-avatar-badge-props'
@@ -0,0 +1,120 @@
import { computed, inject } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import { isEmptyDoubleVariableInDefault } from '../../../../utils'
import type { AvatarProps } from '../avatar'
import type { BadgeProps } from '../../../badge'
export const useAvatarBadgeProps = (props: AvatarProps) => {
const avatarGroup = inject(avatarGroupContextKey, undefined)
// 徽标最大值
const max = computed<BadgeProps['max']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.max,
avatarGroup?.badgeConfig?.max
)
})
// 徽标类型
const type = computed<BadgeProps['type']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.type,
avatarGroup?.badgeConfig?.type,
'primary'
)
})
// 徽标背景颜色
const bgColor = computed<BadgeProps['bgColor']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.bgColor,
avatarGroup?.badgeConfig?.bgColor
)
})
// 徽标文本颜色
const textColor = computed<BadgeProps['textColor']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.textColor,
avatarGroup?.badgeConfig?.textColor
)
})
// 徽标字体大小
const fontSize = computed<BadgeProps['fontSize']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.fontSize,
avatarGroup?.badgeConfig?.fontSize
)
})
// 徽标大小
const size = computed<BadgeProps['size']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.size,
avatarGroup?.badgeConfig?.size
)
})
// 徽标加粗
const bold = computed<BadgeProps['bold']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.bold,
avatarGroup?.badgeConfig?.bold,
false
)
})
// 设置点徽标
const dot = computed<BadgeProps['dot']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.dot,
avatarGroup?.badgeConfig?.dot,
false
)
})
// 设置徽标的位置
const absolutePosition = computed<BadgeProps['absolutePosition']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.absolutePosition,
avatarGroup?.badgeConfig?.absolutePosition,
{}
)
})
// 设置徽标是否居中
const absoluteCenter = computed<BadgeProps['absoluteCenter']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.absoluteCenter,
avatarGroup?.badgeConfig?.absoluteCenter,
true
)
})
// 徽标配置
const badgeConfig = computed<BadgeProps>(() => {
return {
value: props.badge,
max: max.value,
type: type.value,
bgColor: bgColor.value,
textColor: textColor.value,
fontSize: fontSize.value,
size: size.value,
bold: bold.value,
customClass: '',
customStyle: {},
dot: dot.value,
absolute: true,
absolutePosition: absolutePosition.value,
absoluteCenter: absoluteCenter.value,
index: '',
}
})
return {
badgeConfig,
}
}
@@ -0,0 +1,36 @@
import { provide, reactive, toRefs } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import { useOrderedChildren } from '../../../../hooks'
import type { SetupContext } from 'vue'
import type { AvatarContext } from '../../../../tokens'
import type { AvatarGroupEmits, AvatarGroupProps } from '../avatar-group'
export const useAvatarGroup = (
props: AvatarGroupProps,
emits: SetupContext<AvatarGroupEmits>['emit']
) => {
const {
children: avatarItems,
addChild: addItem,
removeChild: removeItem,
} = useOrderedChildren<AvatarContext>()
const handleItemClick = (uid: number) => {
// 查找出对应头像的索引
const index = avatarItems.value.findIndex((item) => item.uid === uid)
emits('click', index)
}
provide(
avatarGroupContextKey,
reactive({
...toRefs(props),
avatarItems,
addItem,
removeItem,
handleItemClick,
})
)
}
@@ -0,0 +1,29 @@
import { computed, inject } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import type { AvatarProps } from '../avatar'
export const useAvatarIconConfig = (config: AvatarProps['iconConfig']) => {
const avatarGroup = inject(avatarGroupContextKey, undefined)
// 图标颜色
const iconColor = computed<string>(() => {
return config?.color || avatarGroup?.iconConfig?.color || ''
})
// 图标大小
const iconSize = computed<string | number>(() => {
return config?.size || avatarGroup?.iconConfig?.size || ''
})
// 图标加粗
const iconBold = computed<boolean>(() => {
return config?.bold || avatarGroup?.iconConfig?.bold || false
})
return {
iconColor,
iconSize,
iconBold,
}
}
@@ -0,0 +1,116 @@
import { computed, inject } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import {
isEmptyDoubleVariableInDefault,
isEmptyVariableInDefault,
} from '../../../../utils'
import type { AvatarProps } from '../avatar'
export const useAvatarProps = (props: AvatarProps) => {
const avatarGroup = inject(avatarGroupContextKey, undefined)
// 头像颜色类型
const type = computed<string>(() => {
return isEmptyDoubleVariableInDefault(props?.type, avatarGroup?.type, '')
})
// 头像尺寸
const size = computed<string | number>(() => {
return isEmptyDoubleVariableInDefault(props?.size, avatarGroup?.size, '')
})
// 头像形状
const shape = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.shape,
avatarGroup?.shape,
'circle'
)
})
// 头像图片模式
const imgMode = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.imgMode,
avatarGroup?.imgMode,
'aspectFill'
)
})
// 背景颜色
const bgColor = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.bgColor,
avatarGroup?.bgColor,
'tn-gray-light'
)
})
// 显示边框
const border = computed<boolean>(() => {
return isEmptyDoubleVariableInDefault(
props?.border,
avatarGroup?.border,
false
)
})
// 边框颜色
const borderColor = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.borderColor,
avatarGroup?.borderColor,
''
)
})
// 是否加粗边框
const borderBold = computed<boolean>(() => {
return isEmptyDoubleVariableInDefault(
props?.borderBold,
avatarGroup?.borderBold,
false
)
})
// 显示阴影
const shadow = computed<boolean>(() => {
return isEmptyDoubleVariableInDefault(
props?.shadow,
avatarGroup?.shadow,
false
)
})
// 阴影颜色
const shadowColor = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.shadowColor,
avatarGroup?.shadowColor,
''
)
})
// 头像间的间距
const avatarGap = computed<number>(() => {
let gap = Number(isEmptyVariableInDefault(avatarGroup?.gap, 0))
if (gap < 0) gap = 0
if (gap > 1) gap = 1
return gap
})
return {
type,
size,
shape,
imgMode,
bgColor,
border,
borderColor,
borderBold,
shadow,
shadowColor,
avatarGap,
}
}
@@ -0,0 +1,97 @@
import {
computed,
getCurrentInstance,
inject,
nextTick,
onUnmounted,
ref,
} from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import { useSelectorQuery } from '../../../../hooks'
import {
debugWarn,
generateId,
isEmptyVariableInDefault,
} from '../../../../utils'
import type { SetupContext } from 'vue'
import type { AvatarEmits, AvatarProps } from '../avatar'
export const useAvatar = (
props: AvatarProps,
emits: SetupContext<AvatarEmits>['emit']
) => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnAvatarGroup', '请在 setup 中使用 useAvatarGroup')
}
const { uid } = instance!
const avatarGroup = inject(avatarGroupContextKey, undefined)
avatarGroup?.addItem({ uid })
const componentId = `ta-${generateId()}`
const { getSelectorNodeInfo } = useSelectorQuery(instance)
// 头像组头像数量
const groupAvatarCount = computed<number>(() => {
return isEmptyVariableInDefault(avatarGroup?.avatarItems.length, 0)
})
const avatarGroupIndex = ref<number>(-1)
nextTick(() => {
// 获取当前头像的索引
const avatarIndex = avatarGroup?.avatarItems.findIndex(
(item) => item.uid === uid
)
avatarGroupIndex.value = isEmptyVariableInDefault(avatarIndex, -1)
if (!avatarWidth.value && avatarGroupIndex.value !== -1) {
getAvatarWidthNodeInfo()
}
})
// 头像宽度信息
const avatarWidth = ref<number>(0)
// 获取头像的宽度信息
let initCount = 0
const getAvatarWidthNodeInfo = async () => {
try {
const rectInfo = await getSelectorNodeInfo(`#${componentId}`)
if (!rectInfo.width) {
throw new Error('获取头像宽度信息失败')
}
avatarWidth.value = rectInfo.width || 0
} catch (err) {
if (initCount > 10) {
initCount = 0
debugWarn('TnAvatar', `获取头像宽度信息失败:${err}`)
return
}
initCount++
setTimeout(() => {
getAvatarWidthNodeInfo()
}, 150)
}
}
const avatarClick = () => {
avatarGroup?.handleItemClick(uid)
emits('click')
}
onUnmounted(() => {
avatarGroup?.removeItem(uid)
})
return {
componentId,
groupAvatarCount,
avatarGroupIndex,
avatarWidth,
avatarClick,
}
}
@@ -0,0 +1,5 @@
import type Avatar from './avatar.vue'
import type AvatarGroup from './avatar-group.vue'
export type AvatarInstance = InstanceType<typeof Avatar>
export type AvatarGroupInstance = InstanceType<typeof AvatarGroup>
+9
View File
@@ -0,0 +1,9 @@
import { withNoopInstall } from '../../utils'
import Badge from './src/badge.vue'
export const TnBadge = withNoopInstall(Badge)
export default TnBadge
export * from './src/badge'
export type { BadgeInstance } from './src/instance'
+117
View File
@@ -0,0 +1,117 @@
import {
useComponentCustomStyleProp,
useComponentIndexProp,
} from '../../base/composables/use-component-common-props'
import { buildProps, definePropType } from '../../../utils'
import { componentTypes } from '../../../constants'
import type { ExtractPropTypes } from 'vue'
import type { ComponentIndex } from '../../base/composables/use-component-common-props'
/**
* @description
*/
export interface BadgeAbsolutePositionConfig {
/**
* @description
*/
top?: string | number
/**
* @description
*/
right?: string | number
}
export const badgeProps = buildProps({
/**
* @description max会显示{max}+icon-
*/
value: {
type: [String, Number],
},
/**
* @description value为number时有效{max}+
*/
max: {
type: [String, Number],
},
/**
* @description
*/
type: {
type: String,
values: componentTypes,
default: 'primary',
},
/**
* @description , tn开头则使用图鸟内置的颜色
*/
bgColor: String,
/**
* @description , tn开头则使用图鸟内置的颜色
*/
textColor: String,
/**
* @description
*/
size: {
type: [String, Number],
},
/**
* @description
*/
fontSize: {
type: [String, Number],
},
/**
* @description
*/
bold: Boolean,
/**
* @description
*/
customStyle: useComponentCustomStyleProp,
/**
* @description
*/
customClass: String,
/**
* @description
*/
dot: Boolean,
/**
* @description
*/
absolute: {
type: Boolean,
default: true,
},
/**
* @description
*/
absolutePosition: {
type: definePropType<BadgeAbsolutePositionConfig>(Object),
default: () => ({}),
},
/**
* @description
*/
absoluteCenter: {
type: Boolean,
default: true,
},
/**
* @description
*/
index: useComponentIndexProp,
})
export const badgeEmits = {
/**
* @description
*/
click: (index: ComponentIndex) =>
typeof index === 'number' || typeof index === 'string',
}
export type BadgeProps = ExtractPropTypes<typeof badgeProps>
export type BadgeEmits = typeof badgeEmits
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue'
import TnIcon from '../../icon/src/icon.vue'
import { badgeEmits, badgeProps } from './badge'
import { useBadge, useBadgeCustomStyle } from './composables'
const props = defineProps(badgeProps)
const emits = defineEmits(badgeEmits)
const slots = useSlots()
const { ns, contentNs, badgeContentClass, badgeContentStyle } =
useBadgeCustomStyle(props)
const { showBadge, contentType, content, badgeClick } = useBadge(props, emits)
// badge
const badgeClass = computed<string>(() => {
const cls: string[] = []
cls.push(ns.b())
if (!slots?.default) {
//
if (props.absolute) {
cls.push(ns.e('absolute'))
if (props.absoluteCenter) cls.push(ns.em('absolute', 'center'))
}
}
return cls.join(' ')
})
</script>
<template>
<view :class="[badgeClass]">
<slot />
<!-- 徽标 -->
<view
v-if="showBadge"
:class="[badgeContentClass]"
:style="badgeContentStyle"
@tap.stop="badgeClick"
>
<template v-if="content">
<TnIcon v-if="contentType === 'icon'" :name="content" />
<span v-else :class="`${contentNs.e('data')}`">{{ content }}</span>
</template>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/badge.scss';
</style>
@@ -0,0 +1,105 @@
import { computed, toRef } from 'vue'
import {
useComponentColor,
useComponentSize,
useNamespace,
} from '../../../../hooks'
import { formatDomSizeValue, isEmpty } from '../../../../utils'
import { useBadge } from './use-badge'
import type { CSSProperties } from 'vue'
import type { BadgeProps } from '../badge'
export const useBadgeCustomStyle = (props: BadgeProps) => {
const ns = useNamespace('badge')
const contentNs = useNamespace('badge-content')
const { contentType } = useBadge(props)
// 解析背景颜色
const [bgColorClass, bgColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'bg'
)
// 解析文字颜色
const [textColorClass, textColorStyle] = useComponentColor(
toRef(props, 'textColor'),
'text'
)
// 解析尺寸大小
const { sizeType } = useComponentSize(props.size)
// 徽标内容对应的类
const badgeContentClass = computed<string>(() => {
const cls: string[] = []
cls.push(contentNs.b())
// 点徽标
if (props.dot) cls.push(contentNs.m('dot'))
// 图标徽标
if (contentType.value === 'icon') cls.push(contentNs.m('icon'))
// 绝对定位
if (props.absolute) {
cls.push(contentNs.e('absolute'))
if (props.absoluteCenter) cls.push(contentNs.em('absolute', 'center'))
}
// 设置类型颜色
if (props.type) cls.push(`tn-type-${props.type}_bg`)
// 背景颜色
if (bgColorClass.value) cls.push(bgColorClass.value)
// 字体颜色
if (textColorClass.value) cls.push(textColorClass.value)
// 尺寸大小
if (props.size && sizeType.value === 'inner')
cls.push(contentNs.m(props.size as string))
// 加粗字体
if (props.bold) cls.push('tn-text-bold')
if (props.customClass) cls.push(props.customClass)
return cls.join(' ')
})
// 徽标对应的样式
const badgeContentStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 背景颜色
if (bgColorStyle.value) style.backgroundColor = bgColorStyle.value
// 字体颜色
if (textColorStyle.value) style.color = textColorStyle.value
// 尺寸大小
if (
props.size &&
(sizeType.value === 'custom' || contentType.value === 'icon')
)
style.width = style.height = formatDomSizeValue(props.size)
// 字体尺寸
if (props.fontSize) style.fontSize = formatDomSizeValue(props.fontSize)
// 绝对定位是徽标偏移量
if (props.absolutePosition.top)
style.top = formatDomSizeValue(props.absolutePosition.top)
if (props.absolutePosition.right)
style.right = formatDomSizeValue(props.absolutePosition.right)
if (!isEmpty(props.customStyle)) {
Object.assign(style, props.customStyle)
}
return style
})
return {
ns,
contentNs,
badgeContentClass,
badgeContentStyle,
}
}
@@ -0,0 +1,2 @@
export * from './badge-custom'
export * from './use-badge'
@@ -0,0 +1,52 @@
import { computed } from 'vue'
import { isNumber, isString } from '../../../../utils'
import type { SetupContext } from 'vue'
import type { BadgeEmits, BadgeProps } from '../badge'
export const badgeContentTypes = ['number', 'string', 'icon'] as const
export type BadgeContentType = (typeof badgeContentTypes)[number]
export const useBadge = (
props: BadgeProps,
emits?: SetupContext<BadgeEmits>['emit']
) => {
// 判断是否需要显示角标
const showBadge = computed<boolean>(() => {
return !!props.dot || (props.value !== '' && props.value !== undefined)
})
// 显示的内容类型
const contentType = computed<BadgeContentType>(() => {
let type: BadgeContentType = 'string'
if (isNumber(props.value)) type = 'number'
if (isString(props.value) && props.value.startsWith('icon-')) type = 'icon'
return type
})
// 显示的内容
const content = computed<string>(() => {
if (props.dot) return ''
if (contentType.value === 'number' && props.max) {
const value = Number(props.value || 0)
const max = Number(props.max || 0)
return value > max ? `${max}+` : `${value}`
}
if (contentType.value === 'icon')
return (props.value as string).replace('icon-', '')
return props.value as string
})
// 角标点击事件
const badgeClick = () => {
if (emits) emits('click', props.index)
}
return {
showBadge,
contentType,
content,
badgeClick,
}
}
@@ -0,0 +1,3 @@
import type Badge from './badge.vue'
export type BadgeInstance = InstanceType<typeof Badge>
@@ -0,0 +1,36 @@
import { buildProps } from '../../../../utils'
import { useFormSizeProps } from '../../composables/use-component-common-props'
import { checkboxCheckedShapes } from '../../types/checkbox'
export const checkboxBaseProps = buildProps({
/**
* @description
*/
size: useFormSizeProps,
/**
* @description
*/
checkedShape: {
type: String,
values: checkboxCheckedShapes,
},
/**
* @description
*/
disabled: Boolean,
/**
* @description
*/
labelDisabled: Boolean,
/**
* @description
*/
border: Boolean,
/**
* @description tn开头则使用图鸟内置的颜色只支持普通颜色
*/
activeColor: {
type: String,
default: '',
},
} as const)
@@ -0,0 +1,16 @@
import { buildProps } from '../../../../utils'
import { formComponentSizes } from '../../../../constants'
export const formMetaProps = buildProps({
/**
* @description
*/
size: {
type: String,
values: formComponentSizes,
},
/**
* @description
*/
disabled: Boolean,
} as const)
@@ -0,0 +1,52 @@
import { buildProps } from '../../../../utils'
import { ZIndex } from '../../../../constants'
export const pickerBaseProps = buildProps({
/**
* @description
*/
showCancel: {
type: Boolean,
default: true,
},
/**
* @description
*/
cancelText: {
type: String,
default: '取 消',
},
/**
* @description
*/
cancelColor: String,
/**
* @description
*/
showConfirm: {
type: Boolean,
default: true,
},
/**
* @description
*/
confirmText: {
type: String,
default: '确 定',
},
/**
* @description
*/
confirmColor: String,
/**
* @description
*/
mask: Boolean,
/**
* zIndex
*/
zIndex: {
type: Number,
default: ZIndex.popup,
},
} as const)
@@ -0,0 +1,34 @@
import { buildProps } from '../../../../utils'
import type { ExtractPropTypes } from 'vue'
export const propgressBaseProps = buildProps({
/**
* @description
*/
percent: {
type: Number,
default: 0,
},
/**
* @description tn开头则使用图鸟内置的颜色使
*/
activeColor: String,
/**
* @description tn开头则使用图鸟内置的颜色使
*/
inactiveColor: String,
/**
* @description
*/
showPercent: Boolean,
/**
* @description ms
*/
duration: {
type: Number,
default: 1500,
},
} as const)
export type ProgressBaseProps = ExtractPropTypes<typeof propgressBaseProps>
@@ -0,0 +1,28 @@
import { buildProps } from '../../../../utils'
import { useFormSizeProps } from '../../composables/use-component-common-props'
export const radioBaseProps = buildProps({
/**
* @description radio单选框尺寸
*/
size: useFormSizeProps,
/**
* @description radio单选框是否禁用
*/
disabled: Boolean,
/**
* @description radio禁止点击标签进行选择
*/
labelDisabled: Boolean,
/**
* @description
*/
border: Boolean,
/**
* @description radio激活时的颜色tn开头则使用图鸟内置的颜色只支持普通颜色
*/
activeColor: {
type: String,
default: '',
},
} as const)
@@ -0,0 +1,16 @@
import { buildProps } from '../../../../utils'
export const stepsBaseProps = buildProps({
/**
* @description tn开头使用图鸟内置颜色
*/
color: String,
/**
* @description tn开头使用图鸟内置颜色
*/
activeColor: String,
/**
* @description
*/
disabled: Boolean,
} as const)
@@ -0,0 +1,16 @@
import { buildProps } from '../../../../utils'
export const subsectionBaseProps = buildProps({
/**
* @description tn开头使用图鸟内置的颜色
*/
color: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
activeColor: String,
/**
* @description
*/
disabled: Boolean,
} as const)
@@ -0,0 +1,11 @@
import { buildProps } from '../../../../utils'
export const swipeActionBaseProps = buildProps({
/**
* @description
*/
autoClose: {
type: Boolean,
default: true,
},
} as const)
@@ -0,0 +1,20 @@
import { buildProps } from '../../../../utils'
export const tabbarBaseProps = buildProps({
/**
* @description
*/
inactiveColor: String,
/**
* @description
*/
activeColor: String,
/**
* @description
*/
iconSize: String,
/**
* @description
*/
fontSize: String,
} as const)
@@ -0,0 +1,20 @@
import { buildProps } from '../../../../utils'
export const tabsBaseProps = buildProps({
/**
* @description tn开头时使用图鸟内置的颜色
*/
color: String,
/**
* @description tn开头时使用图鸟内置的颜色
*/
activeColor: String,
/**
* @description
*/
fontSize: String,
/**
* @description
*/
activeFontSize: String,
} as const)
@@ -0,0 +1,54 @@
import { buildProp, definePropType, generateId } from '../../../../utils'
import { componentSizes, formComponentSizes } from '../../../../constants'
export type ComponentIndex = string | number
/**
* @description Boolean类型定义
*/
export const useComponentBoolean = buildProp({
type: [Boolean, undefined],
default: undefined,
})
/**
* @description
*/
export const useComponentSizeProp = buildProp({
type: String,
values: componentSizes,
required: false,
} as const)
/**
* @description
*/
export const useFormSizeProps = buildProp({
type: String,
values: formComponentSizes,
required: false,
} as const)
/**
* @description
*/
export const useComponentCustomStyleProp = buildProp({
type: Object,
default: () => ({}),
})
/**
* @description index
*/
export const useComponentIndexProp = buildProp({
type: definePropType<ComponentIndex>([String, Number]),
default: () => generateId(),
})
/**
* @description
*/
export const useComponentSafeAreaInsetBottomProp = buildProp({
type: Boolean,
default: true,
})
@@ -0,0 +1,48 @@
import { ref, toRef, watch } from 'vue'
import { useComponentColor } from '../../../../hooks'
import type { ProgressBaseProps } from '../../common-props/progress'
interface ProgressPropsType extends ProgressBaseProps {
[key: string]: any
}
export const useProgressProps = (props: ProgressPropsType) => {
const [activeColorClass, activeColorStyle] = useComponentColor(
toRef(props, 'activeColor'),
'bg'
)
const [inactiveColorClass, inactiveColorStyle] = useComponentColor(
toRef(props, 'inactiveColor'),
'bg'
)
// 当前的进度百分比
const percent = ref(0)
let firstInitPercent = true
// 为了有动画效果,需要在下一次渲染时再设置进度条的长度
watch(
() => props.percent,
(val) => {
if (!firstInitPercent) {
percent.value = val
} else {
setTimeout(() => {
percent.value = val
firstInitPercent = false
}, 50)
}
},
{
immediate: true,
}
)
return {
percent,
activeColorClass,
activeColorStyle,
inactiveColorClass,
inactiveColorStyle,
}
}
@@ -0,0 +1,4 @@
export const checkboxCheckedShapes = ['square', 'circle'] as const
export type CheckboxCheckedShape = (typeof checkboxCheckedShapes)[number]
export type CheckboxValueType = string | number | boolean
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import BubbleBox from './src/bubble-box.vue'
export const TnBubbleBox = withNoopInstall(BubbleBox)
export default TnBubbleBox
export * from './src/bubble-box'
export type { TnBubbleBoxInstance } from './src/instance'
@@ -0,0 +1,102 @@
import { ZIndex } from '../../../constants'
import { buildProps, definePropType, isNumber } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export interface BubbleBoxOptionItem {
/**
* @description
*/
text: string
/**
* @description
*/
icon?: string
/**
* @description tn开头使用图鸟内置的颜色
*/
textColor?: string
/**
* @description
*/
disabled?: boolean
}
export type BubbleBoxOption = BubbleBoxOptionItem[]
export const bubbleBoxPosition = ['top', 'bottom', 'left', 'right'] as const
export const bubbleBoxProps = buildProps({
/**
* @description
*/
options: {
type: definePropType<BubbleBoxOption>(Array),
default: () => [],
},
/**
* @description
*/
position: {
type: String,
values: bubbleBoxPosition,
default: 'top',
},
/**
* @description rpx
*/
width: String,
/**
* @description rpx
*/
height: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
bgColor: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
textColor: String,
/**
* @description
*/
optionItemPadding: String,
/**
* @description
*/
disabled: Boolean,
/**
* @description
*/
autoClose: {
type: Boolean,
default: true,
},
/**
* @description ZIndex
*/
zIndex: {
type: Number,
default: ZIndex.bubble,
},
})
export const bubbleBoxEmits = {
/**
* @description
*/
open: () => true,
/**
* @description
*/
close: () => true,
/**
* @description
*/
click: (index: number) => isNumber(index),
}
export type BubbleBoxProps = ExtractPropTypes<typeof bubbleBoxProps>
export type BubbleBoxEmits = typeof bubbleBoxEmits
export type BubbleBoxPosition = (typeof bubbleBoxPosition)[number]
@@ -0,0 +1,75 @@
<script lang="ts" setup>
import TnOverlay from '../../overlay/src/overlay.vue'
import TnIcon from '../../icon/src/icon.vue'
import { bubbleBoxEmits, bubbleBoxProps } from './bubble-box'
import {
useBubbleBox,
useBubbleBoxCustomStyle,
useBubbleOptions,
} from './composables'
const props = defineProps(bubbleBoxProps)
const emits = defineEmits(bubbleBoxEmits)
const {
showBubble,
openBubbleOptions,
closeBubbleOptions,
bubbleOptionClickEvent,
} = useBubbleBox(props, emits)
const {
ns,
optionsClass,
optionsStyle,
optionsAuxiliaryElementClass,
optionsAuxiliaryElementStyle,
optionItemClass,
optionItemStyle,
} = useBubbleBoxCustomStyle(props, showBubble)
const { bubbleOptions } = useBubbleOptions(props)
</script>
<template>
<view :class="[ns.b()]">
<!-- 遮罩 -->
<TnOverlay
:show="showBubble"
:opacity="0"
:z-index="zIndex - 1"
@click="closeBubbleOptions"
/>
<!-- 内容 -->
<view :class="[ns.e('content')]" @tap.stop="openBubbleOptions">
<slot />
<!-- 气泡弹框选项 -->
<view :class="[optionsClass]" :style="optionsStyle">
<!-- 小三角 -->
<view
:class="[optionsAuxiliaryElementClass]"
:style="optionsAuxiliaryElementStyle"
/>
<!-- 选项内容 -->
<scroll-view class="scroll-view" scroll-y>
<view class="options-content">
<view
v-for="(item, index) in bubbleOptions"
:key="index"
:class="[optionItemClass(item)]"
:style="optionItemStyle(item)"
@tap.stop="bubbleOptionClickEvent(item, index)"
>
<view v-if="item.icon" class="icon">
<TnIcon :name="item.icon" />
</view>
<view class="text">{{ item.text }}</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/bubble-box.scss';
</style>
@@ -0,0 +1,131 @@
import { computed, toRef } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import { formatDomSizeValue } from '../../../../utils'
import type { CSSProperties, Ref } from 'vue'
import type { BubbleBoxProps } from '../bubble-box'
import type { BubbleBoxOptionItemData } from '../types'
type OptionsClassType = (item: BubbleBoxOptionItemData) => string
type OptionsStyleType = (item: BubbleBoxOptionItemData) => CSSProperties
export const useBubbleBoxCustomStyle = (
props: BubbleBoxProps,
showBubble: Ref<boolean>
) => {
const ns = useNamespace('bubble-box')
// 解析颜色
const [bgColorClass, bgColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'bg'
)
const [borderColorClass, borderColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'border'
)
// 选项的类
const optionsClass = computed<string>(() => {
const cls: string[] = [
ns.e('options'),
ns.em('options', props.position),
ns.is('show', showBubble.value),
]
if (bgColorClass.value) cls.push(bgColorClass.value)
return cls.join(' ')
})
// 选项的样式
const optionsStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!bgColorClass.value)
style.backgroundColor = bgColorStyle.value || 'var(--tn-color-white)'
if (props.zIndex) style.zIndex = props.zIndex
if (props.width) style.width = formatDomSizeValue(props.width)
if (props.height) style.height = formatDomSizeValue(props.height)
return style
})
// 选项辅助元素的类
const optionsAuxiliaryElementClass = computed<string>(() => {
const cls: string[] = ['auxiliary-element']
if (borderColorClass.value) cls.push(borderColorClass.value)
return cls.join(' ')
})
// 选项辅助元素的样式
const optionsAuxiliaryElementStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!borderColorClass.value)
style.borderColor = borderColorStyle.value || 'var(--tn-color-white)'
// 根据不同的位置,设置不同的边框样式
if (props.position === 'top') {
style.borderRightColor = 'transparent'
style.borderBottomColor = 'transparent'
style.borderLeftColor = 'transparent'
}
if (props.position === 'right') {
style.borderTopColor = 'transparent'
style.borderBottomColor = 'transparent'
style.borderLeftColor = 'transparent'
}
if (props.position === 'bottom') {
style.borderTopColor = 'transparent'
style.borderRightColor = 'transparent'
style.borderLeftColor = 'transparent'
}
if (props.position === 'left') {
style.borderTopColor = 'transparent'
style.borderRightColor = 'transparent'
style.borderBottomColor = 'transparent'
}
return style
})
// 选项item的类
const optionItemClass = computed<OptionsClassType>(() => {
return (item: BubbleBoxOptionItemData) => {
const cls: string[] = [
ns.e('option-item'),
ns.is('disabled', item.disabled),
]
if (item.color.class) cls.push(item.color.class)
return cls.join(' ')
}
})
// 选项item的样式
const optionItemStyle = computed<OptionsStyleType>(() => {
return (item: BubbleBoxOptionItemData) => {
const style: CSSProperties = {}
if (!item.color.class)
style.color = item.color.style || 'var(--tn-text-color-primary)'
if (props.optionItemPadding) style.padding = props.optionItemPadding
return style
}
})
return {
ns,
optionsClass,
optionsStyle,
optionsAuxiliaryElementClass,
optionsAuxiliaryElementStyle,
optionItemClass,
optionItemStyle,
}
}
@@ -0,0 +1,3 @@
export * from './bubble-box-custom'
export * from './use-bubble-options'
export * from './use-bubble-box'
@@ -0,0 +1,41 @@
import { ref } from 'vue'
import type { SetupContext } from 'vue'
import type { BubbleBoxEmits, BubbleBoxProps } from '../bubble-box'
import type { BubbleBoxOptionItemData } from '../types'
export const useBubbleBox = (
props: BubbleBoxProps,
emits: SetupContext<BubbleBoxEmits>['emit']
) => {
// 显示气泡弹出框
const showBubble = ref<boolean>(false)
const openBubbleOptions = () => {
emits('open')
showBubble.value = true
}
// 关闭气泡弹出框
const closeBubbleOptions = () => {
emits('close')
showBubble.value = false
}
// 气泡框选项点击事件
const bubbleOptionClickEvent = (
item: BubbleBoxOptionItemData,
index: number
) => {
if (props.disabled || item.disabled) return
emits('click', index)
if (props.autoClose) closeBubbleOptions()
}
return {
showBubble,
openBubbleOptions,
closeBubbleOptions,
bubbleOptionClickEvent,
}
}
@@ -0,0 +1,33 @@
import { computed, ref } from 'vue'
import { useComponentColor } from '../../../../hooks'
import { isEmptyDoubleVariableInDefault } from '../../../../utils'
import type { BubbleBoxProps } from '../bubble-box'
import type { BubbleBoxOptionData } from '../types'
export const useBubbleOptions = (props: BubbleBoxProps) => {
const bubbleOptions = computed<BubbleBoxOptionData>(() => {
return props.options.map((item) => {
const textColor = ref(
isEmptyDoubleVariableInDefault(item.textColor, props.textColor)
)
const [textColorClass, textColorStyle] = useComponentColor(
textColor,
'text'
)
return {
text: item.text || '',
icon: item?.icon || '',
disabled: props.disabled || item.disabled || false,
color: {
class: textColorClass.value,
style: textColorStyle.value,
},
}
})
})
return {
bubbleOptions,
}
}
@@ -0,0 +1,3 @@
import type BubbleBox from './bubble-box.vue'
export type TnBubbleBoxInstance = InstanceType<typeof BubbleBox>
@@ -0,0 +1,13 @@
export interface BubbleBoxOptionItemData {
text: string
icon: string
disabled: boolean
color: BubbleBoxOptionDataColor
}
export interface BubbleBoxOptionDataColor {
class: string
style: string
}
export type BubbleBoxOptionData = BubbleBoxOptionItemData[]
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import Button from './src/button.vue'
export const TnButton = withNoopInstall(Button)
export default TnButton
export * from './src/button'
export type { TnButtonInstance } from './src/instance'
+268
View File
@@ -0,0 +1,268 @@
import { buildProps, iconPropType } from '../../../utils'
import { componentShapes, componentTypes } from '../../../constants'
import {
useComponentCustomStyleProp,
useComponentSizeProp,
} from '../../base/composables/use-component-common-props'
import type { ExtractPropTypes } from 'vue'
/**
* FormType有效值
*/
export const buttonFormTypes = ['submit', 'reset'] as const
/**
* OpenType有效值
*/
export const buttonOpenTypes = [
'feedback',
'share',
'contact',
'getPhoneNumber',
'getRealtimePhoneNumber',
'launchApp',
'openSetting',
'getUserInfo',
'chooseAvatar',
'agreePrivacyAuthorization',
] as const
export const buttonProps = buildProps({
/**
* @description
*/
width: {
type: [String, Number],
},
/**
* @description
*/
height: {
type: [String, Number],
},
/**
* @description
*/
size: useComponentSizeProp,
/**
* @description
*/
shape: {
type: String,
values: componentShapes,
default: '',
},
/**
* @description
*/
type: {
type: String,
values: componentTypes,
default: 'primary',
},
/**
* @description
*/
icon: {
type: iconPropType,
},
/**
* @description
*/
bold: Boolean,
/**
* @description
*/
fontSize: {
type: [String, Number],
},
/**
* @description tn开头则使用图鸟内置的颜色
*/
bgColor: String,
/**
* @description tn开头则使用图鸟内置的颜色
*/
textColor: String,
/**
* @description
*/
text: Boolean,
/**
* @description
*/
plain: Boolean,
/**
* @description tn开头则使用图鸟内置的颜色
*/
borderColor: String,
/**
* @description
*/
borderBold: Boolean,
/**
* @description
*/
shadow: Boolean,
/**
* @description tn开头则使用图鸟内置的颜色
*/
shadowColor: String,
/**
* @description
*/
hoverClass: {
type: String,
default: 'tn-u-btn-hover',
},
/**
* @description
*/
customStyle: useComponentCustomStyleProp,
/**
* @description
*/
customClass: String,
/**
* @description
*/
disabled: Boolean,
/**
* @description
*/
onlyButton: Boolean,
/**
* @description
*/
loading: Boolean,
/**
* @description
*/
debounce: {
type: Boolean,
default: false,
},
/**
* @description form表单的事件类型
*/
formType: {
type: String,
values: buttonFormTypes,
},
/**
* @description https://uniapp.dcloud.io/component/button.html
*/
openType: {
type: String,
values: buttonOpenTypes,
},
/**
* @description app时向app传递的参数, QQ小程序和openType为launchApp时生效
*/
appParameter: {
type: String,
default: '',
},
/**
* @description , openType为contact时生效
*/
sessionFrom: {
type: String,
default: '',
},
/**
* @description , , openType为contact时生效
*/
sendMessageTitle: {
type: String,
default: '',
},
/**
* @description , , openType为contact时生效
*/
sendMessagePath: {
type: String,
default: '',
},
/**
* @description , , openType为contact时生效
*/
sendMessageImg: {
type: String,
default: '',
},
/**
* @description , true, "可能要发送的小程序", , openType为contact时生效
*/
showMessageCard: {
type: Boolean,
default: false,
},
/**
* @description 使使
*/
phoneNumberNoQuotaToast: {
type: Boolean,
default: true,
},
clickModifiers: {
type: String,
},
})
export const buttonEmits = {
/**
* @description
*/
click: () => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getphonenumber: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getrealtimephonenumber: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
opensetting: (e: any) => true,
/**
* @description APP成功时回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
launchapp: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getuserinfo: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
chooseavatar: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
agreeprivacyauthorization: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
contact: (e: any) => true,
/**
* @description
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
error: (e: any) => true,
}
export type ButtonProps = ExtractPropTypes<typeof buttonProps>
export type ButtonEmits = typeof buttonEmits
export type ButtonFormType = ButtonProps['formType']
export type ButtonOpenType = ButtonProps['openType']
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import TnLoading from '../../loading/src/loading.vue'
import { buttonEmits, buttonProps } from './button'
import { useButton, useButtonCustomStyle } from './composables'
const props = defineProps(buttonProps)
const emits = defineEmits(buttonEmits)
const {
buttonClick,
getPhoneNumber,
getRealTimePhoneNumber,
openSetting,
launchApp,
getUserInfo,
chooseAvatar,
agreePrivacyAuthorization,
contact,
openTypeError,
} = useButton(props, emits)
const { ns, buttonClass, buttonStyle } = useButtonCustomStyle(props)
</script>
// #ifdef MP-WEIXIN
<script lang="ts">
export default {
options: {
// Vue(shadow)
virtualHost: true,
},
}
</script>
// #endif
<template>
<button
v-if="props.clickModifiers === 'stop'"
class="tn-u-btn-clear"
:class="[buttonClass]"
:style="buttonStyle"
:hover-class="
props.disabled || props.loading || props.onlyButton ? '' : hoverClass
"
:disabled="disabled"
:form-type="formType"
:open-type="openType"
:app-parameter="appParameter"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:phone-number-no-quota-toast="phoneNumberNoQuotaToast"
@tap.stop="buttonClick"
@getphonenumber="getPhoneNumber"
@getrealtimephonenumber="getRealTimePhoneNumber"
@opensetting="openSetting"
@launchapp="launchApp"
@getuserinfo="getUserInfo"
@chooseavatar="chooseAvatar"
@agreeprivacyauthorization="agreePrivacyAuthorization"
@contact="contact"
@error="openTypeError"
>
<!-- TODO: loading状态 -->
<view v-if="loading" :class="[ns.m('loading')]">
<TnLoading show animation mode="flower" color="tn-gray" />
</view>
<!-- icon显示 -->
<view v-if="icon" :class="[ns.e('icon')]">
<TnIcon :name="icon" />
</view>
<slot v-else />
</button>
<button
v-else
class="tn-u-btn-clear"
:class="[buttonClass]"
:style="buttonStyle"
:hover-class="
props.disabled || props.loading || props.onlyButton ? '' : hoverClass
"
:disabled="disabled"
:form-type="formType"
:open-type="openType"
:app-parameter="appParameter"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:phone-number-no-quota-toast="phoneNumberNoQuotaToast"
@tap="buttonClick"
@getphonenumber="getPhoneNumber"
@getrealtimephonenumber="getRealTimePhoneNumber"
@opensetting="openSetting"
@launchapp="launchApp"
@getuserinfo="getUserInfo"
@chooseavatar="chooseAvatar"
@agreeprivacyauthorization="agreePrivacyAuthorization"
@contact="contact"
@error="openTypeError"
>
<!-- TODO: loading状态 -->
<view v-if="loading" :class="[ns.m('loading')]">
<TnLoading show animation mode="flower" color="tn-gray" />
</view>
<!-- icon显示 -->
<view v-if="icon" :class="[ns.e('icon')]">
<TnIcon :name="icon" />
</view>
<slot v-else />
</button>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/button.scss';
</style>
@@ -0,0 +1,135 @@
import { computed, toRef } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import { formatDomSizeValue, isEmpty } from '../../../../utils'
import type { CSSProperties } from 'vue'
import type { ButtonProps } from '../button'
export const useButtonCustomStyle = (props: ButtonProps) => {
const ns = useNamespace('button')
// 解析背景颜色
const [bgColorClass, bgColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'bg'
)
// 解析字体颜色
const [textColorClass, textColorStyle] = useComponentColor(
toRef(props, 'textColor'),
'text'
)
// 解析边框颜色
const [borderColorClass, borderColorStyle] = useComponentColor(
toRef(props, 'borderColor'),
'border'
)
// 解析阴影颜色
const [shadowColorClass, shadowColorStyle] = useComponentColor(
toRef(props, 'shadowColor'),
'shadow'
)
// 按钮动态类
const buttonClass = computed<string>(() => {
const cls: string[] = [ns.b()]
if (props.onlyButton) {
cls.push(ns.m('only-button'))
return cls.join(' ')
}
// 设置文字按钮
if (props.text) cls.push(ns.m('text'))
// 设置朴素按钮
if (props.plain) {
cls.push(ns.m('plain'))
if (props.borderBold) cls.push(ns.m('plain-bold'))
}
// 设置按钮颜色类型
if (props.type) {
if (props.text) {
if (!props.textColor) cls.push(`tn-type-${props.type}_text`)
} else if (props.plain) {
if (!props.borderColor) cls.push(`tn-type-${props.type}_border`)
} else {
if (!props.bgColor) cls.push(`tn-type-${props.type}_bg`)
}
}
// 设置按钮尺寸
if (props.size) cls.push(ns.m(props.size))
// 设置按钮形状
if (!props.text && props.shape) cls.push(ns.m(props.shape))
// 设置字体是否加粗
if (props.bold) cls.push('tn-text-bold')
// 设置背景颜色
if (!props.text && !props.plain) {
if (bgColorClass.value) cls.push(bgColorClass.value)
}
// 设置字体颜色
if (textColorClass.value) cls.push(textColorClass.value)
if (props.plain) {
// 设置边框颜色
if (borderColorClass.value) cls.push(borderColorClass.value)
}
// 设置阴影信息
if (props.shadow) {
cls.push('tn-shadow')
// 设置阴影颜色
if (shadowColorClass.value) cls.push(shadowColorClass.value)
}
if (props.customClass) cls.push(props.customClass)
return cls.join(' ')
})
// 按钮样式
const buttonStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (props.onlyButton) return style
// 设置按钮宽高
if (props.width) {
style.width = formatDomSizeValue(props.width)
if (props.shape === 'circle') style.height = style.width
}
if (props.height && props.shape !== 'circle')
style.height = formatDomSizeValue(props.height)
// 设置按钮字体大小
if (props.fontSize) style.fontSize = formatDomSizeValue(props.fontSize)
// 设置背景颜色
if (!props.text && !props.plain) {
if (bgColorStyle.value) style.backgroundColor = bgColorStyle.value
}
// 设置字体颜色
if (textColorStyle.value) {
style.color = textColorStyle.value
}
// 设置边框颜色
if (props.plain && borderColorStyle.value) {
style.borderColor = borderColorStyle.value
}
// 设置阴影颜色
if (props.shadow && shadowColorStyle.value)
style.boxShadow = shadowColorStyle.value
if (!isEmpty(props.customStyle)) {
Object.assign(style, props.customStyle)
}
return style
})
return {
ns,
buttonClass,
buttonStyle,
}
}
@@ -0,0 +1,2 @@
export * from './button-custom'
export * from './use-button'
@@ -0,0 +1,67 @@
import { debounce } from '../../../../libs/lodash'
import type { SetupContext } from 'vue'
import type { ButtonEmits, ButtonProps } from '../button'
export const useButton = (
props: ButtonProps,
emits: SetupContext<ButtonEmits>['emit']
) => {
// 按钮点击事件
const buttonClickHandle = () => {
if (props.disabled || props.loading) return
emits('click')
}
const buttonClick = props.debounce
? debounce(buttonClickHandle, 250)
: buttonClickHandle
// 获取手机号码回调
const getPhoneNumber = (e: any) => {
emits('getphonenumber', e)
}
// 获取手机号实时验证回调
const getRealTimePhoneNumber = (e: any) => {
emits('getrealtimephonenumber', e)
}
// 打开设置面板
const openSetting = (e: any) => {
emits('opensetting', e)
}
// 打开App成功回调
const launchApp = (e: any) => {
emits('launchapp', e)
}
// 获取用户信息回调
const getUserInfo = (e: any) => {
emits('getuserinfo', e)
}
// 获取用户头像回调
const chooseAvatar = (e: any) => {
emits('chooseavatar', e)
}
// 同意隐私授权回调
const agreePrivacyAuthorization = (e: any) => {
emits('agreeprivacyauthorization', e)
}
// 客服消息回调
const contact = (e: any) => {
emits('contact', e)
}
// 当使用开放能力时,发生错误的回调
const openTypeError = (e: any) => {
emits('error', e)
}
return {
buttonClick,
getPhoneNumber,
getRealTimePhoneNumber,
openSetting,
launchApp,
getUserInfo,
chooseAvatar,
agreePrivacyAuthorization,
contact,
openTypeError,
}
}
@@ -0,0 +1,3 @@
import type Button from './button.vue'
export type TnButtonInstance = InstanceType<typeof Button>
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import Calendar from './src/calendar.vue'
export const TnCalendar = withNoopInstall(Calendar)
export default TnCalendar
export * from './src/calendar'
export type { TnCalendarInstance } from './src/instance'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import {
buildProps,
definePropType,
isArray,
isNumber,
isString,
} from '../../../utils'
import type { ExtractPropTypes } from 'vue'
import type { Arrayable } from '../../../utils'
export const calendarModes = ['date', 'multi', 'range'] as const
export type CalendarModelValueType = Arrayable<string>
export const calendarProps = buildProps({
/**
* @description YYYY/MM/DD 2023/01/01 YYYY-MM-DD 2023-01-01 [2023/01/01, 2023/01/02] [2023-01-01, 2023-01-02]
*/
modelValue: {
type: definePropType<CalendarModelValueType>([String, Array]),
default: '',
},
/**
* @description tn开头使用图鸟内置的颜色
*/
activeBgColor: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
activeTextColor: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
rangeBgColor: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
rangeTextColor: String,
/**
* @description
*/
mode: {
type: String,
values: calendarModes,
default: 'date',
},
/**
* @description YYYY/MM/DD YYYY-MM-DD
*/
minDate: String,
/**
* @description YYYY/MM/DD YYYY-MM-DD
*/
maxDate: String,
/**
* @description
*/
allowChangeYear: {
type: Boolean,
default: true,
},
/**
* @description
*/
allowChangeMonth: {
type: Boolean,
default: true,
},
/**
* @description
*/
showLunar: Boolean,
/**
* @description mode range
*/
rangeStartDesc: {
type: String,
default: '开始',
},
/**
* @description mode range
*/
rangeEndDesc: {
type: String,
default: '结束',
},
})
export const calendarEmits = {
[UPDATE_MODEL_EVENT]: (value: CalendarModelValueType) =>
isArray(value) || isString(value),
[CHANGE_EVENT]: (value: CalendarModelValueType) =>
isArray(value) || isString(value),
/**
* @description
*/
'change-year': (year: number) => isNumber(year),
/**
* @description
*/
'change-month': (month: number) => isNumber(month),
}
export type CalendarProps = ExtractPropTypes<typeof calendarProps>
export type CalendarEmits = typeof calendarEmits
export type CalendarMode = (typeof calendarModes)[number]
@@ -0,0 +1,150 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { calendarEmits, calendarProps } from './calendar'
import { useCalendar, useCalendarCustomStyle } from './composables'
const props = defineProps(calendarProps)
const emit = defineEmits(calendarEmits)
const {
reloadMonthSwiper,
calendarId,
dateContainerHeight,
calendarData,
weekText,
currentMonthIndex,
currentSelectedDate,
minDate,
maxDate,
swiperSwitchMonthEvent,
swiperSwitchMonthAnimationFinishEvent,
dateItemClickEvent,
switchMonth,
switchYear,
} = useCalendar(props, emit)
const { ns, itemClass, itemStyle } = useCalendarCustomStyle(props)
</script>
<template>
<view :id="calendarId" :class="[ns.b(), ns.m(mode)]">
<!-- 操作区域 -->
<view :class="[ns.e('operation')]">
<!-- 年切换按钮 -->
<view
v-if="allowChangeYear && currentSelectedDate.year !== minDate.year"
:class="[ns.e('operation__year-btn')]"
@tap.stop="switchYear('prev')"
>
<TnIcon name="left-triangle" />
</view>
<!-- 年月显示 -->
<view :class="[ns.e('operation__value')]">
{{ currentSelectedDate.year }}
</view>
<!-- 年切换按钮 -->
<view
v-if="allowChangeYear && currentSelectedDate.year !== maxDate.year"
:class="[ns.e('operation__year-btn')]"
@tap.stop="switchYear('next')"
>
<TnIcon name="right-triangle" />
</view>
</view>
<!-- 星期文字提示 -->
<view :class="[ns.e('week-text')]">
<view
v-for="(item, index) in weekText"
:key="index"
:class="[ns.e('week-text__item')]"
>
{{ item }}
</view>
</view>
<!-- 数据展示区域 -->
<view
:class="[ns.e('data')]"
:style="{
height: `${dateContainerHeight ? `${dateContainerHeight}px` : 'auto'}`,
}"
>
<!-- 月份背景 -->
<view :class="[ns.e('data__month-bg')]">
{{ currentSelectedDate.month }}
</view>
<!-- 月份切换按钮 -->
<view
v-if="
allowChangeMonth &&
!(
(currentSelectedDate.year === minDate.year &&
currentSelectedDate.month === minDate.month) ||
currentSelectedDate.month === 1
)
"
class="left"
:class="[ns.e('data__month-btn')]"
@tap.stop="switchMonth('prev')"
>
<TnIcon name="left" />
</view>
<view
v-if="
allowChangeMonth &&
!(
(currentSelectedDate.year === maxDate.year &&
currentSelectedDate.month === maxDate.month) ||
currentSelectedDate.month === 12
)
"
class="right"
:class="[ns.e('data__month-btn')]"
@tap.stop="switchMonth('next')"
>
<TnIcon name="right" />
</view>
<swiper
v-if="!reloadMonthSwiper"
:class="[ns.e('data__swiper')]"
:current="currentMonthIndex"
:indicator-dots="false"
:autoplay="false"
:circular="false"
adjust-height="none"
@change="swiperSwitchMonthEvent"
@animationfinish="swiperSwitchMonthAnimationFinishEvent"
>
<swiper-item
v-for="(item, index) in calendarData"
:key="index"
:class="[ns.e('data__swiper-item')]"
>
<view :class="[ns.e('data__dates')]">
<view
v-for="(dateItem, dateIndex) in item.data"
:key="dateIndex"
:class="[ns.e('data__date'), itemClass(dateItem.status)]"
:style="itemStyle(dateItem.status)"
>
<view
v-if="dateItem.date != 0"
:class="[ns.e('data__date__content')]"
@tap.stop="dateItemClickEvent(dateItem)"
>
<view class="date">{{ dateItem.date }}</view>
<view v-if="dateItem.desc" class="desc">
{{ dateItem.desc }}
</view>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/calendar.scss';
</style>
@@ -0,0 +1,94 @@
import { computed, toRef } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import type { CSSProperties } from 'vue'
import type { CalendarProps } from '../calendar'
import type { CalendarItemDateStatus } from '../types'
type itemClassType = (status: CalendarItemDateStatus) => string
type itemStyleType = (status: CalendarItemDateStatus) => CSSProperties
export const useCalendarCustomStyle = (props: CalendarProps) => {
const ns = useNamespace('calendar')
// 解析颜色
const [activeBgColorClass, activeBgColorStyle] = useComponentColor(
toRef(props, 'activeBgColor'),
'bg'
)
const [activeTextColorClass, activeTextColorStyle] = useComponentColor(
toRef(props, 'activeTextColor'),
'text'
)
const [rangeBgColorClass, rangeBgColorStyle] = useComponentColor(
toRef(props, 'rangeBgColor'),
'bg'
)
const [rangeTextColorClass, rangeTextColorStyle] = useComponentColor(
toRef(props, 'rangeTextColor'),
'text'
)
// dateItem对应的类
const itemClass = computed<itemClassType>(() => {
return (status: CalendarItemDateStatus) => {
const cls: string[] = [ns.is(status)]
if (status === 'active') {
if (activeBgColorClass.value) {
cls.push(activeBgColorClass.value)
}
if (activeTextColorClass.value) {
cls.push(activeTextColorClass.value)
}
} else if (status === 'range') {
if (rangeBgColorClass.value) {
cls.push(rangeBgColorClass.value)
}
if (rangeTextColorClass.value) {
cls.push(rangeTextColorClass.value)
}
}
return cls.join(' ')
}
})
// dateItem对应的样式
const itemStyle = computed<itemStyleType>(() => {
return (status: CalendarItemDateStatus) => {
const style: CSSProperties = {}
if (status === 'active') {
if (!activeBgColorClass.value) {
style.backgroundColor =
activeBgColorStyle.value || 'var(--tn-color-primary)'
}
if (activeTextColorStyle.value) {
style.color = activeTextColorStyle.value
} else if (!activeBgColorClass.value && !activeTextColorClass.value) {
style.color = 'var(--tn-color-white)'
}
} else if (status === 'range') {
if (!rangeBgColorClass.value) {
style.backgroundColor =
rangeBgColorStyle.value || 'var(--tn-color-primary-light-7)'
}
if (rangeTextColorStyle.value) {
style.color = rangeTextColorStyle.value
} else if (!rangeBgColorClass.value && !rangeTextColorClass.value) {
style.color = 'var(--tn-color-primary)'
}
}
return style
}
})
return {
ns,
itemClass,
itemStyle,
}
}
@@ -0,0 +1,2 @@
export * from './calendar-custom'
export * from './use-calendar'
@@ -0,0 +1,67 @@
import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
import { useSelectorQuery } from '../../../../hooks'
import { debugWarn, generateId } from '../../../../utils'
import type { Ref } from 'vue'
import type { CalendarMode } from '../calendar'
export const useCalendarSelector = (
currentDateCount: Ref<number>,
mode: CalendarMode
) => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnCalendar', '请在 setup 函数中使用 useCalendarSelector')
}
const calendarId = `tc-${generateId()}`
const { getSelectorNodeInfo } = useSelectorQuery(instance)
// 单个日期的高度
const singleDateItemHeight = ref<number>(0)
// 日期容器的高度
const dateContainerHeight = computed<number>(
() => Math.ceil(currentDateCount.value / 7) * singleDateItemHeight.value
)
let initCount = 0
// 获取单个日期容器的信息
const getDateItemComponentRectInfo = async () => {
try {
const rectInfo = await getSelectorNodeInfo(
`#${calendarId} .tn-calendar__data__date`
)
initCount = 0
singleDateItemHeight.value = rectInfo.width || 0
if (mode === 'date' || mode === 'multi') {
singleDateItemHeight.value += uni.upx2px(12)
}
} catch (err) {
if (initCount > 10) {
initCount = 0
debugWarn('TnCalendar', `获取单个日期容器信息失败: ${err}`)
return
}
initCount++
setTimeout(() => {
getDateItemComponentRectInfo()
}, 150)
}
}
onMounted(() => {
nextTick(() => {
setTimeout(() => {
getDateItemComponentRectInfo()
}, 50)
})
})
return {
calendarId,
dateContainerHeight,
}
}
@@ -0,0 +1,643 @@
import { computed, nextTick, reactive, ref, watch } from 'vue'
import dayjs from '../../../../libs/dayjs'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { debugWarn, isString } from '../../../../utils'
import { solar2lunar } from '../../libs/lunar-calendar'
import { useCalendarSelector } from './use-calendar-selector'
import type { SetupContext } from 'vue'
import type { Dayjs } from '../../../../libs/dayjs'
import type {
CalendarEmits,
CalendarModelValueType,
CalendarProps,
} from '../calendar'
import type {
CalendarData,
CalendarItem,
CalendarItemDateStatus,
CalendarMonthData,
CalendarRangeSelectData,
CalendarSelectDataMap,
DateData,
} from '../types'
// 默认的日期格式
const DEFAULT_DATE_FORMAT = 'YYYY/MM/DD'
// 格式化日期数据,将-替换为/
const formatDate = <T extends CalendarModelValueType>(date: T): T => {
if (!date || !date.length) return date
if (isString(date)) {
return date.replace(/-/g, '/') as T
}
return date.map((item) => item.replace(/-/g, '/')) as T
}
// 填充0
const fillDateZero = (date: number) => String(date).padStart(2, '0')
// 根据年月日生成日期字符串
const generateDateStr = (date: DateData) =>
`${date.year}/${fillDateZero(date.month)}/${fillDateZero(date.date)}`
export const useCalendar = (
props: CalendarProps,
emits: SetupContext<CalendarEmits>['emit']
) => {
// 重新加载月份swiper
const reloadMonthSwiper = ref<boolean>(false)
// 日历的数据
const calendarData = ref<CalendarData>([])
// 星期提示文字
const weekText = ref<string[]>(['日', '一', '二', '三', '四', '五', '六'])
// 最小年月日
const minDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 最大年月日
const maxDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 当前年月日
const currentDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 当前选中的年月日
const currentSelectedDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 日期范围内数据
const rangeDate = reactive<CalendarRangeSelectData>({})
// 更新modelValue
// 标记是否需要重新渲染日历
let needRenderCalendar = false
const updateModelValue = (changeEmit = true) => {
const { modelValue, mode } = props
const formatModelValue = formatDate(modelValue)
let value: CalendarModelValueType = ''
// 根据不同的模式,返回不同的值
switch (mode) {
case 'date':
// 如果当前选中的日期不为空,则先将之前选中日期的选中状态修改为normal
if (modelValue) {
const dateDayjs = dayjs(
formatModelValue as string,
DEFAULT_DATE_FORMAT
)
updateDateStatus(dateDayjs, 'normal')
}
// 设置当前选中日期为激活状态
updateDateStatus(currentSelectedDate, 'active')
value = generateDateStr(currentSelectedDate)
break
case 'multi':
// 判断当前选中的日期是否已经存在于modelValue中,如果存在,则将其从modelValue中移除
// eslint-disable-next-line no-case-declarations
const activeIndex = (formatModelValue as string[]).indexOf(
generateDateStr(currentSelectedDate)
)
if (formatModelValue.length && activeIndex !== -1) {
updateDateStatus(currentSelectedDate, 'normal')
;(formatModelValue as string[]).splice(activeIndex, 1)
value = [...(formatModelValue as string[])]
} else {
updateDateStatus(currentSelectedDate, 'active')
value = [
...(formatModelValue as string[]),
generateDateStr(currentSelectedDate),
]
}
break
case 'range':
// 如果已选的值为空或者长度为2,则将当前选中的日期设置为开始日期
if (
!formatModelValue ||
formatModelValue.length === 0 ||
formatModelValue.length === 2
) {
if (formatModelValue.length === 2) {
// 清空原来设置的时间
const startDateDayjs = dayjs(
(formatModelValue as string[])[0],
DEFAULT_DATE_FORMAT
)
const endDateDayjs = dayjs(
(formatModelValue as string[])[1],
DEFAULT_DATE_FORMAT
)
updateDateStatus(startDateDayjs, 'normal')
updateDateStatus(endDateDayjs, 'normal')
}
value = [generateDateStr(currentSelectedDate)]
} else {
// 判断当前选中的日期是否比开始日期还要着,如果是则重新设置开始日期
const currentDate = generateDateStr(currentSelectedDate)
if (rangeDate.start) {
if (rangeDate.start.isAfter(generateDateStr(currentSelectedDate))) {
value = [currentDate]
} else {
// 将选择后的日添加为结束日期
value = [
...(formatModelValue as string[]),
generateDateStr(currentSelectedDate),
]
}
}
}
needRenderCalendar = true
break
}
emits(UPDATE_MODEL_EVENT, value)
if (changeEmit) {
nextTick(() => {
emits(CHANGE_EVENT, value)
})
}
}
// 当前月份在中数据中的索引
const currentMonthIndex = computed(() => {
return calendarData.value.findIndex(
(item) => item.month === currentSelectedDate.month
)
})
// 当前月份中日期数据的长度
const currentMonthDateLength = computed(() => {
return calendarData.value[currentMonthIndex.value]?.data?.length || 0
})
// 处理容器信息
const { calendarId, dateContainerHeight } = useCalendarSelector(
currentMonthDateLength,
props.mode
)
// 保存当前设置的日期信息
const activeDateValueMap = new Map<number, CalendarSelectDataMap>()
// 生成日历数据
const generateCalendarData = () => {
const data: CalendarData = []
// 填充日期数据
const _fillDateData = (
month: number,
minDisabledDate = 0,
maxDisabledDate = 0
) => {
const monthData: CalendarMonthData = {
month,
data: [],
}
// 获取对应年份和月份
const activeDates = activeDateValueMap
.get(currentSelectedDate.year)
?.get(month)
// 获取当前月份的天数
const days = new Date(currentSelectedDate.year, month, 0).getDate()
// 获取当前月份的第一天是星期几
const firstDayWeek = new Date(
`${currentSelectedDate.year}/${month}/1`
).getDay()
// 填充空白数据
for (let i = 0; i < firstDayWeek; i++) {
monthData.data.push({
date: 0,
status: 'disabled',
})
}
// 填充日期数据
for (let i = 1; i <= days; i++) {
let status: CalendarItemDateStatus =
i < minDisabledDate || (maxDisabledDate && i > maxDisabledDate)
? 'disabled'
: 'normal'
if (activeDates?.includes(i)) status = 'active'
let desc = ''
if (props.showLunar) {
const lunarValue = solar2lunar(currentSelectedDate.year, month, i)
if (lunarValue !== -1) {
desc =
lunarValue.IDayCn === '初一'
? lunarValue.IMonthCn
: lunarValue.IDayCn
}
}
// 设置日期范围数据
if (props.mode === 'range') {
const { start, end } = rangeDate
if (
start &&
start.isSame(`${currentSelectedDate.year}/${month}/${i}`)
) {
status = 'active'
desc = props.rangeStartDesc
}
if (end && end.isSame(`${currentSelectedDate.year}/${month}/${i}`)) {
status = 'active'
desc = props.rangeEndDesc
}
if (start && end) {
// 判断是否在范围内
const currentDateDayjs = dayjs(
`${currentSelectedDate.year}/${month}/${i}`,
DEFAULT_DATE_FORMAT
)
if (
currentDateDayjs.isAfter(start) &&
currentDateDayjs.isBefore(end)
) {
status = 'range'
}
}
}
monthData.data.push({
date: i,
status,
desc,
})
}
return monthData
}
// 填充月份数据
let minMonth = 1
let maxMonth = 12
// 如果当前选中的年份等于最小年份,那么最小月份就是最小年份的月份
if (currentSelectedDate.year === minDate.year) {
minMonth = minDate.month
}
if (currentSelectedDate.year === maxDate.year) {
maxMonth = maxDate.month
}
if (minMonth === 0 || maxMonth === 0) return
for (let i = minMonth; i <= maxMonth; i++) {
let minDisabledDate = 0
let maxDisabledDate = 0
// 如果当前选中的年份等于最小年份,那么最小月份就是最小年份的月份
if (currentSelectedDate.year === minDate.year && i === minMonth) {
minDisabledDate = minDate.date
maxDisabledDate = 0
}
if (currentSelectedDate.year === maxDate.year && i === maxMonth) {
minDisabledDate = 0
maxDisabledDate = maxDate.date
}
data.push(_fillDateData(i, minDisabledDate, maxDisabledDate))
}
calendarData.value = data
}
watch(
() => props.modelValue,
(val: CalendarModelValueType) => {
if (val || val.length) {
const { mode } = props
let modelValue: string[] = []
if (mode === 'date') modelValue = [val as string]
else modelValue = val as string[]
activeDateValueMap.clear()
// 遍历获取对应已设置的日期信息
modelValue.forEach((item) => {
const dateDayjs = dayjs(formatDate(item), DEFAULT_DATE_FORMAT)
const year = dateDayjs.year()
const month = dateDayjs.month() + 1
const date = dateDayjs.date()
// 设置当前激活的年份和月份
if (currentSelectedDate.year === 0) {
currentSelectedDate.year = year
currentSelectedDate.month = month
}
if (activeDateValueMap.has(year)) {
let monthDates = activeDateValueMap.get(year)?.get(month)
if (!monthDates) monthDates = [date]
else monthDates.push(date)
activeDateValueMap.get(year)?.set(month, monthDates)
} else {
activeDateValueMap.set(year, new Map([[month, [date]]]))
}
})
// 如果是选择日期范围,则设置开始和结束时间
if (mode === 'range') {
rangeDate.start = undefined
rangeDate.end = undefined
if (modelValue?.[0]) {
// 开始时间
rangeDate.start = dayjs(modelValue[0], DEFAULT_DATE_FORMAT)
}
if (modelValue?.[1]) {
// 结束时间
rangeDate.end = dayjs(modelValue[1], DEFAULT_DATE_FORMAT)
}
}
if (needRenderCalendar) {
needRenderCalendar = false
nextTick(() => {
generateCalendarData()
})
}
}
},
{
immediate: true,
deep: true,
}
)
// 根据传递的日期,设置当前日期的状态
const updateDateStatus = (
dateData: DateData | Dayjs,
status: CalendarItemDateStatus
) => {
let month = 0
let date = 0
if (dateData.toString() === '[object Object]') {
month = (dateData as DateData).month
date = (dateData as DateData).date
} else {
month = (dateData as Dayjs).month() + 1
date = (dateData as Dayjs).date()
}
const monthIndex = calendarData.value.findIndex(
(item) => item.month === month
)
if (monthIndex === -1) return
const dateIndex = calendarData.value[monthIndex].data.findIndex(
(item) => item.date === date
)
if (dateIndex === -1) return
calendarData.value[monthIndex].data[dateIndex].status = status
}
// 初始化最小、最大年月日
const initDateData = () => {
let { minDate: _minDate, maxDate: _maxDate, modelValue, mode } = props
// 当前时间的 dayjs 对象
const currentDayjs = dayjs()
currentDate.year = currentDayjs.year()
currentDate.month = currentDayjs.month() + 1
currentDate.date = currentDayjs.date()
// 如果没有设置最小值,那么最小值就是当前年月日
if (!_minDate) {
_minDate = currentDayjs.format(DEFAULT_DATE_FORMAT)
} else {
_minDate = formatDate(_minDate)
}
const minDateDayjs = dayjs(_minDate, DEFAULT_DATE_FORMAT)
minDate.year = minDateDayjs.year()
minDate.month = minDateDayjs.month() + 1
minDate.date = minDateDayjs.date()
// 如果没有设置最大值,那么最大值就是当前年份最后一个月的最后一天
if (!_maxDate) {
_maxDate = currentDayjs.endOf('year').format(DEFAULT_DATE_FORMAT)
} else {
_maxDate = formatDate(_maxDate)
}
const maxDateDayjs = dayjs(_maxDate, DEFAULT_DATE_FORMAT)
maxDate.year = maxDateDayjs.year()
maxDate.month = maxDateDayjs.month() + 1
maxDate.date = maxDateDayjs.date()
let noGenerateAfterInit = false
// 如果没有设置默认值,那么默认值就是当前年月
if (!modelValue || !modelValue?.length) {
currentSelectedDate.year = Math.max(currentDate.year, minDate.year)
currentSelectedDate.month =
currentDate.year === minDate.year
? Math.max(currentDate.month, minDate.month)
: currentDate.month
// 如果当前是单选日期模式,那么默认选中当前日期
if (
mode === 'date' &&
currentSelectedDate.year === currentDate.year &&
currentSelectedDate.month === currentDate.month
) {
currentSelectedDate.date = currentDate.date
emits(UPDATE_MODEL_EVENT, generateDateStr(currentSelectedDate))
}
} else {
let initDefaultDate = ''
switch (mode) {
case 'date':
initDefaultDate = formatDate(modelValue as string)
// 判断设置的默认值是否在最小、最大年月日范围内,如果不在,那么默认值就是最小、最大年月日
if (minDateDayjs.isAfter(initDefaultDate)) {
initDefaultDate = minDateDayjs.format(DEFAULT_DATE_FORMAT)
emits(UPDATE_MODEL_EVENT, initDefaultDate)
}
if (maxDateDayjs.isBefore(initDefaultDate)) {
initDefaultDate = maxDateDayjs.format(DEFAULT_DATE_FORMAT)
emits(UPDATE_MODEL_EVENT, initDefaultDate)
}
break
case 'multi':
// 筛选出在最小、最大年月日范围内的日期
// eslint-disable-next-line no-case-declarations
const multiDefaultModelValue = (modelValue as string[]).filter(
(date) => {
const dateDayjs = dayjs(date)
return (
(dateDayjs.isAfter(minDateDayjs) &&
dateDayjs.isBefore(maxDateDayjs)) ||
dateDayjs.isSame(minDateDayjs) ||
dateDayjs.isSame(maxDateDayjs)
)
}
)
if (
multiDefaultModelValue.length !== (modelValue as string[]).length
) {
emits(UPDATE_MODEL_EVENT, multiDefaultModelValue)
}
initDefaultDate = multiDefaultModelValue[0]
break
case 'range':
if (modelValue.length !== 2) {
debugWarn('TnCalendar', '在 range 模式下 modelValue 长度必须为 2')
return
}
// eslint-disable-next-line no-case-declarations
const rangeMinDateDayjs = dayjs((modelValue as string[])[0])
// eslint-disable-next-line no-case-declarations
const rangeMaxDateDayjs = dayjs((modelValue as string[])[1])
// 判断是否超过最大范围
if (
rangeMinDateDayjs.isAfter(maxDateDayjs) ||
rangeMaxDateDayjs.isBefore(minDateDayjs)
) {
debugWarn('TnCalendar', '在 range 模式下 modelValue 超过最大范围')
return
}
// eslint-disable-next-line no-case-declarations
const rangeDefaultModelValue = modelValue as string[]
if (rangeMinDateDayjs.isBefore(minDateDayjs)) {
rangeDefaultModelValue[0] = minDateDayjs.format(DEFAULT_DATE_FORMAT)
initDefaultDate = rangeDefaultModelValue[0]
}
if (rangeMaxDateDayjs.isAfter(maxDateDayjs)) {
rangeDefaultModelValue[1] = maxDateDayjs.format(DEFAULT_DATE_FORMAT)
}
emits(UPDATE_MODEL_EVENT, rangeDefaultModelValue)
noGenerateAfterInit = true
break
}
if (initDefaultDate) {
const initDefaultDateDayjs = dayjs(initDefaultDate, DEFAULT_DATE_FORMAT)
currentSelectedDate.year = initDefaultDateDayjs.year()
currentSelectedDate.month = initDefaultDateDayjs.month() + 1
currentSelectedDate.date = initDefaultDateDayjs.date()
}
}
if (!noGenerateAfterInit || calendarData.value.length === 0) {
nextTick(() => {
// 生成日历数据
generateCalendarData()
})
}
}
initDateData()
// 如果修改了最小、最大年月日,那么重新初始化最小、最大年月日
watch(
() => [props.minDate, props.maxDate],
() => {
initDateData()
}
)
// dateItem点击事件
const dateItemClickEvent = (date: CalendarItem) => {
if (
(date.status === 'active' && props.mode === 'date') ||
date.status === 'disabled' ||
date.date === 0
)
return
currentSelectedDate.date = date.date
updateModelValue()
}
// 滑动切换月份事件
const swiperSwitchMonthEvent = (event: any) => {
const { current } = event.detail
currentSelectedDate.month = calendarData.value[current].month
clearSwiperAnimationFlagTimer = setTimeout(() => {
swiperSwitchAnimationFinish = true
if (clearSwiperAnimationFlagTimer) {
clearTimeout(clearSwiperAnimationFlagTimer)
clearSwiperAnimationFlagTimer = null
}
}, 300)
emits('change-month', currentSelectedDate.month)
}
// 月份切换动画结束
let swiperSwitchAnimationFinish = true
let clearSwiperAnimationFlagTimer: ReturnType<typeof setTimeout> | null = null
const swiperSwitchMonthAnimationFinishEvent = () => {
swiperSwitchAnimationFinish = true
if (clearSwiperAnimationFlagTimer) {
clearTimeout(clearSwiperAnimationFlagTimer)
clearSwiperAnimationFlagTimer = null
}
}
// 切换月份
const switchMonth = (type: 'prev' | 'next') => {
if (!props.allowChangeMonth || !swiperSwitchAnimationFinish) return
swiperSwitchAnimationFinish = false
if (type === 'prev') {
if (
(currentSelectedDate.year === minDate.year &&
currentSelectedDate.month === minDate.month) ||
currentSelectedDate.month === 1
)
return
currentSelectedDate.month--
} else {
if (
(currentSelectedDate.year === maxDate.year &&
currentSelectedDate.month === maxDate.month) ||
currentSelectedDate.month === 12
)
return
currentSelectedDate.month++
}
}
// 切换年份
const switchYear = (type: 'prev' | 'next') => {
if (!props.allowChangeYear) return
if (type === 'prev') {
if (currentSelectedDate.year === minDate.year) return
currentSelectedDate.year--
currentSelectedDate.month = 12
currentSelectedDate.date = 31
} else {
if (currentSelectedDate.year === maxDate.year) return
currentSelectedDate.year++
currentSelectedDate.month = 1
currentSelectedDate.date = 1
}
emits('change-year', currentSelectedDate.year)
reloadMonthSwiper.value = true
setTimeout(() => {
reloadMonthSwiper.value = false
}, 0)
// 重新生产日历数据
generateCalendarData()
}
return {
reloadMonthSwiper,
calendarId,
weekText,
calendarData,
currentMonthIndex,
dateContainerHeight,
minDate,
maxDate,
currentSelectedDate,
swiperSwitchMonthEvent,
swiperSwitchMonthAnimationFinishEvent,
dateItemClickEvent,
switchMonth,
switchYear,
}
}
@@ -0,0 +1,3 @@
import type Calendar from './calendar.vue'
export type TnCalendarInstance = InstanceType<typeof Calendar>
+43
View File
@@ -0,0 +1,43 @@
import type { Dayjs } from '../../../libs/dayjs'
export type CalendarItemDateStatus = 'normal' | 'active' | 'range' | 'disabled'
export interface CalendarItem {
/**
* @description
*/
date: number
/**
* @description
*/
status: CalendarItemDateStatus
/**
* @description
*/
desc?: string
}
export interface CalendarMonthData {
/**
* @description
*/
month: number
/**
* @description
*/
data: CalendarItem[]
}
export type CalendarData = CalendarMonthData[]
export interface DateData {
year: number
month: number
date: number
}
export type CalendarSelectDataMap = Map<number, number[]>
export type CalendarRangeSelectData = {
start?: Dayjs
end?: Dayjs
}
+13
View File
@@ -0,0 +1,13 @@
import { withInstall, withNoopInstall } from '../../utils'
import Checkbox from './src/checkbox.vue'
import CheckboxGroup from './src/checkbox-group.vue'
export const TnCheckbox = withInstall(Checkbox, {
CheckboxGroup,
})
export default TnCheckbox
export const TnCheckboxGroup = withNoopInstall(CheckboxGroup)
export * from './src/checkbox'
export * from './src/checkbox-group'
export type { CheckboxInstance, CheckboxGroupInstance } from './src/instance'
@@ -0,0 +1,49 @@
import { buildProps, definePropType, isArray } from '../../../utils'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import { checkboxBaseProps } from '../../base/common-props/checkbox'
import type { ExtractPropTypes } from 'vue'
import type { CheckboxValueType } from '../../base/types/checkbox'
export type CheckboxGroupValueType = Exclude<CheckboxValueType, boolean>[]
export const checkboxGroupProps = buildProps({
...checkboxBaseProps,
/**
* @description
*/
modelValue: {
type: definePropType<CheckboxGroupValueType>(Array),
default: () => [],
},
/**
* @description
*/
min: Number,
/**
* @description
*/
max: Number,
/**
* @description
*/
wrap: {
type: Boolean,
default: false,
},
/**
* @description
*/
validateEvent: {
type: Boolean,
default: true,
},
})
export const checkboxGroupEmits = {
[UPDATE_MODEL_EVENT]: (value: CheckboxGroupValueType) => isArray(value),
[CHANGE_EVENT]: (value: CheckboxGroupValueType) => isArray(value),
}
export type CheckboxGroupProps = ExtractPropTypes<typeof checkboxGroupProps>
export type CheckboxGroupEmits = typeof checkboxGroupEmits
@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { provide, reactive, toRefs } from 'vue'
import { checkboxGroupKey } from '../../../tokens'
import { useNamespace } from '../../../hooks'
import { checkboxGroupEmits, checkboxGroupProps } from './checkbox-group'
import { useCheckboxGroup } from './composables'
const props = defineProps(checkboxGroupProps)
const emits = defineEmits(checkboxGroupEmits)
const ns = useNamespace('checkbox-group')
const { changeEvent } = useCheckboxGroup(props, emits)
provide(
checkboxGroupKey,
reactive({
...toRefs(props),
changeEvent,
})
)
</script>
<template>
<view :class="[ns.b(), ns.is('wrap', props.wrap)]">
<slot />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/checkbox-group.scss';
</style>
@@ -0,0 +1,67 @@
import { buildProps, isBoolean, isNumber, isString } from '../../../utils'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import { checkboxBaseProps } from '../../base/common-props/checkbox'
import { useComponentCustomStyleProp } from '../../base/composables/use-component-common-props'
import type { ExtractPropTypes } from 'vue'
import type { CheckboxValueType } from '../../base/types/checkbox'
export const checkboxProps = buildProps({
...checkboxBaseProps,
/**
* @description
*/
modelValue: {
type: [String, Number, Boolean],
default: undefined,
},
/**
* @description
*/
label: {
type: [String, Number],
},
/**
* @description
*/
indeterminate: Boolean,
/**
* @description
*/
activeValue: {
type: [String, Number, Boolean],
default: true,
},
/**
* @description
*/
inactiveValue: {
type: [String, Number, Boolean],
default: false,
},
/**
* @description
*/
customStyle: useComponentCustomStyleProp,
/**
* @description
*/
customClass: String,
/**
* @description
*/
validateEvent: {
type: Boolean,
default: true,
},
})
export const checkboxEmits = {
[UPDATE_MODEL_EVENT]: (value: CheckboxValueType) =>
isString(value) || isNumber(value) || isBoolean(value),
[CHANGE_EVENT]: (value: CheckboxValueType) =>
isString(value) || isNumber(value) || isBoolean(value),
}
export type CheckboxProps = ExtractPropTypes<typeof checkboxProps>
export type CheckboxEmits = typeof checkboxEmits
@@ -0,0 +1,53 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { checkboxEmits, checkboxProps } from './checkbox'
import { useCheckbox, useCheckboxCustomStyle } from './composables'
const props = defineProps(checkboxProps)
defineEmits(checkboxEmits)
const { isGroup, selected, handleCheckboxClick } = useCheckbox(props)
const {
ns,
checkboxClass,
checkboxStyle,
checkboxCheckedBoxClass,
checkboxCheckedBoxStyle,
} = useCheckboxCustomStyle(Object.assign(props))
</script>
<template>
<view
:class="[checkboxClass(selected), { [ns.m('group')]: isGroup }]"
:style="checkboxStyle(selected)"
@tap.stop="handleCheckboxClick('label')"
>
<!-- 左边内容 -->
<view
v-if="$slots.left && !$slots.default"
:class="[ns.em('content', 'left')]"
>
<slot name="left" />
</view>
<!-- 选择框 -->
<view
:class="[
checkboxCheckedBoxClass(selected),
{ [ns.em('checked-box', 'indeterminate')]: !selected && indeterminate },
]"
:style="checkboxCheckedBoxStyle(selected)"
@tap.stop="handleCheckboxClick('checkbox')"
>
<TnIcon v-if="selected" name="check" />
</view>
<!-- 右边内容 -->
<view v-if="$slots.default" :class="[ns.em('content', 'right')]">
<slot />
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/checkbox.scss';
</style>
@@ -0,0 +1,116 @@
import { computed } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import { isEmpty } from '../../../../utils'
import { useCheckboxCommonProps } from './use-checkbox-common-props'
import type { CSSProperties } from 'vue'
import type { CheckboxProps } from '../checkbox'
type selectedClass = (selected: boolean) => string
type selectedStyle = (selected: boolean) => CSSProperties
export const useCheckboxCustomStyle = (props: CheckboxProps) => {
const ns = useNamespace('checkbox')
const { activeColor, disabled, maxDisabled, size, border, checkedShape } =
useCheckboxCommonProps(props)
// 解析颜色
const [bgColorClass, bgColorStyle] = useComponentColor(activeColor, 'bg')
const [textColorClass, textColorStyle] = useComponentColor(
activeColor,
'text'
)
const [borderColorClass, borderColorStyle] = useComponentColor(
activeColor,
'border'
)
// 复选框所属类
const checkboxClass = computed<selectedClass>(() => {
return (selected: boolean) => {
const cls: string[] = [ns.b()]
// 禁止选择
if (disabled.value || maxDisabled.value) cls.push(ns.m('disabled'))
// 设置尺寸
if (size.value) cls.push(ns.m(size.value))
// 激活样式
if (selected) {
cls.push(ns.m('selected'))
if (textColorClass.value) cls.push(textColorClass.value)
}
// 设置激活时的边框颜色
if (border.value) {
cls.push('tn-border')
if (selected && borderColorClass.value) cls.push(borderColorClass.value)
else cls.push('tn-gray-disabled_border')
} else {
cls.push(ns.m('no-border'))
}
if (props.customClass) cls.push(props.customClass)
return cls.join(' ')
}
})
// 复选框所属样式
const checkboxStyle = computed<selectedStyle>(() => {
return (selected: boolean) => {
const style: CSSProperties = {}
// 设置激活时的颜色
if (selected) {
if (border.value && !borderColorClass.value)
style.borderColor =
borderColorStyle.value || 'var(--tn-color-primary)'
if (!textColorClass.value) {
style.color = textColorStyle.value || 'var(--tn-color-primary)'
}
}
if (!isEmpty(props.customStyle)) Object.assign(style, props.customStyle)
return style
}
})
// 复选框选框所属类
const checkboxCheckedBoxClass = computed<selectedClass>(() => {
return (selected: boolean) => {
const cls: string[] = [ns.e('checked-box')]
// 复选框选框的形状
if (checkedShape.value) cls.push(ns.em('checked-box', checkedShape.value))
if (selected || props.indeterminate) {
cls.push(ns.em('checked-box', 'selected'))
if (bgColorClass.value) cls.push(bgColorClass.value)
} else {
cls.push('tn-border tn-gray-disabled_border')
}
return cls.join(' ')
}
})
// 复选框选框所属样式
const checkboxCheckedBoxStyle = computed<selectedStyle>(() => {
return (selected: boolean) => {
const style: CSSProperties = {}
if ((selected || props.indeterminate) && !bgColorClass.value) {
style.backgroundColor = bgColorStyle.value || 'var(--tn-color-primary)'
}
return style
}
})
return {
ns,
checkboxClass,
checkboxStyle,
checkboxCheckedBoxClass,
checkboxCheckedBoxStyle,
}
}
@@ -0,0 +1,4 @@
export * from './use-checkbox-common-props'
export * from './use-checkbox'
export * from './use-checkbox-group'
export * from './checkbox-custom'
@@ -0,0 +1,63 @@
import { computed, inject } from 'vue'
import { checkboxGroupKey } from '../../../../tokens'
import { useFormDisabled, useFormSize } from '../../../form'
import { isEmptyDoubleVariableInDefault } from '../../../../utils'
import type { CheckboxProps } from '../checkbox'
export const useCheckboxCommonProps = (props: CheckboxProps) => {
const checkboxGroup = inject(checkboxGroupKey, undefined)
// 组件尺寸
const size = useFormSize(
computed(() =>
isEmptyDoubleVariableInDefault(props?.size, checkboxGroup?.size)
)
)
// 复选框选框的形状
const checkedShape = computed(() =>
isEmptyDoubleVariableInDefault(
props?.checkedShape,
checkboxGroup?.checkedShape,
'square'
)
)
// 是否禁用
const disabled = useFormDisabled(
computed(() => props?.disabled || checkboxGroup?.disabled || false)
)
const maxDisabled = computed(
() =>
checkboxGroup?.modelValue &&
checkboxGroup?.max &&
checkboxGroup?.modelValue.length >= checkboxGroup?.max &&
!checkboxGroup?.modelValue.includes(props.label!)
)
// 禁止点击标签进行选择
const labelDisabled = computed(
() => props?.labelDisabled || checkboxGroup?.labelDisabled || false
)
// 是否显示边框
const border = computed(() => props?.border || checkboxGroup?.border || false)
// radio激活时的颜色
const activeColor = computed(
() => props?.activeColor || checkboxGroup?.activeColor
)
return {
checkboxGroup,
size,
checkedShape,
disabled,
maxDisabled,
labelDisabled,
border,
activeColor,
}
}
@@ -0,0 +1,56 @@
import { nextTick, watch } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { useFormItem } from '../../../form'
import { debugWarn } from '../../../../utils'
import type { SetupContext } from 'vue'
import type {
CheckboxGroupEmits,
CheckboxGroupProps,
CheckboxGroupValueType,
} from '../checkbox-group'
export const useCheckboxGroup = (
props: CheckboxGroupProps,
emits: SetupContext<CheckboxGroupEmits>['emit']
) => {
const { formItem } = useFormItem()
// 更新复选框组的值
const changeEvent = (val: CheckboxGroupValueType[number]) => {
const { modelValue, max } = props
const selectValues = [...modelValue]
// 判断更新的值是否已经存在于复选框组中
const hasLabel = selectValues.includes(val)
if (hasLabel) {
// 存在则删除
selectValues.splice(selectValues.indexOf(val), 1)
} else {
// 不存在则添加,判断是否超过最大值
if (max && selectValues.length >= max) {
return
}
// 如果没有设置最大值,则直接添加
selectValues.push(val)
}
emits(UPDATE_MODEL_EVENT, selectValues)
nextTick(() => {
emits(CHANGE_EVENT, selectValues)
})
}
watch(
() => props.modelValue,
() => {
if (props.validateEvent) {
formItem?.validate?.('change').catch((err) => {
debugWarn(err)
})
}
}
)
return {
changeEvent,
}
}
@@ -0,0 +1,64 @@
import { computed, getCurrentInstance, nextTick } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { useFormItem } from '../../../form'
import { debugWarn } from '../../../../utils'
import { useCheckboxCommonProps } from './use-checkbox-common-props'
import type { CheckboxProps } from '../checkbox'
import type { CheckboxValueType } from '../../../base/types/checkbox'
// 判断复选框组中是否包含某个值
const hasLabelInGroup = (
groupValue: CheckboxValueType[],
label: CheckboxValueType
) => groupValue.includes(label)
export const useCheckbox = (props: CheckboxProps) => {
const { emit } = getCurrentInstance()!
const { checkboxGroup, disabled, maxDisabled, labelDisabled } =
useCheckboxCommonProps(props)
const { formItem } = useFormItem()
// 判断是否为复选组
const isGroup = computed(() => !!checkboxGroup)
// 在复选组中是否选中当前复选框
const selected = computed(() => {
if (isGroup.value) {
return hasLabelInGroup(checkboxGroup!.modelValue, props.label!)
} else {
return props.modelValue === props.activeValue
}
})
// 复选框点击事件
const handleCheckboxClick = (type: 'checkbox' | 'label') => {
if (disabled.value || maxDisabled.value) return
if (type === 'label' && labelDisabled.value) return
if (isGroup.value) {
checkboxGroup!.changeEvent(props.label!)
} else {
const modelValue = selected.value
? props.inactiveValue
: props.activeValue
emit(UPDATE_MODEL_EVENT, modelValue)
nextTick(() => {
emit(CHANGE_EVENT, modelValue)
})
if (props.validateEvent) {
formItem?.validate?.('change').catch((err) => {
debugWarn(err)
})
}
}
}
return {
isGroup,
selected,
handleCheckboxClick,
}
}
@@ -0,0 +1,5 @@
import type Checkbox from './checkbox.vue'
import type CheckboxGroup from './checkbox-group.vue'
export type CheckboxInstance = InstanceType<typeof Checkbox>
export type CheckboxGroupInstance = InstanceType<typeof CheckboxGroup>
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import circleProgress from './src/circle-progress.vue'
export const TnCircleProgress = withNoopInstall(circleProgress)
export default TnCircleProgress
export * from './src/circle-progress'
export type { TnCircleProgressInstance } from './src/instance'
@@ -0,0 +1,24 @@
import { buildProps } from '../../../utils'
import { propgressBaseProps } from '../../base/common-props/progress'
import type { ExtractPropTypes } from 'vue'
export const circleProgressProps = buildProps({
...propgressBaseProps,
/**
* @description px
*/
radius: {
type: Number,
default: 50,
},
/**
* @description px
*/
ringWidth: {
type: Number,
default: 7,
},
})
export type CircleProgressProps = ExtractPropTypes<typeof circleProgressProps>
@@ -0,0 +1,29 @@
<script lang="ts" setup>
import { circleProgressProps } from './circle-progress'
import { useCircleProgress } from './composables'
const props = defineProps(circleProgressProps)
const { ns, canvasId, radius, activeCircleColor } = useCircleProgress(props)
</script>
<template>
<view
:class="[ns.b()]"
:style="{
width: `${radius * 2}px`,
height: `${radius * 2}px`,
color: activeCircleColor,
}"
>
<!-- 默认圆环 -->
<canvas :id="canvasId" :class="[ns.e('canvas')]" :canvas-id="canvasId" />
<!-- 数值 -->
<view v-if="showPercent || $slots.default" :class="[ns.e('precent-value')]">
<slot> {{ percent }}% </slot>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/circle-progress.scss';
</style>
@@ -0,0 +1 @@
export * from './use-circle-progress'
@@ -0,0 +1,146 @@
import { computed, getCurrentInstance, nextTick, watch } from 'vue'
import { useNamespace } from '../../../../hooks'
import { generateId, isEmptyVariableInDefault } from '../../../../utils'
import type { ComponentInternalInstance } from 'vue'
import type { CircleProgressProps } from '../circle-progress'
export const useCircleProgress = (props: CircleProgressProps) => {
const instance = getCurrentInstance() as ComponentInternalInstance
const ns = useNamespace('circle-progress')
// 圆环的直径
const radius = computed<number>(() => {
return isEmptyVariableInDefault(props?.radius, 50)
})
// 圆环的宽度
const ringWidth = computed<number>(() => {
return isEmptyVariableInDefault(props?.ringWidth, 14)
})
// 圆环的颜色
const circleColor = computed<string>(() => {
return isEmptyVariableInDefault(props?.inactiveColor, '#e6e6e6')
})
// 圆环激活时的颜色
const activeCircleColor = computed<string>(() => {
return isEmptyVariableInDefault(props?.activeColor, '#01beff')
})
// 动画执行时间
const duration = computed<number>(() => {
return isEmptyVariableInDefault(props?.duration, 1500)
})
// 进度信息,为了产生动画效果,需要在进度条变化时,将进度信息保存下来
let currentPercent = 0
let prevPercent = 0
// 生成canvas圆环id
const canvasId = String(generateId())
// canvas容器对象
let progressCanvas: UniApp.CanvasContext | null = null
// 圆环开始角度
const startAngle = -90 * (Math.PI / 180)
// 绘制progress圆环
const drawProgressCircle = (percent: number) => {
if (!progressCanvas) {
progressCanvas = uni.createCanvasContext(canvasId, instance)
}
// 清空画布
progressCanvas.clearRect(0, 0, radius.value * 2, radius.value * 2)
// 绘制底部圆环
progressCanvas.beginPath()
// 设置颜色\线框
progressCanvas.setLineWidth(ringWidth.value)
progressCanvas.setStrokeStyle(circleColor.value)
// 绘制圆环
progressCanvas.arc(
radius.value,
radius.value,
radius.value - ringWidth.value / 2,
startAngle,
Math.PI * 1.5,
false
)
progressCanvas.stroke()
// 如果进度为0,不绘制进度圆环
if (percent === 0) {
progressCanvas.draw()
return
}
// 绘制进度圆环
progressCanvas.beginPath()
// 设置颜色\线框
progressCanvas.setLineCap('round')
progressCanvas.setLineWidth(ringWidth.value)
progressCanvas.setStrokeStyle(activeCircleColor.value)
// 结束角度
const endAngle = (Math.PI * 2 * percent) / 100 - Math.PI / 2
progressCanvas.arc(
radius.value,
radius.value,
radius.value - ringWidth.value / 2,
startAngle,
endAngle,
false
)
progressCanvas.stroke()
progressCanvas.draw()
}
// 计算缓动动画时间函数
function easeOutCubic(t: number, b: number, c: number, d: number) {
return c * ((t = t / d - 1) * t * t + 1) + b
}
// 开始执行动画
let startTime: number | null = null
const progressAnimation = () => {
if (!startTime) startTime = Date.now()
const elapsed = Date.now() - startTime
let percent = easeOutCubic(
elapsed,
prevPercent,
currentPercent - prevPercent,
duration.value
)
if (percent < 0) percent = 0
drawProgressCircle(percent)
if (elapsed < duration.value) {
setTimeout(progressAnimation, 16)
}
}
watch(
() => props.percent,
(nVal: number, oVal: number | undefined) => {
currentPercent = nVal > 100 ? 100 : nVal
prevPercent = !oVal || oVal < 0 ? 0 : oVal
nextTick(() => {
startTime = null
progressAnimation()
})
},
{
immediate: true,
}
)
return {
ns,
canvasId,
radius,
activeCircleColor,
}
}
@@ -0,0 +1,3 @@
import type CircleProgress from './circle-progress.vue'
export type TnCircleProgressInstance = InstanceType<typeof CircleProgress>
+14
View File
@@ -0,0 +1,14 @@
import { withInstall, withNoopInstall } from '../../utils'
import Collapse from './src/collapse.vue'
import CollapseItem from './src/collapse-item.vue'
export const TnCollapse = withInstall(Collapse, {
CollapseItem,
})
export default TnCollapse
export const TnCollapseItem = withNoopInstall(CollapseItem)
export * from './src/collapse'
export * from './src/collapse-item'
export type { TnCollapseInstance, TnCollapseItemInstance } from './src/instance'
@@ -0,0 +1,16 @@
import { buildProps } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export const collapseItemProps = buildProps({
/**
* @description CollapseItem标题
*/
title: String,
/**
* @description CollapseItem是否禁用
*/
disabled: Boolean,
})
export type CollapseItemProps = ExtractPropTypes<typeof collapseItemProps>
@@ -0,0 +1,60 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { collapseItemProps } from './collapse-item'
import { useCollapseItem, useCollapseItemCustomStyle } from './composables'
const props = defineProps(collapseItemProps)
const {
componentContentId,
showArrow,
isActive,
componentHeight,
collapseItemClick,
} = useCollapseItem(props)
const { ns, arrowClass, arrowStyle } = useCollapseItemCustomStyle()
</script>
// #ifdef MP-WEIXIN
<script lang="ts">
export default {
options: {
// Vue(shadow)
virtualHost: true,
},
}
</script>
// #endif
<template>
<view
:class="[ns.b(), ns.is('active', isActive), ns.is('disabled', disabled)]"
:style="{ height: componentHeight }"
>
<!-- 标题 -->
<view :class="[ns.e('title')]" @tap.stop="collapseItemClick">
<view class="content tn-text-ellipsis-1">
<slot name="title">
{{ title }}
</slot>
</view>
<view
v-if="showArrow"
class="arrow"
:class="[arrowClass]"
:style="arrowStyle"
>
<TnIcon name="right" />
</view>
</view>
<!-- 内容区域 -->
<view :id="componentContentId" :class="[ns.e('content')]">
<slot />
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/collapse-item.scss';
</style>
@@ -0,0 +1,50 @@
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import {
buildProps,
definePropType,
isArray,
isNumber,
isString,
} from '../../../utils'
import type { ExtractPropTypes } from 'vue'
import type { Arrayable } from '../../../utils'
export type CollapseModelValue = Arrayable<number>
export const collapseProps = buildProps({
/**
* @description stringstring[]
*/
modelValue: {
type: definePropType<CollapseModelValue>([Number, Array]),
},
/**
* @description
*/
accordion: {
type: Boolean,
default: true,
},
/**
* @description
*/
showArrow: {
type: Boolean,
default: true,
},
/**
* @description show-arrow为true时生效tn开头的颜色使用图鸟内置的颜色
*/
arrowColor: String,
})
export const collapseEmits = {
[UPDATE_MODEL_EVENT]: (value: CollapseModelValue) =>
isArray(value) || isString(value) || isNumber(value),
[CHANGE_EVENT]: (value: CollapseModelValue) =>
isArray(value) || isString(value) || isNumber(value),
}
export type CollapseProps = ExtractPropTypes<typeof collapseProps>
export type CollapseEmits = typeof collapseEmits
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { useNamespace } from '../../../hooks'
import { collapseEmits, collapseProps } from './collapse'
import { useCollapse } from './composables'
const props = defineProps(collapseProps)
const emits = defineEmits(collapseEmits)
const ns = useNamespace('collapse')
useCollapse(props, emits)
</script>
<template>
<view :class="[ns.b()]">
<slot />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/collapse.scss';
</style>
@@ -0,0 +1,41 @@
import { computed, inject, toRef } from 'vue'
import { collapseContextKey } from '../../../../tokens'
import { useComponentColor, useNamespace } from '../../../../hooks'
import type { CSSProperties } from 'vue'
export const useCollapseItemCustomStyle = () => {
const ns = useNamespace('collapse-item')
const collapse = inject(collapseContextKey)
// 解析颜色
const [arrowColorClass, arrowColorStyle] = useComponentColor(
toRef(collapse!, 'arrowColor'),
'text'
)
// 折叠面板图标对应样式
const arrowClass = computed<string>(() => {
const cls: string[] = []
if (arrowColorClass.value) cls.push(arrowColorClass.value)
return cls.join(' ')
})
// 折叠面板图标对应样式
const arrowStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!arrowColorClass.value)
style.color = arrowColorStyle.value || 'var(--tn-color-gray)'
return style
})
return {
ns,
arrowClass,
arrowStyle,
}
}
@@ -0,0 +1,3 @@
export * from './collapse-item-custom'
export * from './use-collapse'
export * from './use-collapse-item'
@@ -0,0 +1,98 @@
import {
computed,
getCurrentInstance,
inject,
nextTick,
onMounted,
ref,
} from 'vue'
import { collapseContextKey } from '../../../../tokens'
import { useSelectorQuery } from '../../../../hooks'
import {
debugWarn,
generateId,
isEmptyVariableInDefault,
} from '../../../../utils'
import type { CollapseItemProps } from '../collapse-item'
export const useCollapseItem = (props: CollapseItemProps) => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnCollapseItem', '请在 setup 函数中使用 TnCollapseItem')
}
const { uid } = instance!
const collapse = inject(collapseContextKey)
if (!collapse) {
debugWarn('TnCollapseItem', '请在 TnCollapse 中使用 TnCollapseItem')
}
collapse?.addItem({ uid })
const componentContentId = `tcic-${generateId()}`
const { getSelectorNodeInfo } = useSelectorQuery(instance)
// 当前组件是否为激活状态
const isActive = computed<boolean>(() => {
if (!collapse) return false
return collapse.activeUid.includes(uid)
})
// 是否显示折叠面板箭头
const showArrow = computed<boolean>(() =>
isEmptyVariableInDefault(collapse?.showArrow, false)
)
// 组件内容的高度
const compoenntContentDefaultHeight = ref<number>(0)
const componentTitleHeight = ref<number>(uni.upx2px(100))
const componentHeight = computed<string>(() => {
if (!isActive.value) return `${componentTitleHeight.value}px`
else
return `${
componentTitleHeight.value + compoenntContentDefaultHeight.value
}px`
})
let initCount = 0
// 获取内容容器高度
const getComponentContentHeight = async () => {
try {
const rectInfo = await getSelectorNodeInfo(`#${componentContentId}`)
initCount = 0
compoenntContentDefaultHeight.value = rectInfo.height || 0
} catch (err) {
if (initCount > 10) {
initCount = 0
debugWarn('TnCollapseItem', `获取内容高度失败: ${err}`)
return
}
initCount++
setTimeout(() => {
getComponentContentHeight()
}, 150)
}
}
// CollapseItem 点击事件
const collapseItemClick = () => {
if (props.disabled) return
collapse?.handleItemClick(uid)
}
onMounted(() => {
nextTick(() => {
getComponentContentHeight()
})
})
return {
componentContentId,
showArrow,
isActive,
componentHeight,
collapseItemClick,
}
}
@@ -0,0 +1,118 @@
import { nextTick, provide, reactive, ref, toRefs, watch } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { collapseContextKey } from '../../../../tokens'
import { useOrderedChildren } from '../../../../hooks'
import { isArray, isNumber } from '../../../../utils'
import type { SetupContext } from 'vue'
import type { CollapseItemContext } from '../../../../tokens'
import type {
CollapseEmits,
CollapseModelValue,
CollapseProps,
} from '../collapse'
export const useCollapse = (
props: CollapseProps,
emits: SetupContext<CollapseEmits>['emit']
) => {
const {
children: items,
addChild: addItem,
removeChild: removeItem,
} = useOrderedChildren<CollapseItemContext>()
// 当前已激活的面板对应的uid
const activeUid = ref<number[]>([])
const currentActiveIndex = ref<CollapseModelValue>()
// 根据modelValue更新activeUID的值
const updateActiveUIDWithModelValue = (val?: CollapseModelValue) => {
nextTick(() => {
let activeIndex: number[]
if (val === undefined || val === -1) {
activeIndex = []
} else if (isNumber(val)) {
activeIndex = [val]
} else {
activeIndex = [...val]
}
activeUid.value = items.value
.filter((uid, index) => activeIndex.includes(index))
.map((item) => item.uid)
})
}
let innerUpdate = false
watch(
() => props.modelValue,
(val) => {
if (innerUpdate) {
innerUpdate = false
return
}
currentActiveIndex.value = val
updateActiveUIDWithModelValue(val)
},
{
immediate: true,
}
)
// 处理CollapseItem点击事件
const handleItemClick = (uid: number) => {
const { accordion } = props
// 获取对应uid对应的索引
const uidIndex = items.value.findIndex((item) => item.uid === uid)
const isActive = activeUid.value.includes(uid)
let value: CollapseModelValue
// 判断是否为手风琴效果
if (accordion) {
// 判断是否已经为激活状态,如果是激活状态则取消激活,否则设置为其他面板激活
if (isActive) {
value = -1
} else {
value = uidIndex
}
} else {
// 判断是否存在于激活面板中,如果存在则取消激活,否则添加到激活面板中
if (isActive) {
value = (currentActiveIndex.value as number[]).filter(
(item) => item !== uidIndex
)
} else {
if (currentActiveIndex.value && isArray(currentActiveIndex.value)) {
value = [...currentActiveIndex.value, uidIndex]
} else {
value = !currentActiveIndex.value
? [uidIndex]
: [currentActiveIndex.value, uidIndex]
}
}
}
// 触发更新事件
innerUpdate = true
currentActiveIndex.value = value
emits(UPDATE_MODEL_EVENT, value)
updateActiveUIDWithModelValue(value)
nextTick(() => {
emits(CHANGE_EVENT, value)
})
}
provide(
collapseContextKey,
reactive({
...toRefs(props),
items,
addItem,
removeItem,
activeUid,
handleItemClick,
})
)
}
@@ -0,0 +1,5 @@
import type Collapse from './collapse.vue'
import type CollapseItem from './collapse-item.vue'
export type TnCollapseInstance = InstanceType<typeof Collapse>
export type TnCollapseItemInstance = InstanceType<typeof CollapseItem>
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import CountDown from './src/count-down.vue'
export const TnCountDown = withNoopInstall(CountDown)
export default TnCountDown
export * from './src/count-down'
export type { TnCountDownInstance } from './src/instance'
@@ -0,0 +1,114 @@
import { computed, toRef } from 'vue'
import {
useComponentColor,
useComponentSize,
useNamespace,
} from '../../../../hooks'
import { formatDomSizeValue } from '../../../../utils'
import type { CSSProperties } from 'vue'
import type { CountDownProps } from '../count-down'
export const useCountDownCustomStyle = (props: CountDownProps) => {
const ns = useNamespace('count-down')
// 解析颜色
const [textColorClass, textColorStyle] = useComponentColor(
toRef(props, 'textColor'),
'text'
)
const [separatorColorClass, separatorColorStyle] = useComponentColor(
toRef(props, 'separatorColor'),
'text'
)
const [borderColorClass, borderColorStyle] = useComponentColor(
toRef(props, 'borderColor'),
'border'
)
const { sizeType } = useComponentSize(props.size)
// 倒计时的类
const countDownClass = computed<string>(() => {
const cls: string[] = [ns.b()]
if (props.size && sizeType.value === 'inner') cls.push(ns.m(props.size))
return cls.join(' ')
})
// 倒计时的样式
const countDownStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 设置字体大小
if (props.size && sizeType.value === 'custom')
style.fontSize = formatDomSizeValue(props.size)
return style
})
// 倒计时文字的类
const textClass = computed<string>(() => {
const cls: string[] = [ns.e('text')]
// 设置字体颜色
if (textColorClass.value) cls.push(textColorClass.value)
// 设置边框颜色
if (props.border) {
cls.push(ns.is('border'))
if (borderColorClass.value) cls.push(borderColorClass.value)
}
return cls.join(' ')
})
// 倒计时文字的样式
const textStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 设置字体颜色
if (!textColorClass.value)
style.color = textColorStyle.value || 'var(--tn-text-color-primary)'
// 设置边框颜色
if (props.border) {
if (!borderColorClass.value)
style.borderColor =
borderColorStyle.value || 'var(--tn-color-gray-disabled)'
}
return style
})
// 分割符的类
const separatorClass = computed<string>(() => {
const cls: string[] = [
ns.e('separator'),
ns.em('separator', props.separatorMode),
]
if (separatorColorClass.value) cls.push(separatorColorClass.value)
return cls.join(' ')
})
// 分割符的样式
const separatorStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!separatorColorClass.value)
style.color =
separatorColorStyle.value || 'var(--tn-text-color-secondary)'
return style
})
return {
ns,
countDownClass,
countDownStyle,
textClass,
textStyle,
separatorClass,
separatorStyle,
}
}
@@ -0,0 +1,40 @@
import type { CountDownSeparatorMode } from '../count-down'
interface CountDownSeparatorItem {
day: string
hour: string
minute: string
second: string
}
type CountDownSeparatorData = Record<
CountDownSeparatorMode,
CountDownSeparatorItem
>
export const useCountDownSeparatorData = () => {
const countDownSeparatorData: CountDownSeparatorData = {
cn: {
day: '天',
hour: '时',
minute: '分',
second: '秒',
},
en: {
day: ':',
hour: ':',
minute: ':',
second: '',
},
}
const getSeparatorData = (
mode: CountDownSeparatorMode,
key: keyof CountDownSeparatorItem
) => {
return countDownSeparatorData[mode][key]
}
return {
getSeparatorData,
}
}
@@ -0,0 +1,3 @@
export * from './count-down-custom'
export * from './count-down-separator-data'
export * from './use-count-down'
@@ -0,0 +1,109 @@
import { ref, watch } from 'vue'
import { formatNumber } from '../../../../utils'
import type { SetupContext } from 'vue'
import type { CountDownEmits, CountDownProps } from '../count-down'
const SECOND = 1
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
export const useCountDown = (
props: CountDownProps,
emits: SetupContext<CountDownEmits>['emit']
) => {
// 当前倒计时时间
let time = 0
// 时间定义
const day = ref<string>('')
const hour = ref<string>('')
const minute = ref<string>('')
const second = ref<string>('')
// 将时间戳转换为对应的时间
const splitCountDownData = () => {
const { showDay, showHour, showMinute, showSecond, autoHideDay } = props
const _day = Math.floor(time / DAY)
// 如果不显示天数则将天的时间加到小时上,后面的分钟和秒如此累加
const _hour = showDay
? Math.floor((time % DAY) / HOUR)
: Math.floor(time / HOUR)
const _minute = Math.floor((time % HOUR) / MINUTE)
const _second = Math.floor(time % MINUTE)
// 判断是否自动隐藏天数
if (!showDay || (autoHideDay && _day === 0)) day.value = ''
else day.value = formatNumber(_day, 4)
hour.value = showHour ? formatNumber(_hour, 4) : ''
minute.value = showMinute ? formatNumber(_minute) : ''
second.value = showSecond ? formatNumber(_second) : ''
}
// 倒计时定时器
let countDownTimer: ReturnType<typeof setInterval> | null = null
// 开始倒计时
const startCountDown = () => {
// 如果倒计时时间为0,重新设置倒计时时间
if (time <= 0) time = props.time
else {
// 先渲染一次
time--
}
// 先停止之前的倒计时
stopCountDown()
emits('start')
// 开始倒计时
countDownTimer = setInterval(() => {
if (time < 0) {
stopCountDown()
emits('end')
return
}
splitCountDownData()
time--
}, 1000)
}
// 停止倒计时
const stopCountDown = () => {
if (countDownTimer) {
clearInterval(countDownTimer)
countDownTimer = null
}
}
// 重置定时器
const resetCountDown = () => {
stopCountDown()
time = props.time
splitCountDownData()
}
watch(
() => props.time,
(_time) => {
resetCountDown()
// 自动开始倒计时
if (props.autoStart && _time > 0) {
startCountDown()
}
},
{
immediate: true,
}
)
return {
day,
hour,
minute,
second,
startCountDown,
stopCountDown,
resetCountDown,
}
}
@@ -0,0 +1,99 @@
import { buildProps } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export const countDownSeparatorMode = ['cn', 'en'] as const
export const countDownProps = buildProps({
/**
* @description
*/
time: {
type: Number,
required: true,
default: 0,
},
/**
* @description
*/
autoStart: {
type: Boolean,
default: true,
},
/**
* @description `sm` `lg` `xl`
*/
size: String,
/**
* @description tn开头使用图鸟内置的颜色
*/
textColor: String,
/**
* @description
*/
showDay: Boolean,
/**
* @description
*/
showHour: {
type: Boolean,
default: true,
},
/**
* @description
*/
showMinute: {
type: Boolean,
default: true,
},
/**
* @description
*/
showSecond: {
type: Boolean,
default: true,
},
/**
* @description
*/
autoHideDay: {
type: Boolean,
default: true,
},
/**
* @description `cn` `en` 显示 : 分割符
*/
separatorMode: {
type: String,
values: countDownSeparatorMode,
default: 'en',
},
/**
* @description tn开头使用图鸟内置的颜色
*/
separatorColor: String,
/**
* @description
*/
border: Boolean,
/**
* @description tn开头使用图鸟内置的颜色
*/
borderColor: String,
})
export const countDownEmits = {
/**
* @description
*/
start: () => true,
/**
* @description
*/
end: () => true,
}
export type CountDownProps = ExtractPropTypes<typeof countDownProps>
export type CountDownEmits = typeof countDownEmits
export type CountDownSeparatorMode = (typeof countDownSeparatorMode)[number]
@@ -0,0 +1,93 @@
<script lang="ts" setup>
import { countDownEmits, countDownProps } from './count-down'
import {
useCountDown,
useCountDownCustomStyle,
useCountDownSeparatorData,
} from './composables'
const props = defineProps(countDownProps)
const emits = defineEmits(countDownEmits)
const {
day,
hour,
minute,
second,
startCountDown,
stopCountDown,
resetCountDown,
} = useCountDown(props, emits)
const {
countDownClass,
countDownStyle,
textClass,
textStyle,
separatorClass,
separatorStyle,
} = useCountDownCustomStyle(props)
const { getSeparatorData } = useCountDownSeparatorData()
defineExpose({
/**
* @description 开始倒计时
*/
start: startCountDown,
/**
* @description 停止倒计时
*/
stop: stopCountDown,
/**
* @description 重置倒计时
*/
reset: resetCountDown,
})
</script>
<template>
<view :class="[countDownClass]" :style="countDownStyle">
<!-- -->
<template v-if="day">
<view class="day" :class="[textClass]" :style="textStyle">{{ day }}</view>
<view class="day" :class="[separatorClass]" :style="separatorStyle">
{{ getSeparatorData(separatorMode, 'day') }}
</view>
</template>
<!-- -->
<template v-if="hour">
<view class="hour" :class="[textClass]" :style="textStyle">
{{ hour }}
</view>
<view class="hour" :class="[separatorClass]" :style="separatorStyle">
{{ getSeparatorData(separatorMode, 'hour') }}
</view>
</template>
<!-- -->
<template v-if="minute">
<view class="minute" :class="[textClass]" :style="textStyle">
{{ minute }}
</view>
<view class="minute" :class="[separatorClass]" :style="separatorStyle">
{{ getSeparatorData(separatorMode, 'minute') }}
</view>
</template>
<!-- -->
<template v-if="second">
<view class="second" :class="[textClass]" :style="textStyle">
{{ second }}
</view>
<view
v-if="getSeparatorData(separatorMode, 'second')"
class="minute"
:class="[separatorClass]"
:style="separatorStyle"
>
{{ getSeparatorData(separatorMode, 'second') }}
</view>
</template>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/count-down.scss';
</style>
@@ -0,0 +1,3 @@
import type CountDown from './count-down.vue'
export type TnCountDownInstance = InstanceType<typeof CountDown>

Some files were not shown because too many files have changed in this diff Show More