遮罩层Overlay
浮层的基座:在内容之上盖一层半透明遮罩,让下层内容退到背后(对话框、抽屉、图片预览、卡片加载态)
反馈 规格 40 · 遮罩层 Overlay 6 端实现 触摸优先
<!-- ① 令牌: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-overlay.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/Overlay.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
tone=default:遮罩本身不带语义,role 与标题由内容自己给;一次轻点遮罩即关闭。
查看代码(演示页原文 · 20 行)
<section class="demo-block" data-demo="basic">
<p class="demo-label">基础遮罩(tone=default:点遮罩关闭;遮罩是纯装饰,内容语义由槽位承担)</p>
<div class="demo-frame" data-assert="overlay-basic">
<div class="demo-frame__body">
<button class="demo-trigger" type="button" id="overlay-basic-trigger"
data-behavior="click-toggles-class:#overlay-basic|is-open">打开遮罩</button>
</div>
<div class="kole-m-overlay kole-m-overlay--default" id="overlay-basic" role="presentation"
data-open="false">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<div class="demo-card">
<p class="demo-card__title">遮罩上的内容</p>
<p class="demo-card__text">遮罩本身不带语义,role 与标题由内容自己给。</p>
<button class="demo-close" type="button" id="overlay-basic-close">关闭</button>
</div>
</div>
</div>
</div>
</section>tone=strong:遮挡更强,用于需要专注的确认;默认展开以便直接对照两层浓度。
查看代码(演示页原文 · 14 行)
<section class="demo-block" data-demo="strong">
<p class="demo-label">浓遮罩(tone=strong:遮挡强、适合需要专注的确认,默认展开对照)</p>
<div class="demo-frame" data-assert="overlay-strong">
<div class="kole-m-overlay kole-m-overlay--strong is-open" id="overlay-strong" role="presentation"
data-open="true">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<p class="demo-card">
<button class="demo-close" type="button" id="overlay-strong-close">点遮罩或此处关闭</button>
</p>
</div>
</div>
</div>
</section>tone=blur:背景内容做 backdrop 模糊,用于图片预览;模糊只影响遮罩下方,内容卡片保持清晰。
查看代码(演示页原文 · 19 行)
<section class="demo-block" data-demo="blur">
<p class="demo-label">模糊遮罩(tone=blur:背景内容做 backdrop 模糊,用于图片预览)</p>
<div class="demo-frame" data-assert="overlay-blur">
<div class="demo-frame__body">
<p style="margin: 0 0 8px;">这一行是遮罩**下方**的内容,blur 档会把它糊掉。</p>
<p style="margin: 0;">第二行同样是背景,用于对照模糊前后的可读性差别。</p>
</div>
<div class="kole-m-overlay kole-m-overlay--blur is-open" id="overlay-blur" role="presentation"
data-open="true">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<div class="demo-card">
<p class="demo-card__title">图片预览</p>
<p class="demo-card__text">模糊档只影响遮罩下方,内容卡片本身保持清晰。</p>
</div>
</div>
</div>
</div>
</section>02 组件状态
contained=true:绝对定位填充最近的定位祖先,只遮住一张卡,页面其它区域仍可操作。
查看代码(演示页原文 · 19 行)
<section class="demo-block" data-demo="contained">
<p class="demo-label">局部遮罩(contained=true:绝对定位填充最近的定位祖先,不覆盖全屏)</p>
<div class="demo-frame" data-assert="overlay-contained">
<div class="demo-frame__body">
<p style="margin: 0 0 8px;">卡片加载态:只遮住这张卡,页面其它区域仍可操作。</p>
<div class="demo-card" style="width: auto; position: relative;">
<p class="demo-card__title">数据概览</p>
<p class="demo-card__text">本月成交 128 单 · 环比 +12%</p>
<div class="kole-m-overlay kole-m-overlay--default kole-m-overlay--contained is-open"
role="presentation" data-open="true">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<span class="demo-card__text">加载中…</span>
</div>
</div>
</div>
</div>
</div>
</section>closeOnMask=false:误触遮罩不收起;填到一半的表单不该因为一次误触就丢弃已填数据。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="keep-open">
<p class="demo-label">遮罩不关闭(closeOnMask=false:误触遮罩不收起,默认展开)</p>
<div class="demo-frame" data-assert="overlay-keep-open">
<div class="kole-m-overlay kole-m-overlay--default is-open" id="overlay-keep-open"
role="presentation" data-open="true" data-close-on-mask="false">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<div class="demo-card">
<p class="demo-card__title">填写中</p>
<p class="demo-card__text">填到一半的表单不该因为一次误触就丢弃,所以关掉遮罩点击。</p>
<button class="demo-close" type="button" id="overlay-keep-open-close">关闭</button>
</div>
</div>
</div>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
open | boolean | false | 状态 open:展开且拦下所有手势(规格 §40.4) | N |
contained | boolean | false | 变体 contained:绝对定位填充最近的定位祖先,做卡内局部遮罩(规格 §40.3) | N |
tone | 'default' | 'strong' | 'blur' | 'default' | 变体 tone:遮罩浓度与质感(规格 §40.3) | N |
lockScroll | boolean | true | 是否锁住下层滚动;组件只写 data-lock-scroll 标记(规格 §40.5) | N |
closeOnMask | boolean | true | 遮罩点击是否关闭;表单类内容通常设为 false(规格 §40.5) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
close | — | 点击遮罩(closeOnMask=true 时)触发;是否收起由宿主决定(规格 §40.5) |
插槽
| 名称 | 说明 |
|---|---|
default | 遮罩之上的内容;语义(role / aria-modal)由宿主提供(规格 §40.2 content) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-overlay-blur | 6px | 组件内部默认值,可在业务侧覆盖 |
--kole-m-overlay-duration | var(--kole-m-duration-slide) | 组件内部默认值,可在业务侧覆盖 |
何时使用
- 浮层的基座:在内容之上盖一层半透明遮罩,让下层内容退到背后(对话框、抽屉、图片预览、卡片加载态)
- 与弹出层的分工:弹出层是能独立使用的完整浮层,遮罩层只提供「变暗 + 拦手势 + 承载任意内容」
- 触屏上遮罩必须真的吃掉触摸事件,否则惯性滚动会从遮罩底下穿过去把下层页面滚走
- 遮罩关闭的键盘路径不落在遮罩上:遮罩显式 tabindex="-1",键盘用户靠内容里的关闭按钮
- lockScroll=true 时组件只在根上写 data-lock-scroll 标记,实际的 overflow: hidden 由宿主执行
交互与触控
- 一次轻点遮罩即关闭;closeOnMask=false 时不关闭
- 遮罩显式 tabindex="-1",不进键盘序列;键盘用户靠内容里的关闭按钮与 Esc 关闭
- 开合动效 240ms(--kole-m-duration-slide),prefers-reduced-motion 下瞬时切换
- contained=true 时要求宿主祖先链上存在定位元素,否则会向上找到视口
无障碍
- 遮罩面 aria-hidden="true"(纯装饰)且 tabindex="-1"(不进键盘序列)
- 根 role="presentation":容器本身不产生语义,避免读屏把它当成一个空的分组
- 内容语义完全由宿主提供:焦点陷阱、aria-modal、初始焦点与关闭后焦点归还都属于宿主职责
- 遮罩关闭不依赖颜色:开合只改透明度且有 240ms 过渡
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 弹出层Popup | 需要带方向位移与标题栏的完整浮层时用弹出层,遮罩层只做底座不负责内容结构 |
| 对话框Dialog | 需要确认或输入的中断式浮层用对话框,它自己已含遮罩与按钮组 |
| 加载Loading | 局部加载态需要转圈图标时用加载组件,遮罩层只提供变暗与拦手势 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 焦点陷阱(focus trap)与初始焦点策略 |
| 禁止发明 | 多层遮罩的层叠顺序管理 |
| 禁止发明 | 手势下滑关闭与拖拽阻尼 |
| 禁止发明 | 滚动锁定的实现细节(组件只写标记,改 DOM 由宿主做) |
| 规格未定 | blur 档在低端机上的性能开销是否可接受(未做降级探测) |
| 规格未定 | contained=true 时是否应自动为宿主补 position: relative |
| 规格未定 | 是否要支持「点遮罩不关但双击关」这类折中策略 |
结构(anatomy)
| 字段 | 说明 |
|---|---|
overlay | 根元素,定位容器与开合开关;role="presentation",自身不承担语义 |
scrim | 遮罩面(原生 button),aria-hidden="true" 且 tabindex="-1" —— 可点但不进键盘序列 |
content | 内容容器(默认插槽落点),语义由宿主决定(对话框给 role="dialog"、面板给 role="region") |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
tone | default / strong / blur | default .kole-m-overlay--default strong .kole-m-overlay--strong blur .kole-m-overlay--blur |
contained | false / true | false (由数据驱动,无专属类) true .kole-m-overlay--contained |
代表变体
| 变体 | 标签 |
|---|---|
tone=default · contained=false | 基础全屏遮罩 |
tone=strong · contained=false | 浓遮罩(需要专注的确认) |
tone=blur · contained=true | 模糊 + 局部(卡内加载态) |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/Overlay.css · 纯样式(CSS) · 66 行
/* Kole UI Mobile · Overlay 样式 — 对齐移动端规格 §40
遮罩层:浮层基座。它自己不承载内容语义 —— scrim 是纯装饰(aria-hidden),
内容放进 content 槽位并由宿主给 role(对话框 role=dialog、面板 role=region …)。
两种尺寸:默认固定全屏(fixed);contained=true 时改为绝对定位填充最近的定位祖先,
用于「卡片内局部加载遮罩」这类不覆盖全屏的场景。
注意:scrim 是真·指针目标,但**不写 cursor: pointer** —— 触屏没有指针形态,
鼠标专属的交互提示会把「可点」这件事表达成只有鼠标用户能收到(键盘路径由
content 里的关闭按钮与 Esc 承担)。 */
.kole-m-overlay {
--kole-m-overlay-blur: 6px;
--kole-m-overlay-duration: var(--kole-m-duration-slide);
position: fixed;
inset: 0;
z-index: 2000;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity var(--kole-m-overlay-duration) var(--kole-m-ease-slide);
}
.kole-m-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
/* 变体 contained=true:填充最近的定位祖先(局部遮罩,如卡片加载态) */
.kole-m-overlay--contained {
position: absolute;
z-index: 10;
}
/* 变体 tone:三档遮罩浓度与质感 */
.kole-m-overlay--default .kole-m-overlay__scrim { background: var(--kole-color-mask); }
.kole-m-overlay--strong .kole-m-overlay__scrim { background: var(--kole-color-mask-strong); }
.kole-m-overlay--blur .kole-m-overlay__scrim {
background: var(--kole-color-mask);
-webkit-backdrop-filter: blur(var(--kole-m-overlay-blur));
backdrop-filter: blur(var(--kole-m-overlay-blur));
}
.kole-m-overlay__scrim {
position: absolute;
inset: 0;
border: 0;
padding: 0;
}
.kole-m-overlay__content {
position: relative;
z-index: 1;
box-sizing: border-box;
max-width: 80%;
max-height: 80%;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
@media (prefers-reduced-motion: reduce) {
.kole-m-overlay { transition: none; }
}
frameworks-mobile/Overlay.html · H5 原生(无框架) · 192 行
<!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 · Overlay(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="Overlay.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: 220px;
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-card { box-sizing: border-box; width: 240px; padding: var(--kole-space-16);
border-radius: var(--kole-radius-large); background: var(--kole-color-card-bg);
color: var(--kole-color-text-body); box-shadow: var(--kole-shadow-high); }
.demo-card__title { margin: 0 0 var(--kole-space-8); font-size: var(--kole-m-font-size-body);
font-weight: 500; color: var(--kole-color-text-title); }
.demo-card__text { margin: 0 0 var(--kole-space-12); font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary); line-height: 1.6; }
.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-close { min-height: var(--kole-m-touch-target); padding: 0 var(--kole-m-gutter);
border: 0; border-radius: var(--kole-radius-base); background: var(--kole-color-brand);
color: var(--kole-color-text-inverse); font-family: inherit;
font-size: var(--kole-m-font-size-label); cursor: pointer; touch-action: manipulation; }
.demo-close: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="basic">
<p class="demo-label">基础遮罩(tone=default:点遮罩关闭;遮罩是纯装饰,内容语义由槽位承担)</p>
<div class="demo-frame" data-assert="overlay-basic">
<div class="demo-frame__body">
<button class="demo-trigger" type="button" id="overlay-basic-trigger"
data-behavior="click-toggles-class:#overlay-basic|is-open">打开遮罩</button>
</div>
<div class="kole-m-overlay kole-m-overlay--default" id="overlay-basic" role="presentation"
data-open="false">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<div class="demo-card">
<p class="demo-card__title">遮罩上的内容</p>
<p class="demo-card__text">遮罩本身不带语义,role 与标题由内容自己给。</p>
<button class="demo-close" type="button" id="overlay-basic-close">关闭</button>
</div>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="strong">
<p class="demo-label">浓遮罩(tone=strong:遮挡强、适合需要专注的确认,默认展开对照)</p>
<div class="demo-frame" data-assert="overlay-strong">
<div class="kole-m-overlay kole-m-overlay--strong is-open" id="overlay-strong" role="presentation"
data-open="true">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<p class="demo-card">
<button class="demo-close" type="button" id="overlay-strong-close">点遮罩或此处关闭</button>
</p>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="blur">
<p class="demo-label">模糊遮罩(tone=blur:背景内容做 backdrop 模糊,用于图片预览)</p>
<div class="demo-frame" data-assert="overlay-blur">
<div class="demo-frame__body">
<p style="margin: 0 0 8px;">这一行是遮罩**下方**的内容,blur 档会把它糊掉。</p>
<p style="margin: 0;">第二行同样是背景,用于对照模糊前后的可读性差别。</p>
</div>
<div class="kole-m-overlay kole-m-overlay--blur is-open" id="overlay-blur" role="presentation"
data-open="true">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<div class="demo-card">
<p class="demo-card__title">图片预览</p>
<p class="demo-card__text">模糊档只影响遮罩下方,内容卡片本身保持清晰。</p>
</div>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="contained">
<p class="demo-label">局部遮罩(contained=true:绝对定位填充最近的定位祖先,不覆盖全屏)</p>
<div class="demo-frame" data-assert="overlay-contained">
<div class="demo-frame__body">
<p style="margin: 0 0 8px;">卡片加载态:只遮住这张卡,页面其它区域仍可操作。</p>
<div class="demo-card" style="width: auto; position: relative;">
<p class="demo-card__title">数据概览</p>
<p class="demo-card__text">本月成交 128 单 · 环比 +12%</p>
<div class="kole-m-overlay kole-m-overlay--default kole-m-overlay--contained is-open"
role="presentation" data-open="true">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<span class="demo-card__text">加载中…</span>
</div>
</div>
</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="overlay-keep-open">
<div class="kole-m-overlay kole-m-overlay--default is-open" id="overlay-keep-open"
role="presentation" data-open="true" data-close-on-mask="false">
<button class="kole-m-overlay__scrim" type="button" aria-hidden="true" tabindex="-1"></button>
<div class="kole-m-overlay__content">
<div class="demo-card">
<p class="demo-card__title">填写中</p>
<p class="demo-card__text">填到一半的表单不该因为一次误触就丢弃,所以关掉遮罩点击。</p>
<button class="demo-close" type="button" id="overlay-keep-open-close">关闭</button>
</div>
</div>
</div>
</div>
</section>
</div>
<script>
/* 演示页交互:类名与 data-open 由**同一个函数**写入(避免视觉与状态分叉)。
遮罩点击只在 closeOnMask !== 'false' 时生效;关闭按钮始终生效。 */
(function () {
function setOpen(root, open) {
root.classList.toggle('is-open', open);
root.setAttribute('data-open', open ? 'true' : 'false');
}
var basic = document.getElementById('overlay-basic');
var trig = document.getElementById('overlay-basic-trigger');
if (basic && trig) {
trig.addEventListener('click', function () {
setOpen(basic, !basic.classList.contains('is-open'));
});
var close = document.getElementById('overlay-basic-close');
if (close) close.addEventListener('click', function () { setOpen(basic, false); });
}
var strong = document.getElementById('overlay-strong');
var strongClose = document.getElementById('overlay-strong-close');
if (strong && strongClose) strongClose.addEventListener('click', function () { setOpen(strong, false); });
var keep = document.getElementById('overlay-keep-open');
var keepClose = document.getElementById('overlay-keep-open-close');
if (keep && keepClose) keepClose.addEventListener('click', function () { setOpen(keep, false); });
document.querySelectorAll('.kole-m-overlay__scrim').forEach(function (scrim) {
scrim.addEventListener('click', function () {
var root = scrim.parentElement;
if (!root || root.getAttribute('data-close-on-mask') === 'false') return;
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/Overlay.jsx · React · 45 行
import React from 'react';
import './Overlay.css';
/* 遮罩层(移动端)— 规格 §40
浮层基座:遮罩本身是纯装饰(aria-hidden="true"),内容语义由宿主放进默认插槽并自己给 role。
遮罩用原生 button 承载点击(button 天然可聚焦),但显式 tabIndex={-1} 让它**不进键盘序列** ——
关闭的键盘路径由内容里的关闭按钮与 Esc 承担,靠 Tab 停在遮罩上会让读屏读到空名元素。
受控:本端不存开合,只回传关闭意图 onClose,是否收起由宿主决定。 */
export default function Overlay({
open = false,
contained = false,
tone = 'default',
lockScroll = true,
closeOnMask = true,
onClose,
children = null,
}) {
const cls =
'kole-m-overlay' +
` kole-m-overlay--${tone}` +
(contained ? ' kole-m-overlay--contained' : '') +
(open ? ' is-open' : '');
return (
<div
className={cls}
role="presentation"
data-open={open ? 'true' : 'false'}
data-lock-scroll={lockScroll ? 'true' : 'false'}
>
<button
className="kole-m-overlay__scrim"
type="button"
aria-hidden="true"
tabIndex={-1}
onClick={() => {
if (!closeOnMask) return;
if (onClose) onClose();
}}
/>
<div className="kole-m-overlay__content">{children}</div>
</div>
);
}
frameworks-mobile/Overlay.vue2.vue · Vue 2 · 49 行
<template>
<div
class="kole-m-overlay"
:class="overlayClass"
role="presentation"
:data-open="open ? 'true' : 'false'"
:data-lock-scroll="lockScroll ? 'true' : 'false'"
>
<button
class="kole-m-overlay__scrim"
type="button"
aria-hidden="true"
tabindex="-1"
@click="onScrimClick"
></button>
<div class="kole-m-overlay__content"><slot></slot></div>
</div>
</template>
<script>
export default {
name: 'KoleMOverlay',
props: {
open: { type: Boolean, default: false },
contained: { type: Boolean, default: false },
tone: { type: String, default: 'default' },
lockScroll: { type: Boolean, default: true },
closeOnMask: { type: Boolean, default: true }
},
computed: {
overlayClass: function () {
return [
'kole-m-overlay--' + this.tone,
this.contained ? 'kole-m-overlay--contained' : '',
this.open ? 'is-open' : ''
].filter(Boolean);
}
},
methods: {
onScrimClick: function () {
if (!this.closeOnMask) return;
this.$emit('close');
}
}
};
</script>
<style src="./Overlay.css"></style>
frameworks-mobile/Overlay.vue3.vue · Vue 3 · 45 行
<template>
<div
class="kole-m-overlay"
:class="overlayClass"
role="presentation"
:data-open="open ? 'true' : 'false'"
:data-lock-scroll="lockScroll ? 'true' : 'false'"
>
<button
class="kole-m-overlay__scrim"
type="button"
aria-hidden="true"
tabindex="-1"
@click="onScrimClick"
></button>
<div class="kole-m-overlay__content"><slot></slot></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
open: { type: Boolean, default: false },
contained: { type: Boolean, default: false },
tone: { type: String, default: 'default' },
lockScroll: { type: Boolean, default: true },
closeOnMask: { type: Boolean, default: true }
});
const emit = defineEmits(['close']);
const overlayClass = computed(() => [
`kole-m-overlay--${props.tone}`,
props.contained ? 'kole-m-overlay--contained' : '',
props.open ? 'is-open' : ''
].filter(Boolean));
function onScrimClick() {
if (!props.closeOnMask) return;
emit('close');
}
</script>
<style src="./Overlay.css"></style>
frameworks-mobile/Overlay.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 94 行
<template>
<view
class="kole-m-overlay"
:class="overlayClass"
:data-open="open ? 'true' : 'false'"
:data-lock-scroll="lockScroll ? 'true' : 'false'"
>
<view
class="kole-m-overlay__scrim"
aria-hidden="true"
@tap="onScrimTap"
></view>
<view class="kole-m-overlay__content"><slot></slot></view>
</view>
</template>
<script setup>
/* uni-app 端 · 遮罩层(移动端)— 规格 §40
跨端差异:遮罩用 view + @tap(小程序没有可点但不进 Tab 序列的原生 button 写法);
它 aria-hidden,因此不承担任何可访问名称,关闭的键盘路径由内容里的按钮承担。
尺寸用 rpx(2rpx ≈ 1px)。 */
import { computed } from 'vue';
const props = defineProps({
open: { type: Boolean, default: false },
contained: { type: Boolean, default: false },
tone: { type: String, default: 'default' },
lockScroll: { type: Boolean, default: true },
closeOnMask: { type: Boolean, default: true }
});
const emit = defineEmits(['close']);
const overlayClass = computed(() => [
`kole-m-overlay--${props.tone}`,
props.contained ? 'kole-m-overlay--contained' : '',
props.open ? 'is-open' : ''
].filter(Boolean));
function onScrimTap() {
if (!props.closeOnMask) return;
emit('close');
}
</script>
<style>
.kole-m-overlay {
--kole-m-overlay-blur: 12rpx;
--kole-m-overlay-duration: 240ms;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2000;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.kole-m-overlay.is-open { opacity: 1; }
.kole-m-overlay--contained {
position: absolute;
z-index: 10;
}
.kole-m-overlay--default .kole-m-overlay__scrim { background-color: var(--kole-color-mask); }
.kole-m-overlay--strong .kole-m-overlay__scrim { background-color: var(--kole-color-mask-strong); }
.kole-m-overlay--blur .kole-m-overlay__scrim {
background-color: var(--kole-color-mask);
backdrop-filter: blur(var(--kole-m-overlay-blur));
}
.kole-m-overlay__scrim {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.kole-m-overlay__content {
position: relative;
z-index: 1;
box-sizing: border-box;
max-width: 80%;
max-height: 80%;
overflow: auto;
}
</style>
测试与回归
断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。
断言 16 条 · 全部通过 报告 2026-09-22 23:01:05
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-overlay.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "40 · 遮罩层 Overlay",
"confidence": "high",
"slug": "mobile-overlay",
"name": "遮罩层 Overlay",
"semanticTypeCandidates": [
"overlay",
"scrim",
"mask"
],
"variantDimensions": [
{
"name": "tone",
"values": [
"default",
"strong",
"blur"
]
},
{
"name": "contained",
"values": [
"false",
"true"
]
}
],
"representativeVariants": [
{
"tone": "default",
"contained": "false",
"label": "基础全屏遮罩"
},
{
"tone": "strong",
"contained": "false",
"label": "浓遮罩(需要专注的确认)"
},
{
"tone": "blur",
"contained": "true",
"label": "模糊 + 局部(卡内加载态)"
}
],
"anatomy": {
"overlay": "根元素,定位容器与开合开关;role=\"presentation\",自身不承担语义",
"scrim": "遮罩面(原生 button),aria-hidden=\"true\" 且 tabindex=\"-1\" —— 可点但不进键盘序列",
"content": "内容容器(默认插槽落点),语义由宿主决定(对话框给 role=\"dialog\"、面板给 role=\"region\")"
},
"structurePatterns": {
"tone": "default 普通 / strong 浓遮罩 / blur 叠加背景模糊",
"contained": "false 固定全屏 / true 绝对定位填充最近的定位祖先",
"状态类": "is-open 展开(视觉与手势同时让开)"
},
"usageHints": [
"浮层的基座:在内容之上盖一层半透明遮罩,让下层内容退到背后(对话框、抽屉、图片预览、卡片加载态)",
"与弹出层的分工:弹出层是能独立使用的完整浮层,遮罩层只提供「变暗 + 拦手势 + 承载任意内容」",
"触屏上遮罩必须真的吃掉触摸事件,否则惯性滚动会从遮罩底下穿过去把下层页面滚走",
"遮罩关闭的键盘路径不落在遮罩上:遮罩显式 tabindex=\"-1\",键盘用户靠内容里的关闭按钮",
"lockScroll=true 时组件只在根上写 data-lock-scroll 标记,实际的 overflow: hidden 由宿主执行"
],
"doNotInvent": [
"焦点陷阱(focus trap)与初始焦点策略",
"多层遮罩的层叠顺序管理",
"手势下滑关闭与拖拽阻尼",
"滚动锁定的实现细节(组件只写标记,改 DOM 由宿主做)"
],
"unknowns": [
"blur 档在低端机上的性能开销是否可接受(未做降级探测)",
"contained=true 时是否应自动为宿主补 position: relative",
"是否要支持「点遮罩不关但双击关」这类折中策略"
],
"interaction": [
"一次轻点遮罩即关闭;closeOnMask=false 时不关闭",
"遮罩显式 tabindex=\"-1\",不进键盘序列;键盘用户靠内容里的关闭按钮与 Esc 关闭",
"开合动效 240ms(--kole-m-duration-slide),prefers-reduced-motion 下瞬时切换",
"contained=true 时要求宿主祖先链上存在定位元素,否则会向上找到视口"
],
"accessibility": [
"遮罩面 aria-hidden=\"true\"(纯装饰)且 tabindex=\"-1\"(不进键盘序列)",
"根 role=\"presentation\":容器本身不产生语义,避免读屏把它当成一个空的分组",
"内容语义完全由宿主提供:焦点陷阱、aria-modal、初始焦点与关闭后焦点归还都属于宿主职责",
"遮罩关闭不依赖颜色:开合只改透明度且有 240ms 过渡"
],
"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:展开且拦下所有手势(规格 §40.4)",
"required": false
},
{
"name": "contained",
"type": "boolean",
"default": "false",
"desc": "变体 contained:绝对定位填充最近的定位祖先,做卡内局部遮罩(规格 §40.3)",
"required": false
},
{
"name": "tone",
"type": "'default' | 'strong' | 'blur'",
"default": "'default'",
"desc": "变体 tone:遮罩浓度与质感(规格 §40.3)",
"required": false
},
{
"name": "lockScroll",
"type": "boolean",
"default": "true",
"desc": "是否锁住下层滚动;组件只写 data-lock-scroll 标记(规格 §40.5)",
"required": false
},
{
"name": "closeOnMask",
"type": "boolean",
"default": "true",
"desc": "遮罩点击是否关闭;表单类内容通常设为 false(规格 §40.5)",
"required": false
}
],
"events": [
{
"name": "close",
"params": "—",
"desc": "点击遮罩(closeOnMask=true 时)触发;是否收起由宿主决定(规格 §40.5)"
}
],
"slots": [
{
"name": "default",
"desc": "遮罩之上的内容;语义(role / aria-modal)由宿主提供(规格 §40.2 content)"
}
]
},
"variantClasses": {
"tone": {
"default": [
".kole-m-overlay--default"
],
"strong": [
".kole-m-overlay--strong"
],
"blur": [
".kole-m-overlay--blur"
]
},
"contained": {
"false": [],
"true": [
".kole-m-overlay--contained"
]
}
},
"demos": [
{
"id": "basic",
"group": "01 组件类型",
"title": "基础遮罩",
"desc": "tone=default:遮罩本身不带语义,role 与标题由内容自己给;一次轻点遮罩即关闭。",
"variant": "tone=default"
},
{
"id": "strong",
"group": "01 组件类型",
"title": "浓遮罩",
"desc": "tone=strong:遮挡更强,用于需要专注的确认;默认展开以便直接对照两层浓度。",
"variant": "tone=strong"
},
{
"id": "blur",
"group": "01 组件类型",
"title": "模糊遮罩",
"desc": "tone=blur:背景内容做 backdrop 模糊,用于图片预览;模糊只影响遮罩下方,内容卡片保持清晰。",
"variant": "tone=blur"
},
{
"id": "contained",
"group": "02 组件状态",
"title": "局部遮罩",
"desc": "contained=true:绝对定位填充最近的定位祖先,只遮住一张卡,页面其它区域仍可操作。",
"variant": "contained=true"
},
{
"id": "keep-open",
"group": "02 组件状态",
"title": "遮罩不关闭",
"desc": "closeOnMask=false:误触遮罩不收起;填到一半的表单不该因为一次误触就丢弃已填数据。",
"variant": "closeOnMask=false"
}
],
"related": [
{
"slug": "mobile-popup",
"why": "需要带方向位移与标题栏的完整浮层时用弹出层,遮罩层只做底座不负责内容结构"
},
{
"slug": "mobile-dialog",
"why": "需要确认或输入的中断式浮层用对话框,它自己已含遮罩与按钮组"
},
{
"slug": "mobile-loading",
"why": "局部加载态需要转圈图标时用加载组件,遮罩层只提供变暗与拦手势"
}
]
}