Float
通用浮窗组件栈系统,不遮罩、不抢焦点、不阻塞背景交互——形态类似 picture-in-picture,可拖拽、可定位、支持多浮窗共存。提供声明式 <Float> 与命令式 float.show() 两种用法。命令式用法需要在应用根部挂载 <FloatOpener>(Nuxt 项目自动处理)。
与 Dialog 的区别
- Dialog:模态、遮罩、抢焦、Esc / 点遮罩关闭、抢占整个屏幕
- Float:非模态、无遮罩、不抢焦、不阻塞背景交互、可拖拽到任意位置
演示页已挂载 <FloatOpener />
本页脚本顶部已渲染 <FloatOpener />,点击下方按钮即可打开浮窗。自己接入时必须在应用 root 显式挂一次(Nuxt 通过 @tripo3d/design/nuxt module 自动注册)。
Anatomy
Float浮窗壳:Teleport + Transition + 拖拽定位 + 默认 header(拖把手 + title + 关闭按钮)。面板外观(背景/圆角/阴影/padding)不内置,业务通过 props.class 自加FloatOpener单例挂载点,在应用 root 挂一次。渲染当前所有活动浮窗float命令式 helper:show / update / close,支持多例栈useFloat浮窗内部子组件读 close 上下文
声明式用法
适合"一进页面就显示一个固定浮窗"或简单受控场景。
v-model:open 控制开关
命令式用法
适合异步流程、跨组件触发、需要动态传参、或同时挂多个浮窗的场景。
float.show
命令式打开,返回 key 可用于 update/close。
挂载 FloatOpener
命令式 API 依赖单例挂载点 <FloatOpener />:
- Nuxt 项目:通过
@tripo3d/design/nuxt模块自动注入 - Vue 3 项目:需在
App.vue或根布局中手动挂载一次
<!-- App.vue -->
<template>
<RouterView />
<FloatOpener />
</template>在 Float 内部关闭自己
命令式打开的组件可通过 useFloat() 拿到 close 方法:
<script setup lang="ts">
import { Float, useFloat } from '@tripo3d/design';
const { close } = useFloat();
const props = defineProps<{ taskId: string }>();
</script>
<template>
<Float :title="`Task ${props.taskId}`" class="p-3 rounded-3 bg-gray-4">
<div class="cancel-drag">
<button @click="close">关闭自己</button>
</div>
</Float>
</template>useFloat 仅命令式有效
- 命令式:
float.show注入openerprop,useFloat().close走全局栈关掉自己 - 声明式:
opener为空,useFloat().close是 noop——请改用v-model:open绑定值关闭
业务封装组件不要把 opener 声明为 prop
useFloat() 通过 useAttrs().opener 读取 float-opener 注入的 key。如果你的业务组件把 opener 也声明为 defineProps 字段,attrs 路径会拿不到 opener(被消化为 prop)。两种规避:
- 仅在当前组件外层(以
<Float :opener>包内容)调useFloat,让 inject 路径接管 —— 推荐 - 显式从
props.opener自己构造 close:() => float.close(props.opener),不依赖useFloat
多浮窗共存
float.show 每次返回独立 key,多个浮窗可同时存在;关闭一个不影响其它。
同时打开两个浮窗
点击后两个浮窗同时挂载,各自可独立拖动 / 关闭。
⚠ Float 自身不做单例守卫。如果业务需要"再次 show 自动关旧"(类似
imgViewer.show)的语义,需在业务侧自己维护activeKey并在 show 前 close 旧的。
自定义 header(slot 三档)
Float 默认 header 包含"拖把手 icon + title prop + 关闭按钮"。通过 slot 可三档替换:
<!-- 1. 完全用默认 header,只传 title prop -->
<Float title="设置面板" :width="320" class="p-3 rounded-3 bg-gray-4">
<div class="cancel-drag">主内容</div>
</Float>
<!-- 2. 仅替换中间标题文本,保留拖把手与关闭按钮 -->
<Float :width="320" class="p-3 rounded-3 bg-gray-4">
<template #title>
<span class="c-purple-1">自定义标题样式</span>
</template>
<div class="cancel-drag">主内容</div>
</Float>
<!-- 3. 完全覆盖 header(自己画拖把手 + 关闭) -->
<Float :width="320" class="p-3 rounded-3 bg-gray-4">
<template #header>
<header class="flex justify-between">
<span>自定义</span>
<button class="cancel-drag">×</button>
</header>
</template>
<div class="cancel-drag">主内容</div>
</Float>面板外观由消费方负责
Float 默认仅渲染 header(拖把手 + title + 关闭按钮),不内置面板背景 / 圆角 / 阴影 / padding。需要带视觉的面板时通过 class 透传到外层 wrapper,header 与 default slot 都会 被包在面板内(wrapper 是 flex flex-col gap-3 的容器,padding/背景作用于整体)。
设计与 Dialog 一致:Float 决定"在哪 + 怎么动 + header 形态",长什么样由业务定义。
cancel-drag 契约
Float 外层 wrapper 默认整个区域可拖。需要"不参与窗口拖动"的子元素(按钮、可点击区、滚动区、文本框)必须加 cancel-drag class。
<Float title="任务面板" class="p-3 rounded-3 bg-gray-4">
<!-- 整块业务区不参与拖动 -->
<div class="cancel-drag">
<button @click="onAction">点我执行</button>
</div>
</Float>未加 cancel-drag 的区域按住即开始移动整个浮窗。
API
Float Props
| 名称 | 类型 | 默认值 | 说明 |
|---|---|---|---|
open | boolean | false | 受控开关;命令式由 float-opener 注入,声明式可 v-model:open |
opener | string | — | 浮窗 key,命令式自动注入;声明式留空 |
title | string | — | 默认 header 中间的文本(可用 #title slot 覆盖) |
width | number | 448 | 浮窗整体宽度(px) |
default-position | 'center' | { x?: number; y?: number } | 'center' | 首次出现的初始位置;x/y 缺失轴自动居中 |
class | string | — | 透传到外层 wrapper,通常用来注入面板视觉(bg/rounded/shadow/padding) |
Float Events
| 事件 | Payload | 说明 |
|---|---|---|
update:open | (open: boolean) | 声明式受控模式下的开关变化事件 |
Float Slots
| 插槽 | 作用域 | 说明 |
|---|---|---|
default | — | 主内容区,与 header 之间靠外层 gap-3 自动分隔 |
title | — | 仅替换默认 header 中间文本(保留拖把手与关闭按钮) |
header | — | 完全覆盖默认 header,业务自行画拖把手与关闭按钮 |
float 命令式 API
| 方法 | 签名 | 说明 |
|---|---|---|
float.show | (component, props?, key?) => key | 挂载浮窗,返回 key。不传 key 自动生成新 key(多例);传 key 时 upsert——已存在则合并 params 保持实例 mount,不存在则用此 key 新建(单例) |
float.update | (key, props) => void | 更新已打开浮窗的 props |
float.close | (key) => void | 关闭浮窗(带 150ms 退场动画) |
useFloat()
| 字段 | 类型 | 说明 |
|---|---|---|
close | () => void | 在命令式打开的组件内部关闭自身(声明式无效,请用 v-model:open) |
单例语义:用固定 key 实现
float.show 的第三参数 key 是控制单例的关键:同一 key 的 show 走 upsert 路径,组件实例复用,业务 state 切换通过自身 reactive 通知模板。视觉上"再次 show 原地替换内容",不会触发 fade-out → 重新 fade-in 的抖动。
固定 key upsert 演示
两个按钮共用同一 key,先后点击 'Task A' / 'Task B' 浮窗内容原地切换,实例不卸载;关闭后再点又重新挂载。可观察 header 上 taskId 变化无 fade 抖动。
key 命名约定
固定 key 是进程内全局共享的——所有 float.show(_, _, '相同 key') 都会被识别为同一浮窗。为防止撞名:
- 库内部保留
@tripo3d/design:前缀(例如 imgViewer 用@tripo3d/design:img-viewer) - 业务侧用自己的命名空间前缀(
@my-app:xxx/feature:xxx/<UUID>等),不要用裸字符串如'modal'/'panel'这种通用词
无显式 key(多例)的场景不受此约束,key 是自动生成的随机串。
imgViewer.show 就是基于此模式实现的图片预览业务封装,在此基础上额外做了"ObjectURL 资源回收 + 响应式视图暴露 + token 守卫旧 close 句柄"。复杂浮窗业务可参考它。
SSR
float.show / close / update 在 SSR 环境(globalThis.window === undefined)下是 noop:show 返回空 key,close/update 直接返回。<FloatOpener /> 与 <Float /> 内部用 <Teleport to="body">,仅在客户端 hydrate 后挂载,不影响 SSR 渲染。