cl-popup.uvue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. <template>
  2. <!-- #ifdef H5 -->
  3. <teleport to="uni-app" :disabled="!enablePortal">
  4. <!-- #endif -->
  5. <!-- #ifdef MP -->
  6. <root-portal :enable="enablePortal">
  7. <!-- #endif -->
  8. <view
  9. class="cl-popup-wrapper"
  10. :class="[`cl-popup-wrapper--${direction}`]"
  11. :style="{
  12. zIndex,
  13. pointerEvents
  14. }"
  15. v-show="visible"
  16. v-if="keepAlive ? true : visible"
  17. @touchmove.stop.prevent
  18. >
  19. <view
  20. class="cl-popup-mask"
  21. :class="[
  22. {
  23. 'is-open': status == 1,
  24. 'is-close': status == 2
  25. },
  26. pt.mask?.className
  27. ]"
  28. @tap="maskClose"
  29. v-if="showMask"
  30. ></view>
  31. <view
  32. class="cl-popup"
  33. :class="[
  34. {
  35. 'is-open': status == 1,
  36. 'is-close': status == 2,
  37. 'is-custom-navbar': router.isCustomNavbarPage(),
  38. 'stop-transition': swipe.isTouch
  39. },
  40. pt.className
  41. ]"
  42. :style="popupStyle"
  43. @touchstart="onTouchStart"
  44. @touchmove="onTouchMove"
  45. @touchend="onTouchEnd"
  46. @touchcancel="onTouchEnd"
  47. >
  48. <view
  49. class="cl-popup__inner"
  50. :class="[
  51. {
  52. 'is-dark': isDark
  53. },
  54. pt.inner?.className
  55. ]"
  56. :style="{
  57. paddingBottom
  58. }"
  59. >
  60. <view
  61. class="cl-popup__draw"
  62. :class="[
  63. {
  64. '!bg-surface-400': swipe.isMove
  65. },
  66. pt.draw?.className
  67. ]"
  68. v-if="isSwipeClose"
  69. ></view>
  70. <view
  71. class="cl-popup__header"
  72. :class="[pt.header?.className]"
  73. v-if="showHeader"
  74. >
  75. <slot name="header">
  76. <cl-text
  77. :size="16"
  78. ellipsis
  79. :pt="{
  80. className: `font-bold ${pt.header?.text?.className}`
  81. }"
  82. >{{ title }}</cl-text
  83. >
  84. </slot>
  85. <cl-icon
  86. name="close-circle-fill"
  87. :size="22"
  88. :pt="{
  89. className: parseClass([
  90. 'absolute right-[12px] text-surface-400',
  91. [isDark, 'text-surface-50']
  92. ])
  93. }"
  94. @tap="close"
  95. @touchmove.stop
  96. v-if="isOpen && showClose"
  97. ></cl-icon>
  98. </view>
  99. <view
  100. class="cl-popup__container"
  101. :class="[pt.container?.className]"
  102. @touchmove.stop
  103. >
  104. <slot></slot>
  105. </view>
  106. </view>
  107. </view>
  108. </view>
  109. <!-- #ifdef MP -->
  110. </root-portal>
  111. <!-- #endif -->
  112. <!-- #ifdef H5 -->
  113. </teleport>
  114. <!-- #endif -->
  115. </template>
  116. <script lang="ts" setup>
  117. import { computed, reactive, ref, watch, type PropType } from "vue";
  118. import { getSafeAreaHeight, isAppIOS, parseClass, parsePt, getUnit } from "@/.cool";
  119. import type { ClPopupDirection, PassThroughProps } from "../../types";
  120. import { isDark, router } from "@/.cool";
  121. import { config } from "../../config";
  122. defineOptions({
  123. name: "cl-popup"
  124. });
  125. defineSlots<{
  126. header: () => any;
  127. }>();
  128. // 组件属性定义
  129. const props = defineProps({
  130. // 透传样式配置
  131. pt: {
  132. type: Object,
  133. default: () => ({})
  134. },
  135. // 是否可见
  136. modelValue: {
  137. type: Boolean,
  138. default: false
  139. },
  140. // 标题
  141. title: {
  142. type: String,
  143. default: null
  144. },
  145. // 弹出方向
  146. direction: {
  147. type: String as PropType<ClPopupDirection>,
  148. default: "bottom"
  149. },
  150. // 弹出框宽度
  151. size: {
  152. type: [String, Number],
  153. default: ""
  154. },
  155. // 是否显示头部
  156. showHeader: {
  157. type: Boolean,
  158. default: true
  159. },
  160. // 显示关闭按钮
  161. showClose: {
  162. type: Boolean,
  163. default: true
  164. },
  165. // 是否显示遮罩层
  166. showMask: {
  167. type: Boolean,
  168. default: true
  169. },
  170. // 是否点击遮罩层关闭弹窗
  171. maskClosable: {
  172. type: Boolean,
  173. default: true
  174. },
  175. // 是否开启拖拽关闭
  176. swipeClose: {
  177. type: Boolean,
  178. default: true
  179. },
  180. // 拖拽关闭的阈值
  181. swipeCloseThreshold: {
  182. type: Number,
  183. default: 150
  184. },
  185. // 触摸事件响应方式
  186. pointerEvents: {
  187. type: String as PropType<"auto" | "none">,
  188. default: "auto"
  189. },
  190. // 是否开启缓存
  191. keepAlive: {
  192. type: Boolean,
  193. default: false
  194. },
  195. // 是否启用 portal
  196. enablePortal: {
  197. type: Boolean,
  198. default: true
  199. }
  200. });
  201. // 定义组件事件
  202. const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]);
  203. // 透传样式类型定义
  204. type HeaderPassThrough = {
  205. className?: string;
  206. text?: PassThroughProps;
  207. };
  208. type PassThrough = {
  209. className?: string;
  210. inner?: PassThroughProps;
  211. header?: HeaderPassThrough;
  212. container?: PassThroughProps;
  213. mask?: PassThroughProps;
  214. draw?: PassThroughProps;
  215. };
  216. // 解析透传样式配置
  217. const pt = computed(() => parsePt<PassThrough>(props.pt));
  218. // 控制弹出层显示/隐藏
  219. const visible = ref(false);
  220. // 0: 初始状态 1: 打开中 2: 关闭中
  221. const status = ref(0);
  222. // 标记弹出层是否处于打开状态(包含动画过程)
  223. const isOpen = ref(false);
  224. // 标记弹出层是否已完全打开(动画结束)
  225. const isOpened = ref(false);
  226. // 弹出层z-index值
  227. const zIndex = ref(config.zIndex);
  228. // 计算弹出层高度
  229. const height = computed(() => {
  230. switch (props.direction) {
  231. case "top":
  232. case "bottom":
  233. return getUnit(props.size); // 顶部和底部弹出时使用传入的size
  234. case "left":
  235. case "right":
  236. return "100%"; // 左右弹出时占满全高
  237. default:
  238. return "";
  239. }
  240. });
  241. // 计算弹出层宽度
  242. const width = computed(() => {
  243. switch (props.direction) {
  244. case "top":
  245. case "bottom":
  246. return "100%"; // 顶部和底部弹出时占满全宽
  247. case "left":
  248. case "right":
  249. case "center":
  250. return getUnit(props.size); // 其他方向使用传入的size
  251. default:
  252. return "";
  253. }
  254. });
  255. // 底部安全距离
  256. const paddingBottom = computed(() => {
  257. let h = 0;
  258. if (props.direction == "bottom") {
  259. h += getSafeAreaHeight("bottom");
  260. }
  261. return h + "px";
  262. });
  263. // 是否显示拖动条
  264. const isSwipeClose = computed(() => {
  265. return props.direction == "bottom" && props.swipeClose;
  266. });
  267. // 动画定时器
  268. let timer: number = 0;
  269. // 打开弹出层
  270. function open() {
  271. // 递增z-index,保证多个弹出层次序
  272. zIndex.value = config.zIndex++;
  273. if (!visible.value) {
  274. // 显示弹出层
  275. visible.value = true;
  276. // 触发事件
  277. emit("update:modelValue", true);
  278. emit("open");
  279. // 等待DOM更新后开始动画
  280. setTimeout(
  281. () => {
  282. // 设置打开状态
  283. status.value = 1;
  284. // 动画结束后触发opened事件
  285. // @ts-ignore
  286. timer = setTimeout(() => {
  287. isOpened.value = true;
  288. emit("opened");
  289. }, 350);
  290. },
  291. isAppIOS() ? 100 : 50
  292. );
  293. }
  294. }
  295. // 关闭弹出层
  296. function close() {
  297. if (status.value == 1) {
  298. // 重置打开状态
  299. isOpened.value = false;
  300. // 设置关闭状态
  301. status.value = 2;
  302. // 触发事件
  303. emit("close");
  304. // 清除未完成的定时器
  305. if (timer != 0) {
  306. clearTimeout(timer);
  307. }
  308. // 动画结束后隐藏弹出层
  309. // @ts-ignore
  310. timer = setTimeout(() => {
  311. // 隐藏弹出层
  312. visible.value = false;
  313. // 重置状态
  314. status.value = 0;
  315. // 触发事件
  316. emit("update:modelValue", false);
  317. emit("closed");
  318. }, 350);
  319. }
  320. }
  321. // 点击遮罩层关闭
  322. function maskClose() {
  323. if (props.maskClosable) {
  324. close();
  325. }
  326. emit("maskClose");
  327. }
  328. // 滑动状态类型定义
  329. type Swipe = {
  330. isMove: boolean; // 是否移动
  331. isTouch: boolean; // 是否处于触摸状态
  332. startY: number; // 开始触摸的Y坐标
  333. offsetY: number; // Y轴偏移量
  334. };
  335. // 初始化滑动状态数据
  336. const swipe = reactive<Swipe>({
  337. isMove: false, // 是否移动
  338. isTouch: false, // 默认非触摸状态
  339. startY: 0, // 初始Y坐标为0
  340. offsetY: 0 // 初始偏移量为0
  341. });
  342. /**
  343. * 触摸开始事件处理
  344. * @param e 触摸事件对象
  345. * 当弹出层获得焦点且允许滑动关闭时,记录触摸起始位置
  346. */
  347. function onTouchStart(e: UniTouchEvent) {
  348. if (props.direction != "bottom") {
  349. return;
  350. }
  351. if (isOpened.value && isSwipeClose.value) {
  352. swipe.isTouch = true; // 标记开始触摸
  353. swipe.startY = e.touches[0].clientY; // 记录起始Y坐标
  354. }
  355. }
  356. /**
  357. * 触摸移动事件处理
  358. * @param e 触摸事件对象
  359. * 计算手指移动距离,更新弹出层位置
  360. */
  361. function onTouchMove(e: UniTouchEvent) {
  362. if (swipe.isTouch) {
  363. // 标记为移动状态
  364. swipe.isMove = true;
  365. // 计算Y轴偏移量
  366. const offsetY = (e.touches[0] as UniTouch).clientY - swipe.startY;
  367. // 只允许向下滑动(offsetY > 0)
  368. if (offsetY > 0) {
  369. swipe.offsetY = offsetY;
  370. }
  371. }
  372. }
  373. /**
  374. * 触摸结束事件处理
  375. * 根据滑动距离判断是否关闭弹出层
  376. */
  377. function onTouchEnd() {
  378. if (swipe.isTouch) {
  379. // 结束触摸状态
  380. swipe.isTouch = false;
  381. // 结束移动状态
  382. swipe.isMove = false;
  383. // 如果滑动距离超过阈值,则关闭弹出层
  384. if (swipe.offsetY > props.swipeCloseThreshold) {
  385. close();
  386. }
  387. // 重置偏移量
  388. swipe.offsetY = 0;
  389. }
  390. }
  391. /**
  392. * 计算弹出层样式
  393. * 根据滑动状态动态设置transform属性实现位移动画
  394. */
  395. const popupStyle = computed(() => {
  396. const style = {};
  397. // 基础样式
  398. style["height"] = height.value;
  399. style["width"] = width.value;
  400. // 处于触摸状态时添加位移效果
  401. if (swipe.isTouch) {
  402. style["transform"] = `translateY(${swipe.offsetY}px)`;
  403. }
  404. return style;
  405. });
  406. // 监听modelValue变化
  407. watch(
  408. computed(() => props.modelValue),
  409. (val: boolean) => {
  410. if (val) {
  411. open();
  412. } else {
  413. close();
  414. }
  415. },
  416. {
  417. immediate: true
  418. }
  419. );
  420. // 监听状态变化
  421. watch(status, (val: number) => {
  422. isOpen.value = val == 1;
  423. });
  424. defineExpose({
  425. isOpened,
  426. isOpen,
  427. open,
  428. close
  429. });
  430. </script>
  431. <style lang="scss" scoped>
  432. .cl-popup-wrapper {
  433. @apply h-full w-full;
  434. @apply fixed top-0 bottom-0 left-0 right-0;
  435. pointer-events: none;
  436. .cl-popup-mask {
  437. @apply absolute top-0 bottom-0 left-0 right-0;
  438. @apply h-full w-full;
  439. @apply bg-black opacity-0;
  440. transition-property: opacity;
  441. &.is-open {
  442. @apply opacity-40;
  443. }
  444. &.is-open,
  445. &.is-close {
  446. transition-duration: 0.3s;
  447. }
  448. }
  449. .cl-popup {
  450. @apply absolute duration-300;
  451. transition-property: transform;
  452. &__inner {
  453. @apply bg-white h-full w-full flex flex-col;
  454. &.is-dark {
  455. @apply bg-surface-700;
  456. }
  457. }
  458. &__draw {
  459. @apply bg-surface-200 rounded-md;
  460. @apply absolute top-2 left-1/2;
  461. height: 5px;
  462. width: 35px;
  463. transform: translateX(-50%);
  464. transition-property: background-color;
  465. transition-duration: 0.2s;
  466. }
  467. &__header {
  468. @apply flex flex-row items-center flex-wrap;
  469. height: 45px;
  470. padding: 0 40px 0 13px;
  471. }
  472. &__container {
  473. flex: 1;
  474. }
  475. &.stop-transition {
  476. @apply transition-none;
  477. }
  478. }
  479. &--left {
  480. .cl-popup {
  481. @apply left-0 top-0;
  482. transform: translateX(-100%);
  483. &.is-open {
  484. transform: translateX(0);
  485. }
  486. }
  487. }
  488. &--right {
  489. .cl-popup {
  490. @apply right-0 top-0;
  491. transform: translateX(100%);
  492. &.is-open {
  493. transform: translateX(0);
  494. }
  495. }
  496. }
  497. &--top {
  498. .cl-popup {
  499. @apply left-0 top-0;
  500. transform: translateY(-100%);
  501. .cl-popup__inner {
  502. @apply rounded-b-2xl;
  503. }
  504. &.is-open {
  505. transform: translateY(0);
  506. }
  507. }
  508. }
  509. &--left,
  510. &--right,
  511. &--top {
  512. & > .cl-popup {
  513. // #ifdef H5
  514. top: 44px;
  515. // #endif
  516. &.is-custom-navbar {
  517. top: 0;
  518. }
  519. }
  520. }
  521. &--left,
  522. &--right {
  523. & > .cl-popup {
  524. // #ifdef H5
  525. height: calc(100% - 44px) !important;
  526. // #endif
  527. }
  528. }
  529. &--bottom {
  530. & > .cl-popup {
  531. @apply left-0 bottom-0;
  532. transform: translateY(100%);
  533. .cl-popup__inner {
  534. @apply rounded-t-2xl;
  535. }
  536. &.is-open {
  537. transform: translateY(0);
  538. }
  539. &.is-close {
  540. transform: translateY(100%);
  541. }
  542. }
  543. }
  544. &--center {
  545. @apply flex flex-col items-center justify-center;
  546. & > .cl-popup {
  547. transform: scale(1.3);
  548. opacity: 0;
  549. transition-property: transform, opacity;
  550. .cl-popup__inner {
  551. @apply rounded-2xl;
  552. }
  553. &.is-open {
  554. transform: translate(0, 0) scale(1);
  555. opacity: 1;
  556. }
  557. }
  558. }
  559. }
  560. </style>