Files
aurora-admin/site/m/component/mobile-sticky.html
T
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

1532 lines
82 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
(function () {
var m = 'auto';
try { var v = localStorage.getItem('kole-mode'); if (v === 'light' || v === 'dark' || v === 'auto') m = v; } catch (e) {}
var dark = m === 'dark' || (m === 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('kole-dark', !!dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
})();
</script>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3E%3Crect%20width='24'%20height='24'%20rx='6'%20fill='%232F54EB'/%3E%3Cg%20fill='none'%20stroke='%23FFFFFF'%20stroke-width='2.25'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M7%205.5V18.5'/%3E%3Cpath%20d='M17%205.5l-6.25%204.06'/%3E%3Cpath%20d='M10.75%2014.44L17%2018.5'/%3E%3C/g%3E%3C/svg%3E">
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1C1F26" media="(prefers-color-scheme: dark)">
<title>吸顶容器 Sticky — Kole UI Mobile</title>
<meta name="description" content="吸顶容器 Sticky:移动端组件(6 端实现:CSS / H5 原生 / React / Vue 2 / Vue 3 / uni-app)。与 PC 端物理隔离:类名 kole-m-、令牌 --kole-m-。规格来源:移动端规格 39 · 吸顶容器 Sticky。">
<link rel="stylesheet" href="../../../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="../style.css">
</head>
<body>
<header class="m-top">
<div class="m-top-inner">
<a class="m-logo" href="../index.html">
<span class="m-logo-mark" aria-hidden="true"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 5.5V18.5"/><path d="M17 5.5l-6.25 4.06"/><path d="M10.75 14.44L17 18.5"/></svg></span>
<span class="m-logo-text">Kole UI<em>移动端</em></span>
</a>
<div class="m-ver-picker" id="m-ver-picker">
<button id="m-ver-trigger" class="m-ver-trigger" type="button" data-single="1" aria-haspopup="listbox" aria-expanded="false" aria-label="选择版本" title="文档版本(清单来自部署根的 versions.json)">
<span id="m-ver">v1.0.0</span>
<svg class="m-ver-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div id="m-ver-menu" class="m-ver-menu hidden" role="listbox" aria-label="文档版本">
<div class="m-ver-menu-head" id="m-ver-head">文档版本</div>
</div>
</div>
<nav class="m-nav"> <a href="../index.html" class="active" aria-current="page">组件总览</a>
<a href="../guide.html">快速开始</a>
<a href="../design.html">设计规范</a>
<a href="../faq.html">常见问题</a>
<a href="../changelog.html">更新日志</a></nav>
<div class="m-mode">
<button id="m-mode-trigger" class="m-mode-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-label="主题模式" title="主题模式">
<svg class="m-mode-icon" id="m-icon-moon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="m-mode-icon" id="m-icon-sun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="m-mode-icon" id="m-icon-auto" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<span id="m-mode-cur">自动模式</span>
<svg class="m-mode-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div id="m-mode-menu" class="m-menu hidden" role="listbox" aria-label="主题模式">
<div class="m-menu-head">主题模式</div>
<button class="m-menu-opt" type="button" role="option" data-mode="light" aria-selected="false"><span data-mode-label="light">日间模式</span></button>
<button class="m-menu-opt" type="button" role="option" data-mode="dark" aria-selected="false"><span data-mode-label="dark">夜间模式</span></button>
<button class="m-menu-opt" type="button" role="option" data-mode="auto" aria-selected="true"><span data-mode-label="auto">自动模式</span></button>
</div>
</div>
</div>
</header>
<div class="m-body">
<details class="m-side" open>
<summary><span>移动端导航</span><span class="cur">吸顶容器</span></summary>
<nav>
<div class="m-side-section"><span>平台</span></div>
<a class="m-side-link m-side-platform" href="../../index.html"><span class="sp-top"><span class="sp-name">PC 端组件</span></span><span class="sp-sub">103 个中后台组件 · 5 端</span></a>
<a class="m-side-link m-side-platform" href="../index.html"><span class="sp-top"><span class="sp-name">移动端组件</span><span class="sp-badge">当前</span></span><span class="sp-sub">47 个组件 · 6 端 · 含 uni-app</span></a>
<div class="m-side-section"><span>开发指南</span></div>
<a class="m-side-link" href="../index.html">组件总览</a>
<a class="m-side-link" href="../guide.html">快速开始</a>
<a class="m-side-link" href="../design.html">设计规范</a>
<a class="m-side-link" href="../faq.html">常见问题</a>
<a class="m-side-link" href="../changelog.html">更新日志</a>
<a class="m-side-link" href="../platform.html">平台与端<em>移动端</em></a>
<a class="m-side-link" href="../../../tests/mobile/index.html" target="_blank">测试与回归<em>移动端</em></a>
<div class="m-side-section"><span>组件</span><span class="cnt">47</span></div>
<div class="m-side-group"><span>通用</span><span class="cnt">5</span></div>
<a class="m-side-link" href="mobile-button.html">按钮<em>Button</em></a>
<a class="m-side-link" href="mobile-divider.html">分割线<em>Divider</em></a>
<a class="m-side-link" href="mobile-icon.html">图标<em>Icon</em></a>
<a class="m-side-link" href="mobile-layout.html">布局<em>Layout</em></a>
<a class="m-side-link" href="mobile-link.html">链接<em>Link</em></a>
<div class="m-side-group"><span>导航</span><span class="cnt">6</span></div>
<a class="m-side-link" href="navbar.html">顶部导航栏<em>NavBar</em></a>
<a class="m-side-link" href="tabbar.html">底部标签栏<em>TabBar</em></a>
<a class="m-side-link" href="mobile-grid.html">宫格<em>Grid</em></a>
<a class="m-side-link" href="mobile-steps.html">步骤条<em>Steps</em></a>
<a class="m-side-link" href="mobile-segmented.html">分段器<em>Segmented</em></a>
<a class="m-side-link is-active" href="mobile-sticky.html" aria-current="page">吸顶容器<em>Sticky</em></a>
<div class="m-side-group"><span>数据录入</span><span class="cnt">16</span></div>
<a class="m-side-link" href="mobile-numberkeyboard.html">数字键盘<em>NumberKeyboard</em></a>
<a class="m-side-link" href="mobile-datepicker.html">日期选择器<em>DatePicker</em></a>
<a class="m-side-link" href="mobile-input.html">输入框<em>Input</em></a>
<a class="m-side-link" href="mobile-search.html">搜索框<em>Search</em></a>
<a class="m-side-link" href="mobile-stepper.html">步进器<em>Stepper</em></a>
<a class="m-side-link" href="mobile-switch.html">开关<em>Switch</em></a>
<a class="m-side-link" href="mobile-textarea.html">多行文本框<em>Textarea</em></a>
<a class="m-side-link" href="mobile-checkbox.html">多选框<em>Checkbox</em></a>
<a class="m-side-link" href="mobile-form.html">表单<em>Form</em></a>
<a class="m-side-link" href="mobile-radio.html">单选框<em>Radio</em></a>
<a class="m-side-link" href="mobile-rate.html">评分<em>Rate</em></a>
<a class="m-side-link" href="mobile-slider.html">滑动选择器<em>Slider</em></a>
<a class="m-side-link" href="mobile-cascader.html">级联选择器<em>Cascader</em></a>
<a class="m-side-link" href="mobile-colorpicker.html">颜色选择器<em>ColorPicker</em></a>
<a class="m-side-link" href="mobile-picker.html">选择器<em>Picker</em></a>
<a class="m-side-link" href="mobile-upload.html">上传<em>Upload</em></a>
<div class="m-side-group"><span>数据展示</span><span class="cnt">8</span></div>
<a class="m-side-link" href="cell.html">单元格<em>Cell</em></a>
<a class="m-side-link" href="mobile-badge.html">徽标<em>Badge</em></a>
<a class="m-side-link" href="mobile-tag.html">标签<em>Tag</em></a>
<a class="m-side-link" href="mobile-avatar.html">头像<em>Avatar</em></a>
<a class="m-side-link" href="mobile-list.html">列表<em>List</em></a>
<a class="m-side-link" href="mobile-collapse.html">折叠面板<em>Collapse</em></a>
<a class="m-side-link" href="mobile-table.html">表格<em>Table</em></a>
<a class="m-side-link" href="mobile-typography.html">排版<em>Typography</em></a>
<div class="m-side-group"><span>反馈</span><span class="cnt">12</span></div>
<a class="m-side-link" href="actionsheet.html">动作面板<em>ActionSheet</em></a>
<a class="m-side-link" href="pullrefresh.html">下拉刷新<em>PullRefresh</em></a>
<a class="m-side-link" href="swipecell.html">滑动单元格<em>SwipeCell</em></a>
<a class="m-side-link" href="mobile-popup.html">弹出层<em>Popup</em></a>
<a class="m-side-link" href="mobile-toast.html">轻提示<em>Toast</em></a>
<a class="m-side-link" href="mobile-dialog.html">对话框<em>Dialog</em></a>
<a class="m-side-link" href="mobile-noticebar.html">通知栏<em>NoticeBar</em></a>
<a class="m-side-link" href="mobile-loading.html">加载<em>Loading</em></a>
<a class="m-side-link" href="mobile-progress.html">进度条<em>Progress</em></a>
<a class="m-side-link" href="mobile-message.html">消息通知<em>Message</em></a>
<a class="m-side-link" href="mobile-overlay.html">遮罩层<em>Overlay</em></a>
<a class="m-side-link" href="mobile-popover.html">弹出气泡<em>Popover</em></a>
</nav>
</details>
<main class="m-main">
<section class="m-hero">
<h1>吸顶容器<span class="en" style="font-family: var(--kole-font-family-num); font-size: 14px; color: var(--kole-color-text-secondary); margin-left: 8px;">Sticky</span></h1>
<p>让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)</p>
<p style="margin-top: 8px;" class="m-chips">
<span class="m-chip m">导航</span>
<span class="m-chip">规格 39 · 吸顶容器 Sticky</span>
<span class="m-chip">6 端实现</span>
<span class="m-chip">触摸优先</span>
</p>
<div class="m-code" style="margin-top: var(--kole-space-12);">
<div class="m-code-head"><span>引入(H5 原生;其余 5 端见「快速开始」)</span><button class="m-copy" type="button" data-copy="code-import">复制</button></div>
<pre id="code-import"><code>&lt;!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 --&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;kole-ui/mobile/tokens.css&quot;&gt;
&lt;!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) --&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;kole-ui/mobile/components/mobile-sticky.css&quot;&gt;
&lt;!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) --&gt;</code></pre>
</div>
</section>
<section class="m-block">
<h2 id="demo">演示</h2>
<p class="m-sub">每个演示都是真实渲染:预览帧加载 <code>frameworks-mobile/Sticky.html?demo=&lt;id&gt;</code>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 <a href="../../../frameworks-mobile/Sticky.html" target="_blank">演示页 ↗</a>。</p>
<h3 class="m-demo-group">01 组件类型</h3>
<div class="m-demo" id="demo-basic">
<div class="m-demo-head"><span class="m-demo-title">基础吸顶</span><span class="m-chip">position=top</span></div>
<p class="m-demo-desc">position: sticky 的默认形态:在框内向下滚动时标题条贴住框顶,不脱离文档流因此无需补占位元素。</p>
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Sticky.html?demo=basic" title="吸顶容器 Sticky · 基础吸顶" loading="lazy"></iframe></div>
<details class="m-details"><summary>查看代码(演示页原文 · 19 行)</summary>
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Sticky.html · basic</span><button class="m-copy" type="button" data-copy="code-demo-1">复制</button></div>
<pre id="code-demo-1"><code>&lt;section class=&quot;demo-block&quot; data-demo=&quot;basic&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;基础吸顶(position: sticky + 默认偏移 = 导航栏高度:在框内滚动,标题条贴住导航栏下沿)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-basic&quot; data-assert=&quot;sticky-basic&quot;&gt;
&lt;div class=&quot;demo-navbar&quot; aria-hidden=&quot;true&quot;&gt;模拟固定导航栏(高 44px)&lt;/div&gt;
&lt;div class=&quot;demo-row&quot; style=&quot;height: 88px&quot;&gt;(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)&lt;/div&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top&quot; id=&quot;sticky-basic&quot; data-stuck=&quot;false&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;订单列表 · 共 6 条&lt;/span&gt;
&lt;span class=&quot;kole-m-sticky__extra&quot;&gt;9 月&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260920-001 · 已发货&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260920-002 · 待付款&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260919-014 · 已完成&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260919-013 · 已完成&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260918-007 · 已取消&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260918-006 · 已完成&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260917-003 · 已完成&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;demo-hint&quot;&gt;滚动后标题条贴在导航栏下沿保持可见(sticky 不脱离文档流,因此无需补占位元素)&lt;/p&gt;
&lt;/section&gt;</code></pre></div>
</details>
</div>
<div class="m-demo" id="demo-shadow">
<div class="m-demo-head"><span class="m-demo-title">吸顶后出阴影</span><span class="m-chip">shadow=true</span></div>
<p class="m-demo-desc">shadow=true:未吸顶时与内容齐平,贴合后才出现投影,用形态而不是颜色表达「已经贴住了」。</p>
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Sticky.html?demo=shadow" title="吸顶容器 Sticky · 吸顶后出阴影" loading="lazy"></iframe></div>
<details class="m-details"><summary>查看代码(演示页原文 · 21 行)</summary>
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Sticky.html · shadow</span><button class="m-copy" type="button" data-copy="code-demo-2">复制</button></div>
<pre id="code-demo-2"><code>&lt;section class=&quot;demo-block&quot; data-demo=&quot;shadow&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;吸顶后出阴影(shadow=true:未吸顶时与内容齐平,贴合后才有投影)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-shadow&quot; data-assert=&quot;sticky-shadow&quot;&gt;
&lt;div class=&quot;demo-navbar&quot; aria-hidden=&quot;true&quot;&gt;模拟固定导航栏(高 44px)&lt;/div&gt;
&lt;div class=&quot;demo-row&quot; style=&quot;height: 88px&quot;&gt;(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)&lt;/div&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top kole-m-sticky--shadow&quot; id=&quot;sticky-shadow&quot; data-stuck=&quot;false&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;分组:待处理&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4821 · 待分派&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4822 · 待分派&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4823 · 处理中&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4824 · 处理中&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4825 · 待回访&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4826 · 待回访&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;demo-hint&quot;&gt;
&lt;button class=&quot;kole-m-sticky__action&quot; type=&quot;button&quot; id=&quot;sticky-shadow-toggle&quot;
data-behavior=&quot;click-toggles-class:#sticky-shadow|is-stuck&quot;&gt;切换 is-stuck 对照&lt;/button&gt;
手动切换贴合态,可直接对照有/无阴影两种形态
&lt;/p&gt;
&lt;/section&gt;</code></pre></div>
</details>
</div>
<div class="m-demo" id="demo-safe-area">
<div class="m-demo-head"><span class="m-demo-title">安全区叠加</span><span class="m-chip">safeArea=true</span></div>
<p class="m-demo-desc">safeArea=true:偏移量再叠加刘海高度;无刘海设备上安全区为 0px,表现与不带 safe 一致。</p>
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Sticky.html?demo=safe-area" title="吸顶容器 Sticky · 安全区叠加" loading="lazy"></iframe></div>
<details class="m-details"><summary>查看代码(演示页原文 · 14 行)</summary>
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Sticky.html · safe-area</span><button class="m-copy" type="button" data-copy="code-demo-3">复制</button></div>
<pre id="code-demo-3"><code>&lt;section class=&quot;demo-block&quot; data-demo=&quot;safe-area&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;安全区叠加(safeArea=true:偏移 = 容器偏移 + 刘海高度,无刘海时为 0px)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-safe&quot; data-assert=&quot;sticky-safe-area&quot;&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top kole-m-sticky--safe is-stuck&quot; data-stuck=&quot;true&quot;
style=&quot;--kole-m-sticky-offset: 0px&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;安全区吸顶(覆盖 offset=0)&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;覆盖 --kole-m-sticky-offset: 0px 后贴合在框顶&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;真实设备上再叠加 env(safe-area-inset-top)&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;顶部安全区为 0px 时与不带 safe 的表现一致&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 4&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 5&lt;/div&gt;
&lt;/div&gt;
&lt;/section&gt;</code></pre></div>
</details>
</div>
<h3 class="m-demo-group">02 组件状态</h3>
<div class="m-demo" id="demo-bottom">
<div class="m-demo-head"><span class="m-demo-title">贴底吸顶</span><span class="m-chip">position=bottom</span></div>
<p class="m-demo-desc">position=bottom:内容不足一屏时贴住容器底,常用于订单合计条;吸顶后描边换到上边。</p>
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Sticky.html?demo=bottom" title="吸顶容器 Sticky · 贴底吸顶" loading="lazy"></iframe></div>
<details class="m-details"><summary>查看代码(演示页原文 · 13 行)</summary>
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Sticky.html · bottom</span><button class="m-copy" type="button" data-copy="code-demo-4">复制</button></div>
<pre id="code-demo-4"><code>&lt;section class=&quot;demo-block&quot; data-demo=&quot;bottom&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;贴底吸顶(position=bottom:内容不足一屏时贴住框底,常用于底部合计条)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-bottom&quot; data-assert=&quot;sticky-bottom&quot;&gt;
&lt;div class=&quot;demo-row&quot;&gt;商品 1 · ¥128.00&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;商品 2 · ¥256.00&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;商品 3 · ¥64.00&lt;/div&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--bottom kole-m-sticky--shadow&quot; data-stuck=&quot;true&quot;
style=&quot;--kole-m-sticky-offset: 0px&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;合计 ¥448.00&lt;/span&gt;
&lt;span class=&quot;kole-m-sticky__extra&quot;&gt;已减 ¥12&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/section&gt;</code></pre></div>
</details>
</div>
<div class="m-demo" id="demo-action">
<div class="m-demo-head"><span class="m-demo-title">带动作</span><span class="m-chip">actionText 非空</span></div>
<p class="m-demo-desc">右侧动作是原生 button、热区 44px;点击回传 action,具体做什么由宿主决定。</p>
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/Sticky.html?demo=action" title="吸顶容器 Sticky · 带动作" loading="lazy"></iframe></div>
<details class="m-details"><summary>查看代码(演示页原文 · 15 行)</summary>
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/Sticky.html · action</span><button class="m-copy" type="button" data-copy="code-demo-5">复制</button></div>
<pre id="code-demo-5"><code>&lt;section class=&quot;demo-block&quot; data-demo=&quot;action&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;带动作(右侧动作是原生 button,热区 44px;点击回传 action 由宿主处理)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-action&quot; data-assert=&quot;sticky-action&quot;&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top is-stuck&quot; id=&quot;sticky-action&quot; data-stuck=&quot;true&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;收货地址&lt;/span&gt;
&lt;button class=&quot;kole-m-sticky__action&quot; type=&quot;button&quot;
data-behavior=&quot;click-sets-attr:#sticky-action|data-clicked|true&quot;&gt;管理&lt;/button&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;浙江省杭州市余杭区文一西路 969 号&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;收货人:张* | 138****8841&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 3&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 4&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 5&lt;/div&gt;
&lt;/div&gt;
&lt;/section&gt;</code></pre></div>
</details>
</div>
</section>
<section class="m-block">
<h2 id="api">API</h2>
<p class="m-sub">props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs</p>
<h3 class="m-h3">Props</h3>
<table class="m-table">
<thead><tr><th>名称</th><th>类型</th><th>默认值</th><th>说明</th><th>必传</th></tr></thead>
<tbody>
<tr><td><code>position</code></td><td><code>'top' | 'bottom'</code></td><td><code>'top'</code></td><td>变体 position:贴顶还是贴底(规格 §39.3)</td><td>N</td></tr>
<tr><td><code>safeArea</code></td><td><code>boolean</code></td><td><code>false</code></td><td>变体 safeArea:偏移叠加刘海 / 底部横条安全区(规格 §39.3)</td><td>N</td></tr>
<tr><td><code>shadow</code></td><td><code>boolean</code></td><td><code>false</code></td><td>变体 shadow:仅吸顶后才有投影(规格 §39.3)</td><td>N</td></tr>
<tr><td><code>stuck</code></td><td><code>boolean</code></td><td><code>false</code></td><td>状态 stuck:是否已贴合边缘,由宿主按滚动位置传入(规格 §39.4)</td><td>N</td></tr>
<tr><td><code>title</code></td><td><code>string</code></td><td><code>''</code></td><td>标题文字,单行省略(规格 §39.2 title)</td><td>N</td></tr>
<tr><td><code>actionText</code></td><td><code>string</code></td><td><code>''</code></td><td>右侧动作按钮文字;为空时不渲染按钮(规格 §39.2 action)</td><td>N</td></tr>
</tbody>
</table>
<p class="m-note">「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。</p>
<h3 class="m-h3">事件</h3>
<table class="m-table">
<thead><tr><th>名称</th><th>参数</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>action</code></td><td><code>—</code></td><td>点击右侧动作按钮时触发(规格 §39.5)</td></tr>
</tbody>
</table>
<h3 class="m-h3">插槽</h3>
<table class="m-table">
<thead><tr><th>名称</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>default</code></td><td>额外的栏内内容(放在标题与动作之间)</td></tr>
</tbody>
</table>
<h3 class="m-h3">CSS 变量</h3>
<p class="m-sub">组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。</p>
<table class="m-table">
<thead><tr><th>名称</th><th>默认值</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>--kole-m-sticky-offset</code></td><td><code>var(--kole-m-navbar-height)</code></td><td>组件内部默认值,可在业务侧覆盖</td></tr>
</tbody>
</table>
</section>
<section class="m-block">
<h2 id="usage">何时使用</h2>
<ul>
<li>让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)</li>
<li>用 CSS 原生 position: sticky —— 触屏惯性滚动下 JS 的 fixed 方案会抖动,且脱离文档流后要补占位元素</li>
<li>贴合位置由组件级变量 --kole-m-sticky-offset 决定,默认等于导航栏高度</li>
<li>组件本身不监听滚动:is-stuck 由宿主按滚动位置切换</li>
<li>吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致</li>
</ul>
</section>
<section class="m-block">
<h2 id="interaction">交互与触控</h2>
<ul>
<li>贴合位置由组件级变量 --kole-m-sticky-offset 决定,业务侧覆盖它即可适配自有导航</li>
<li>safeArea=true 时偏移叠加 --kole-m-safe-top / --kole-m-safe-bottom;env() 不可用时为 0px</li>
<li>组件本身不监听滚动:is-stuck 由宿主按滚动位置切换</li>
<li>触屏热区:整条高度 ≥ 44px;右侧动作按钮自身撑满 44px</li>
<li>position=bottom 时吸顶后的描边换到上边</li>
</ul>
</section>
<section class="m-block">
<h2 id="a11y">无障碍</h2>
<ul>
<li>根是普通容器,标题文字正常参与读屏朗读;is-stuck 是纯视觉增强,不添加任何 aria-*</li>
<li>右侧动作是原生 button,名称由可见文字承担;装饰性图形必须 aria-hidden=&quot;true&quot;</li>
<li>吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致</li>
<li>prefers-reduced-motion 下不引入任何过渡</li>
</ul>
</section>
<section class="m-block">
<h2 id="related">相似组件</h2>
<p class="m-sub">从「该用哪一个」的角度区分;PC 端的对应实现见 <a href="../../index.html">PC 文档站</a>。</p>
<table class="m-table">
<thead><tr><th>组件</th><th>何时用它而不是本组件</th></tr></thead>
<tbody>
<tr><td><a href="navbar.html">顶部导航栏</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">NavBar</em></td><td>页面级固定导航用导航栏(fixed 且带安全区与返回),区块内的贴合才用吸顶容器</td></tr>
<tr><td><a href="cell.html">单元格</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">Cell</em></td><td>吸顶条下面承载的列表项用单元格,吸顶容器只负责那一行的贴合行为</td></tr>
<tr><td><a href="mobile-list.html">列表</a><em style="font-family: var(--kole-font-family-num); font-style: normal; font-size: 11px; color: var(--kole-color-text-placeholder); margin-left: 6px;">List</em></td><td>需要分组标题列表时用列表组件,而不是给每一行都套一个吸顶容器</td></tr>
</tbody>
</table>
</section>
<section class="m-block">
<h2 id="gaps">规格未定 / 禁止发明</h2>
<table class="m-table">
<thead><tr><th>类别</th><th>条目</th></tr></thead>
<tbody>
<tr><td><span class="m-chip">禁止发明</span></td><td>吸顶触发的位移/缩放动画(规格只定义了贴合,没有定义形态演变)</td></tr>
<tr><td><span class="m-chip">禁止发明</span></td><td>多个吸顶条的层叠顺序与相互推挤(层叠上下文规则由宿主决定)</td></tr>
<tr><td><span class="m-chip">禁止发明</span></td><td>进入/离开视口时的埋点事件与曝光统计</td></tr>
<tr><td><span class="m-chip">禁止发明</span></td><td>拖拽排序与吸附</td></tr>
<tr><td><span class="m-chip">规格未定</span></td><td>吸顶判定是否应由组件内部提供一个可选的滚动监听辅助(当前完全交给宿主)</td></tr>
<tr><td><span class="m-chip">规格未定</span></td><td>是否需要在吸顶时自动隐藏相邻内容(当前不做,靠宿主布局)</td></tr>
<tr><td><span class="m-chip">规格未定</span></td><td>贴底形态在内容不足一屏时是否应始终贴底</td></tr>
</tbody>
</table>
</section>
<section class="m-block">
<h2 id="anatomy">结构(anatomy)</h2>
<table class="m-table">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>sticky</code></td><td>根元素,就是滚动内容流里的那一行(sticky 不脱离文档流,因此不需要占位元素)</td></tr>
<tr><td><code>title</code></td><td>标题文字,单行省略,占满剩余宽度</td></tr>
<tr><td><code>extra</code></td><td>右侧附加说明(数量、合计等),可选</td></tr>
<tr><td><code>action</code></td><td>右侧动作按钮(原生 button,热区 44px),可选</td></tr>
</tbody>
</table>
</section>
<section class="m-block">
<h2 id="variants">变体维度与类名映射</h2>
<p class="m-sub">类名映射由构建脚本从契约 <code>variantClasses</code> 生成,并被 <code>verify:mobile-docs</code> 逐条对照组件 CSS 校验(类/变量必须真实存在)。</p>
<table class="m-table">
<thead><tr><th>维度</th><th>取值</th><th>对应类名 / 变量</th></tr></thead>
<tbody>
<tr><td><code>position</code></td><td>top / bottom</td><td><div><b>top</b> <span class="m-cls">.kole-m-sticky--top</span></div><div><b>bottom</b> <span class="m-cls">.kole-m-sticky--bottom</span></div></td></tr>
<tr><td><code>safeArea</code></td><td>false / true</td><td><div><b>false</b> <span class="m-none">(由数据驱动,无专属类)</span></div><div><b>true</b> <span class="m-cls">.kole-m-sticky--safe</span></div></td></tr>
<tr><td><code>shadow</code></td><td>false / true</td><td><div><b>false</b> <span class="m-none">(由数据驱动,无专属类)</span></div><div><b>true</b> <span class="m-cls">.kole-m-sticky--shadow</span></div></td></tr>
</tbody>
</table>
</section>
<section class="m-block">
<h2 id="rep-variants">代表变体</h2>
<table class="m-table">
<thead><tr><th>变体</th><th>标签</th></tr></thead>
<tbody>
<tr><td><code>position=top · safeArea=false · shadow=false</code></td><td>基础吸顶(列表标题条)</td></tr>
<tr><td><code>position=top · safeArea=false · shadow=true</code></td><td>吸顶后出阴影(与内容分层)</td></tr>
<tr><td><code>position=top · safeArea=true · shadow=false</code></td><td>安全区叠加(刘海屏)</td></tr>
<tr><td><code>position=bottom · safeArea=false · shadow=true</code></td><td>贴底吸顶(合计条)</td></tr>
</tbody>
</table>
</section>
<section class="m-block">
<h2 id="tokens">用到的令牌</h2>
<p class="m-sub">构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。</p>
<div class="m-chips">
<span class="m-chip m" title="正文 / 列表项">--kole-m-font-size-body</span>
<span class="m-chip m" title="次级说明">--kole-m-font-size-label</span>
<span class="m-chip m" title="页面左右留白">--kole-m-gutter</span>
<span class="m-chip m" title="顶部导航栏内容高度(不含安全区)">--kole-m-navbar-height</span>
<span class="m-chip m" title="底部安全区;支持 env() 时由 @supports 覆盖为 env(safe-area-inset-bottom)">--kole-m-safe-bottom</span>
<span class="m-chip m" title="顶部安全区;支持 env() 时由 @supports 覆盖为 env(safe-area-inset-top)">--kole-m-safe-top</span>
<span class="m-chip m" title="可点区域最小边长">--kole-m-touch-target</span>
<span class="m-chip inherit" title="分割线/边框">--kole-color-border</span>
<span class="m-chip inherit" title="品牌主色:主按钮、选中态、链接">--kole-color-brand</span>
<span class="m-chip inherit" title="品牌浅色背景:选中背景、标签背景、表格悬停">--kole-color-brand-bg</span>
<span class="m-chip inherit" title="卡片背景">--kole-color-card-bg</span>
<span class="m-chip inherit" title="输入框聚焦外发光">--kole-color-focus-ring</span>
<span class="m-chip inherit" title="次要文字 · 5.10:1(原 #8C8C8C 仅 3.36:1)">--kole-color-text-secondary</span>
<span class="m-chip inherit" title="标题文字 · 15.4:1">--kole-color-text-title</span>
<span class="m-chip inherit" title="">--kole-font-family</span>
<span class="m-chip inherit" title="按钮、输入框、选择器">--kole-radius-base</span>
<span class="m-chip inherit" title="卡片悬停、下拉菜单、Popover">--kole-shadow-medium</span>
<span class="m-chip inherit" title="">--kole-space-12</span>
<span class="m-chip inherit" title="">--kole-space-8</span>
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-sticky-offset</span>
</div>
</section>
<section class="m-block">
<h2 id="sources">6 端源码</h2>
<p class="m-sub">同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。</p>
<details class="m-details">
<summary>frameworks-mobile/Sticky.css · 纯样式(CSS) · 97 行</summary>
<div class="m-code">
<div class="m-code-head"><span>frameworks-mobile/Sticky.css</span><button class="m-copy" type="button" data-copy="code-259">复制</button></div>
<pre id="code-259"><code>/* Kole UI Mobile · Sticky 样式 — 对齐移动端规格 §39
吸顶容器:用 CSS 原生 position: sticky(不是 JS 监听滚动 + position: fixed)——
后者在触屏上会随惯性滚动抖动,且离开文档流后会占位丢失、需要手动补占位元素。
sticky 的贴合位置由 `--kole-m-sticky-offset` 决定(默认等于导航栏高度,业务侧可覆盖),
safeArea=true 时再叠加安全区;吸顶后的形态变化由宿主按滚动位置切 is-stuck。 */
.kole-m-sticky {
/* 组件级变量:贴合边缘的偏移量(业务侧可覆盖,贴底用法通常覆盖为 0) */
--kole-m-sticky-offset: var(--kole-m-navbar-height);
position: sticky;
z-index: 100;
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--kole-space-8);
min-height: var(--kole-m-touch-target);
padding: var(--kole-space-8) var(--kole-m-gutter);
background: var(--kole-color-card-bg);
color: var(--kole-color-text-title);
font-family: var(--kole-font-family);
font-size: var(--kole-m-font-size-body);
font-weight: 500;
line-height: 1.4;
}
/* 变体 position:贴顶 / 贴底 */
.kole-m-sticky--top { top: var(--kole-m-sticky-offset); }
.kole-m-sticky--bottom { bottom: var(--kole-m-sticky-offset); }
/* 变体 safeArea=true:偏移叠加刘海 / 底部横条安全区(env() 不可用时安全区为 0px) */
.kole-m-sticky--top.kole-m-sticky--safe {
top: calc(var(--kole-m-sticky-offset) + var(--kole-m-safe-top));
}
.kole-m-sticky--bottom.kole-m-sticky--safe {
bottom: calc(var(--kole-m-sticky-offset) + var(--kole-m-safe-bottom));
}
/* 变体 shadow=true:吸顶后才出现的阴影(未吸顶时与内容齐平,不留悬空感) */
.kole-m-sticky--shadow { box-shadow: none; }
.kole-m-sticky--shadow.is-stuck { box-shadow: var(--kole-shadow-medium); }
/* 状态 is-stuck:已贴合边缘(由宿主按滚动位置切换) */
.kole-m-sticky.is-stuck {
border-block-end: 1px solid var(--kole-color-border);
}
.kole-m-sticky--bottom.is-stuck {
border-block-end: 0;
border-block-start: 1px solid var(--kole-color-border);
}
.kole-m-sticky__title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kole-m-sticky__extra { flex: 0 0 auto; font-size: var(--kole-m-font-size-label); font-weight: 400; color: var(--kole-color-text-secondary); }
/* 容器内的动作按钮(可选):原生 button,热区 44px,负外边距吸收不撑高栏体 */
.kole-m-sticky__action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: var(--kole-m-touch-target);
height: var(--kole-m-touch-target);
margin-inline-end: calc(-1 * var(--kole-space-12));
padding: 0 var(--kole-space-8);
border: 0;
border-radius: var(--kole-radius-base);
background: none;
color: var(--kole-color-brand);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
font-weight: 400;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
}
.kole-m-sticky__action:active { background: var(--kole-color-brand-bg); }
.kole-m-sticky__action:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.kole-m-sticky { transition: none; }
}
</code></pre>
</div>
</details>
<details class="m-details">
<summary>frameworks-mobile/Sticky.html · H5 原生(无框架) · 196 行</summary>
<div class="m-code">
<div class="m-code-head"><span>frameworks-mobile/Sticky.html</span><button class="m-copy" type="button" data-copy="code-260">复制</button></div>
<pre id="code-260"><code>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;zh-CN&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0, viewport-fit=cover&quot;&gt;
&lt;title&gt;Kole UI Mobile · Sticky(H5)&lt;/title&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;../.design_library/kole-ui-mobile/colors_and_type.css&quot;&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;Sticky.css&quot;&gt;
&lt;style&gt;
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); }
/* 滚动容器:sticky 的贴合参照是**最近的可滚动祖先**,所以在框内滚动就能看到吸顶效果 */
.demo-scroll { position: relative; height: 200px; overflow-y: auto; -webkit-overflow-scrolling: touch;
background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
/* 模拟页面级固定导航:吸顶条的默认偏移 = 导航栏高度,恰好贴在它下沿 */
.demo-navbar { position: absolute; top: 0; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; height: var(--kole-m-navbar-height);
padding: 0 var(--kole-m-gutter); background: var(--kole-color-card-bg);
border-block-end: 1px solid var(--kole-color-border);
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary); }
.demo-row { padding: var(--kole-space-12) var(--kole-m-gutter);
font-size: var(--kole-m-font-size-label); color: var(--kole-color-text-secondary);
border-bottom: 1px solid var(--kole-color-border); }
.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-block[hidden] { display: none; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;demo&quot;&gt;
&lt;section class=&quot;demo-block&quot; data-demo=&quot;basic&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;基础吸顶(position: sticky + 默认偏移 = 导航栏高度:在框内滚动,标题条贴住导航栏下沿)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-basic&quot; data-assert=&quot;sticky-basic&quot;&gt;
&lt;div class=&quot;demo-navbar&quot; aria-hidden=&quot;true&quot;&gt;模拟固定导航栏(高 44px)&lt;/div&gt;
&lt;div class=&quot;demo-row&quot; style=&quot;height: 88px&quot;&gt;(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)&lt;/div&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top&quot; id=&quot;sticky-basic&quot; data-stuck=&quot;false&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;订单列表 · 共 6 条&lt;/span&gt;
&lt;span class=&quot;kole-m-sticky__extra&quot;&gt;9 月&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260920-001 · 已发货&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260920-002 · 待付款&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260919-014 · 已完成&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260919-013 · 已完成&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260918-007 · 已取消&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260918-006 · 已完成&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;20260917-003 · 已完成&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;demo-hint&quot;&gt;滚动后标题条贴在导航栏下沿保持可见(sticky 不脱离文档流,因此无需补占位元素)&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;demo-block&quot; data-demo=&quot;shadow&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;吸顶后出阴影(shadow=true:未吸顶时与内容齐平,贴合后才有投影)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-shadow&quot; data-assert=&quot;sticky-shadow&quot;&gt;
&lt;div class=&quot;demo-navbar&quot; aria-hidden=&quot;true&quot;&gt;模拟固定导航栏(高 44px)&lt;/div&gt;
&lt;div class=&quot;demo-row&quot; style=&quot;height: 88px&quot;&gt;(占位:让吸顶条初始位于阈值下方,滚动 44px 后才贴合)&lt;/div&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top kole-m-sticky--shadow&quot; id=&quot;sticky-shadow&quot; data-stuck=&quot;false&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;分组:待处理&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4821 · 待分派&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4822 · 待分派&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4823 · 处理中&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4824 · 处理中&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4825 · 待回访&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;工单 #4826 · 待回访&lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;demo-hint&quot;&gt;
&lt;button class=&quot;kole-m-sticky__action&quot; type=&quot;button&quot; id=&quot;sticky-shadow-toggle&quot;
data-behavior=&quot;click-toggles-class:#sticky-shadow|is-stuck&quot;&gt;切换 is-stuck 对照&lt;/button&gt;
手动切换贴合态,可直接对照有/无阴影两种形态
&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;demo-block&quot; data-demo=&quot;safe-area&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;安全区叠加(safeArea=true:偏移 = 容器偏移 + 刘海高度,无刘海时为 0px)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-safe&quot; data-assert=&quot;sticky-safe-area&quot;&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top kole-m-sticky--safe is-stuck&quot; data-stuck=&quot;true&quot;
style=&quot;--kole-m-sticky-offset: 0px&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;安全区吸顶(覆盖 offset=0)&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;覆盖 --kole-m-sticky-offset: 0px 后贴合在框顶&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;真实设备上再叠加 env(safe-area-inset-top)&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;顶部安全区为 0px 时与不带 safe 的表现一致&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 4&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 5&lt;/div&gt;
&lt;/div&gt;
&lt;/section&gt;
&lt;section class=&quot;demo-block&quot; data-demo=&quot;bottom&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;贴底吸顶(position=bottom:内容不足一屏时贴住框底,常用于底部合计条)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-bottom&quot; data-assert=&quot;sticky-bottom&quot;&gt;
&lt;div class=&quot;demo-row&quot;&gt;商品 1 · ¥128.00&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;商品 2 · ¥256.00&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;商品 3 · ¥64.00&lt;/div&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--bottom kole-m-sticky--shadow&quot; data-stuck=&quot;true&quot;
style=&quot;--kole-m-sticky-offset: 0px&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;合计 ¥448.00&lt;/span&gt;
&lt;span class=&quot;kole-m-sticky__extra&quot;&gt;已减 ¥12&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/section&gt;
&lt;section class=&quot;demo-block&quot; data-demo=&quot;action&quot;&gt;
&lt;p class=&quot;demo-label&quot;&gt;带动作(右侧动作是原生 button,热区 44px;点击回传 action 由宿主处理)&lt;/p&gt;
&lt;div class=&quot;demo-scroll&quot; id=&quot;scroll-action&quot; data-assert=&quot;sticky-action&quot;&gt;
&lt;div class=&quot;kole-m-sticky kole-m-sticky--top is-stuck&quot; id=&quot;sticky-action&quot; data-stuck=&quot;true&quot;&gt;
&lt;span class=&quot;kole-m-sticky__title&quot;&gt;收货地址&lt;/span&gt;
&lt;button class=&quot;kole-m-sticky__action&quot; type=&quot;button&quot;
data-behavior=&quot;click-sets-attr:#sticky-action|data-clicked|true&quot;&gt;管理&lt;/button&gt;
&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;浙江省杭州市余杭区文一西路 969 号&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;收货人:张* | 138****8841&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 3&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 4&lt;/div&gt;
&lt;div class=&quot;demo-row&quot;&gt;内容行 5&lt;/div&gt;
&lt;/div&gt;
&lt;/section&gt;
&lt;/div&gt;
&lt;script&gt;
/* 演示页交互:滚动容器里的 sticky 贴合时机只有浏览器知道 —— 演示页用 scrollTop 判断,
把 is-stuck / data-stuck 同步到**真源那一个函数**(避免类名与属性分叉)。
生产环境同样由宿主驱动;组件本体不监听滚动。 */
(function () {
/* 贴合态的唯一写入点:类名与属性在同一个函数里落笔,避免「视觉贴住了、属性还说没贴」 */
function setStuck(bar, stuck) {
bar.classList.toggle('is-stuck', stuck);
bar.setAttribute('data-stuck', stuck ? 'true' : 'false');
}
/* 贴合判定:比较吸顶条的实际 top 与「容器内容区 top + 偏移量」——
贴合后两者相等,未贴合时吸顶条还在偏移量之下。
容器内容区 top = 容器 border-box top + 上边框宽度(sticky 的偏移是相对 padding box 的,
漏掉这 1px 会把「刚刚贴住」误判成「还没贴」)。判据不依赖硬编码的偏移数值。 */
function isStuck(box, bar) {
var offset = parseFloat(getComputedStyle(bar).top) || 0;
var border = parseFloat(getComputedStyle(box).borderTopWidth) || 0;
var boxTop = box.getBoundingClientRect().top;
var barTop = bar.getBoundingClientRect().top;
return barTop &lt;= boxTop + border + offset + 0.5;
}
function bind(scrollSel, stickySel) {
var box = document.querySelector(scrollSel);
var bar = document.querySelector(stickySel);
if (!box || !bar) return;
function sync() {
setStuck(bar, isStuck(box, bar));
}
box.addEventListener('scroll', sync, { passive: true });
sync();
}
bind('#scroll-basic', '#sticky-basic');
bind('#scroll-shadow', '#sticky-shadow');
/* 对照开关:手动翻转贴合态,用于并排看有/无阴影两种形态 */
var toggle = document.getElementById('sticky-shadow-toggle');
var shadowBar = document.getElementById('sticky-shadow');
if (toggle &amp;&amp; shadowBar) {
toggle.addEventListener('click', function () {
setStuck(shadowBar, !shadowBar.classList.contains('is-stuck'));
});
}
})();
(function () {
var bar = document.getElementById('sticky-action');
if (!bar) return;
var btn = bar.querySelector('.kole-m-sticky__action');
if (!btn) return;
btn.addEventListener('click', function () {
bar.setAttribute('data-clicked', 'true');
});
})();
&lt;/script&gt;
&lt;script&gt;
/* ?demo=&lt;id&gt; → 只显示该演示块(文档站按块预览用;无参数时全部显示,测试与回归走无参数路径) */
(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 &amp;&amp; !b.hidden) label.hidden = true;
});
})();
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
</div>
</details>
<details class="m-details">
<summary>frameworks-mobile/Sticky.jsx · React · 44 行</summary>
<div class="m-code">
<div class="m-code-head"><span>frameworks-mobile/Sticky.jsx</span><button class="m-copy" type="button" data-copy="code-261">复制</button></div>
<pre id="code-261"><code>import React from 'react';
import './Sticky.css';
/* 吸顶容器(移动端)— 规格 §39
用 CSS 原生 position: sticky(不是 JS 监听滚动 + position: fixed):
后者在触屏惯性滚动时会抖动,离开文档流后还要手动补占位元素。
贴合偏移由 --kole-m-sticky-offset 决定(默认导航栏高度),safeArea=true 时叠加安全区。
is-stuck 由**宿主**按滚动位置切换 —— 组件不监听滚动(sticky 的贴合判定只有浏览器知道)。 */
export default function Sticky({
position = 'top',
safeArea = false,
shadow = false,
stuck = false,
title = '',
actionText = '',
onAction,
children = null,
}) {
const cls =
'kole-m-sticky' +
` kole-m-sticky--${position}` +
(safeArea ? ' kole-m-sticky--safe' : '') +
(shadow ? ' kole-m-sticky--shadow' : '') +
(stuck ? ' is-stuck' : '');
return (
&lt;div className={cls} data-stuck={stuck ? 'true' : 'false'}&gt;
{title ? &lt;span className=&quot;kole-m-sticky__title&quot;&gt;{title}&lt;/span&gt; : null}
{children}
{actionText ? (
&lt;button
className=&quot;kole-m-sticky__action&quot;
type=&quot;button&quot;
onClick={() =&gt; {
if (onAction) onAction();
}}
&gt;
{actionText}
&lt;/button&gt;
) : null}
&lt;/div&gt;
);
}
</code></pre>
</div>
</details>
<details class="m-details">
<summary>frameworks-mobile/Sticky.vue2.vue · Vue 2 · 39 行</summary>
<div class="m-code">
<div class="m-code-head"><span>frameworks-mobile/Sticky.vue2.vue</span><button class="m-copy" type="button" data-copy="code-262">复制</button></div>
<pre id="code-262"><code>&lt;template&gt;
&lt;div class=&quot;kole-m-sticky&quot; :class=&quot;stickyClass&quot; :data-stuck=&quot;stuck ? 'true' : 'false'&quot;&gt;
&lt;span v-if=&quot;title&quot; class=&quot;kole-m-sticky__title&quot;&gt;{{ title }}&lt;/span&gt;
&lt;slot&gt;&lt;/slot&gt;
&lt;button
v-if=&quot;actionText&quot;
class=&quot;kole-m-sticky__action&quot;
type=&quot;button&quot;
@click=&quot;$emit('action')&quot;
&gt;{{ actionText }}&lt;/button&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script&gt;
export default {
name: 'KoleMSticky',
props: {
position: { type: String, default: 'top' },
safeArea: { type: Boolean, default: false },
shadow: { type: Boolean, default: false },
stuck: { type: Boolean, default: false },
title: { type: String, default: '' },
actionText: { type: String, default: '' }
},
computed: {
stickyClass: function () {
return [
'kole-m-sticky--' + this.position,
this.safeArea ? 'kole-m-sticky--safe' : '',
this.shadow ? 'kole-m-sticky--shadow' : '',
this.stuck ? 'is-stuck' : ''
].filter(Boolean);
}
}
};
&lt;/script&gt;
&lt;style src=&quot;./Sticky.css&quot;&gt;&lt;/style&gt;
</code></pre>
</div>
</details>
<details class="m-details">
<summary>frameworks-mobile/Sticky.vue3.vue · Vue 3 · 40 行</summary>
<div class="m-code">
<div class="m-code-head"><span>frameworks-mobile/Sticky.vue3.vue</span><button class="m-copy" type="button" data-copy="code-263">复制</button></div>
<pre id="code-263"><code>&lt;template&gt;
&lt;div class=&quot;kole-m-sticky&quot; :class=&quot;stickyClass&quot; :data-stuck=&quot;stuck ? 'true' : 'false'&quot;&gt;
&lt;span v-if=&quot;title&quot; class=&quot;kole-m-sticky__title&quot;&gt;{{ title }}&lt;/span&gt;
&lt;slot&gt;&lt;/slot&gt;
&lt;button
v-if=&quot;actionText&quot;
class=&quot;kole-m-sticky__action&quot;
type=&quot;button&quot;
@click=&quot;onActionClick&quot;
&gt;{{ actionText }}&lt;/button&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script setup&gt;
import { computed } from 'vue';
const props = defineProps({
position: { type: String, default: 'top' },
safeArea: { type: Boolean, default: false },
shadow: { type: Boolean, default: false },
stuck: { type: Boolean, default: false },
title: { type: String, default: '' },
actionText: { type: String, default: '' }
});
const emit = defineEmits(['action']);
const stickyClass = computed(() =&gt; [
`kole-m-sticky--${props.position}`,
props.safeArea ? 'kole-m-sticky--safe' : '',
props.shadow ? 'kole-m-sticky--shadow' : '',
props.stuck ? 'is-stuck' : ''
].filter(Boolean));
function onActionClick() {
emit('action');
}
&lt;/script&gt;
&lt;style src=&quot;./Sticky.css&quot;&gt;&lt;/style&gt;
</code></pre>
</div>
</details>
<details class="m-details">
<summary>frameworks-mobile/Sticky.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 110 行</summary>
<div class="m-code">
<div class="m-code-head"><span>frameworks-mobile/Sticky.uniapp.vue</span><button class="m-copy" type="button" data-copy="code-264">复制</button></div>
<pre id="code-264"><code>&lt;template&gt;
&lt;view class=&quot;kole-m-sticky&quot; :class=&quot;stickyClass&quot; :data-stuck=&quot;stuck ? 'true' : 'false'&quot;&gt;
&lt;text v-if=&quot;title&quot; class=&quot;kole-m-sticky__title&quot;&gt;{{ title }}&lt;/text&gt;
&lt;slot&gt;&lt;/slot&gt;
&lt;view
v-if=&quot;actionText&quot;
class=&quot;kole-m-sticky__action&quot;
role=&quot;button&quot;
@tap=&quot;onActionTap&quot;
&gt;
&lt;text&gt;{{ actionText }}&lt;/text&gt;
&lt;/view&gt;
&lt;/view&gt;
&lt;/template&gt;
&lt;script setup&gt;
/* uni-app 端 · 吸顶容器(移动端)— 规格 §39
跨端差异:小程序 / App 端没有 CSS sticky 的可靠实现(部分内核把 sticky 退化为 static),
因此本端**由宿主用 scroll-view 的 @scroll 事件**判断贴合时机并传 stuck;
组件自身只负责形态(偏移量、安全区、吸顶后的阴影与描边)。
尺寸用 rpx(2rpx ≈ 1px);偏移量靠 --kole-m-sticky-offset 覆盖。 */
import { computed } from 'vue';
const props = defineProps({
position: { type: String, default: 'top' },
safeArea: { type: Boolean, default: false },
shadow: { type: Boolean, default: false },
stuck: { type: Boolean, default: false },
title: { type: String, default: '' },
actionText: { type: String, default: '' }
});
const emit = defineEmits(['action']);
const stickyClass = computed(() =&gt; [
`kole-m-sticky--${props.position}`,
props.safeArea ? 'kole-m-sticky--safe' : '',
props.shadow ? 'kole-m-sticky--shadow' : '',
props.stuck ? 'is-stuck' : ''
].filter(Boolean));
function onActionTap() {
emit('action');
}
&lt;/script&gt;
&lt;style&gt;
.kole-m-sticky {
--kole-m-sticky-offset: 88rpx;
/* 安全区补偿:小程序 / App 端没有 env(),由宿主读系统安全区后覆盖这个变量 */
--kole-m-sticky-safe-extra: 0rpx;
--kole-m-touch-target: 88rpx;
--kole-m-font-size-body: 32rpx;
--kole-m-font-size-label: 28rpx;
--kole-m-gutter: 32rpx;
position: relative;
z-index: 100;
box-sizing: border-box;
display: flex;
align-items: center;
min-height: var(--kole-m-touch-target);
padding: 16rpx var(--kole-m-gutter);
background-color: var(--kole-color-card-bg);
color: var(--kole-color-text-title);
font-size: var(--kole-m-font-size-body);
font-weight: 500;
}
.kole-m-sticky--top { top: var(--kole-m-sticky-offset); }
.kole-m-sticky--bottom { bottom: var(--kole-m-sticky-offset); }
.kole-m-sticky--top.kole-m-sticky--safe {
top: calc(var(--kole-m-sticky-offset) + var(--kole-m-sticky-safe-extra));
}
.kole-m-sticky--bottom.kole-m-sticky--safe {
bottom: calc(var(--kole-m-sticky-offset) + var(--kole-m-sticky-safe-extra));
}
.kole-m-sticky.is-stuck {
border-bottom: 2rpx solid var(--kole-color-border);
}
.kole-m-sticky--shadow.is-stuck {
box-shadow: var(--kole-shadow-medium);
}
.kole-m-sticky__title {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kole-m-sticky__action {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: var(--kole-m-touch-target);
height: var(--kole-m-touch-target);
margin-right: -24rpx;
padding: 0 16rpx;
border-radius: 8rpx;
color: var(--kole-color-brand);
font-size: var(--kole-m-font-size-label);
font-weight: 400;
}
&lt;/style&gt;
</code></pre>
</div>
</details>
</section>
<section class="m-block">
<h2 id="tests">测试与回归</h2>
<p class="m-sub">断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 <code>tests/_runtime.js</code>,触控行为动词来自移动端 <code>tests/mobile/_behaviors.js</code>)。</p>
<p>
<span class="m-status pass">断言 17 条 · 全部通过</span>
<span class="m-status">报告 2026-09-20 16:50:33</span>
</p>
<div class="m-code">
<div class="m-code-head"><span>复现命令</span><button class="m-copy" type="button" data-copy="code-test">复制</button></div>
<pre id="code-test"><code>node site/dev-server.js &amp;
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs # 全量 5 个组件
npm run verify:mobile-docs # 本页内容完整性 + API 与源码一致性</code></pre>
</div>
</section>
<section class="m-block">
<h2 id="contract">设计契约</h2>
<details class="m-details">
<summary>components/mobile-sticky.json(点击展开原始 JSON)</summary>
<div class="m-code"><pre><code>{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "39 · 吸顶容器 Sticky",
"confidence": "high",
"slug": "mobile-sticky",
"name": "吸顶容器 Sticky",
"semanticTypeCandidates": [
"sticky",
"affix",
"section-header"
],
"variantDimensions": [
{
"name": "position",
"values": [
"top",
"bottom"
]
},
{
"name": "safeArea",
"values": [
"false",
"true"
]
},
{
"name": "shadow",
"values": [
"false",
"true"
]
}
],
"representativeVariants": [
{
"position": "top",
"safeArea": "false",
"shadow": "false",
"label": "基础吸顶(列表标题条)"
},
{
"position": "top",
"safeArea": "false",
"shadow": "true",
"label": "吸顶后出阴影(与内容分层)"
},
{
"position": "top",
"safeArea": "true",
"shadow": "false",
"label": "安全区叠加(刘海屏)"
},
{
"position": "bottom",
"safeArea": "false",
"shadow": "true",
"label": "贴底吸顶(合计条)"
}
],
"anatomy": {
"sticky": "根元素,就是滚动内容流里的那一行(sticky 不脱离文档流,因此不需要占位元素)",
"title": "标题文字,单行省略,占满剩余宽度",
"extra": "右侧附加说明(数量、合计等),可选",
"action": "右侧动作按钮(原生 button,热区 44px),可选"
},
"structurePatterns": {
"position": "top 贴顶(最常用)/ bottom 贴底(合计条一类)",
"safeArea": "false 只用偏移量 / true 再叠加刘海或底部横条安全区",
"shadow": "false 恒定无投影 / true 仅吸顶后才有投影",
"状态类": "is-stuck 已贴合边缘(由宿主按滚动位置切换)"
},
"usageHints": [
"让一段内容在滚动时贴住滚动容器的边缘保持可见(列表标题、分组、购物车合计条)",
"用 CSS 原生 position: sticky —— 触屏惯性滚动下 JS 的 fixed 方案会抖动,且脱离文档流后要补占位元素",
"贴合位置由组件级变量 --kole-m-sticky-offset 决定,默认等于导航栏高度",
"组件本身不监听滚动:is-stuck 由宿主按滚动位置切换",
"吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致"
],
"doNotInvent": [
"吸顶触发的位移/缩放动画(规格只定义了贴合,没有定义形态演变)",
"多个吸顶条的层叠顺序与相互推挤(层叠上下文规则由宿主决定)",
"进入/离开视口时的埋点事件与曝光统计",
"拖拽排序与吸附"
],
"unknowns": [
"吸顶判定是否应由组件内部提供一个可选的滚动监听辅助(当前完全交给宿主)",
"是否需要在吸顶时自动隐藏相邻内容(当前不做,靠宿主布局)",
"贴底形态在内容不足一屏时是否应始终贴底"
],
"interaction": [
"贴合位置由组件级变量 --kole-m-sticky-offset 决定,业务侧覆盖它即可适配自有导航",
"safeArea=true 时偏移叠加 --kole-m-safe-top / --kole-m-safe-bottom;env() 不可用时为 0px",
"组件本身不监听滚动:is-stuck 由宿主按滚动位置切换",
"触屏热区:整条高度 ≥ 44px;右侧动作按钮自身撑满 44px",
"position=bottom 时吸顶后的描边换到上边"
],
"accessibility": [
"根是普通容器,标题文字正常参与读屏朗读;is-stuck 是纯视觉增强,不添加任何 aria-*",
"右侧动作是原生 button,名称由可见文字承担;装饰性图形必须 aria-hidden=\"true\"",
"吸顶不改变文档顺序:读屏与键盘的遍历顺序与未吸顶时完全一致",
"prefers-reduced-motion 下不引入任何过渡"
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。",
"props": [
{
"name": "position",
"type": "'top' | 'bottom'",
"default": "'top'",
"desc": "变体 position:贴顶还是贴底(规格 §39.3)",
"required": false
},
{
"name": "safeArea",
"type": "boolean",
"default": "false",
"desc": "变体 safeArea:偏移叠加刘海 / 底部横条安全区(规格 §39.3)",
"required": false
},
{
"name": "shadow",
"type": "boolean",
"default": "false",
"desc": "变体 shadow:仅吸顶后才有投影(规格 §39.3)",
"required": false
},
{
"name": "stuck",
"type": "boolean",
"default": "false",
"desc": "状态 stuck:是否已贴合边缘,由宿主按滚动位置传入(规格 §39.4)",
"required": false
},
{
"name": "title",
"type": "string",
"default": "''",
"desc": "标题文字,单行省略(规格 §39.2 title)",
"required": false
},
{
"name": "actionText",
"type": "string",
"default": "''",
"desc": "右侧动作按钮文字;为空时不渲染按钮(规格 §39.2 action)",
"required": false
}
],
"events": [
{
"name": "action",
"params": "—",
"desc": "点击右侧动作按钮时触发(规格 §39.5)"
}
],
"slots": [
{
"name": "default",
"desc": "额外的栏内内容(放在标题与动作之间)"
}
]
},
"variantClasses": {
"position": {
"top": [
".kole-m-sticky--top"
],
"bottom": [
".kole-m-sticky--bottom"
]
},
"safeArea": {
"false": [],
"true": [
".kole-m-sticky--safe"
]
},
"shadow": {
"false": [],
"true": [
".kole-m-sticky--shadow"
]
}
},
"demos": [
{
"id": "basic",
"group": "01 组件类型",
"title": "基础吸顶",
"desc": "position: sticky 的默认形态:在框内向下滚动时标题条贴住框顶,不脱离文档流因此无需补占位元素。",
"variant": "position=top"
},
{
"id": "shadow",
"group": "01 组件类型",
"title": "吸顶后出阴影",
"desc": "shadow=true:未吸顶时与内容齐平,贴合后才出现投影,用形态而不是颜色表达「已经贴住了」。",
"variant": "shadow=true"
},
{
"id": "safe-area",
"group": "01 组件类型",
"title": "安全区叠加",
"desc": "safeArea=true:偏移量再叠加刘海高度;无刘海设备上安全区为 0px,表现与不带 safe 一致。",
"variant": "safeArea=true"
},
{
"id": "bottom",
"group": "02 组件状态",
"title": "贴底吸顶",
"desc": "position=bottom:内容不足一屏时贴住容器底,常用于订单合计条;吸顶后描边换到上边。",
"variant": "position=bottom"
},
{
"id": "action",
"group": "02 组件状态",
"title": "带动作",
"desc": "右侧动作是原生 button、热区 44px;点击回传 action,具体做什么由宿主决定。",
"variant": "actionText 非空"
}
],
"related": [
{
"slug": "navbar",
"why": "页面级固定导航用导航栏(fixed 且带安全区与返回),区块内的贴合才用吸顶容器"
},
{
"slug": "cell",
"why": "吸顶条下面承载的列表项用单元格,吸顶容器只负责那一行的贴合行为"
},
{
"slug": "mobile-list",
"why": "需要分组标题列表时用列表组件,而不是给每一行都套一个吸顶容器"
}
]
}</code></pre></div>
</details>
</section>
<footer class="m-foot">
<p>生成物:本页由 <code>node tools/build-mobile.mjs</code> 从契约 <code>components/mobile-sticky.json</code> 与 6 端源码生成,请勿手改。</p>
<p>规格原文:<code>.design_library/kole-ui-mobile/spec/移动端规格.md</code> · 隔离门禁:<code>node tools/verify-mobile-isolation.mjs</code></p>
</footer>
</main>
</div>
<script>
(function () {
/* 左栏:≤1000px 默认收起(手机上不让导航占满一屏),跨断点时同步一次;不监听 resize,
免得跟用户手动的展开/收起抢状态。桌面不设 open 之外的动作。 */
var side = document.querySelector('details.m-side');
if (side && window.matchMedia) {
var mq = window.matchMedia('(max-width: 1000px)');
var sync = function () { if (mq.matches) side.removeAttribute('open'); else side.setAttribute('open', ''); };
sync();
if (mq.addEventListener) mq.addEventListener('change', sync);
else if (mq.addListener) mq.addListener(sync);
}
/* 单演示预览帧按内容高度自适应(同源可读 contentDocument;上限 360px、下限 140px)。
固定高度会在内容少的演示上留一大片空白(实测 SwipeCell 单个演示只用 ~120px)。 */
function fitDemoFrames() {
var frames = document.querySelectorAll('.m-demo-stage iframe');
Array.prototype.forEach.call(frames, function (f) {
var fit = function () {
try {
var d = f.contentDocument;
if (!d || !d.body) return;
/* 只量 body.scrollHeight:documentElement.scrollHeight 等于**帧视口高度**(html 撑满视口),
拿它做 max 会形成「帧高→视口高→量到的高度」反馈环,永远收敛在初始值(实测锁在 308px)。 */
var h = d.body.scrollHeight;
if (!h) h = d.documentElement ? d.documentElement.scrollHeight : 0;
if (!h) return;
f.style.height = Math.min(360, Math.max(140, h + 8)) + 'px';
} catch (e) { /* 跨源或未就绪:保持 CSS 兜底高度 */ }
};
f.addEventListener('load', function () { fit(); setTimeout(fit, 300); });
fit();
});
}
fitDemoFrames();
window.addEventListener('load', fitDemoFrames);
/* 主题模式(三态):与 PC 完全同一约定 —— localStorage[kole-mode] ∈ light|dark|auto,
auto 跟随系统,反色靠 html.kole-dark(令牌文件里已写好该组,两端共用一份令牌)。
用户在 PC 站选夜间,进移动端站也是夜间。 */
var MODE_KEY = 'kole-mode';
var MODE_LABEL = { light: '日间模式', dark: '夜间模式', auto: '自动模式' };
function readMode() {
try { var v = localStorage.getItem(MODE_KEY); if (v === 'light' || v === 'dark' || v === 'auto') return v; } catch (e) {}
return 'auto';
}
function systemDark() {
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
}
function applyMode(mode) {
var dark = mode === 'dark' || (mode === 'auto' && systemDark());
document.documentElement.classList.toggle('kole-dark', dark);
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
var cur = document.getElementById('m-mode-cur');
if (cur) cur.textContent = MODE_LABEL[mode] || MODE_LABEL.auto;
Array.prototype.forEach.call(document.querySelectorAll('.m-menu-opt'), function (o) {
o.setAttribute('aria-selected', o.getAttribute('data-mode') === mode ? 'true' : 'false');
});
/* 三态图标与 PC 同口径:显式选择时按时态给图标(日间=月亮 / 夜间=太阳),
自动模式下恒为「跟随系统」的显示器图标(此时两个显式图标都不显示)。 */
var moon = document.getElementById('m-icon-moon');
var sun = document.getElementById('m-icon-sun');
var auto = document.getElementById('m-icon-auto');
if (moon) moon.style.display = (mode === 'light') ? '' : 'none';
if (sun) sun.style.display = (mode === 'dark') ? '' : 'none';
if (auto) auto.style.display = (mode === 'auto') ? '' : 'none';
}
function bindModeMenu() {
var trigger = document.getElementById('m-mode-trigger');
var menu = document.getElementById('m-mode-menu');
if (!trigger || !menu) return;
var close = function (refocus) {
menu.classList.add('hidden'); trigger.setAttribute('aria-expanded', 'false');
if (refocus) trigger.focus();
};
trigger.addEventListener('click', function () {
var open = menu.classList.toggle('hidden');
trigger.setAttribute('aria-expanded', open ? 'false' : 'true');
});
trigger.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close(true);
});
Array.prototype.forEach.call(menu.querySelectorAll('.m-menu-opt'), function (opt) {
opt.addEventListener('click', function () {
var mode = opt.getAttribute('data-mode');
try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
applyMode(mode);
close(true);
});
});
document.addEventListener('click', function (e) {
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !trigger.contains(e.target)) close(false);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.classList.contains('hidden')) close(true);
});
/* 自动模式下跟随系统切换 */
if (window.matchMedia) {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
var onChange = function () { if (readMode() === 'auto') applyMode('auto'); };
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
applyMode(readMode());
}
bindModeMenu();
/* 版本下拉:与 PC 顶栏同一实现口径 —— 清单从部署根取(<prefix>/versions.json 与
<prefix>/site/versions.json 两份按序尝试,取有可用条目的那份),拿不到就退回不可点的
纯角标(data-single="1"),不留一个点不开的下拉。
清单不许把用户带去外站:path 只允许 ".."(站点根自身)或 x.y.z 快照目录名。 */
var VERSION_KEY_PATH = '..';
function isValidVersionPath(p) {
if (typeof p !== 'string' || !p.length || p.indexOf('\\') >= 0 || p.indexOf('//') >= 0) return false;
if (p === VERSION_KEY_PATH) return true;
return /^\d+\.\d+\.\d+$/.test(p);
}
/* 清单里的 path 相对部署根,".." = /site/、x.y.z = /x.y.z/site/。
当前页可能不在部署根下(快照页),故先还原部署根绝对路径再算回相对当前页。 */
function versionHref(absSite) {
var i = location.pathname.indexOf('/site/');
var prefix = i >= 0 ? location.pathname.slice(0, i) : '';
if (!prefix || absSite.indexOf(prefix + '/') !== 0) return absSite;
var rest = absSite.slice(prefix.length + 1);
var up = '';
var segs = prefix.slice(1).split('/');
for (var k = 0; k < segs.length; k++) up += "../";
return up + rest;
}
function buildVersionList(doc) {
var raw = doc && doc.versions;
if (!raw || !raw.length) return [];
var i = location.pathname.indexOf('/site/');
var prefix = i >= 0 ? location.pathname.slice(0, i) : '';
var list = [];
for (var k = 0; k < raw.length; k++) {
var v = raw[k] || {};
var ver = v.version ? String(v.version) : '';
var path = v.path ? String(v.path) : '';
if (!ver || !isValidVersionPath(path)) continue;
var absSite = (path === VERSION_KEY_PATH ? '' : '/' + path) + '/site/';
list.push({ version: ver, date: v.date ? String(v.date) : '', latest: !!v.latest, href: versionHref(absSite), absSite: absSite, current: false });
}
if (list.length) {
/* 当前版本 = 绝对路径上命中的最长前缀(最具体的那个)——与 PC 的判定一致,
快照页与根站点都能算对。 */
var here = location.pathname;
var best = -1;
for (var j = 0; j < list.length; j++) {
if (here.indexOf(list[j].absSite) === 0 && list[j].absSite.length > best) best = list[j].absSite.length;
}
for (var m = 0; m < list.length; m++) list[m].current = best >= 0 && list[m].absSite.length === best;
if (best < 0 && list[0]) list[0].current = true;
}
return list;
}
function bindVersionMenu() {
var trigger = document.getElementById('m-ver-trigger');
var menu = document.getElementById('m-ver-menu');
var chip = document.getElementById('m-ver');
if (!trigger || !menu) return;
var close = function (refocus) {
menu.classList.add('hidden'); trigger.setAttribute('aria-expanded', 'false');
if (refocus) trigger.focus();
};
var interactive = false;
var render = function (list) {
interactive = !!(list && list.length);
/* 只有清单拿不到才退回纯角标;只要有一版就是下拉(里面至少能看清当前是哪个版本) */
trigger.setAttribute('data-single', interactive ? '0' : '1');
if (!interactive) { menu.classList.add("hidden"); return; }
var cur = null;
for (var k = 0; k < list.length; k++) if (list[k].current) cur = list[k];
if (chip) chip.textContent = 'v' + ((cur && cur.version) || VERSION);
/* 重建选项(幂等:重复调用不会叠加) */
Array.prototype.slice.call(menu.querySelectorAll('.m-ver-opt')).forEach(function (n) { n.remove(); });
list.forEach(function (it) {
var opt = document.createElement('button');
opt.type = 'button'; opt.className = 'm-ver-opt'; opt.setAttribute('role', 'option');
opt.setAttribute('data-version', it.version);
opt.setAttribute('aria-selected', it.current ? 'true' : 'false');
var tag = it.current ? '当前浏览' : (it.latest ? '最新版' : (it.date || ''));
opt.innerHTML = '<span class="m-ver-opt-num">v' + it.version + '</span><span class="m-ver-opt-tag">' + tag + '</span>';
if (it.href) opt.setAttribute('data-href', it.href);
opt.addEventListener("click", function () {
if (!it.current && it.href) location.href = it.href;
else close(true);
});
menu.appendChild(opt);
});
};
trigger.addEventListener('click', function () {
if (!interactive) return;
if (menu.classList.contains('hidden')) { menu.classList.remove('hidden'); trigger.setAttribute('aria-expanded', 'true'); }
else close(false);
});
trigger.addEventListener('keydown', function (e) {
if (!interactive) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); menu.classList.remove('hidden'); trigger.setAttribute('aria-expanded', 'true'); }
else if (e.key === 'Escape') close(true);
});
document.addEventListener('click', function (e) {
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !trigger.contains(e.target)) close(false);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.classList.contains('hidden')) close(true);
});
/* 两份清单按序尝试(不并行:快照页那份必然 404,并行会把 404 记进控制台,
而站点门禁的「零控制台错误」是硬断言)。 */
if (!window.fetch || location.protocol === 'file:') return;
var i = location.pathname.indexOf('/site/');
if (i < 0) return;
var prefix = location.pathname.slice(0, i);
var urls = [prefix + '/versions.json', prefix + '/site/versions.json'];
var attempt = function (idx) {
if (idx >= urls.length) return;
fetch(urls[idx], { cache: 'no-store' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (doc) { var list = buildVersionList(doc); if (list.length) render(list); else attempt(idx + 1); })
.catch(function () { attempt(idx + 1); });
};
attempt(0);
}
bindVersionMenu();
function done(btn) {
var old = btn.textContent; btn.textContent = '已复制'; btn.classList.add('is-done');
setTimeout(function () { btn.textContent = old; btn.classList.remove('is-done'); }, 1200);
}
document.addEventListener('click', function (e) {
var btn = e.target && e.target.closest ? e.target.closest('.m-copy') : null;
if (!btn) return;
var src = document.getElementById(btn.getAttribute('data-copy') || '');
var text = src ? (src.innerText || src.textContent || '') : '';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { done(btn); }, function () { fallback(btn, text); });
} else { fallback(btn, text); }
});
function fallback(btn, text) {
try {
var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', '');
ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta);
ta.select(); document.execCommand("copy"); document.body.removeChild(ta); done(btn);
} catch (err) { btn.textContent = '复制失败'; }
}
})();
</script>
</body>
</html>