移动端导航弹出层

弹出层Popup

从指定方向弹出的浮层基座,承载面板、抽屉、动作列表等内容

反馈 规格 11 · 弹出层 Popup 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-popup.css">

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

演示

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

01 组件类型

居中弹窗placement=center

居中浮层用缩放淡入而不是位移,避免在窄屏上「从某一边冒出来」的错觉。

查看代码(演示页原文 · 20 行)
frameworks-mobile/Popup.html · center
<section class="demo-block" data-demo="center">
  <p class="demo-label">居中弹窗(placement=center,点按钮打开)</p>
  <div class="demo-frame" data-assert="popup-center">
    <div class="demo-frame__body">
      <button class="demo-trigger" type="button"
              data-behavior="click-toggles-class:#popup-center|is-open">打开居中弹窗</button>
    </div>
    <div class="kole-m-popup__mask" aria-hidden="true"></div>
    <div class="kole-m-popup kole-m-popup--center kole-m-popup--round" id="popup-center"
         data-state="closed" role="dialog" aria-modal="true" aria-label="确认提交">
      <div class="kole-m-popup__header">
        <span>确认提交</span>
        <button class="kole-m-popup__close" type="button" aria-label="关闭弹窗">✕</button>
      </div>
      <div class="kole-m-popup__body">
        <p>提交后将进入审核流程,期间不可修改。</p>
      </div>
    </div>
  </div>
</section>
底部弹出placement=bottom

移动端最常用的形态:拇指可达且不遮挡上下文;round=true 时靠内容一侧切圆角。

查看代码(演示页原文 · 20 行)
frameworks-mobile/Popup.html · bottom
<section class="demo-block" data-demo="bottom">
  <p class="demo-label">底部弹出(placement=bottom + round=true:滑入 240ms,靠内容一侧切圆角)</p>
  <div class="demo-frame" data-assert="popup-bottom">
    <div class="demo-frame__body">
      <button class="demo-trigger" type="button"
              data-behavior="click-toggles-class:#popup-bottom|is-open">打开底部面板</button>
    </div>
    <div class="kole-m-popup__mask" aria-hidden="true"></div>
    <div class="kole-m-popup kole-m-popup--bottom kole-m-popup--round" id="popup-bottom"
         data-state="closed" role="dialog" aria-modal="true" aria-label="筛选条件">
      <div class="kole-m-popup__header">
        <span>筛选条件</span>
        <button class="kole-m-popup__close" type="button" aria-label="关闭筛选面板">✕</button>
      </div>
      <div class="kole-m-popup__body">
        <p>底部浮层是移动端最常用的形态:拇指可达,且不遮挡上下文。</p>
      </div>
    </div>
  </div>
</section>
顶部弹出placement=top

从屏幕上方滑入,适合「一次性选择后立即关闭」的地址选择与全局提示条。

查看代码(演示页原文 · 20 行)
frameworks-mobile/Popup.html · top
<section class="demo-block" data-demo="top">
  <p class="demo-label">顶部弹出(placement=top:常用于全局提示条与地址选择)</p>
  <div class="demo-frame" data-assert="popup-top">
    <div class="demo-frame__body">
      <button class="demo-trigger" type="button"
              data-behavior="click-toggles-class:#popup-top|is-open">打开顶部面板</button>
    </div>
    <div class="kole-m-popup__mask" aria-hidden="true"></div>
    <div class="kole-m-popup kole-m-popup--top kole-m-popup--round" id="popup-top"
         data-state="closed" role="dialog" aria-modal="true" aria-label="收货地址">
      <div class="kole-m-popup__header">
        <span>收货地址</span>
        <button class="kole-m-popup__close" type="button" aria-label="关闭地址面板">✕</button>
      </div>
      <div class="kole-m-popup__body">
        <p>顶部浮层从屏幕上方滑入,适合「一次性选择后立即关闭」的场景。</p>
      </div>
    </div>
  </div>
</section>

02 组件状态

左右侧滑placement=left|right

高度铺满、宽度受限(不超过 80%),用于次级导航或详情;默认展开以便对照。

查看代码(演示页原文 · 16 行)
frameworks-mobile/Popup.html · sides
<section class="demo-block" data-demo="sides">
  <p class="demo-label">左右侧滑(placement=left / right:高度铺满,默认展开对照)</p>
  <div class="demo-frame" data-assert="popup-sides">
    <div class="kole-m-popup__mask is-open" aria-hidden="true"></div>
    <div class="kole-m-popup kole-m-popup--right kole-m-popup--round is-open"
         role="dialog" aria-modal="true" aria-label="侧滑面板">
      <div class="kole-m-popup__header">
        <span>侧滑面板</span>
        <button class="kole-m-popup__close" type="button" aria-label="关闭侧滑面板">✕</button>
      </div>
      <div class="kole-m-popup__body">
        <p>侧滑用于展示次级导航或详情,宽度受限(不超过 80%)。</p>
      </div>
    </div>
  </div>
</section>
遮罩不关闭closeOnMask=false

closeOnMask=false:误触遮罩不关闭浮层,表单类内容不该因为一次误触就丢弃已填数据。

查看代码(演示页原文 · 16 行)
frameworks-mobile/Popup.html · keep-open
<section class="demo-block" data-demo="keep-open">
  <p class="demo-label">遮罩不关闭(closeOnMask=false:误触遮罩不关闭浮层,默认展开)</p>
  <div class="demo-frame" data-assert="popup-keep-open">
    <div class="kole-m-popup__mask is-open" aria-hidden="true"></div>
    <div class="kole-m-popup kole-m-popup--center kole-m-popup--round is-open"
         data-close-on-mask="false" role="dialog" aria-modal="true" aria-label="填写表单">
      <div class="kole-m-popup__header">
        <span>填写表单</span>
        <button class="kole-m-popup__close" type="button" aria-label="关闭表单">✕</button>
      </div>
      <div class="kole-m-popup__body">
        <p>表单类内容不该因为一次误触就丢弃已填数据,因此关闭遮罩点击。</p>
      </div>
    </div>
  </div>
</section>
内容超长状态 scroll

高度上限为屏幕的 80%,超出部分在内容区内部滚动,头部与关闭按钮固定不动。

查看代码(演示页原文 · 21 行)
frameworks-mobile/Popup.html · scroll
<section class="demo-block" data-demo="scroll">
  <p class="demo-label">内容超长(body 内部滚动,头部与遮罩不滚动;默认展开)</p>
  <div class="demo-frame" data-assert="popup-scroll">
    <div class="kole-m-popup__mask is-open" aria-hidden="true"></div>
    <div class="kole-m-popup kole-m-popup--bottom kole-m-popup--round is-open"
         role="dialog" aria-modal="true" aria-label="服务条款">
      <div class="kole-m-popup__header">
        <span>服务条款</span>
        <button class="kole-m-popup__close" type="button" aria-label="关闭服务条款">✕</button>
      </div>
      <div class="kole-m-popup__body" data-assert="popup-scroll-body">
        <p>第一条 · 本服务由 Kole UI 提供,用于演示移动端浮层的内容滚动行为。</p>
        <p>第二条 · 浮层高度上限为屏幕的 80%,超出部分在内容区内部滚动。</p>
        <p>第三条 · 头部与关闭按钮固定,不随内容滚动,保证任何位置都能关闭。</p>
        <p>第四条 · 遮罩层不参与滚动,滚动链不会穿透到页面正文。</p>
        <p>第五条 · 关闭后焦点回到触发元素,键盘用户不会丢失位置。</p>
        <p>第六条 · 条款内容仅为演示数据,不代表任何真实服务约定。</p>
      </div>
    </div>
  </div>
</section>

API

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

Props

名称类型默认值说明必传
placement'center' | 'bottom' | 'top' | 'left' | 'right''bottom'变体 placement:弹出方向(规格 §11.3)N
roundbooleanfalse变体 round:贴边方向在靠内容一侧切圆角(规格 §11.3)N
openbooleanfalse状态 open:展开且遮罩可见(规格 §11.4)N
closeOnMaskbooleantrue遮罩点击是否关闭浮层;表单类内容通常设为 false(规格 §11.5)N
titlestring''标题区文字,同时作为关闭按钮 aria-label 的一部分(规格 §11.2 header)N

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

事件

名称参数说明
close—点击遮罩(closeOnMask=true 时)或关闭按钮时触发(规格 §11.5)

插槽

名称说明
default内容区,可滚动;超出时在内部滚动而遮罩不滚动(规格 §11.2 body)

CSS 变量

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

名称默认值说明
--kole-m-popup-max-size80%浮层在弹出方向上的最大占比
--kole-m-popup-radiusvar(--kole-radius-large)round=true 时的圆角半径

何时使用

  • 从指定方向弹出的浮层基座,承载面板、抽屉、动作列表等内容
  • 遮罩点击关闭;closeOnMask=false 时不关闭
  • 滑入动画 240ms,缓动 cubic-bezier(.32,.72,0,1)
  • 内容超出时 body 内部滚动,遮罩不滚动
  • 浮层 role="dialog" + aria-modal="true"
  • 关闭按钮为原生 button 并带 aria-label

交互与触控

  • 遮罩点击关闭;closeOnMask=false 时不关闭
  • 滑入动画 240ms,缓动 cubic-bezier(.32,.72,0,1)
  • 内容超出时 body 内部滚动,遮罩不滚动

无障碍

  • 浮层 role="dialog" + aria-modal="true"
  • 遮罩 aria-hidden="true"(纯装饰)
  • 关闭按钮为原生 button 并带 aria-label

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

组件何时用它而不是本组件
动作面板ActionSheet固定从底部弹出的操作列表用动作面板,需要其它方向或自定义内容时才用弹出层
分割线Divider浮层内的分区用分割线,而不是靠留白或额外容器表达
单元格Cell浮层里列选项用单元格,不要用带指针悬浮态的桌面式列表

规格未定 / 禁止发明

类别条目
禁止发明多浮层堆叠的层级规则
禁止发明手势下滑关闭的阈值
规格未定各方向的内容最大尺寸
规格未定是否需要焦点陷阱(focus trap)

结构(anatomy)

字段说明
mask遮罩,点击关闭(可关)
popup浮层容器,按方向定位
header可选标题区
body内容区,可滚动
close可选关闭按钮

变体维度与类名映射

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

维度取值对应类名 / 变量
placementcenter / bottom / top / left / right
center .kole-m-popup--center
bottom .kole-m-popup--bottom
top .kole-m-popup--top
left .kole-m-popup--left
right .kole-m-popup--right
roundfalse / true
false (由数据驱动,无专属类)
true .kole-m-popup--round

代表变体

变体标签
placement=center · round=true居中弹窗
placement=bottom · round=true底部弹出(最常用)
placement=top · round=true顶部弹出
placement=right · round=true右侧滑出

用到的令牌

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

--kole-m-duration-slide --kole-m-ease-slide --kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-m-touch-target --kole-color-card-bg --kole-color-focus-ring --kole-color-mask --kole-color-table-header-bg --kole-color-text-body --kole-color-text-secondary --kole-font-family --kole-radius-large --kole-space-12 --kole-space-16 --kole-space-8 --kole-m-popup-max-size --kole-m-popup-radius

6 端源码

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

frameworks-mobile/Popup.css · 纯样式(CSS) · 169 行
frameworks-mobile/Popup.css
/* Kole UI Mobile · Popup 样式 — 对齐移动端规格 §11
   弹出层基座:五个方向;遮罩点击关闭(可关);滑入 240ms cubic-bezier(.32,.72,0,1);
   内容超出时 body 内部滚动;round=true 时贴边方向在靠内容一侧切圆角(逻辑属性,RTL 安全)。 */

.kole-m-popup {
  --kole-m-popup-max-size: 80%;                    /* 浮层在弹出方向上的最大占比 */
  --kole-m-popup-radius: var(--kole-radius-large); /* round=true 时的圆角半径 */
  position: fixed;
  z-index: 2001;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  max-width: var(--kole-m-popup-max-size);
  max-height: var(--kole-m-popup-max-size);
  background: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-family: var(--kole-font-family);
  font-size: var(--kole-m-font-size-body);
  transition: transform var(--kole-m-duration-slide) var(--kole-m-ease-slide),
    opacity var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}

.kole-m-popup__mask {
  position: fixed;
  inset: 0;
  z-index: 2000;
  background: var(--kole-color-mask);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--kole-m-duration-slide) var(--kole-m-ease-slide);
}

.kole-m-popup__mask.is-open {
  opacity: 1;
  pointer-events: auto;
}

/* 变体 placement:五个方向的定位与初始位移 */
.kole-m-popup--center {
  left: 50%;
  top: 50%;
  width: max-content;
  transform: translate(-50%, -50%) scale(0.92);
  opacity: 0;
  border-radius: var(--kole-m-popup-radius);
}

.kole-m-popup--center.is-open {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
}

.kole-m-popup--bottom {
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  max-width: none;
  transform: translateY(100%);
}

.kole-m-popup--bottom.is-open { transform: translateY(0); }

.kole-m-popup--top {
  left: 0;
  right: 0;
  top: 0;
  width: 100%;
  max-width: none;
  transform: translateY(-100%);
}

.kole-m-popup--top.is-open { transform: translateY(0); }

.kole-m-popup--left {
  top: 0;
  bottom: 0;
  left: 0;
  height: 100%;
  max-height: none;
  transform: translateX(-100%);
}

.kole-m-popup--left.is-open { transform: translateX(0); }

.kole-m-popup--right {
  top: 0;
  bottom: 0;
  right: 0;
  height: 100%;
  max-height: none;
  transform: translateX(100%);
}

.kole-m-popup--right.is-open { transform: translateX(0); }

/* 变体 round=true:贴边方向在靠内容一侧切圆角 */
.kole-m-popup--bottom.kole-m-popup--round {
  border-start-start-radius: var(--kole-m-popup-radius);
  border-start-end-radius: var(--kole-m-popup-radius);
}

.kole-m-popup--top.kole-m-popup--round {
  border-end-start-radius: var(--kole-m-popup-radius);
  border-end-end-radius: var(--kole-m-popup-radius);
}

.kole-m-popup--left.kole-m-popup--round {
  border-start-end-radius: var(--kole-m-popup-radius);
  border-end-end-radius: var(--kole-m-popup-radius);
}

.kole-m-popup--right.kole-m-popup--round {
  border-start-start-radius: var(--kole-m-popup-radius);
  border-end-start-radius: var(--kole-m-popup-radius);
}

.kole-m-popup__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--kole-space-8);
  padding: var(--kole-space-16) var(--kole-m-gutter) var(--kole-space-8);
  font-size: var(--kole-m-font-size-body);
  font-weight: 500;
  line-height: 1.4;
}

.kole-m-popup__body {
  flex: 1 1 auto;
  min-height: 0;
  padding: 0 var(--kole-m-gutter) var(--kole-space-16);
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  font-size: var(--kole-m-font-size-label);
  color: var(--kole-color-text-secondary);
  line-height: 1.7;
}

.kole-m-popup__body > p { margin: 0 0 var(--kole-space-12); }

.kole-m-popup__close {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: var(--kole-m-touch-target);
  height: var(--kole-m-touch-target);
  margin-inline-end: calc(-1 * var(--kole-space-12));
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--kole-color-text-secondary);
  font-family: inherit;
  font-size: var(--kole-m-font-size-body);
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}

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

.kole-m-popup__close:active { background: var(--kole-color-table-header-bg); }
frameworks-mobile/Popup.html · H5 原生(无框架) · 205 行
frameworks-mobile/Popup.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 · Popup(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Popup.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); }
  /* 展示框:transform 建立包含块,浮层与遮罩限定在框内(生产环境落在视口上) */
  .demo-frame { position: relative; max-width: 375px; margin: 0 auto; height: 240px;
    overflow: hidden; transform: translateZ(0); background: var(--kole-color-page-bg);
    border-block: 1px solid var(--kole-color-border); }
  .demo-frame__body { padding: var(--kole-m-gutter); font-size: var(--kole-m-font-size-label);
    color: var(--kole-color-text-secondary); line-height: 1.7; }
  .demo-trigger { min-height: var(--kole-m-touch-target); padding: 0 var(--kole-m-gutter);
    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); cursor: pointer; touch-action: manipulation; }
  .demo-trigger: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="center">
    <p class="demo-label">居中弹窗(placement=center,点按钮打开)</p>
    <div class="demo-frame" data-assert="popup-center">
      <div class="demo-frame__body">
        <button class="demo-trigger" type="button"
                data-behavior="click-toggles-class:#popup-center|is-open">打开居中弹窗</button>
      </div>
      <div class="kole-m-popup__mask" aria-hidden="true"></div>
      <div class="kole-m-popup kole-m-popup--center kole-m-popup--round" id="popup-center"
           data-state="closed" role="dialog" aria-modal="true" aria-label="确认提交">
        <div class="kole-m-popup__header">
          <span>确认提交</span>
          <button class="kole-m-popup__close" type="button" aria-label="关闭弹窗">✕</button>
        </div>
        <div class="kole-m-popup__body">
          <p>提交后将进入审核流程,期间不可修改。</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="bottom">
    <p class="demo-label">底部弹出(placement=bottom + round=true:滑入 240ms,靠内容一侧切圆角)</p>
    <div class="demo-frame" data-assert="popup-bottom">
      <div class="demo-frame__body">
        <button class="demo-trigger" type="button"
                data-behavior="click-toggles-class:#popup-bottom|is-open">打开底部面板</button>
      </div>
      <div class="kole-m-popup__mask" aria-hidden="true"></div>
      <div class="kole-m-popup kole-m-popup--bottom kole-m-popup--round" id="popup-bottom"
           data-state="closed" role="dialog" aria-modal="true" aria-label="筛选条件">
        <div class="kole-m-popup__header">
          <span>筛选条件</span>
          <button class="kole-m-popup__close" type="button" aria-label="关闭筛选面板">✕</button>
        </div>
        <div class="kole-m-popup__body">
          <p>底部浮层是移动端最常用的形态:拇指可达,且不遮挡上下文。</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="top">
    <p class="demo-label">顶部弹出(placement=top:常用于全局提示条与地址选择)</p>
    <div class="demo-frame" data-assert="popup-top">
      <div class="demo-frame__body">
        <button class="demo-trigger" type="button"
                data-behavior="click-toggles-class:#popup-top|is-open">打开顶部面板</button>
      </div>
      <div class="kole-m-popup__mask" aria-hidden="true"></div>
      <div class="kole-m-popup kole-m-popup--top kole-m-popup--round" id="popup-top"
           data-state="closed" role="dialog" aria-modal="true" aria-label="收货地址">
        <div class="kole-m-popup__header">
          <span>收货地址</span>
          <button class="kole-m-popup__close" type="button" aria-label="关闭地址面板">✕</button>
        </div>
        <div class="kole-m-popup__body">
          <p>顶部浮层从屏幕上方滑入,适合「一次性选择后立即关闭」的场景。</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="sides">
    <p class="demo-label">左右侧滑(placement=left / right:高度铺满,默认展开对照)</p>
    <div class="demo-frame" data-assert="popup-sides">
      <div class="kole-m-popup__mask is-open" aria-hidden="true"></div>
      <div class="kole-m-popup kole-m-popup--right kole-m-popup--round is-open"
           role="dialog" aria-modal="true" aria-label="侧滑面板">
        <div class="kole-m-popup__header">
          <span>侧滑面板</span>
          <button class="kole-m-popup__close" type="button" aria-label="关闭侧滑面板">✕</button>
        </div>
        <div class="kole-m-popup__body">
          <p>侧滑用于展示次级导航或详情,宽度受限(不超过 80%)。</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="keep-open">
    <p class="demo-label">遮罩不关闭(closeOnMask=false:误触遮罩不关闭浮层,默认展开)</p>
    <div class="demo-frame" data-assert="popup-keep-open">
      <div class="kole-m-popup__mask is-open" aria-hidden="true"></div>
      <div class="kole-m-popup kole-m-popup--center kole-m-popup--round is-open"
           data-close-on-mask="false" role="dialog" aria-modal="true" aria-label="填写表单">
        <div class="kole-m-popup__header">
          <span>填写表单</span>
          <button class="kole-m-popup__close" type="button" aria-label="关闭表单">✕</button>
        </div>
        <div class="kole-m-popup__body">
          <p>表单类内容不该因为一次误触就丢弃已填数据,因此关闭遮罩点击。</p>
        </div>
      </div>
    </div>
  </section>

  <section class="demo-block" data-demo="scroll">
    <p class="demo-label">内容超长(body 内部滚动,头部与遮罩不滚动;默认展开)</p>
    <div class="demo-frame" data-assert="popup-scroll">
      <div class="kole-m-popup__mask is-open" aria-hidden="true"></div>
      <div class="kole-m-popup kole-m-popup--bottom kole-m-popup--round is-open"
           role="dialog" aria-modal="true" aria-label="服务条款">
        <div class="kole-m-popup__header">
          <span>服务条款</span>
          <button class="kole-m-popup__close" type="button" aria-label="关闭服务条款">✕</button>
        </div>
        <div class="kole-m-popup__body" data-assert="popup-scroll-body">
          <p>第一条 · 本服务由 Kole UI 提供,用于演示移动端浮层的内容滚动行为。</p>
          <p>第二条 · 浮层高度上限为屏幕的 80%,超出部分在内容区内部滚动。</p>
          <p>第三条 · 头部与关闭按钮固定,不随内容滚动,保证任何位置都能关闭。</p>
          <p>第四条 · 遮罩层不参与滚动,滚动链不会穿透到页面正文。</p>
          <p>第五条 · 关闭后焦点回到触发元素,键盘用户不会丢失位置。</p>
          <p>第六条 · 条款内容仅为演示数据,不代表任何真实服务约定。</p>
        </div>
      </div>
    </div>
  </section>
</div>
<script>
  /* 演示页交互:触发器切换浮层开合;遮罩点击关闭(closeOnMask=false 的示例不关)。
     is-open 类驱动视觉,data-state 属性作为状态标记(同一函数写入,不会分叉)。 */
  (function () {
    function setOpen(panel, open) {
      panel.classList.toggle('is-open', open);
      panel.setAttribute('data-state', open ? 'open' : 'closed');
      var frame = panel.parentElement;
      var mask = frame ? frame.querySelector('.kole-m-popup__mask') : null;
      if (mask) mask.classList.toggle('is-open', open);
    }
    document.querySelectorAll('.demo-trigger').forEach(function (trig) {
      trig.addEventListener('click', function () {
        var frame = trig.parentElement.parentElement;
        var panel = frame ? frame.querySelector('.kole-m-popup') : null;
        if (panel) setOpen(panel, !panel.classList.contains('is-open'));
      });
    });
    document.querySelectorAll('.kole-m-popup__mask').forEach(function (mask) {
      mask.addEventListener('click', function () {
        var panel = mask.parentElement.querySelector('.kole-m-popup');
        if (panel && panel.getAttribute('data-close-on-mask') !== 'false') setOpen(panel, false);
      });
    });
    document.querySelectorAll('.kole-m-popup__close').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var panel = btn.closest('.kole-m-popup');
        if (panel) setOpen(panel, 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/Popup.jsx · React · 49 行
frameworks-mobile/Popup.jsx
import React from 'react';
import './Popup.css';

/* 弹出层(移动端)— 规格 §11;五个方向;遮罩点击关闭(closeOnMask=false 时不关);
   浮层 role=dialog + aria-modal,遮罩纯装饰(aria-hidden)。 */
export default function Popup({
  placement = 'bottom',
  round = false,
  open = false,
  closeOnMask = true,
  title = '',
  onClose,
  children = null,
}) {
  const cls =
    'kole-m-popup' +
    ` kole-m-popup--${placement}` +
    (round ? ' kole-m-popup--round' : '') +
    (open ? ' is-open' : '');

  return (
    <>
      <div
        className={'kole-m-popup__mask' + (open ? ' is-open' : '')}
        aria-hidden="true"
        onClick={() => {
          if (closeOnMask && onClose) onClose();
        }}
      />
      <div className={cls} role="dialog" aria-modal="true" aria-label={title || undefined}>
        <div className="kole-m-popup__header">
          <span>{title}</span>
          <button
            className="kole-m-popup__close"
            type="button"
            aria-label={title ? `关闭${title}` : '关闭浮层'}
            onClick={() => {
              if (onClose) onClose();
            }}
          >
            ✕
          </button>
        </div>
        <div className="kole-m-popup__body">{children}</div>
      </div>
    </>
  );
}
frameworks-mobile/Popup.vue2.vue · Vue 2 · 55 行
frameworks-mobile/Popup.vue2.vue
<template>
  <div>
    <div class="kole-m-popup__mask" :class="{ 'is-open': open }" aria-hidden="true" @click="onMaskClick"></div>
    <div
      class="kole-m-popup"
      :class="popupClass"
      role="dialog"
      aria-modal="true"
      :aria-label="title || null"
    >
      <div class="kole-m-popup__header">
        <span>{{ title }}</span>
        <button class="kole-m-popup__close" type="button" :aria-label="closeLabel" @click="onCloseClick">✕</button>
      </div>
      <div class="kole-m-popup__body"><slot></slot></div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'KoleMPopup',
  props: {
    placement: { type: String, default: 'bottom' },
    round: { type: Boolean, default: false },
    open: { type: Boolean, default: false },
    closeOnMask: { type: Boolean, default: true },
    title: { type: String, default: '' }
  },
  computed: {
    popupClass: function () {
      return [
        'kole-m-popup--' + this.placement,
        this.round ? 'kole-m-popup--round' : '',
        this.open ? 'is-open' : ''
      ].filter(Boolean);
    },
    closeLabel: function () {
      return this.title ? '关闭' + this.title : '关闭浮层';
    }
  },
  methods: {
    onMaskClick: function () {
      if (!this.closeOnMask) return;
      this.$emit('close');
    },
    onCloseClick: function () {
      this.$emit('close');
    }
  }
};
</script>

<style src="./Popup.css"></style>
frameworks-mobile/Popup.vue3.vue · Vue 3 · 51 行
frameworks-mobile/Popup.vue3.vue
<template>
  <div>
    <div class="kole-m-popup__mask" :class="{ 'is-open': open }" aria-hidden="true" @click="onMaskClick"></div>
    <div
      class="kole-m-popup"
      :class="popupClass"
      role="dialog"
      aria-modal="true"
      :aria-label="title || null"
    >
      <div class="kole-m-popup__header">
        <span>{{ title }}</span>
        <button class="kole-m-popup__close" type="button" :aria-label="closeLabel" @click="onCloseClick">✕</button>
      </div>
      <div class="kole-m-popup__body"><slot></slot></div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  placement: { type: String, default: 'bottom' },
  round: { type: Boolean, default: false },
  open: { type: Boolean, default: false },
  closeOnMask: { type: Boolean, default: true },
  title: { type: String, default: '' }
});
const emit = defineEmits(['close']);

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

const closeLabel = computed(() => (props.title ? '关闭' + props.title : '关闭浮层'));

function onMaskClick() {
  if (!props.closeOnMask) return;
  emit('close');
}

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

<style src="./Popup.css"></style>
frameworks-mobile/Popup.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 196 行
frameworks-mobile/Popup.uniapp.vue
<template>
  <view>
    <view class="kole-m-popup__mask" :class="{ 'is-open': open }" aria-hidden="true" @tap="onMaskTap"></view>
    <view
      class="kole-m-popup"
      :class="popupClass"
      :role="'dialog'"
      :aria-modal="'true'"
      :aria-label="title || ''"
    >
      <view class="kole-m-popup__header">
        <text>{{ title }}</text>
        <view class="kole-m-popup__close" role="button" :aria-label="closeLabel" @tap="onCloseTap">
          <text>✕</text>
        </view>
      </view>
      <view class="kole-m-popup__body"><slot></slot></view>
    </view>
  </view>
</template>

<script setup>
/* uni-app 端 · 弹出层(移动端)— 规格 §11
   跨端差异:用 view / text;关闭按钮用 view + role="button" + aria-label,点击用 @tap;
   尺寸用 rpx(2rpx ≈ 1px);五个方向的定位与位移靠样式,不依赖 DOM 测量。 */
import { computed } from 'vue';

const props = defineProps({
  placement: { type: String, default: 'bottom' },
  round: { type: Boolean, default: false },
  open: { type: Boolean, default: false },
  closeOnMask: { type: Boolean, default: true },
  title: { type: String, default: '' }
});
const emit = defineEmits(['close']);

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

const closeLabel = computed(() => (props.title ? '关闭' + props.title : '关闭浮层'));

function onMaskTap() {
  if (!props.closeOnMask) return;
  emit('close');
}

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

<style>
.kole-m-popup {
  --kole-m-popup-max-size: 80%;
  --kole-m-duration-slide: 240ms;
  --kole-m-touch-target: 88rpx;
  --kole-m-font-size-body: 32rpx;
  --kole-m-font-size-label: 28rpx;
  --kole-m-gutter: 32rpx;
  position: fixed;
  z-index: 2001;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  max-width: var(--kole-m-popup-max-size);
  max-height: var(--kole-m-popup-max-size);
  background-color: var(--kole-color-card-bg);
  color: var(--kole-color-text-body);
  font-size: var(--kole-m-font-size-body);
  transition: transform var(--kole-m-duration-slide) ease-out;
}

.kole-m-popup__mask {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;
  background-color: var(--kole-color-mask);
  opacity: 0;
  transition: opacity var(--kole-m-duration-slide) ease-out;
}

.kole-m-popup__mask.is-open { opacity: 1; }

.kole-m-popup--center {
  left: 50%;
  top: 50%;
  width: 560rpx;
  transform: translate(-50%, -50%) scale(0.92);
  opacity: 0;
  border-radius: 16rpx;
}

.kole-m-popup--center.is-open { transform: translate(-50%, -50%) scale(1); opacity: 1; }

.kole-m-popup--bottom {
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  max-width: none;
  transform: translateY(100%);
}

.kole-m-popup--bottom.is-open { transform: translateY(0); }

.kole-m-popup--top {
  left: 0;
  right: 0;
  top: 0;
  width: 100%;
  max-width: none;
  transform: translateY(-100%);
}

.kole-m-popup--top.is-open { transform: translateY(0); }

.kole-m-popup--left {
  top: 0;
  bottom: 0;
  left: 0;
  height: 100%;
  max-height: none;
  transform: translateX(-100%);
}

.kole-m-popup--left.is-open { transform: translateX(0); }

.kole-m-popup--right {
  top: 0;
  bottom: 0;
  right: 0;
  height: 100%;
  max-height: none;
  transform: translateX(100%);
}

.kole-m-popup--right.is-open { transform: translateX(0); }

.kole-m-popup--bottom.kole-m-popup--round {
  border-top-left-radius: 16rpx;
  border-top-right-radius: 16rpx;
}

.kole-m-popup--top.kole-m-popup--round {
  border-bottom-left-radius: 16rpx;
  border-bottom-right-radius: 16rpx;
}

.kole-m-popup--left.kole-m-popup--round {
  border-top-right-radius: 16rpx;
  border-bottom-right-radius: 16rpx;
}

.kole-m-popup--right.kole-m-popup--round {
  border-top-left-radius: 16rpx;
  border-bottom-left-radius: 16rpx;
}

.kole-m-popup__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 32rpx var(--kole-m-gutter) 16rpx;
  font-size: var(--kole-m-font-size-body);
  font-weight: 500;
}

.kole-m-popup__body {
  flex: 1;
  padding: 0 var(--kole-m-gutter) 32rpx;
  overflow: auto;
  font-size: var(--kole-m-font-size-label);
  color: var(--kole-color-text-secondary);
  line-height: 1.7;
}

.kole-m-popup__close {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: var(--kole-m-touch-target);
  height: var(--kole-m-touch-target);
  margin-right: -24rpx;
  border-radius: 50%;
  color: var(--kole-color-text-secondary);
  font-size: var(--kole-m-font-size-body);
}
</style>

测试与回归

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

断言 20 条 · 全部通过 报告 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-popup.json(点击展开原始 JSON)
{
  "schemaVersion": 1,
  "sourceKind": "authored-spec",
  "provenance": "authored-in-repo",
  "specFile": "spec/移动端规格.md",
  "confidence": "high",
  "specSection": "11 · 弹出层 Popup",
  "slug": "mobile-popup",
  "name": "弹出层 Popup",
  "semanticTypeCandidates": [
    "popup",
    "overlay",
    "layer"
  ],
  "variantDimensions": [
    {
      "name": "placement",
      "values": [
        "center",
        "bottom",
        "top",
        "left",
        "right"
      ]
    },
    {
      "name": "round",
      "values": [
        "false",
        "true"
      ]
    }
  ],
  "representativeVariants": [
    {
      "placement": "center",
      "round": "true",
      "label": "居中弹窗"
    },
    {
      "placement": "bottom",
      "round": "true",
      "label": "底部弹出(最常用)"
    },
    {
      "placement": "top",
      "round": "true",
      "label": "顶部弹出"
    },
    {
      "placement": "right",
      "round": "true",
      "label": "右侧滑出"
    }
  ],
  "anatomy": {
    "mask": "遮罩,点击关闭(可关)",
    "popup": "浮层容器,按方向定位",
    "header": "可选标题区",
    "body": "内容区,可滚动",
    "close": "可选关闭按钮"
  },
  "structurePatterns": {
    "placement": "center / bottom / top / left / right",
    "round": "false / true(贴边方向在靠内容一侧切圆角)"
  },
  "usageHints": [
    "从指定方向弹出的浮层基座,承载面板、抽屉、动作列表等内容",
    "遮罩点击关闭;closeOnMask=false 时不关闭",
    "滑入动画 240ms,缓动 cubic-bezier(.32,.72,0,1)",
    "内容超出时 body 内部滚动,遮罩不滚动",
    "浮层 role=\"dialog\" + aria-modal=\"true\"",
    "关闭按钮为原生 button 并带 aria-label"
  ],
  "doNotInvent": [
    "多浮层堆叠的层级规则",
    "手势下滑关闭的阈值"
  ],
  "unknowns": [
    "各方向的内容最大尺寸",
    "是否需要焦点陷阱(focus trap)"
  ],
  "interaction": [
    "遮罩点击关闭;closeOnMask=false 时不关闭",
    "滑入动画 240ms,缓动 cubic-bezier(.32,.72,0,1)",
    "内容超出时 body 内部滚动,遮罩不滚动"
  ],
  "accessibility": [
    "浮层 role=\"dialog\" + aria-modal=\"true\"",
    "遮罩 aria-hidden=\"true\"(纯装饰)",
    "关闭按钮为原生 button 并带 aria-label"
  ],
  "api": {
    "source": "implementation",
    "note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
    "requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
    "props": [
      {
        "name": "placement",
        "type": "'center' | 'bottom' | 'top' | 'left' | 'right'",
        "default": "'bottom'",
        "desc": "变体 placement:弹出方向(规格 §11.3)",
        "required": false
      },
      {
        "name": "round",
        "type": "boolean",
        "default": "false",
        "desc": "变体 round:贴边方向在靠内容一侧切圆角(规格 §11.3)",
        "required": false
      },
      {
        "name": "open",
        "type": "boolean",
        "default": "false",
        "desc": "状态 open:展开且遮罩可见(规格 §11.4)",
        "required": false
      },
      {
        "name": "closeOnMask",
        "type": "boolean",
        "default": "true",
        "desc": "遮罩点击是否关闭浮层;表单类内容通常设为 false(规格 §11.5)",
        "required": false
      },
      {
        "name": "title",
        "type": "string",
        "default": "''",
        "desc": "标题区文字,同时作为关闭按钮 aria-label 的一部分(规格 §11.2 header)",
        "required": false
      }
    ],
    "events": [
      {
        "name": "close",
        "params": "—",
        "desc": "点击遮罩(closeOnMask=true 时)或关闭按钮时触发(规格 §11.5)"
      }
    ],
    "slots": [
      {
        "name": "default",
        "desc": "内容区,可滚动;超出时在内部滚动而遮罩不滚动(规格 §11.2 body)"
      }
    ]
  },
  "variantClasses": {
    "placement": {
      "center": [
        ".kole-m-popup--center"
      ],
      "bottom": [
        ".kole-m-popup--bottom"
      ],
      "top": [
        ".kole-m-popup--top"
      ],
      "left": [
        ".kole-m-popup--left"
      ],
      "right": [
        ".kole-m-popup--right"
      ]
    },
    "round": {
      "false": [],
      "true": [
        ".kole-m-popup--round"
      ]
    }
  },
  "demos": [
    {
      "id": "center",
      "group": "01 组件类型",
      "title": "居中弹窗",
      "desc": "居中浮层用缩放淡入而不是位移,避免在窄屏上「从某一边冒出来」的错觉。",
      "variant": "placement=center"
    },
    {
      "id": "bottom",
      "group": "01 组件类型",
      "title": "底部弹出",
      "desc": "移动端最常用的形态:拇指可达且不遮挡上下文;round=true 时靠内容一侧切圆角。",
      "variant": "placement=bottom"
    },
    {
      "id": "top",
      "group": "01 组件类型",
      "title": "顶部弹出",
      "desc": "从屏幕上方滑入,适合「一次性选择后立即关闭」的地址选择与全局提示条。",
      "variant": "placement=top"
    },
    {
      "id": "sides",
      "group": "02 组件状态",
      "title": "左右侧滑",
      "desc": "高度铺满、宽度受限(不超过 80%),用于次级导航或详情;默认展开以便对照。",
      "variant": "placement=left|right"
    },
    {
      "id": "keep-open",
      "group": "02 组件状态",
      "title": "遮罩不关闭",
      "desc": "closeOnMask=false:误触遮罩不关闭浮层,表单类内容不该因为一次误触就丢弃已填数据。",
      "variant": "closeOnMask=false"
    },
    {
      "id": "scroll",
      "group": "02 组件状态",
      "title": "内容超长",
      "desc": "高度上限为屏幕的 80%,超出部分在内容区内部滚动,头部与关闭按钮固定不动。",
      "variant": "状态 scroll"
    }
  ],
  "related": [
    {
      "slug": "actionsheet",
      "why": "固定从底部弹出的操作列表用动作面板,需要其它方向或自定义内容时才用弹出层"
    },
    {
      "slug": "mobile-divider",
      "why": "浮层内的分区用分割线,而不是靠留白或额外容器表达"
    },
    {
      "slug": "cell",
      "why": "浮层里列选项用单元格,不要用带指针悬浮态的桌面式列表"
    }
  ]
}