popover —— 弹出层
本頁內容尚未翻譯。
Popover 是「能放东西的 tooltip」:提示里可以有按钮、表单、菜单、图片。它不替代 tooltip——tooltip 只能放纯文本且禁止交互;也不替代 dialog——dialog 是模态的、抢焦点的。core/popover 与 tooltip 同样薄,差异全在交互重量上。
- 各端实现
<LhPopover>:卡片式详情、筛选面板、内联表单、操作按钮组。 - 需要用户在浮层内停留并交互的场景。
- 不该用它:纯文本一句话提示(用
tooltip,更轻更克制);需要打断用户流程的强确认(用dialog,模态);二次确认操作(用popconfirm,语义更准确)。
import { LH_POPOVER_TRIGGERS, LH_POPOVER_PLACEMENT_DEFAULT, getPopoverClass, getPopoverWidthStyle, getPopoverTriggerAria, getPopoverPopperAria, getDefaultDelay, canCloseOnEscape,} from '@lh-ui/share/core/popover'import type { LhPopoverTrigger, LhPopoverState } from '@lh-ui/share/core/popover'隐藏延迟 100ms 的存在理由:tooltip 隐藏是 0ms(移出立即消失),popover 留 100ms——为了让指针能从触发器走进浮层。鼠标从触发器移到浮层的过程中会短暂离开触发元素,0ms 延迟会在路途中就关掉浮层,用户根本进不去。这是 popover 可交互带来的直接约束。
LH_POPOVER_TRIGGERS 作为校验常量:触发方式是有限枚举(click/hover/focus/manual),导出为 readonly 数组供 UI 层做 props 校验(Vue 的 validator、React 的运行时断言),同时是 TS 类型的运行时对应物。
role="dialog" 而非 "tooltip":WAI-ARIA 规定 tooltip 内不能有交互元素,可交互的浮层必须是 dialog 语义。配 aria-labelledby 指向标题元素,读屏软件打开时播报标题而非整段内容。
canCloseOnEscape 的 disabled 拦截:默认允许 Esc 关闭,disabled 时阻止——禁用状态下浮层本就不该被触发,这个守卫防止已打开的浮层在组件被禁用后仍被 Esc 关掉(业务语义上 disabled 通常意味着「操作锁定」)。
箭头是可选的:arrow !== false 才加 --arrow 类名——箭头需要额外的定位计算(shared/popper 的 computeArrowOffset),纯信息型弹出层可以省掉。
| 函数 | 签名 | 返回 | 作用 |
|---|---|---|---|
getDefaultDelay |
(showAfter?, hideAfter?) => { show, hide } |
延迟 | 默认 200 / 100 |
getPopoverClass |
(state: { placement, arrow?, popperClass? }) => string[] |
类名 | 方位 + 箭头 + 自定义 |
getPopoverWidthStyle |
(width?: string | number) => Record<string, string> |
内联样式 | 宽度归一 |
getPopoverTriggerAria |
(visible, popperId?) => LhAriaAttrs |
aria 包 | aria-expanded/controls/haspopup |
getPopoverPopperAria |
(id?, titleId?) => LhAriaAttrs |
aria 包 | role=dialog + aria-labelledby |
canCloseOnEscape |
(state: { disabled }) => boolean |
是否允许 | disabled 时拦截 |
常量:LH_POPOVER_TRIGGERS(4 种触发)、LH_POPOVER_PLACEMENT_DEFAULT = 'top'。
export type LhPopoverTrigger = 'click' | 'hover' | 'focus' | 'manual'
export interface LhPopoverState { placement?: LhPlacement // 12 方位(shared/popper) trigger?: LhPopoverTrigger width?: string | number // 弹出层宽度 title?: string visible?: boolean disabled?: boolean arrow?: boolean // 默认 true showAfter?: number hideAfter?: number}getPopoverTriggerAria(true, 'pop-1')// { 'aria-expanded': true, 'aria-controls': 'pop-1', 'aria-haspopup': 'true' }getPopoverTriggerAria(false) // { 'aria-expanded': false, 'aria-haspopup': 'true' }
getPopoverPopperAria('pop-1', 'pop-1-title')// { role: 'dialog', id: 'pop-1', 'aria-labelledby': 'pop-1-title' }- 触发器三件套:
aria-haspopup声明「我会弹出东西」、aria-expanded声明「现在开着没」、aria-controls指向「弹出的是哪个元素」。键盘用户靠 expanded 状态判断当前局面。 aria-controls需要浮层存在:浮层v-if渲染时 id 才存在;未传 popperId 时只输出 expanded/haspopup。不要让 controls 指向已卸载的元素——关闭后浮层移除,触发器的 controls 会悬空(读屏软件容忍,但严格校验会报)。- 标题即门面:
aria-labelledby指向<div class="lh-popover__title" :id="titleId">。标题要写清楚这个弹出层是什么(「筛选条件」「订单详情」),不要写动词(「点击这里」)。 - 焦点管理是 UI 层的活:
role="dialog"不自带焦点圈——是否 trap 焦点、初始焦点落哪,由 UI 层用shared/focus-trap决定。非模态 popover 通常不 trap(用户仍可操作页面其他部分),但点击外部要关闭。 - 键盘打开:click 触发下,触发器是按钮即可 Enter/Space 打开;hover 触发对键盘用户不可达——关键操作不要只提供 hover 触发。
import { getDefaultDelay, getPopoverClass, getPopoverWidthStyle, canCloseOnEscape,} from '@lh-ui/share/core/popover'
getDefaultDelay(undefined, undefined) // { show: 200, hide: 100 }getDefaultDelay(0, 0) // { show: 0, hide: 0 }(显式即时)
getPopoverClass({ placement: 'bottom' })// ['lh-popover__popper', 'lh-popover__popper--bottom', 'lh-popover__popper--arrow']getPopoverClass({ placement: 'top', arrow: false })// ['lh-popover__popper', 'lh-popover__popper--top']getPopoverClass({ placement: 'top', popperClass: 'my-pop' })// ['lh-popover__popper', '…--top', '…--arrow', 'my-pop']
getPopoverWidthStyle(240) // { width: '240px' }getPopoverWidthStyle('50%') // { width: '50%' }getPopoverWidthStyle(undefined) // {}
canCloseOnEscape({ disabled: false }) // truecanCloseOnEscape({ disabled: true }) // false<!-- Vue3 --><template> <LhPopover :width="280" trigger="click" title="筛选条件"> <template #reference> <LhButton>筛选</LhButton> </template> <div class="filter-body"> <LhCheckbox v-model="form.active">仅看启用</LhCheckbox> <LhButton type="primary" @click="apply">应用</LhButton> </div> </LhPopover></template>// 点击外部关闭(UI 层):document 监听 click,判断 target 是否在触发器与浮层之外function onDocumentClick(e: MouseEvent) { if (!triggerEl.value?.contains(e.target as Node) && !popperEl.value?.contains(e.target as Node)) { state.visible = false }}watch(() => state.visible, (v) => { v ? document.addEventListener('click', onDocumentClick) : document.removeEventListener('click', onDocumentClick)})// React:useRef 拿触发器与浮层节点;外部点击用自定义 useClickOutside hook;// 浮层 portal 到 document.body,避免被父级 overflow:hidden 裁掉// uniapp:<view> 浮层;「点击外部关闭」在小程序端用 catchtouchstart 全局遮罩实现| 类名 | 语义 |
|---|---|
.lh-popover__popper |
弹出层容器 |
.lh-popover__popper--{top|top-start|…} |
12 方位(含翻转后的实际方位) |
.lh-popover__popper--arrow |
带箭头 |
.lh-popover__title |
标题 |
.lh-popover__body |
内容区 |
底色 --lh-bg-color,边框 --lh-border-color,阴影 --lh-box-shadow-popover,圆角 --lh-border-radius-base,z-index --lh-z-index-popover。
- 浮层要 teleport 到 body:留在原 DOM 位置会被父级的
overflow: hidden、transform、z-index层叠上下文坑掉。teleport 后定位坐标用shared/popper的computePosition基于视口计算。 - 翻转后的方位类名:
getPopoverClass吃的是实际生效的 placement(popper flip 之后的值),不是 prop 原值——否则箭头朝向与视觉不符。UI 层要把 popper 返回的真实方位传进来。 width与内容自适应:设了固定宽度时窄内容会被拉伸;不设时长内容可能撑出视口。复杂内容建议传min-width而非width(通过 popperClass 自定义样式)。- hover 触发的移动端:触屏没有 hover,hover 触发的 popover 在移动端要点一下才有「悬停」状态、再点别处才关——体验很差。跨端组件建议按端切换触发方式。
- 嵌套 popover:A 的浮层里再开 B,关闭 A 时要级联关 B,否则 B 悬空。共享 popper 状态时注意。
manual触发:完全由外部v-model:visible控制——常配合「选中行即展开」这类逻辑,此时外部点击关闭逻辑要自己写(core 的点击外部关闭也是 UI 层实现的)。- 与 tooltip 的代码复用:两者共用
shared/popper和shared/delay,但 aria 角色不可混用——别为了复用把 tooltip 的role="tooltip"用在可交互浮层上,违反 ARIA 且读屏软件行为不可预期。
@lh-ui/share/core/popover → getDefaultDelay( )(填好参数后点运行)Copyright © 2024-2026 风默文学 | 宁ICP备2024006574号 |
苏公网安备32040002010537