648 lines
12 KiB
Plaintext
648 lines
12 KiB
Plaintext
<template>
|
|
<!-- #ifdef H5 -->
|
|
<teleport to="uni-app" :disabled="!enablePortal">
|
|
<!-- #endif -->
|
|
|
|
<!-- #ifdef MP -->
|
|
<root-portal :enable="enablePortal">
|
|
<!-- #endif -->
|
|
|
|
<view
|
|
class="cl-popup-wrapper"
|
|
:class="[`cl-popup-wrapper--${direction}`]"
|
|
:style="{
|
|
zIndex,
|
|
pointerEvents
|
|
}"
|
|
v-show="visible"
|
|
v-if="keepAlive ? true : visible"
|
|
@touchmove.stop.prevent
|
|
>
|
|
<view
|
|
class="cl-popup-mask"
|
|
:class="[
|
|
{
|
|
'is-open': status == 1,
|
|
'is-close': status == 2
|
|
},
|
|
pt.mask?.className
|
|
]"
|
|
@tap="maskClose"
|
|
v-if="showMask"
|
|
></view>
|
|
|
|
<view
|
|
class="cl-popup"
|
|
:class="[
|
|
{
|
|
'is-open': status == 1,
|
|
'is-close': status == 2,
|
|
'is-custom-navbar': router.isCustomNavbarPage(),
|
|
'stop-transition': swipe.isTouch
|
|
},
|
|
pt.className
|
|
]"
|
|
:style="popupStyle"
|
|
@touchstart="onTouchStart"
|
|
@touchmove="onTouchMove"
|
|
@touchend="onTouchEnd"
|
|
@touchcancel="onTouchEnd"
|
|
>
|
|
<view
|
|
class="cl-popup__inner"
|
|
:class="[
|
|
{
|
|
'is-dark': isDark
|
|
},
|
|
pt.inner?.className
|
|
]"
|
|
:style="{
|
|
paddingBottom
|
|
}"
|
|
>
|
|
<view
|
|
class="cl-popup__draw"
|
|
:class="[
|
|
{
|
|
'!bg-surface-400': swipe.isMove
|
|
},
|
|
pt.draw?.className
|
|
]"
|
|
v-if="isSwipeClose"
|
|
></view>
|
|
|
|
<view
|
|
class="cl-popup__header"
|
|
:class="[pt.header?.className]"
|
|
v-if="showHeader"
|
|
>
|
|
<slot name="header">
|
|
<cl-text
|
|
ellipsis
|
|
:pt="{
|
|
className: `text-lg font-bold ${pt.header?.text?.className}`
|
|
}"
|
|
>{{ title }}</cl-text
|
|
>
|
|
</slot>
|
|
|
|
<cl-icon
|
|
name="close-circle-fill"
|
|
:size="40"
|
|
:pt="{
|
|
className: parseClass([
|
|
'absolute right-[24rpx] text-surface-400',
|
|
[isDark, 'text-surface-50']
|
|
])
|
|
}"
|
|
@tap="close"
|
|
@touchmove.stop
|
|
v-if="isOpen && showClose"
|
|
></cl-icon>
|
|
</view>
|
|
|
|
<view
|
|
class="cl-popup__container"
|
|
:class="[pt.container?.className]"
|
|
@touchmove.stop
|
|
>
|
|
<slot></slot>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- #ifdef MP -->
|
|
</root-portal>
|
|
<!-- #endif -->
|
|
|
|
<!-- #ifdef H5 -->
|
|
</teleport>
|
|
<!-- #endif -->
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { computed, reactive, ref, watch, type PropType } from "vue";
|
|
import { getSafeAreaHeight, isAppIOS, parseClass, parsePt, parseRpx } from "@/cool";
|
|
import type { ClPopupDirection, PassThroughProps } from "../../types";
|
|
import { isDark, router } from "@/cool";
|
|
import { config } from "../../config";
|
|
|
|
defineOptions({
|
|
name: "cl-popup"
|
|
});
|
|
|
|
defineSlots<{
|
|
header: () => any;
|
|
}>();
|
|
|
|
// 组件属性定义
|
|
const props = defineProps({
|
|
// 透传样式配置
|
|
pt: {
|
|
type: Object,
|
|
default: () => ({})
|
|
},
|
|
// 是否可见
|
|
modelValue: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 标题
|
|
title: {
|
|
type: String,
|
|
default: null
|
|
},
|
|
// 弹出方向
|
|
direction: {
|
|
type: String as PropType<ClPopupDirection>,
|
|
default: "bottom"
|
|
},
|
|
// 弹出框宽度
|
|
size: {
|
|
type: [String, Number],
|
|
default: ""
|
|
},
|
|
// 是否显示头部
|
|
showHeader: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 显示关闭按钮
|
|
showClose: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 是否显示遮罩层
|
|
showMask: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 是否点击遮罩层关闭弹窗
|
|
maskClosable: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 是否开启拖拽关闭
|
|
swipeClose: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 拖拽关闭的阈值
|
|
swipeCloseThreshold: {
|
|
type: Number,
|
|
default: 150
|
|
},
|
|
// 触摸事件响应方式
|
|
pointerEvents: {
|
|
type: String as PropType<"auto" | "none">,
|
|
default: "auto"
|
|
},
|
|
// 是否开启缓存
|
|
keepAlive: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 是否启用 portal
|
|
enablePortal: {
|
|
type: Boolean,
|
|
default: true
|
|
}
|
|
});
|
|
|
|
// 定义组件事件
|
|
const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]);
|
|
|
|
// 透传样式类型定义
|
|
type HeaderPassThrough = {
|
|
className?: string;
|
|
text?: PassThroughProps;
|
|
};
|
|
|
|
type PassThrough = {
|
|
className?: string;
|
|
inner?: PassThroughProps;
|
|
header?: HeaderPassThrough;
|
|
container?: PassThroughProps;
|
|
mask?: PassThroughProps;
|
|
draw?: PassThroughProps;
|
|
};
|
|
|
|
// 解析透传样式配置
|
|
const pt = computed(() => parsePt<PassThrough>(props.pt));
|
|
|
|
// 控制弹出层显示/隐藏
|
|
const visible = ref(false);
|
|
|
|
// 0: 初始状态 1: 打开中 2: 关闭中
|
|
const status = ref(0);
|
|
|
|
// 标记弹出层是否处于打开状态(包含动画过程)
|
|
const isOpen = ref(false);
|
|
|
|
// 标记弹出层是否已完全打开(动画结束)
|
|
const isOpened = ref(false);
|
|
|
|
// 弹出层z-index值
|
|
const zIndex = ref(config.zIndex);
|
|
|
|
// 计算弹出层高度
|
|
const height = computed(() => {
|
|
switch (props.direction) {
|
|
case "top":
|
|
case "bottom":
|
|
return parseRpx(props.size); // 顶部和底部弹出时使用传入的size
|
|
case "left":
|
|
case "right":
|
|
return "100%"; // 左右弹出时占满全高
|
|
default:
|
|
return "";
|
|
}
|
|
});
|
|
|
|
// 计算弹出层宽度
|
|
const width = computed(() => {
|
|
switch (props.direction) {
|
|
case "top":
|
|
case "bottom":
|
|
return "100%"; // 顶部和底部弹出时占满全宽
|
|
case "left":
|
|
case "right":
|
|
case "center":
|
|
return parseRpx(props.size); // 其他方向使用传入的size
|
|
default:
|
|
return "";
|
|
}
|
|
});
|
|
|
|
// 底部安全距离
|
|
const paddingBottom = computed(() => {
|
|
let h = 0;
|
|
|
|
if (props.direction == "bottom") {
|
|
h += getSafeAreaHeight("bottom");
|
|
}
|
|
|
|
return h + "px";
|
|
});
|
|
|
|
// 是否显示拖动条
|
|
const isSwipeClose = computed(() => {
|
|
return props.direction == "bottom" && props.swipeClose;
|
|
});
|
|
|
|
// 动画定时器
|
|
let timer: number = 0;
|
|
|
|
// 打开弹出层
|
|
function open() {
|
|
// 递增z-index,保证多个弹出层次序
|
|
zIndex.value = config.zIndex++;
|
|
|
|
if (!visible.value) {
|
|
// 显示弹出层
|
|
visible.value = true;
|
|
|
|
// 触发事件
|
|
emit("update:modelValue", true);
|
|
emit("open");
|
|
|
|
// 等待DOM更新后开始动画
|
|
setTimeout(
|
|
() => {
|
|
// 设置打开状态
|
|
status.value = 1;
|
|
|
|
// 动画结束后触发opened事件
|
|
// @ts-ignore
|
|
timer = setTimeout(() => {
|
|
isOpened.value = true;
|
|
emit("opened");
|
|
}, 350);
|
|
},
|
|
isAppIOS() ? 100 : 50
|
|
);
|
|
}
|
|
}
|
|
|
|
// 关闭弹出层
|
|
function close() {
|
|
if (status.value == 1) {
|
|
// 重置打开状态
|
|
isOpened.value = false;
|
|
|
|
// 设置关闭状态
|
|
status.value = 2;
|
|
|
|
// 触发事件
|
|
emit("close");
|
|
|
|
// 清除未完成的定时器
|
|
if (timer != 0) {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
// 动画结束后隐藏弹出层
|
|
// @ts-ignore
|
|
timer = setTimeout(() => {
|
|
// 隐藏弹出层
|
|
visible.value = false;
|
|
|
|
// 重置状态
|
|
status.value = 0;
|
|
|
|
// 触发事件
|
|
emit("update:modelValue", false);
|
|
emit("closed");
|
|
}, 350);
|
|
}
|
|
}
|
|
|
|
// 点击遮罩层关闭
|
|
function maskClose() {
|
|
if (props.maskClosable) {
|
|
close();
|
|
}
|
|
|
|
emit("maskClose");
|
|
}
|
|
// 滑动状态类型定义
|
|
type Swipe = {
|
|
isMove: boolean; // 是否移动
|
|
isTouch: boolean; // 是否处于触摸状态
|
|
startY: number; // 开始触摸的Y坐标
|
|
offsetY: number; // Y轴偏移量
|
|
};
|
|
|
|
// 初始化滑动状态数据
|
|
const swipe = reactive<Swipe>({
|
|
isMove: false, // 是否移动
|
|
isTouch: false, // 默认非触摸状态
|
|
startY: 0, // 初始Y坐标为0
|
|
offsetY: 0 // 初始偏移量为0
|
|
});
|
|
|
|
/**
|
|
* 触摸开始事件处理
|
|
* @param e 触摸事件对象
|
|
* 当弹出层获得焦点且允许滑动关闭时,记录触摸起始位置
|
|
*/
|
|
function onTouchStart(e: UniTouchEvent) {
|
|
if (props.direction != "bottom") {
|
|
return;
|
|
}
|
|
|
|
if (isOpened.value && isSwipeClose.value) {
|
|
swipe.isTouch = true; // 标记开始触摸
|
|
swipe.startY = e.touches[0].clientY; // 记录起始Y坐标
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 触摸移动事件处理
|
|
* @param e 触摸事件对象
|
|
* 计算手指移动距离,更新弹出层位置
|
|
*/
|
|
function onTouchMove(e: UniTouchEvent) {
|
|
if (swipe.isTouch) {
|
|
// 标记为移动状态
|
|
swipe.isMove = true;
|
|
|
|
// 计算Y轴偏移量
|
|
const offsetY = (e.touches[0] as UniTouch).pageY - swipe.startY;
|
|
|
|
// 只允许向下滑动(offsetY > 0)
|
|
if (offsetY > 0) {
|
|
swipe.offsetY = offsetY;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 触摸结束事件处理
|
|
* 根据滑动距离判断是否关闭弹出层
|
|
*/
|
|
function onTouchEnd() {
|
|
if (swipe.isTouch) {
|
|
// 结束触摸状态
|
|
swipe.isTouch = false;
|
|
|
|
// 结束移动状态
|
|
swipe.isMove = false;
|
|
|
|
// 如果滑动距离超过阈值,则关闭弹出层
|
|
if (swipe.offsetY > props.swipeCloseThreshold) {
|
|
close();
|
|
}
|
|
|
|
// 重置偏移量
|
|
swipe.offsetY = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 计算弹出层样式
|
|
* 根据滑动状态动态设置transform属性实现位移动画
|
|
*/
|
|
const popupStyle = computed(() => {
|
|
const style = {};
|
|
|
|
// 基础样式
|
|
style["height"] = height.value;
|
|
style["width"] = width.value;
|
|
|
|
// 处于触摸状态时添加位移效果
|
|
if (swipe.isTouch) {
|
|
style["transform"] = `translateY(${swipe.offsetY}px)`;
|
|
}
|
|
|
|
return style;
|
|
});
|
|
|
|
// 监听modelValue变化
|
|
watch(
|
|
computed(() => props.modelValue),
|
|
(val: boolean) => {
|
|
if (val) {
|
|
open();
|
|
} else {
|
|
close();
|
|
}
|
|
},
|
|
{
|
|
immediate: true
|
|
}
|
|
);
|
|
|
|
// 监听状态变化
|
|
watch(status, (val: number) => {
|
|
isOpen.value = val == 1;
|
|
});
|
|
|
|
defineExpose({
|
|
isOpened,
|
|
isOpen,
|
|
open,
|
|
close
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.cl-popup-wrapper {
|
|
@apply h-full w-full;
|
|
@apply fixed top-0 bottom-0 left-0 right-0;
|
|
pointer-events: none;
|
|
|
|
.cl-popup-mask {
|
|
@apply absolute top-0 bottom-0 left-0 right-0;
|
|
@apply h-full w-full;
|
|
@apply bg-black opacity-0;
|
|
transition-property: opacity;
|
|
|
|
&.is-open {
|
|
@apply opacity-40;
|
|
}
|
|
|
|
&.is-open,
|
|
&.is-close {
|
|
transition-duration: 0.3s;
|
|
}
|
|
}
|
|
|
|
.cl-popup {
|
|
@apply absolute duration-300;
|
|
transition-property: transform;
|
|
|
|
&__inner {
|
|
@apply bg-white h-full w-full flex flex-col;
|
|
|
|
&.is-dark {
|
|
@apply bg-surface-700;
|
|
}
|
|
}
|
|
|
|
&__draw {
|
|
@apply bg-surface-200 rounded-md;
|
|
@apply absolute top-2 left-1/2;
|
|
height: 10rpx;
|
|
width: 70rpx;
|
|
transform: translateX(-50%);
|
|
transition-property: background-color;
|
|
transition-duration: 0.2s;
|
|
}
|
|
|
|
&__header {
|
|
@apply flex flex-row items-center flex-wrap;
|
|
height: 90rpx;
|
|
padding: 0 80rpx 0 26rpx;
|
|
}
|
|
|
|
&__container {
|
|
flex: 1;
|
|
}
|
|
|
|
&.stop-transition {
|
|
@apply transition-none;
|
|
}
|
|
}
|
|
|
|
&--left {
|
|
.cl-popup {
|
|
@apply left-0 top-0;
|
|
transform: translateX(-100%);
|
|
|
|
&.is-open {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
&--right {
|
|
.cl-popup {
|
|
@apply right-0 top-0;
|
|
transform: translateX(100%);
|
|
|
|
&.is-open {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
&--top {
|
|
.cl-popup {
|
|
@apply left-0 top-0;
|
|
transform: translateY(-100%);
|
|
|
|
.cl-popup__inner {
|
|
@apply rounded-b-2xl;
|
|
}
|
|
|
|
&.is-open {
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
&--left,
|
|
&--right,
|
|
&--top {
|
|
& > .cl-popup {
|
|
// #ifdef H5
|
|
top: 44px;
|
|
// #endif
|
|
|
|
&.is-custom-navbar {
|
|
top: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
&--left,
|
|
&--right {
|
|
& > .cl-popup {
|
|
// #ifdef H5
|
|
height: calc(100% - 44px) !important;
|
|
// #endif
|
|
}
|
|
}
|
|
|
|
&--bottom {
|
|
& > .cl-popup {
|
|
@apply left-0 bottom-0;
|
|
transform: translateY(100%);
|
|
|
|
.cl-popup__inner {
|
|
@apply rounded-t-2xl;
|
|
}
|
|
|
|
&.is-open {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
&.is-close {
|
|
transform: translateY(100%);
|
|
}
|
|
}
|
|
}
|
|
|
|
&--center {
|
|
@apply flex flex-col items-center justify-center;
|
|
|
|
& > .cl-popup {
|
|
transform: scale(1.3);
|
|
opacity: 0;
|
|
transition-property: transform, opacity;
|
|
|
|
.cl-popup__inner {
|
|
@apply rounded-2xl;
|
|
}
|
|
|
|
&.is-open {
|
|
transform: translate(0, 0) scale(1);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|