PVQ-UI 回流落库:锚点浮层 .pvq-popover(v1.3.1)
状态:已落库(库版本
1.3.1,base 层,experimental) 来源:pvq-weixinyuedu插件提出的《回流需求-pvq-ui-浮层组件.md》(依据《PVQ-UI项目规则》§2「通用组件只存在于 pvq-ui;发现缺失 → 回流到库,先文档、后代码」) 关联:《PVQ-UI组件库开发方案.md》§4.1/§7、《组件提取对照表.md》§2.16、《设计令牌与组件清单.md》Popover 条目、《PVQ-UI组件库调用手册.md》§4.7 机器可读真源:site/inc/components.json的popover条目
1. 为什么收进库
插件「引文同步」要把微信读书点评以悬浮卡片呈现在引语旁(桌面悬停、触屏点按、键盘聚焦)。盘点库中 34 个组件后确认没有任何浮层气泡类:
| 既有组件 | 为什么不能替代 |
|---|---|
card |
常驻内容容器,padding:24px、overflow:hidden、带入场动画,无定位与开合能力 |
collapse |
原地展开,撑高文档流 ⇒ 布局位移;需求要的是「出卡片」而非「展开」 |
confirm-modal |
模态、阻断交互、居中,不适合「扫一眼就关」的轻量注释 |
toast |
固定角落通知,不锚定触发元素 |
缺的能力是以某个元素为锚、脱离文档流、按需显隐的浮层——这是前端通用原语(术语解释、作者卡、预览、迷你详情都会用到),留在各插件各写一遍正是《项目规则》§2 要避免的漂移。故收进库。
2. 落库决策(本案的四个关键取舍)
- 层级:base 层(
assets/css/pvq-ui.css)。前后台都要用,且不依赖 admin / frontend 层。 - 稳定性:
experimental。按《库演进治理方案》§5.3,新组件经 1~2 个插件验证后再转stable(weixinyuedu 为第一个验证方)。 - 开关契约只有两个状态:
hidden属性 = 彻底不显示(display:none);--open修饰符 = 淡入显示。库只认这两个,不碰业务。 - 默认即浮层态,不做
pvq-wr-js式的渐进增强分支(与插件侧现状的差异,见 §7)。- 库 CSS 里
.pvq-popover本身就是position:fixed+opacity:0; visibility:hidden,不需要调用方在<head>同步加类来消除 FOUC——这比插件侧「head 脚本加.pvq-wr-js」更省事,插件收敛后thought-boot.js可删。 - 代价:JS 缺席时浮层保持不可见(信息需服务端另给兜底,如
<noscript>中的静态呈现)。这个取舍是刻意的:库不在无坐标的情况下伪装显示浮层,否则position:fixed且top/left:auto的元素会停在视口左上角,比不可见更糟。
- 库 CSS 里
3. 类名契约(BEM 双横线,遵守 §6)
<button type="button" tabindex="0"
data-pvq-popover="pvq-demo-pop" aria-describedby="pvq-demo-pop">悬停 / 点按我</button>
<div class="pvq-popover" id="pvq-demo-pop" role="note" hidden>
<div class="pvq-popover__body">…业务内容…</div>
<div class="pvq-popover__foot">…可选脚注…</div>
<span class="pvq-popover__arrow"></span>
</div>
| 类名 | 作用 |
|---|---|
.pvq-popover |
浮层本体(block) |
.pvq-popover__body |
内容区 |
.pvq-popover__foot |
脚注区(可选,border-top 分隔) |
.pvq-popover__arrow |
可选小箭头(JS 自动对准锚点中心) |
.pvq-popover--open |
打开态(由 JS 添加/移除) |
.pvq-popover--above |
由 JS 写在锚点上方时添加 |
- 库不定义任何内容槽(无
__title/__icon)——内容是业务的,浮层只给外壳与脚注分隔。 - 浮层不设背景遮罩、不做焦点陷阱(它不是模态)。
4. 定位契约
position: fixed;坐标由库 JS 用getBoundingClientRect()计算后写入内联top/left。- 不依赖任何祖先的
position——锚点浮层若用position:absolute就必须假定业务容器是定位上下文,而那是业务的自由,库不该假定。 - 默认贴锚点下方,间距
gap(默认8px);下方空间不足且上方放得下 → 翻到上方并加--above。 - 水平夹取(左右各留
gap)、垂直同样不越界(top夹在gap ~ vh-gap-height)。 - 宽度上限
min(24rem, calc(100vw - 2rem));z-index: 90。 - 打开态下锚点滚动(捕获阶段监听,含任意滚动容器)/ 窗口缩放 → 重新计算坐标。
已知边界(写进文档,不让调用方踩):祖先若带
transform/filter/backdrop-filter/will-change,会成为position:fixed的包含块而使坐标偏移。库不移动浮层 DOM(保持最小侵入);这类容器里使用需调用方把浮层放到该容器之外。
5. 交互契约
| 触发 | 行为 |
|---|---|
mouseenter / mouseleave(锚点) |
打开 / 延时 closeDelay(默认 160ms) 关闭,给鼠标移入浮层留时间 |
mouseenter / mouseleave(浮层) |
取消关闭计时 / 延时关闭 |
focusin / focusout(锚点) |
打开 / 焦点既不在锚点也不在浮层内时关闭 |
| 点击锚点 | 开合切换(触屏主路径) |
Escape |
关闭并把焦点交还锚点 |
| 点击浮层与锚点之外 | 关闭 |
- 触屏与桌面用
matchMedia('(hover: hover)')区分——触屏即便上报 hover 也常是「伪悬停」(首次点按才触发),不能直接信。 - 关闭有退出过渡(
EXIT_MS = 180ms,与 CSStransition: … .18s对齐),过渡走完才置hidden,不掐断动画。 prefers-reduced-motion: reduce→ 禁用过渡与位移。
6. JS 契约
6.1 命令式
const pop = PVQUI.popover( anchorEl, {
content: '<p>…</p>', // 字符串 → innerHTML;或传 DOM 节点
target: null, // 可选:既有 .pvq-popover 元素(不传则由库创建并挂 <body>)
placement: 'auto', // auto | below | above
gap: 8,
closeDelay: 160,
role: 'note', // 由库创建时写入的 role,默认 note
onOpen: fn, onClose: fn
} );
pop.open(); pop.close(); pop.toggle(); pop.destroy();
- 返回值即实例:
{ el, anchor, open, close, toggle, destroy }。 - ⚠️
content为字符串时按 HTML 插入(点评内容可能含格式,需求明确要求)。调用方不得把未经转义的用户输入拼进来——XSS 责任在调用方;只处理可信内容时建议改传 DOM 节点。
6.2 声明式(零 JS 调用,后端渲染友好)
initPopovers()(随 initAll() 自动调用)扫描锚点 [data-pvq-popover]:
- 值为目标浮层
id;值为空时回退取aria-describedby。 - 目标必须存在且带
.pvq-popover类(避免误绑普通aria-describedby说明文本)。 - 声明式只走默认参数(
placement: auto、gap: 8、closeDelay: 160);要定制用命令式。
6.3 事件与幂等
- 派发
pvq-popover:open/pvq-popover:close(bubbles: true,detail{ popover, el, anchor, reason },reason∈pointer|focus|click|escape|outside|api)。库不自带任何业务语义。 - 幂等:同一浮层只初始化一次(实例挂在
el.__pvqPopover);document/window级监听(Escape、外部点击、scroll、resize)只绑一次(模块级标志),不随initAll在resize上重跑而叠加——这是 1.3.0initConfirms()幂等教训的直接落实。 destroy()解绑该实例的全部监听并清理库创建的节点;清除__pvqPopover后可重新挂载。
7. 无障碍契约(硬性)
- 锚点:
tabindex="0"+aria-describedby="<浮层 id>"(HTML 契约,库不注入——库不猜业务语义,缺了就是契约违规)。 - 浮层:
role="note"。选note(注释)而非tooltip:tooltip语义要求「纯说明文本、不含可交互内容」,而本组件允许浮层内含链接/按钮等富内容。 - 浮层不是模态:不得设
aria-modal,不得做焦点陷阱。 Escape关闭后焦点交还锚点。- 触屏无悬停,需使用方自备可见可点提示(伪元素文案属业务文案,库不生成)。
8. 明确不做(避免过度设计)
- ❌ 不做业务内容结构(无
__title/__label/__icon固定槽)。 - ❌ 不做异步加载、不做点击外部导航、不做自动消失计时(那是
toast的活)。 - ❌ 不做拖拽、不做吸附、不做多层嵌套浮层、不做
position:absolute定位模式。
9. 与插件侧现状的差异(收敛对照)
| 项 | 插件侧现状(pvq-weixinyuedu) |
库定稿 |
|---|---|---|
| 类名 | .pvq-wr-thought / __trigger / __label / __body / __foot / __time / --open |
外壳 .pvq-popover / __body / __foot / __arrow / --open / --above;.pvq-wr-thought 只留业务内容样式 |
| 显隐分支 | .pvq-wr-js(head 同步脚本加,消 FOUC) |
不需要——库默认即浮层态(见 §2.4),thought-boot.js 可删 |
| 定位/交互 | thought.js 自实现(place() / 翻转 / CLOSE_DELAY / EXIT_MS) |
删掉,改调 PVQUI.popover() 或声明式 data-pvq-popover |
| 触发属性 | data-pvq-thought-trigger + aria-describedby 绑定 |
data-pvq-popover="<id>"(aria-describedby 仍被兼容) |
| 事件 | 无 | 新增 pvq-popover:open / pvq-popover:close |
| 无 JS | 静态块跟随文档流可见 | 库不提供该兜底(见 §2.4 取舍) |
收敛判定:components.json 出现 popover 条目后,插件按《项目规则》§8 执行「删插件副本 + grep 防回潮校验」。当前为 experimental,插件侧建议先完成收敛再等库转 stable。
10. 验收标准
- [ ]
components.json可见popover条目,官网画廊自动渲染(含可真实悬停的 demo)。 - [ ] 类名全部 BEM 双横线,无单横线修饰符。
- [ ] popover 段
grep -E '#[0-9a-fA-F]{3,8}|rgba?\(' assets/css/pvq-ui.css零命中(零硬编码色值)。 - [ ] 锚点滚动 / 窗口缩放时浮层跟随,不错位。
- [ ] 视口边缘自动翻转与夹取,不溢出。
- [ ] 键盘:锚点可聚焦、
Escape可关闭、焦点正确交还。 - [ ] 不依赖任何祖先
position,无定位容器里坐标依然正确。 - [ ]
prefers-reduced-motion下无动画。 - [ ]
initAll反复调用(resize)不产生重复监听。
11. 本次同步的文档与产物
assets/css/pvq-ui.css:新增 Popover 段 +@version→1.3.1。assets/js/pvq-ui.js:新增PVQUI.popover()/initPopovers(),并入initAll+@version→1.3.1。site/inc/components.json:新增popover条目 +version→1.3.1。doc/模块文档/设计令牌与组件清单.md:补 API 条目 + 当前库版本。doc/开发文档/PVQ-UI组件库调用手册.md:新增 §4.7 调用接口 + 速查表一行。doc/开发文档/PVQ-UI组件库开发方案.md:§4.1 base 文件清单 + §7 JS 交互 + §3.1 v1.3.1 说明段。doc/开发文档/组件提取对照表.md:§2.16 新增(跨插件回流,非三套原有组件)。CHANGELOG.md:1.3.1块(MINOR,向后兼容)。readme.md:当前版本 + 合规示例。examples/popover-demo.html:可交互验证页。- 版本两 loader:
pvq-ui-loader.php(仓库根,各插件复制源)与site/pvq-ui-loader.php。 site/inc/config.php:PVQ_UI_VERSION→1.3.1。