|
@@ -0,0 +1,327 @@
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+/**
|
|
|
|
|
+ * 浮动面板展示层:组合 Vant Overlay + FloatingPanel,关闭动画与 CSS 变量。
|
|
|
|
|
+ * 锚点合并 / autoHeight 逻辑见 `floatPanelAnchors.ts`、`useFloatPanelAnchorsAutoHeight.ts`。
|
|
|
|
|
+ */
|
|
|
|
|
+import { computed, nextTick, onUpdated, ref, watch, watchPostEffect } from 'vue';
|
|
|
|
|
+import { useElementSize } from '@vueuse/core';
|
|
|
|
|
+import { computeAnchorsAndSnapHeight } from './floatPanelAnchors';
|
|
|
|
|
+import type { FloatPanelInstance, FloatPanelProps } from './types';
|
|
|
|
|
+import { useFloatPanelAnchorsAutoHeight } from './useFloatPanelAnchorsAutoHeight';
|
|
|
|
|
+
|
|
|
|
|
+// --- props(v-model:panel-height / panel-anchor)---
|
|
|
|
|
+const {
|
|
|
|
|
+ closeOnClickOverlay,
|
|
|
|
|
+ overlay = true,
|
|
|
|
|
+ overlayClass,
|
|
|
|
|
+ overlayStyle,
|
|
|
|
|
+
|
|
|
|
|
+ containerClass,
|
|
|
|
|
+ containerStyle,
|
|
|
|
|
+
|
|
|
|
|
+ title,
|
|
|
|
|
+ maxContainerHeight = innerHeight,
|
|
|
|
|
+ full = false,
|
|
|
|
|
+ closable = false,
|
|
|
|
|
+
|
|
|
|
|
+ contentDraggable = true,
|
|
|
|
|
+
|
|
|
|
|
+ autoHeight = false,
|
|
|
|
|
+
|
|
|
|
|
+ duration = 0.3,
|
|
|
|
|
+} = defineProps<FloatPanelProps>();
|
|
|
|
|
+
|
|
|
|
|
+// --- 动画:秒 -> 数值;与 Vant `duration`、关闭收尾定时器共用 ---
|
|
|
|
|
+const durationSec = computed(() => {
|
|
|
|
|
+ const n = Number(duration);
|
|
|
|
|
+ return Number.isFinite(n) && n >= 0 ? n : 0.3;
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/** 关闭动画期间保持遮罩,避免 height 已为 0 时立刻隐藏打断过渡 */
|
|
|
|
|
+const isClosing = ref(false);
|
|
|
|
|
+const closeTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+function clearCloseTimer() {
|
|
|
|
|
+ if (closeTimer.value != null) {
|
|
|
|
|
+ clearTimeout(closeTimer.value);
|
|
|
|
|
+ closeTimer.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ clearCloseTimer();
|
|
|
|
|
+ isClosing.value = false;
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// --- 样式 class ---
|
|
|
|
|
+const overlayClassList = computed(() => {
|
|
|
|
|
+ const value = Array.isArray(overlay) ? overlayClass : overlayClass ? [overlayClass] : [];
|
|
|
|
|
+ return ['float-panel-overlay', ...value, { hide: !overlay }];
|
|
|
|
|
+});
|
|
|
|
|
+const containerClassList = computed(() => {
|
|
|
|
|
+ const value = Array.isArray(containerClass) ? containerClass : containerClass ? [containerClass] : [];
|
|
|
|
|
+ return ['float-panel-container', ...value, { 'float-panel--auto-height': autoHeight }];
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+const height = defineModel<number>('panel-height', { default: 20 });
|
|
|
|
|
+const anchors = defineModel<number[]>('panel-anchor', { default: [] });
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
|
+ /** 关闭动画与遮罩收尾结束(`duration` 为 0 时为同步收尾后) */
|
|
|
|
|
+ closed: [];
|
|
|
|
|
+}>();
|
|
|
|
|
+
|
|
|
|
|
+/** 立刻把 v-model height 置 0(Vant 做过渡);遮罩用 isClosing 多留一程;结束后 `emit('closed')` */
|
|
|
|
|
+function applyCloseAnimation() {
|
|
|
|
|
+ clearCloseTimer();
|
|
|
|
|
+ isClosing.value = true;
|
|
|
|
|
+ height.value = 0;
|
|
|
|
|
+ const ms = durationSec.value * 1000;
|
|
|
|
|
+ if (ms <= 0) {
|
|
|
|
|
+ isClosing.value = false;
|
|
|
|
|
+ emit('closed');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ closeTimer.value = setTimeout(() => {
|
|
|
|
|
+ closeTimer.value = null;
|
|
|
|
|
+ isClosing.value = false;
|
|
|
|
|
+ emit('closed');
|
|
|
|
|
+ }, ms);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// --- 布局测量:头高参与 setHeight;内容外包层高度参与 autoHeight ---
|
|
|
|
|
+const panelHeaderRef = useTemplateRef('panel-header');
|
|
|
|
|
+const { height: panelHeaderHeight } = useElementSize(panelHeaderRef, { height: 30 }, { box: 'border-box' });
|
|
|
|
|
+
|
|
|
|
|
+const panelContentRootRef = useTemplateRef('panel-content-root');
|
|
|
|
|
+const { height: measuredContentHeight } = useElementSize(panelContentRootRef, { height: 0 }, { box: 'border-box' });
|
|
|
|
|
+const panelContentHeight = ref(0);
|
|
|
|
|
+
|
|
|
|
|
+/** autoHeight 下内容区被限制高度后,useElementSize 只有可视高;锚点需按 scrollHeight 取真实内容高 */
|
|
|
|
|
+const intrinsicAutoContentHeight = ref(0);
|
|
|
|
|
+function syncIntrinsicAutoContentHeight() {
|
|
|
|
|
+ if (!autoHeight) return;
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ const el = panelContentRootRef.value;
|
|
|
|
|
+ const sh = el?.scrollHeight ?? 0;
|
|
|
|
|
+ if (intrinsicAutoContentHeight.value !== sh) intrinsicAutoContentHeight.value = sh;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+watchPostEffect(() => {
|
|
|
|
|
+ if (!autoHeight) return;
|
|
|
|
|
+ height.value;
|
|
|
|
|
+ panelContentHeight.value;
|
|
|
|
|
+ measuredContentHeight.value;
|
|
|
|
|
+ syncIntrinsicAutoContentHeight();
|
|
|
|
|
+});
|
|
|
|
|
+onUpdated(() => {
|
|
|
|
|
+ if (autoHeight) syncIntrinsicAutoContentHeight();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+const measuredContentHeightForAnchors = computed(() =>
|
|
|
|
|
+ autoHeight ? intrinsicAutoContentHeight.value : measuredContentHeight.value,
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+const style = computed(() => {
|
|
|
|
|
+ return {
|
|
|
|
|
+ '--van-floating-panel-header-height': `${panelHeaderHeight.value}px`,
|
|
|
|
|
+ '--van-floating-panel-content-height': `${panelContentHeight.value}px`,
|
|
|
|
|
+ };
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// --- 对外 API(expose / useFloatPanel ref)---
|
|
|
|
|
+const instance: FloatPanelInstance = {
|
|
|
|
|
+ getAnchors() {
|
|
|
|
|
+ return toValue(anchors);
|
|
|
|
|
+ },
|
|
|
|
|
+ setAnchors(values: number | number[], reset = true) {
|
|
|
|
|
+ const result = computeAnchorsAndSnapHeight(
|
|
|
|
|
+ values,
|
|
|
|
|
+ reset,
|
|
|
|
|
+ height.value,
|
|
|
|
|
+ maxContainerHeight,
|
|
|
|
|
+ anchors.value,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!result) return;
|
|
|
|
|
+ anchors.value = result.anchors;
|
|
|
|
|
+ height.value = result.height;
|
|
|
|
|
+ },
|
|
|
|
|
+ setHeight(value: number, updateAnchor = true) {
|
|
|
|
|
+ const total = value + panelHeaderHeight.value;
|
|
|
|
|
+ if (updateAnchor) {
|
|
|
|
|
+ instance.setAnchors(total, false);
|
|
|
|
|
+ height.value = total;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ height.value = total;
|
|
|
|
|
+ },
|
|
|
|
|
+ closeAnimated: applyCloseAnimation,
|
|
|
|
|
+
|
|
|
|
|
+ snapMin() {
|
|
|
|
|
+ const target = toValue(anchors).find((a) => a > 0);
|
|
|
|
|
+ if (target === undefined) return false;
|
|
|
|
|
+ if (height.value === target) return false;
|
|
|
|
|
+ instance.setAnchors(target, false);
|
|
|
|
|
+ height.value = target;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ },
|
|
|
|
|
+ snapMax() {
|
|
|
|
|
+ const list = toValue(anchors);
|
|
|
|
|
+ const target = list[list.length - 1];
|
|
|
|
|
+ if (target === undefined) return false;
|
|
|
|
|
+ if (height.value === target) return false;
|
|
|
|
|
+ instance.setAnchors(target, false);
|
|
|
|
|
+ height.value = target;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ },
|
|
|
|
|
+ snapFull() {
|
|
|
|
|
+ const target = maxContainerHeight;
|
|
|
|
|
+ if (height.value === target) return false;
|
|
|
|
|
+ instance.setAnchors(target, false);
|
|
|
|
|
+ height.value = target;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ },
|
|
|
|
|
+ snapToFirstAnchor() {
|
|
|
|
|
+ return instance.snapMin();
|
|
|
|
|
+ },
|
|
|
|
|
+ snapToLastAnchor() {
|
|
|
|
|
+ return instance.snapMax();
|
|
|
|
|
+ },
|
|
|
|
|
+ snapToMaxContainerHeight() {
|
|
|
|
|
+ return instance.snapFull();
|
|
|
|
|
+ },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const { resetAutoHeightSyncOnReopen } = useFloatPanelAnchorsAutoHeight({
|
|
|
|
|
+ height,
|
|
|
|
|
+ measuredContentHeight: measuredContentHeightForAnchors,
|
|
|
|
|
+ panelHeaderHeight,
|
|
|
|
|
+ closable: () => closable,
|
|
|
|
|
+ full: () => full,
|
|
|
|
|
+ maxContainerHeight: () => maxContainerHeight,
|
|
|
|
|
+ autoHeight: () => autoHeight,
|
|
|
|
|
+ setAnchors: (v, r) => instance.setAnchors(v, r),
|
|
|
|
|
+ setHeight: (v, u) => instance.setHeight(v, u),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+const onClose = () => applyCloseAnimation();
|
|
|
|
|
+const onClickOverlay = () => {
|
|
|
|
|
+ if (closeOnClickOverlay) onClose();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 与 Vant `height-change` 一致:内容区高度 = 面板总高 − 头高。逻辑层改 `v-model:height` 时未必触发该事件,需自行同步 CSS 变量 */
|
|
|
|
|
+function syncPanelContentHeightFromTotal(total: number) {
|
|
|
|
|
+ panelContentHeight.value =
|
|
|
|
|
+ total <= 0 ? 0 : Math.max(0, total - panelHeaderHeight.value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onUpdateHeight = ({ height: h }: { height: number }) => {
|
|
|
|
|
+ syncPanelContentHeightFromTotal(h);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ [height, panelHeaderHeight],
|
|
|
|
|
+ () => syncPanelContentHeightFromTotal(height.value),
|
|
|
|
|
+ { flush: 'post', immediate: true },
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+watch(height, (h, prev) => {
|
|
|
|
|
+ if (h !== 0) {
|
|
|
|
|
+ clearCloseTimer();
|
|
|
|
|
+ isClosing.value = false;
|
|
|
|
|
+ } else if (prev !== undefined && prev !== 0 && !isClosing.value) {
|
|
|
|
|
+ // 非 closeAnimated:等 v-model 同步到 Wrapper 后再通知(便于 `onUpdate:panelHeight` 先置 pending)
|
|
|
|
|
+ nextTick(() => emit('closed'));
|
|
|
|
|
+ }
|
|
|
|
|
+ resetAutoHeightSyncOnReopen(prev, h);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+defineExpose(instance);
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<template>
|
|
|
|
|
+ <!-- isClosing:height 已为 0 时仍短暂展示遮罩,与 Vant 收起动画同长 -->
|
|
|
|
|
+ <van-overlay
|
|
|
|
|
+ :class="overlayClassList"
|
|
|
|
|
+ :style="overlayStyle"
|
|
|
|
|
+ :show="height !== 0 || isClosing"
|
|
|
|
|
+ :duration="durationSec"
|
|
|
|
|
+ @click="onClickOverlay"
|
|
|
|
|
+ >
|
|
|
|
|
+ <van-floating-panel
|
|
|
|
|
+ :class="containerClassList"
|
|
|
|
|
+ :style="[containerStyle, style]"
|
|
|
|
|
+ v-model:height="height"
|
|
|
|
|
+ :duration="durationSec"
|
|
|
|
|
+ :anchors="anchors"
|
|
|
|
|
+ :content-draggable="contentDraggable"
|
|
|
|
|
+ @click.stop
|
|
|
|
|
+ @height-change="onUpdateHeight"
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #header>
|
|
|
|
|
+ <div class="van-floating-panel__header">
|
|
|
|
|
+ <div ref="panel-header">
|
|
|
|
|
+ <van-icon v-if="closable" class="van-floating-panel__header_icon" name="cross" size="18" @click.stop="onClose()" />
|
|
|
|
|
+ <div class="van-floating-panel__header-bar"></div>
|
|
|
|
|
+ <slot name="header" :title="title">
|
|
|
|
|
+ <div>{{ title }}</div>
|
|
|
|
|
+ </slot>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template #default>
|
|
|
|
|
+ <div ref="panel-content-root" class="float-panel-content-root">
|
|
|
|
|
+ <slot name="content"></slot>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </van-floating-panel>
|
|
|
|
|
+ </van-overlay>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+.float-panel-overlay {
|
|
|
|
|
+ --van-overlay-z-index: var(--van-floating-panel-z-index);
|
|
|
|
|
+ &.hide {
|
|
|
|
|
+ --van-overlay-background: transparent;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.float-panel-container {
|
|
|
|
|
+ $gap: calc((30px - var(--van-floating-panel-bar-height)) / 2);
|
|
|
|
|
+
|
|
|
|
|
+ :deep(.van-floating-panel__header) {
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ > div {
|
|
|
|
|
+ padding-top: $gap;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ min-height: 30px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .van-floating-panel__header-bar {
|
|
|
|
|
+ & + * {
|
|
|
|
|
+ padding: calc($gap / 2) 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .van-floating-panel__header_icon {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: calc($gap / 2);
|
|
|
|
|
+ right:calc($gap / 2);
|
|
|
|
|
+ color: var(--van-floating-panel-bar-color);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(.van-floating-panel__content) {
|
|
|
|
|
+ height: var(--van-floating-panel-content-height);
|
|
|
|
|
+ overflow: hidden !important;
|
|
|
|
|
+ flex: none;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ > * {
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|