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 FengMo WenXue