移动端导航弹出气泡

弹出气泡Popover

在某个元素的旁边弹出一小块说明或轻量操作(运费规则、字段解释、更多操作),说完就收

反馈 规格 41 · 弹出气泡 Popover 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">

<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/mobile-popover.css">

<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->

演示

每个演示都是真实渲染:预览帧加载 frameworks-mobile/Popover.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。

01 组件类型

四个方向placement=top|bottom|left|right

上方 / 下方 / 左侧 / 右侧:气泡各自相对自己的触发器定位,所以四个可以同页并列而互不影响。

查看代码(演示页原文 · 41 行)
frameworks-mobile/Popover.html · placements
<section class="demo-block" data-demo="placements">
  <p class="demo-label">四个方向(placement=top / bottom / left / right:气泡永远贴着它自己的触发器)</p>
  <div class="demo-stage demo-stage--grid" data-assert="popover-placements">
    <div class="kole-m-popover" id="pop-top" data-placement="top" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-sets-attr:#pop-top|data-open|true">上方</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--top kole-m-popover__panel--arrow"
           role="dialog" aria-label="上方气泡">
        <p class="kole-m-popover__text">气泡在上方</p>
      </div>
    </div>

    <div class="kole-m-popover" id="pop-bottom" data-placement="bottom" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-sets-attr:#pop-bottom|data-open|true">下方</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow"
           role="dialog" aria-label="下方气泡">
        <p class="kole-m-popover__text">气泡在下方</p>
      </div>
    </div>

    <div class="kole-m-popover" id="pop-left" data-placement="left" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-sets-attr:#pop-left|data-open|true">左侧</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--left kole-m-popover__panel--arrow"
           role="dialog" aria-label="左侧气泡">
        <p class="kole-m-popover__text">气泡在左侧</p>
      </div>
    </div>

    <div class="kole-m-popover" id="pop-right" data-placement="right" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-sets-attr:#pop-right|data-open|true">右侧</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--right kole-m-popover__panel--arrow"
           role="dialog" aria-label="右侧气泡">
        <p class="kole-m-popover__text">气泡在右侧</p>
      </div>
    </div>
  </div>
  <p class="demo-hint">四个气泡各自相对自己的触发器定位,互不影响;点一下对应按钮即可看到它出现在那一侧。</p>
</section>
无箭头arrow=false

arrow=false:省掉三角,仅靠位置表达归属,适合小屏上空间紧张的场景。

查看代码(演示页原文 · 12 行)
frameworks-mobile/Popover.html · arrow
<section class="demo-block" data-demo="arrow">
  <p class="demo-label">无箭头(arrow=false:小屏上空间紧张时省掉三角,气泡位置不变)</p>
  <div class="demo-stage demo-stage--center" data-assert="popover-arrow">
    <div class="kole-m-popover" id="pop-noarrow" data-placement="bottom" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-sets-attr:#pop-noarrow|data-open|true">无箭头气泡</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--bottom" role="dialog" aria-label="无箭头气泡">
        <p class="kole-m-popover__text">不带箭头,仅靠位置表达归属</p>
      </div>
    </div>
  </div>
</section>

02 组件状态

带标题title 非空

title 给出这条气泡在说什么,正文再补一句;标题同时作为面板的 aria-label 供读屏播报。

查看代码(演示页原文 · 14 行)
frameworks-mobile/Popover.html · title
<section class="demo-block" data-demo="title">
  <p class="demo-label">带标题(title + text:标题给出这条气泡在说什么,正文再补一句)</p>
  <div class="demo-stage demo-stage--center" data-assert="popover-title">
    <div class="kole-m-popover" id="pop-title" data-placement="bottom" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-sets-attr:#pop-title|data-open|true">运费说明</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow"
           role="dialog" aria-label="运费说明">
        <p class="kole-m-popover__title">运费规则</p>
        <p class="kole-m-popover__text">单笔满 99 元免运费,偏远地区不参与。</p>
      </div>
    </div>
  </div>
</section>
点击外部关闭closeOnOutside=true

气泡没有遮罩可依赖,落点在气泡与触发器之外时收起;触屏上这是唯一的隐式关闭路径。

查看代码(演示页原文 · 24 行)
frameworks-mobile/Popover.html · outside
<section class="demo-block" data-demo="outside">
  <p class="demo-label">点击外部关闭(气泡没有遮罩可依赖:落点在气泡与触发器之外时收起)</p>
  <div class="demo-stage demo-stage--center" data-assert="popover-outside">
    <div class="kole-m-popover" id="pop-outside" data-placement="bottom" data-open="false">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
              data-behavior="click-toggles-class:#pop-outside-panel|is-open">先点我展开</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow"
           id="pop-outside-panel" role="dialog" aria-label="待关闭的气泡">
        <p class="kole-m-popover__text">展开后,再点下面的「外部区域」即可收起</p>
      </div>
    </div>
    <div class="kole-m-popover" id="pop-outside-open" data-placement="bottom" data-open="true">
      <button class="kole-m-popover__trigger" type="button" aria-expanded="true" aria-haspopup="dialog">已展开的气泡</button>
      <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow is-open"
           role="dialog" aria-label="已展开的气泡">
        <p class="kole-m-popover__text">这条默认就是展开的,用来对照「点外部后是否真的收起」</p>
      </div>
    </div>
  </div>
  <p class="demo-hint">
    <button class="demo-outside" type="button"
            data-behavior="click-sets-attr:#pop-outside-open|data-open|false">外部区域(点这里收起)</button>
  </p>
</section>

API

props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs

Props

名称类型默认值说明必传
openbooleanfalse状态 open:气泡展开可见(规格 §41.4)N
placement'top' | 'bottom' | 'left' | 'right''bottom'变体 placement:气泡相对触发器出现在哪一侧(规格 §41.3)N
triggerTextstring''触发器文字,同时作为面板 aria-label 的兜底名称(规格 §41.2 trigger)N
titlestring''标题,可选;同时作为面板的 aria-label(规格 §41.2 title)N
textstring''正文文字,可选(规格 §41.2 text)N
arrowbooleantrue变体 arrow:是否渲染指向触发器的小三角(规格 §41.3)N
closeOnOutsidebooleantrue落点在气泡与触发器之外时是否关闭(规格 §41.5)N

「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。

事件

名称参数说明
toggle—点击触发器时触发;是否展开由宿主决定(受控)(规格 §41.5)
close—落点在气泡与触发器之外、且 closeOnOutside=true 时触发(规格 §41.5)

插槽

名称说明
default气泡内容(放在 title / text 之后,用于自定义内容)

CSS 变量

组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。

名称默认值说明
--kole-m-popover-gapvar(--kole-space-8)组件内部默认值,可在业务侧覆盖
--kole-m-popover-arrow5px组件内部默认值,可在业务侧覆盖
--kole-m-popover-max-width240px组件内部默认值,可在业务侧覆盖

何时使用

  • 在某个元素的旁边弹出一小块说明或轻量操作(运费规则、字段解释、更多操作),说完就收
  • 触屏没有悬停:桌面端移开鼠标就收起,触屏必须显式给一条关闭路径(点击气泡之外)
  • 气泡是相对某个触发元素定位的,不是铺满视口的浮层 —— 这是它与轻提示 / 遮罩层的根本区别
  • 一次轻点触发器展开 / 收起,同一个按钮负责开与关
  • 边缘空间不足时的翻转(flip)由宿主决定:把 placement 换成对侧即可,组件不做自动测量

交互与触控

  • 一次轻点触发器展开 / 收起,同一个按钮负责开与关
  • 点击外部关闭:落点不在「气泡或触发器」之内时收起;气泡没有遮罩可依赖,靠文档级 pointerdown 判定
  • uni-app 端没有 document,改用铺满视口的透明捕获层(层级低于面板)
  • 不响应长按、双击与拖动;气泡自身不消费纵向滚动
  • 气泡与触发器之间留 --kole-m-popover-gap(默认 8px)的间隙,避免气泡盖住触发器的按下反馈
  • 开合动效 120ms(--kole-duration-fast),减少动态偏好下瞬时切换

无障碍

  • 触发器是原生 button,带 aria-expanded(读屏能播报「已展开 / 已折叠」)与 aria-haspopup
  • 面板 role="dialog" + aria-label:名称取 title,为空时退回触发器文字,保证读屏不会读到一个匿名对话框
  • 弹出时不移动焦点(气泡是补充说明而非中断式浮层)
  • 气泡不因展开而隐藏任何内容:关闭后触发器仍在原地可再次打开

从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。

组件何时用它而不是本组件
弹出层Popup需要铺满视口、带方向位移与标题栏的浮层时用弹出层;气泡只贴着触发器,不覆盖整屏
轻提示Toast只是短暂告知结果、不需要贴着某个元素时用轻提示;气泡会一直停在触发器旁边等用户处理
对话框Dialog需要用户确认或输入的中断式浮层用对话框,气泡不该承载需要决策的内容

规格未定 / 禁止发明

类别条目
禁止发明基于可用空间的自动翻转与自动方位选择(placement 由宿主决定)
禁止发明悬停触发(触屏没有悬停;桌面端若需要,由宿主包装)
禁止发明气泡内的表单校验与提交流程
禁止发明气泡之间的互斥开关(哪个开着由宿主管理)
规格未定是否需要在气泡贴近视口边缘时自动夹在边界内(当前只做静态定位)
规格未定role="dialog" 对纯文字说明类气泡是否过重(当前统一用 dialog + aria-label)
规格未定点击外部关闭是否需要区分「点了另一个气泡」的情况

结构(anatomy)

字段说明
popover根元素,position: relative 的包一层,气泡在其中绝对定位(因此永远贴着自己的触发器)
trigger触发器(原生 button),热区 ≥44px,写 aria-expanded 与 aria-haspopup
panel气泡面板,按 placement 贴着触发器的某一侧;role="dialog" 并带 aria-label
title / text标题与正文,可选
箭头arrow=true 时由纯 CSS 三角(border 拼出,无图片、无 hex)指向触发器

变体维度与类名映射

类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。

维度取值对应类名 / 变量
placementtop / bottom / left / right
top .kole-m-popover__panel--top
bottom .kole-m-popover__panel--bottom
left .kole-m-popover__panel--left
right .kole-m-popover__panel--right
arrowfalse / true
false (由数据驱动,无专属类)
true .kole-m-popover__panel--arrow

代表变体

变体标签
placement=top · arrow=true上方气泡(带箭头)
placement=bottom · arrow=true下方气泡(最常用)
placement=left · arrow=true左侧气泡
placement=right · arrow=false右侧不带箭头

用到的令牌

构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。

--kole-m-font-size-label --kole-m-touch-target --kole-color-border --kole-color-brand --kole-color-card-bg --kole-color-focus-ring --kole-color-table-header-bg --kole-color-text-body --kole-color-text-title --kole-duration-fast --kole-ease-out --kole-font-family --kole-radius-base --kole-radius-medium --kole-shadow-medium --kole-space-12 --kole-space-16 --kole-space-4 --kole-space-8 --kole-m-popover-arrow --kole-m-popover-gap --kole-m-popover-max-width

6 端源码

同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。

frameworks-mobile/Popover.css · 纯样式(CSS) · 142 行
frameworks-mobile/Popover.css
/* Kole UI Mobile · Popover 样式 — 对齐移动端规格 §41
   弹出气泡:由**触发元素定位**——根是 position: relative 的包一层,气泡在其中绝对定位,
   所以气泡永远贴着它自己的触发器,不铺满视口(这不是 toast/overlay 那种全屏浮层)。
   四个方向靠 placement 修饰符切换;箭头是纯 CSS 三角形(border 拼出,无图片、无 hex)。
   点击外部关闭由宿主(或演示页脚本)判定:气泡是绝对定位的普通元素,没有遮罩可以依赖。 */

.kole-m-popover {
  --kole-m-popover-gap: var(--kole-space-8);
  --kole-m-popover-arrow: 5px;
  --kole-m-popover-max-width: 240px;
  position: relative;
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  font-family: var(--kole-font-family);
  font-size: var(--kole-m-font-size-label);
  line-height: 1.5;
}

.kole-m-popover__panel {
  position: absolute;
  z-index: 1200;
  box-sizing: border-box;
  max-width: var(--kole-m-popover-max-width);
  padding: var(--kole-space-8) var(--kole-space-12);
  border-radius: var(--kole-radius-medium);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  box-shadow: var(--kole-shadow-medium);
  text-align: start;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity var(--kole-duration-fast) var(--kole-ease-out),
    visibility var(--kole-duration-fast) var(--kole-ease-out);
}

.kole-m-popover__panel.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

/* 变体 placement:四个方向(面板贴着触发器的那一侧) */
.kole-m-popover__panel--top {
  left: 0;
  bottom: calc(100% + var(--kole-m-popover-gap));
}

.kole-m-popover__panel--bottom {
  left: 0;
  top: calc(100% + var(--kole-m-popover-gap));
}

.kole-m-popover__panel--left {
  right: calc(100% + var(--kole-m-popover-gap));
  top: 0;
}

.kole-m-popover__panel--right {
  left: calc(100% + var(--kole-m-popover-gap));
  top: 0;
}

/* 变体 arrow=true:指向触发器的小三角(border 拼出,颜色取卡片底) */
.kole-m-popover__panel--arrow::after {
  content: '';
  position: absolute;
  width: 0;
  height: 0;
  border: var(--kole-m-popover-arrow) solid transparent;
  pointer-events: none;
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--top::after {
  left: var(--kole-space-16);
  top: 100%;
  border-top-color: var(--kole-color-card-bg);
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--bottom::after {
  left: var(--kole-space-16);
  bottom: 100%;
  border-bottom-color: var(--kole-color-card-bg);
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--left::after {
  top: var(--kole-space-16);
  left: 100%;
  border-left-color: var(--kole-color-card-bg);
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--right::after {
  top: var(--kole-space-16);
  right: 100%;
  border-right-color: var(--kole-color-card-bg);
}

.kole-m-popover__title {
  margin: 0 0 var(--kole-space-4);
  font-size: var(--kole-m-font-size-label);
  font-weight: 500;
  color: var(--kole-color-text-title);
}

.kole-m-popover__text { margin: 0; }

/* 触发器:宿主提供的可点元素(原生 button / a);热区 ≥ 44px、可聚焦、有焦点环 */
.kole-m-popover__trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-height: var(--kole-m-touch-target);
  padding: 0 var(--kole-space-12);
  border: 1px solid var(--kole-color-border);
  border-radius: var(--kole-radius-base);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-family: inherit;
  font-size: var(--kole-m-font-size-label);
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}

.kole-m-popover__trigger:active { background: var(--kole-color-table-header-bg); }

.kole-m-popover__trigger:focus-visible {
  outline: 2px solid var(--kole-color-focus-ring);
  outline-offset: -2px;
}

.kole-m-popover__trigger[aria-expanded="true"] {
  border-color: var(--kole-color-brand);
  color: var(--kole-color-brand);
}

@media (prefers-reduced-motion: reduce) {
  .kole-m-popover__panel { transition: none; }
}
frameworks-mobile/Popover.html · H5 原生(无框架) · 191 行
frameworks-mobile/Popover.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · Popover(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Popover.css">
<style>
  body { margin: 0; background: var(--kole-color-page-bg); font-family: var(--kole-font-family); color: var(--kole-color-text-body); }
  .demo { max-width: 375px; margin: 0 auto; padding: var(--kole-m-gutter) 0; }
  .demo-label { margin: 0; padding: var(--kole-space-12) var(--kole-m-gutter) var(--kole-space-8);
    font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
  /* 展示舞台:气泡是绝对定位的(贴着触发器),所以舞台只给留白,不加 overflow 裁剪 */
  .demo-stage { display: flex; align-items: flex-start; justify-content: center; gap: var(--kole-space-16);
    padding: var(--kole-space-64) var(--kole-m-gutter); background: var(--kole-color-card-bg);
    border-block: 1px solid var(--kole-color-border); }
  .demo-stage--grid { display: grid; grid-template-columns: 1fr 1fr; justify-items: center;
    row-gap: var(--kole-space-64); }
  .demo-stage--center { justify-content: center; padding: var(--kole-space-64) var(--kole-m-gutter); }
  .demo-hint { margin: 0; padding: var(--kole-space-8) var(--kole-m-gutter) 0;
    font-size: var(--kole-m-font-size-caption); color: var(--kole-color-text-tertiary); }
  .demo-outside { min-height: var(--kole-m-touch-target); padding: 0 var(--kole-m-gutter);
    border: 1px dashed var(--kole-color-border-strong); border-radius: var(--kole-radius-base);
    background: var(--kole-color-page-bg); color: var(--kole-color-text-secondary);
    font-family: inherit; font-size: var(--kole-m-font-size-label); cursor: pointer; touch-action: manipulation; }
  .demo-outside:focus-visible { outline: 2px solid var(--kole-color-focus-ring); outline-offset: 2px; }
  .demo-block[hidden] { display: none; }
</style>
</head>
<body>
<div class="demo">
  <section class="demo-block" data-demo="placements">
    <p class="demo-label">四个方向(placement=top / bottom / left / right:气泡永远贴着它自己的触发器)</p>
    <div class="demo-stage demo-stage--grid" data-assert="popover-placements">
      <div class="kole-m-popover" id="pop-top" data-placement="top" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-sets-attr:#pop-top|data-open|true">上方</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--top kole-m-popover__panel--arrow"
             role="dialog" aria-label="上方气泡">
          <p class="kole-m-popover__text">气泡在上方</p>
        </div>
      </div>

      <div class="kole-m-popover" id="pop-bottom" data-placement="bottom" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-sets-attr:#pop-bottom|data-open|true">下方</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow"
             role="dialog" aria-label="下方气泡">
          <p class="kole-m-popover__text">气泡在下方</p>
        </div>
      </div>

      <div class="kole-m-popover" id="pop-left" data-placement="left" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-sets-attr:#pop-left|data-open|true">左侧</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--left kole-m-popover__panel--arrow"
             role="dialog" aria-label="左侧气泡">
          <p class="kole-m-popover__text">气泡在左侧</p>
        </div>
      </div>

      <div class="kole-m-popover" id="pop-right" data-placement="right" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-sets-attr:#pop-right|data-open|true">右侧</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--right kole-m-popover__panel--arrow"
             role="dialog" aria-label="右侧气泡">
          <p class="kole-m-popover__text">气泡在右侧</p>
        </div>
      </div>
    </div>
    <p class="demo-hint">四个气泡各自相对自己的触发器定位,互不影响;点一下对应按钮即可看到它出现在那一侧。</p>
  </section>

  <section class="demo-block" data-demo="arrow">
    <p class="demo-label">无箭头(arrow=false:小屏上空间紧张时省掉三角,气泡位置不变)</p>
    <div class="demo-stage demo-stage--center" data-assert="popover-arrow">
      <div class="kole-m-popover" id="pop-noarrow" data-placement="bottom" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-sets-attr:#pop-noarrow|data-open|true">无箭头气泡</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--bottom" role="dialog" aria-label="无箭头气泡">
          <p class="kole-m-popover__text">不带箭头,仅靠位置表达归属</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="title">
    <p class="demo-label">带标题(title + text:标题给出这条气泡在说什么,正文再补一句)</p>
    <div class="demo-stage demo-stage--center" data-assert="popover-title">
      <div class="kole-m-popover" id="pop-title" data-placement="bottom" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-sets-attr:#pop-title|data-open|true">运费说明</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow"
             role="dialog" aria-label="运费说明">
          <p class="kole-m-popover__title">运费规则</p>
          <p class="kole-m-popover__text">单笔满 99 元免运费,偏远地区不参与。</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="outside">
    <p class="demo-label">点击外部关闭(气泡没有遮罩可依赖:落点在气泡与触发器之外时收起)</p>
    <div class="demo-stage demo-stage--center" data-assert="popover-outside">
      <div class="kole-m-popover" id="pop-outside" data-placement="bottom" data-open="false">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="false" aria-haspopup="dialog"
                data-behavior="click-toggles-class:#pop-outside-panel|is-open">先点我展开</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow"
             id="pop-outside-panel" role="dialog" aria-label="待关闭的气泡">
          <p class="kole-m-popover__text">展开后,再点下面的「外部区域」即可收起</p>
        </div>
      </div>
      <div class="kole-m-popover" id="pop-outside-open" data-placement="bottom" data-open="true">
        <button class="kole-m-popover__trigger" type="button" aria-expanded="true" aria-haspopup="dialog">已展开的气泡</button>
        <div class="kole-m-popover__panel kole-m-popover__panel--bottom kole-m-popover__panel--arrow is-open"
             role="dialog" aria-label="已展开的气泡">
          <p class="kole-m-popover__text">这条默认就是展开的,用来对照「点外部后是否真的收起」</p>
        </div>
      </div>
    </div>
    <p class="demo-hint">
      <button class="demo-outside" type="button"
              data-behavior="click-sets-attr:#pop-outside-open|data-open|false">外部区域(点这里收起)</button>
    </p>
  </section>
</div>
<script>
  /* 演示页交互:触发器切换自己的气泡(is-open + data-open + aria-expanded 同一函数写入);
     点击「气泡与触发器之外」的落点收起 —— 气泡没有遮罩可依赖,所以靠 document 上的
     pointerdown 判定落点,这与生产端各框架实现同口径。 */
  (function () {
    function setOpen(root, open) {
      var panel = root.querySelector('.kole-m-popover__panel');
      var trig = root.querySelector('.kole-m-popover__trigger');
      root.setAttribute('data-open', open ? 'true' : 'false');
      if (panel) panel.classList.toggle('is-open', open);
      if (trig) trig.setAttribute('aria-expanded', open ? 'true' : 'false');
    }
    function rootOf(el) {
      return el.closest ? el.closest('.kole-m-popover') : null;
    }
    document.querySelectorAll('.kole-m-popover').forEach(function (root) {
      var trig = root.querySelector('.kole-m-popover__trigger');
      if (!trig) return;
      trig.addEventListener('click', function () {
        setOpen(root, root.getAttribute('data-open') !== 'true');
      });
    });
    document.addEventListener('pointerdown', function (e) {
      var inRoot = rootOf(e.target);
      document.querySelectorAll('.kole-m-popover').forEach(function (root) {
        if (root === inRoot) return;
        if (root.getAttribute('data-open') === 'true') setOpen(root, false);
      });
    });
    /* demo-outside 是触发器之外的落点:click 里也收一次,保证键盘用户能用 */
    document.querySelectorAll('.demo-outside').forEach(function (btn) {
      btn.addEventListener('click', function () {
        document.querySelectorAll('.kole-m-popover[data-open="true"]').forEach(function (root) {
          setOpen(root, false);
        });
      });
    });
  })();
</script>
<script>
  /* ?demo=<id> → 只显示该演示块(文档站按块预览用;无参数时全部显示,测试与回归走无参数路径) */
  (function () {
    var id = new URLSearchParams(location.search).get('demo');
    if (!id) return;
    var blocks = Array.prototype.slice.call(document.querySelectorAll('.demo-block'));
    var hit = false;
    blocks.forEach(function (b) {
      var on = b.getAttribute('data-demo') === id;
      if (on) hit = true;
      b.hidden = !on;
    });
    if (!hit) { blocks.forEach(function (b) { b.hidden = false; }); return; }
    document.body.classList.add('demo-single');
    var demoBox = document.querySelector('.demo');
    if (demoBox) demoBox.style.minHeight = 'auto';
    blocks.forEach(function (b) {
      var label = b.querySelector('.demo-label');
      if (label && !b.hidden) label.hidden = true;
    });
  })();
</script>
</body>
</html>
frameworks-mobile/Popover.jsx · React · 61 行
frameworks-mobile/Popover.jsx
import React, { useEffect, useRef } from 'react';
import './Popover.css';

/* 弹出气泡(移动端)— 规格 §41
   由触发元素定位:根是 relative 的包一层,面板在其中绝对定位,因此气泡永远贴着自己的触发器,
   不铺满视口。四个方向靠 placement;箭头是纯 CSS 三角。
   点击外部关闭:气泡没有遮罩可依赖,因此由本端监听 document 的 pointerdown 判定
   「落点既不在面板内、也不在触发器内」时回传 onClose。宿主不采纳时视觉不变(受控)。 */
export default function Popover({
  open = false,
  placement = 'bottom',
  triggerText = '',
  title = '',
  text = '',
  arrow = true,
  closeOnOutside = true,
  onToggle,
  onClose,
  children = null,
}) {
  const rootRef = useRef(null);

  useEffect(() => {
    if (!open || !closeOnOutside) return undefined;
    function onDocPointerDown(e) {
      const root = rootRef.current;
      if (root && e.target && root.contains(e.target)) return;
      if (onClose) onClose();
    }
    document.addEventListener('pointerdown', onDocPointerDown);
    return () => document.removeEventListener('pointerdown', onDocPointerDown);
  }, [open, closeOnOutside, onClose]);

  const panelCls =
    'kole-m-popover__panel' +
    ` kole-m-popover__panel--${placement}` +
    (arrow ? ' kole-m-popover__panel--arrow' : '') +
    (open ? ' is-open' : '');

  return (
    <div className="kole-m-popover" ref={rootRef} data-placement={placement} data-open={open ? 'true' : 'false'}>
      <button
        className="kole-m-popover__trigger"
        type="button"
        aria-expanded={open ? 'true' : 'false'}
        aria-haspopup="dialog"
        onClick={() => {
          if (onToggle) onToggle();
        }}
      >
        {triggerText}
      </button>
      <div className={panelCls} role="dialog" aria-label={title || triggerText || undefined}>
        {title ? <p className="kole-m-popover__title">{title}</p> : null}
        {text ? <p className="kole-m-popover__text">{text}</p> : null}
        {children}
      </div>
    </div>
  );
}
frameworks-mobile/Popover.vue2.vue · Vue 2 · 76 行
frameworks-mobile/Popover.vue2.vue
<template>
  <div ref="rootEl" class="kole-m-popover" :data-placement="placement" :data-open="open ? 'true' : 'false'">
    <button
      class="kole-m-popover__trigger"
      type="button"
      :aria-expanded="open ? 'true' : 'false'"
      aria-haspopup="dialog"
      @click="$emit('toggle')"
    >{{ triggerText }}</button>
    <div
      class="kole-m-popover__panel"
      :class="panelClass"
      role="dialog"
      :aria-label="title || triggerText || null"
    >
      <p v-if="title" class="kole-m-popover__title">{{ title }}</p>
      <p v-if="text" class="kole-m-popover__text">{{ text }}</p>
      <slot></slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'KoleMPopover',
  props: {
    open: { type: Boolean, default: false },
    placement: { type: String, default: 'bottom' },
    triggerText: { type: String, default: '' },
    title: { type: String, default: '' },
    text: { type: String, default: '' },
    arrow: { type: Boolean, default: true },
    closeOnOutside: { type: Boolean, default: true }
  },
  computed: {
    panelClass: function () {
      return [
        'kole-m-popover__panel--' + this.placement,
        this.arrow ? 'kole-m-popover__panel--arrow' : '',
        this.open ? 'is-open' : ''
      ].filter(Boolean);
    }
  },
  watch: {
    open: function () {
      this.syncOutside();
    },
    closeOnOutside: function () {
      this.syncOutside();
    }
  },
  mounted: function () {
    this.syncOutside();
  },
  beforeDestroy: function () {
    document.removeEventListener('pointerdown', this.onDocPointerDown);
  },
  methods: {
    /* 点击外部关闭:气泡没有遮罩可依赖,靠 document 上的 pointerdown 判定落点 */
    syncOutside: function () {
      document.removeEventListener('pointerdown', this.onDocPointerDown);
      if (this.open && this.closeOnOutside) {
        document.addEventListener('pointerdown', this.onDocPointerDown);
      }
    },
    onDocPointerDown: function (e) {
      var root = this.$refs.rootEl;
      if (root && e.target && root.contains(e.target)) return;
      this.$emit('close');
    }
  }
};
</script>

<style src="./Popover.css"></style>
frameworks-mobile/Popover.vue3.vue · Vue 3 · 73 行
frameworks-mobile/Popover.vue3.vue
<template>
  <div ref="rootEl" class="kole-m-popover" :data-placement="placement" :data-open="open ? 'true' : 'false'">
    <button
      class="kole-m-popover__trigger"
      type="button"
      :aria-expanded="open ? 'true' : 'false'"
      aria-haspopup="dialog"
      @click="onTriggerClick"
    >{{ triggerText }}</button>
    <div
      class="kole-m-popover__panel"
      :class="panelClass"
      role="dialog"
      :aria-label="title || triggerText || null"
    >
      <p v-if="title" class="kole-m-popover__title">{{ title }}</p>
      <p v-if="text" class="kole-m-popover__text">{{ text }}</p>
      <slot></slot>
    </div>
  </div>
</template>

<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';

const props = defineProps({
  open: { type: Boolean, default: false },
  placement: { type: String, default: 'bottom' },
  triggerText: { type: String, default: '' },
  title: { type: String, default: '' },
  text: { type: String, default: '' },
  arrow: { type: Boolean, default: true },
  closeOnOutside: { type: Boolean, default: true }
});
const emit = defineEmits(['toggle', 'close']);

const rootEl = ref(null);

const panelClass = computed(() => [
  `kole-m-popover__panel--${props.placement}`,
  props.arrow ? 'kole-m-popover__panel--arrow' : '',
  props.open ? 'is-open' : ''
].filter(Boolean));

function onTriggerClick() {
  emit('toggle');
}

/* 点击外部关闭:气泡没有遮罩可依赖,靠 document 上的 pointerdown 判定落点 ——
   落点只要有一步落在根治下(触发器或面板)就算「内部」,其余一律回传 close。 */
function onDocPointerDown(e) {
  const root = rootEl.value;
  if (root && e.target && root.contains(e.target)) return;
  emit('close');
}

watch(
  () => [props.open, props.closeOnOutside],
  () => {
    const want = props.open && props.closeOnOutside;
    document.removeEventListener('pointerdown', onDocPointerDown);
    if (want) document.addEventListener('pointerdown', onDocPointerDown);
  },
  { immediate: true }
);

onBeforeUnmount(() => {
  document.removeEventListener('pointerdown', onDocPointerDown);
});
</script>

<style src="./Popover.css"></style>
frameworks-mobile/Popover.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 182 行
frameworks-mobile/Popover.uniapp.vue
<template>
  <view class="kole-m-popover" :data-placement="placement" :data-open="open ? 'true' : 'false'">
    <!-- 点击外部关闭:小程序 / App 端没有 document 级事件,改用一个透明捕获层铺满视口。
         层级低于面板(1100 < 面板 1200),因此只有落在气泡之外的手势才会命中它。 -->
    <view
      v-if="open && closeOnOutside"
      class="kole-m-popover__catcher"
      aria-hidden="true"
      @tap="onCatcherTap"
    ></view>
    <view
      class="kole-m-popover__trigger"
      role="button"
      :aria-expanded="open ? 'true' : 'false'"
      @tap="onTriggerTap"
    >
      <text>{{ triggerText }}</text>
    </view>
    <view
      class="kole-m-popover__panel"
      :class="panelClass"
      :role="'dialog'"
      :aria-label="title || triggerText"
    >
      <text v-if="title" class="kole-m-popover__title">{{ title }}</text>
      <text v-if="text" class="kole-m-popover__text">{{ text }}</text>
      <slot></slot>
    </view>
  </view>
</template>

<script setup>
/* uni-app 端 · 弹出气泡(移动端)— 规格 §41
   跨端差异:没有 document,所以「点击外部关闭」用铺满视口的透明捕获层(catcher)实现,
   而不是监听 document 的 pointerdown;触发器用 view + role="button" + @tap。
   尺寸用 rpx(2rpx ≈ 1px,88rpx = 44px 触控最小边长)。 */
import { computed } from 'vue';

const props = defineProps({
  open: { type: Boolean, default: false },
  placement: { type: String, default: 'bottom' },
  triggerText: { type: String, default: '' },
  title: { type: String, default: '' },
  text: { type: String, default: '' },
  arrow: { type: Boolean, default: true },
  closeOnOutside: { type: Boolean, default: true }
});
const emit = defineEmits(['toggle', 'close']);

const panelClass = computed(() => [
  `kole-m-popover__panel--${props.placement}`,
  props.arrow ? 'kole-m-popover__panel--arrow' : '',
  props.open ? 'is-open' : ''
].filter(Boolean));

function onTriggerTap() {
  emit('toggle');
}

function onCatcherTap() {
  emit('close');
}
</script>

<style>
.kole-m-popover {
  --kole-m-popover-gap: 16rpx;
  --kole-m-popover-arrow: 10rpx;
  --kole-m-popover-max-width: 480rpx;
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-label: 28rpx;
  position: relative;
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  font-size: var(--kole-m-font-size-label);
  line-height: 1.5;
}

.kole-m-popover__catcher {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 1100;
}

.kole-m-popover__panel {
  position: absolute;
  z-index: 1200;
  box-sizing: border-box;
  max-width: var(--kole-m-popover-max-width);
  padding: 16rpx 24rpx;
  border-radius: 12rpx;
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  box-shadow: var(--kole-shadow-medium);
  opacity: 0;
  visibility: hidden;
}

.kole-m-popover__panel.is-open {
  opacity: 1;
  visibility: visible;
}

.kole-m-popover__panel--top {
  left: 0;
  bottom: calc(100% + var(--kole-m-popover-gap));
}

.kole-m-popover__panel--bottom {
  left: 0;
  top: calc(100% + var(--kole-m-popover-gap));
}

.kole-m-popover__panel--left {
  right: calc(100% + var(--kole-m-popover-gap));
  top: 0;
}

.kole-m-popover__panel--right {
  left: calc(100% + var(--kole-m-popover-gap));
  top: 0;
}

.kole-m-popover__panel--arrow::after {
  content: '';
  position: absolute;
  width: 0;
  height: 0;
  border: var(--kole-m-popover-arrow) solid transparent;
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--top::after {
  left: 32rpx;
  top: 100%;
  border-top-color: var(--kole-color-card-bg);
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--bottom::after {
  left: 32rpx;
  bottom: 100%;
  border-bottom-color: var(--kole-color-card-bg);
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--left::after {
  top: 32rpx;
  left: 100%;
  border-left-color: var(--kole-color-card-bg);
}

.kole-m-popover__panel--arrow.kole-m-popover__panel--right::after {
  top: 32rpx;
  right: 100%;
  border-right-color: var(--kole-color-card-bg);
}

.kole-m-popover__title {
  margin-bottom: 8rpx;
  font-weight: 500;
  color: var(--kole-color-text-title);
}

.kole-m-popover__text { display: block; }

.kole-m-popover__trigger {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-height: var(--kole-m-touch-target);
  padding: 0 24rpx;
  border: 2rpx solid var(--kole-color-border);
  border-radius: 8rpx;
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-label);
}
</style>

测试与回归

断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。

断言 22 条 · 全部通过 报告 2026-09-20 16:50:33

复现命令
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs   # 全量 5 个组件
npm run verify:mobile-docs                                           # 本页内容完整性 + API 与源码一致性

设计契约

components/mobile-popover.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "specSection": "41 · 弹出气泡 Popover",
  "confidence": "high",
  "slug": "mobile-popover",
  "name": "弹出气泡 Popover",
  "semanticTypeCandidates": [
    "popover",
    "tooltip",
    "hint-bubble"
  ],
  "variantDimensions": [
    {
      "name": "placement",
      "values": [
        "top",
        "bottom",
        "left",
        "right"
      ]
    },
    {
      "name": "arrow",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "placement": "top",
      "arrow": "true",
      "label": "上方气泡(带箭头)"
    },
    {
      "placement": "bottom",
      "arrow": "true",
      "label": "下方气泡(最常用)"
    },
    {
      "placement": "left",
      "arrow": "true",
      "label": "左侧气泡"
    },
    {
      "placement": "right",
      "arrow": "false",
      "label": "右侧不带箭头"
    }
  ],
  "anatomy": {
    "popover": "根元素,position: relative 的包一层,气泡在其中绝对定位(因此永远贴着自己的触发器)",
    "trigger": "触发器(原生 button),热区 ≥44px,写 aria-expanded 与 aria-haspopup",
    "panel": "气泡面板,按 placement 贴着触发器的某一侧;role=\"dialog\" 并带 aria-label",
    "title / text": "标题与正文,可选",
    "箭头": "arrow=true 时由纯 CSS 三角(border 拼出,无图片、无 hex)指向触发器"
  },
  "structurePatterns": {
    "placement": "top / bottom / left / right(气泡相对触发器出现在哪一侧)",
    "arrow": "false 不带三角 / true 三角指向触发器",
    "状态类": "is-open 展开(触发器同步 aria-expanded=true)"
  },
  "usageHints": [
    "在某个元素的旁边弹出一小块说明或轻量操作(运费规则、字段解释、更多操作),说完就收",
    "触屏没有悬停:桌面端移开鼠标就收起,触屏必须显式给一条关闭路径(点击气泡之外)",
    "气泡是相对某个触发元素定位的,不是铺满视口的浮层 —— 这是它与轻提示 / 遮罩层的根本区别",
    "一次轻点触发器展开 / 收起,同一个按钮负责开与关",
    "边缘空间不足时的翻转(flip)由宿主决定:把 placement 换成对侧即可,组件不做自动测量"
  ],
  "doNotInvent": [
    "基于可用空间的自动翻转与自动方位选择(placement 由宿主决定)",
    "悬停触发(触屏没有悬停;桌面端若需要,由宿主包装)",
    "气泡内的表单校验与提交流程",
    "气泡之间的互斥开关(哪个开着由宿主管理)"
  ],
  "unknowns": [
    "是否需要在气泡贴近视口边缘时自动夹在边界内(当前只做静态定位)",
    "role=\"dialog\" 对纯文字说明类气泡是否过重(当前统一用 dialog + aria-label)",
    "点击外部关闭是否需要区分「点了另一个气泡」的情况"
  ],
  "interaction": [
    "一次轻点触发器展开 / 收起,同一个按钮负责开与关",
    "点击外部关闭:落点不在「气泡或触发器」之内时收起;气泡没有遮罩可依赖,靠文档级 pointerdown 判定",
    "uni-app 端没有 document,改用铺满视口的透明捕获层(层级低于面板)",
    "不响应长按、双击与拖动;气泡自身不消费纵向滚动",
    "气泡与触发器之间留 --kole-m-popover-gap(默认 8px)的间隙,避免气泡盖住触发器的按下反馈",
    "开合动效 120ms(--kole-duration-fast),减少动态偏好下瞬时切换"
  ],
  "accessibility": [
    "触发器是原生 button,带 aria-expanded(读屏能播报「已展开 / 已折叠」)与 aria-haspopup",
    "面板 role=\"dialog\" + aria-label:名称取 title,为空时退回触发器文字,保证读屏不会读到一个匿名对话框",
    "弹出时不移动焦点(气泡是补充说明而非中断式浮层)",
    "气泡不因展开而隐藏任何内容:关闭后触发器仍在原地可再次打开"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "open",
        "type": "boolean",
        "default": "false",
        "desc": "状态 open:气泡展开可见(规格 §41.4)",
        "required": false
      },
      {
        "name": "placement",
        "type": "'top' | 'bottom' | 'left' | 'right'",
        "default": "'bottom'",
        "desc": "变体 placement:气泡相对触发器出现在哪一侧(规格 §41.3)",
        "required": false
      },
      {
        "name": "triggerText",
        "type": "string",
        "default": "''",
        "desc": "触发器文字,同时作为面板 aria-label 的兜底名称(规格 §41.2 trigger)",
        "required": false
      },
      {
        "name": "title",
        "type": "string",
        "default": "''",
        "desc": "标题,可选;同时作为面板的 aria-label(规格 §41.2 title)",
        "required": false
      },
      {
        "name": "text",
        "type": "string",
        "default": "''",
        "desc": "正文文字,可选(规格 §41.2 text)",
        "required": false
      },
      {
        "name": "arrow",
        "type": "boolean",
        "default": "true",
        "desc": "变体 arrow:是否渲染指向触发器的小三角(规格 §41.3)",
        "required": false
      },
      {
        "name": "closeOnOutside",
        "type": "boolean",
        "default": "true",
        "desc": "落点在气泡与触发器之外时是否关闭(规格 §41.5)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "toggle",
        "params": "—",
        "desc": "点击触发器时触发;是否展开由宿主决定(受控)(规格 §41.5)"
      },
      {
        "name": "close",
        "params": "—",
        "desc": "落点在气泡与触发器之外、且 closeOnOutside=true 时触发(规格 §41.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "气泡内容(放在 title / text 之后,用于自定义内容)"
      }
    ]
  },
  "variantClasses": {
    "placement": {
      "top": [
        ".kole-m-popover__panel--top"
      ],
      "bottom": [
        ".kole-m-popover__panel--bottom"
      ],
      "left": [
        ".kole-m-popover__panel--left"
      ],
      "right": [
        ".kole-m-popover__panel--right"
      ]
    },
    "arrow": {
      "false": [],
      "true": [
        ".kole-m-popover__panel--arrow"
      ]
    }
  },
  "demos": [
    {
      "id": "placements",
      "group": "01 组件类型",
      "title": "四个方向",
      "desc": "上方 / 下方 / 左侧 / 右侧:气泡各自相对自己的触发器定位,所以四个可以同页并列而互不影响。",
      "variant": "placement=top|bottom|left|right"
    },
    {
      "id": "arrow",
      "group": "01 组件类型",
      "title": "无箭头",
      "desc": "arrow=false:省掉三角,仅靠位置表达归属,适合小屏上空间紧张的场景。",
      "variant": "arrow=false"
    },
    {
      "id": "title",
      "group": "02 组件状态",
      "title": "带标题",
      "desc": "title 给出这条气泡在说什么,正文再补一句;标题同时作为面板的 aria-label 供读屏播报。",
      "variant": "title 非空"
    },
    {
      "id": "outside",
      "group": "02 组件状态",
      "title": "点击外部关闭",
      "desc": "气泡没有遮罩可依赖,落点在气泡与触发器之外时收起;触屏上这是唯一的隐式关闭路径。",
      "variant": "closeOnOutside=true"
    }
  ],
  "related": [
    {
      "slug": "mobile-popup",
      "why": "需要铺满视口、带方向位移与标题栏的浮层时用弹出层;气泡只贴着触发器,不覆盖整屏"
    },
    {
      "slug": "mobile-toast",
      "why": "只是短暂告知结果、不需要贴着某个元素时用轻提示;气泡会一直停在触发器旁边等用户处理"
    },
    {
      "slug": "mobile-dialog",
      "why": "需要用户确认或输入的中断式浮层用对话框,气泡不该承载需要决策的内容"
    }
  ]
}