Regression / regression (push) Canceled after 0s
本提交含两条并行工作线,因互相咬合(package.json scripts、regression.yml、 npm run build 链)无法按文件干净拆分,故合并为一次自洽提交。 ## 详情页减重(本轮主任务:修复「AI 干太重」) - 导航收敛:宽屏只用右侧目录、≤1200px 只用页内 sticky 导航,纯 CSS 媒体查询 实现(不引入 JS 宽度监听)。此前两套导航同时可见,active 状态互相打架。 - 入口去重:标题区由「查看示例/在线测试/契约 JSON」三个减为「在线测试」一个; 契约 JSON 归入实现资源;删除示例底部重复的「在线测试」。 - 示例工具栏:删除与目录锚点重复的示例下拉选择器;「全部展开代码」只在 示例数 >1 时出现(103 个组件里 49 个仅 1 个示例,此前恒显示)。 - 重复文案:示例区两句同义导语合并为一句。 - 首页 CTA 由 5 个减为 2 个(浏览组件/快速开始),测试总览入口挂到已有的 通过率统计卡上,不再另占 Hero 按钮。 - 统一详情取数:抽出 fetchDetail/loadDetail 作为 details/*.json 的唯一路径, FAQ 不再自行 fetch 一遍,与组件页共用缓存与失败兜底。 ## i18n - 删除 12 组重复键(含整段 FAQ 说明),字典 604 → 585 唯一键。 - 删除本次改动产生的 6 个死键。 - verify-i18n.mjs 新增 `unique dictionary keys` 断言:重复键在对象字面量里 是静默的后值覆盖,此前无从发现;现由门禁拦住。 ## 文档事实修正(实测为准) - TESTING/CONTRIBUTING:79 → 103 组件;旧断言数改为回指 tests/report.json。 - PLATFORMS:移动端 108 文件/18 端 → 282 文件/47 端;契约 5 → 47; 令牌 17 → 15;uni-app SFC 21 → 50。 - AGENTS:断言 1405/18 页 → 1464/103 页(PC)、807/47 页(移动端)。 - package.json:YOUR-ACCOUNT 占位 → gitea 实址与 kole-ui.mymoyu.top。 ## 品牌标识(并行会话成果,一并入库) - brand-mark.json 收归真源,build:brand 生成 favicon 与单色 SVG; PC 与移动端共用资产,verify:brand 24 条断言。 - regression.yml 增加 verify:brand 步骤。 ## 门禁与验收 新增工具:verify-component-page.mjs(86 条真实浏览器断言,随详情页改造同步 更新为「只允许一套导航可见」)、verify-brand-mark.mjs、lib/i18n-dead-keys.mjs (只读诊断)。 回归:PC 100%(1464/1464,103 页,N/A 50)· 移动端 100%(807/807,47 页)。 门禁:component-page 86 · i18n 17 · brand 24 · routes all · smoke all · theme OK · isolation 31 · nav all · examples 9 · api-docs 10 · mobile-docs 12。 已知未做:i18n 另有约 44 条历史死键(非本次产生),已记为 ROADMAP S8-P6; 顶栏与悬浮区的两个主题入口为刻意设计(verify-theme 断言其互斥),未删。
1610 lines
76 KiB
HTML
1610 lines
76 KiB
HTML
<!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="../../assets/kole-mark.svg">
|
||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
||
<meta name="theme-color" content="#1C1F26" media="(prefers-color-scheme: dark)">
|
||
<title>下拉刷新 PullRefresh — Kole UI Mobile</title>
|
||
<meta name="description" content="下拉刷新 PullRefresh:移动端组件(6 端实现:CSS / H5 原生 / React / Vue 2 / Vue 3 / uni-app)。与 PC 端物理隔离:类名 kole-m-、令牌 --kole-m-。规格来源:移动端规格 4 · 下拉刷新 PullRefresh。">
|
||
<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" data-brand-mark="mono" aria-hidden="true"></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" href="mobile-sticky.html">吸顶容器<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 is-active" href="pullrefresh.html" aria-current="page">下拉刷新<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;">PullRefresh</span></h1>
|
||
<p>列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口</p>
|
||
<p style="margin-top: 8px;" class="m-chips">
|
||
<span class="m-chip m">反馈</span>
|
||
<span class="m-chip">规格 4 · 下拉刷新 PullRefresh</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><!-- ① 令牌: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/pullrefresh.css">
|
||
|
||
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) --></code></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="demo">演示</h2>
|
||
<p class="m-sub">每个演示都是真实渲染:预览帧加载 <code>frameworks-mobile/PullRefresh.html?demo=<id></code>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 <a href="../../../frameworks-mobile/PullRefresh.html" target="_blank">演示页 ↗</a>。</p>
|
||
<h3 class="m-demo-group">01 组件类型</h3>
|
||
<div class="m-demo" id="demo-pull">
|
||
<div class="m-demo-head"><span class="m-demo-title">下拉中</span><span class="m-chip">state=pull</span></div>
|
||
<p class="m-demo-desc">未达阈值:指示器随位移旋转,提示「下拉即可刷新」。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/PullRefresh.html?demo=pull" title="下拉刷新 PullRefresh · 下拉中" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 14 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/PullRefresh.html · pull</span><button class="m-copy" type="button" data-copy="code-demo-1">复制</button></div>
|
||
<pre id="code-demo-1"><code><section class="demo-block" data-demo="pull">
|
||
<p class="demo-label">下拉中(state=pull,未达阈值 60px)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-pull">
|
||
<div class="kole-m-pullrefresh is-pulling" style="--kole-m-pullrefresh-offset: 36px">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">下拉即可刷新</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-ready">
|
||
<div class="m-demo-head"><span class="m-demo-title">已达阈值</span><span class="m-chip">state=ready</span></div>
|
||
<p class="m-demo-desc">达到 60px 提示改为「松开立即刷新」,松手即进入刷新。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/PullRefresh.html?demo=ready" title="下拉刷新 PullRefresh · 已达阈值" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 14 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/PullRefresh.html · ready</span><button class="m-copy" type="button" data-copy="code-demo-2">复制</button></div>
|
||
<pre id="code-demo-2"><code><section class="demo-block" data-demo="ready">
|
||
<p class="demo-label">已达阈值(state=ready,松手立即刷新)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-ready">
|
||
<div class="kole-m-pullrefresh is-ready" style="--kole-m-pullrefresh-offset: 60px">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">松开立即刷新</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-refreshing">
|
||
<div class="m-demo-head"><span class="m-demo-title">刷新中</span><span class="m-chip">state=refreshing</span></div>
|
||
<p class="m-demo-desc">指示器持续旋转、下拉不回弹;期间再次下拉不重复触发。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/PullRefresh.html?demo=refreshing" title="下拉刷新 PullRefresh · 刷新中" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 15 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/PullRefresh.html · refreshing</span><button class="m-copy" type="button" data-copy="code-demo-3">复制</button></div>
|
||
<pre id="code-demo-3"><code><section class="demo-block" data-demo="refreshing">
|
||
<p class="demo-label">刷新中(state=refreshing,在框内向下拖动触发)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-refreshing">
|
||
<div class="kole-m-pullrefresh" id="pr-gesture" data-behavior="pull-triggers:#pr-gesture|is-refreshing">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">下拉即可刷新</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
|
||
<button class="kole-m-pullrefresh__fallback" type="button" id="pr-refresh">刷新列表</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section></code></pre></div>
|
||
</details>
|
||
</div>
|
||
<div class="m-demo" id="demo-done">
|
||
<div class="m-demo-head"><span class="m-demo-title">完成提示</span><span class="m-chip">state=done</span></div>
|
||
<p class="m-demo-desc">完成态短暂停留后复位(停留时长规格未定,见「规格未定」)。</p>
|
||
<div class="m-demo-stage"><iframe src="../../../frameworks-mobile/PullRefresh.html?demo=done" title="下拉刷新 PullRefresh · 完成提示" loading="lazy"></iframe></div>
|
||
<details class="m-details"><summary>查看代码(演示页原文 · 15 行)</summary>
|
||
<div class="m-code"><div class="m-code-head"><span>frameworks-mobile/PullRefresh.html · done</span><button class="m-copy" type="button" data-copy="code-demo-4">复制</button></div>
|
||
<pre id="code-demo-4"><code><section class="demo-block" data-demo="done">
|
||
<p class="demo-label">完成提示(state=done)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-done">
|
||
<div class="kole-m-pullrefresh is-done" style="--kole-m-pullrefresh-offset: 60px">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">刷新完成</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-003(新)</li><li>订单 20260920-001</li></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section></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>threshold</code></td><td><code>number</code></td><td><code>60</code></td><td>触发阈值:浏览器端(css/h5/react/vue2/vue3)单位为 px,默认 60;uni-app 端单位为 rpx,默认 120(≈ 60px @375pt)</td><td>N</td></tr>
|
||
<tr><td><code>refreshing</code></td><td><code>boolean</code></td><td><code>false</code></td><td>刷新中状态;由业务侧在 refresh 事件后置位(规格 §4.4)</td><td>N</td></tr>
|
||
<tr><td><code>done</code></td><td><code>boolean</code></td><td><code>false</code></td><td>完成提示状态(规格 §4.4)</td><td>N</td></tr>
|
||
<tr><td><code>fallbackLabel</code></td><td><code>string</code></td><td><code>'刷新列表'</code></td><td>非手势等价入口的文案(规格 §4.6)</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>refresh</code></td><td><code>—</code></td><td>松手达到阈值时触发(手动点等价入口同样触发)</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>列表内容;组件在外层包裹手势与指示区(规格 §4.2)</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-pullrefresh-threshold</code></td><td><code>60px</code></td><td>组件内部默认值,可在业务侧覆盖</td></tr>
|
||
<tr><td><code>--kole-m-pullrefresh-offset</code></td><td><code>0px</code></td><td>组件内部默认值,可在业务侧覆盖</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="usage">何时使用</h2>
|
||
<ul>
|
||
<li>列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口</li>
|
||
<li>手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑</li>
|
||
<li>达到阈值后松手进入 refreshing;未达阈值松手回弹</li>
|
||
<li>刷新期间再次下拉不重复触发</li>
|
||
<li>指示区 role=status + aria-live=polite,状态文字变化被读屏播报</li>
|
||
<li>需保留一个非手势的等价入口(如列表底部的刷新按钮)</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="interaction">交互与触控</h2>
|
||
<ul>
|
||
<li>手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑</li>
|
||
<li>达到阈值后松手进入 refreshing;未达阈值松手回弹</li>
|
||
<li>刷新期间再次下拉不重复触发</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="a11y">无障碍</h2>
|
||
<ul>
|
||
<li>指示区 role=status + aria-live=polite,状态文字变化被读屏播报</li>
|
||
<li>需保留一个非手势的等价入口(如列表底部的刷新按钮)</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="swipecell.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;">SwipeCell</em></td><td>同属手势交互:下拉是纵向、滑动是横向,两者靠方向判定让位</td></tr>
|
||
<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>刷新常与顶栏配合(刷新后更新标题或角标)</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>
|
||
</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>viewport</code></td><td>包裹滚动内容的容器,负责手势</td></tr>
|
||
<tr><td><code>indicator</code></td><td>下拉指示区,含箭头或旋转图标与状态文字</td></tr>
|
||
<tr><td><code>content</code></td><td>业务内容</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>state</code></td><td>pull / ready / refreshing / done</td><td><div><b>pull</b> <span class="m-cls">.is-pulling</span></div><div><b>ready</b> <span class="m-cls">.is-ready</span></div><div><b>refreshing</b> <span class="m-cls">.is-refreshing</span></div><div><b>done</b> <span class="m-cls">.is-done</span></div></td></tr>
|
||
<tr><td><code>threshold</code></td><td>60</td><td><div><b>60</b> <code>--kole-m-pullrefresh-threshold</code></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>state=pull · threshold=60</code></td><td>下拉中</td></tr>
|
||
<tr><td><code>state=ready · threshold=60</code></td><td>已达阈值</td></tr>
|
||
<tr><td><code>state=refreshing · threshold=60</code></td><td>刷新中</td></tr>
|
||
<tr><td><code>state=done · threshold=60</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-duration-refresh</span>
|
||
<span class="m-chip m" title="次级说明">--kole-m-font-size-label</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.18:1(原 #52C41A 仅 2.27:1)">--kole-color-success</span>
|
||
<span class="m-chip inherit" title="正文文字 · 14.0:1">--kole-color-text-body</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="">--kole-ease-out</span>
|
||
<span class="m-chip inherit" title="">--kole-font-family</span>
|
||
<span class="m-chip inherit" title="">--kole-icon-size-20</span>
|
||
<span class="m-chip inherit" title="">--kole-space-8</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-pullrefresh-offset</span>
|
||
<span class="m-chip" title="组件级变量(可在业务侧覆盖)">--kole-m-pullrefresh-threshold</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="m-block">
|
||
<h2 id="sources">6 端源码</h2>
|
||
<p class="m-sub">同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。</p>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/PullRefresh.css · 纯样式(CSS) · 100 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/PullRefresh.css</span><button class="m-copy" type="button" data-copy="code-19">复制</button></div>
|
||
<pre id="code-19"><code>/* Kole UI Mobile · PullRefresh 样式 — 对齐移动端规格 §4
|
||
下拉刷新:Pointer Events 手势,位移以纵向为主;阈值默认 60px;
|
||
指示区 role=status + aria-live=polite,状态文字变化被读屏播报。 */
|
||
|
||
.kole-m-pullrefresh {
|
||
/* 组件级变量:阈值与实时位移(业务侧可覆盖阈值) */
|
||
--kole-m-pullrefresh-threshold: 60px;
|
||
--kole-m-pullrefresh-offset: 0px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
font-family: var(--kole-font-family);
|
||
color: var(--kole-color-text-body);
|
||
}
|
||
|
||
.kole-m-pullrefresh__indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--kole-space-8);
|
||
height: var(--kole-m-pullrefresh-offset);
|
||
overflow: hidden;
|
||
color: var(--kole-color-text-secondary);
|
||
font-size: var(--kole-m-font-size-label);
|
||
transition: height var(--kole-m-duration-refresh) var(--kole-ease-out);
|
||
}
|
||
|
||
/* 刷新中:指示区固定为阈值高度,旋转图标常显 */
|
||
.kole-m-pullrefresh.is-refreshing .kole-m-pullrefresh__indicator {
|
||
height: var(--kole-m-pullrefresh-threshold);
|
||
}
|
||
|
||
/* 完成提示:短暂显示后由业务侧复位 */
|
||
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__indicator {
|
||
height: var(--kole-m-pullrefresh-threshold);
|
||
color: var(--kole-color-success);
|
||
}
|
||
|
||
.kole-m-pullrefresh__spinner {
|
||
flex: 0 0 auto;
|
||
width: var(--kole-icon-size-20);
|
||
height: var(--kole-icon-size-20);
|
||
box-sizing: border-box;
|
||
border: 2px solid var(--kole-color-border);
|
||
border-top-color: var(--kole-color-brand);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.kole-m-pullrefresh.is-pulling .kole-m-pullrefresh__spinner {
|
||
transform: rotate(calc(var(--kole-m-pullrefresh-offset) * 3));
|
||
}
|
||
|
||
.kole-m-pullrefresh.is-ready .kole-m-pullrefresh__spinner,
|
||
.kole-m-pullrefresh.is-refreshing .kole-m-pullrefresh__spinner,
|
||
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__spinner {
|
||
animation: kole-m-pullrefresh-spin 800ms linear infinite;
|
||
}
|
||
|
||
.kole-m-pullrefresh.is-ready .kole-m-pullrefresh__spinner {
|
||
border-top-color: var(--kole-color-success);
|
||
}
|
||
|
||
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__spinner {
|
||
border-color: var(--kole-color-success);
|
||
animation: none;
|
||
}
|
||
|
||
@keyframes kole-m-pullrefresh-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.kole-m-pullrefresh__content {
|
||
transform: translateY(var(--kole-m-pullrefresh-offset));
|
||
transition: transform var(--kole-m-duration-refresh) var(--kole-ease-out);
|
||
/* 纵向滚动交给浏览器,横向手势不被本组件截获 */
|
||
touch-action: pan-y;
|
||
}
|
||
|
||
/* 非手势的等价入口(规格 §4.6):手势不可用时仍能刷新 */
|
||
.kole-m-pullrefresh__fallback {
|
||
display: block;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
min-height: var(--kole-m-touch-target);
|
||
border: 0;
|
||
border-top: 1px solid var(--kole-color-border);
|
||
background: var(--kole-color-card-bg);
|
||
color: var(--kole-color-brand);
|
||
font-family: inherit;
|
||
font-size: var(--kole-m-font-size-label);
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.kole-m-pullrefresh__fallback:focus-visible {
|
||
outline: 2px solid var(--kole-color-focus-ring);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.kole-m-pullrefresh__fallback:active { background: var(--kole-color-brand-bg); }
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/PullRefresh.html · H5 原生(无框架) · 172 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/PullRefresh.html</span><button class="m-copy" type="button" data-copy="code-20">复制</button></div>
|
||
<pre id="code-20"><code><!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 · PullRefresh(H5)</title>
|
||
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
|
||
<link rel="stylesheet" href="PullRefresh.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); }
|
||
.demo-frame { position: relative; max-width: 375px; margin: 0 auto; height: 200px;
|
||
overflow: hidden; background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
|
||
.demo-list { margin: 0; padding: 0; list-style: none; }
|
||
.demo-list li { padding: var(--kole-space-12) var(--kole-m-gutter); border-bottom: 1px solid var(--kole-color-border);
|
||
.demo-block[hidden] { display: none; }
|
||
body.demo-single .demo-frame { margin-top: 0; }
|
||
body.demo-single .demo-label:first-child { padding-top: var(--kole-space-12); }
|
||
font-size: var(--kole-m-font-size-label); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="demo">
|
||
<section class="demo-block" data-demo="pull">
|
||
<p class="demo-label">下拉中(state=pull,未达阈值 60px)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-pull">
|
||
<div class="kole-m-pullrefresh is-pulling" style="--kole-m-pullrefresh-offset: 36px">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">下拉即可刷新</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="demo-block" data-demo="ready">
|
||
<p class="demo-label">已达阈值(state=ready,松手立即刷新)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-ready">
|
||
<div class="kole-m-pullrefresh is-ready" style="--kole-m-pullrefresh-offset: 60px">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">松开立即刷新</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="demo-block" data-demo="refreshing">
|
||
<p class="demo-label">刷新中(state=refreshing,在框内向下拖动触发)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-refreshing">
|
||
<div class="kole-m-pullrefresh" id="pr-gesture" data-behavior="pull-triggers:#pr-gesture|is-refreshing">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">下拉即可刷新</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-001</li><li>订单 20260920-002</li></ul>
|
||
<button class="kole-m-pullrefresh__fallback" type="button" id="pr-refresh">刷新列表</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="demo-block" data-demo="done">
|
||
<p class="demo-label">完成提示(state=done)</p>
|
||
<div class="demo-frame" data-assert="pullrefresh-done">
|
||
<div class="kole-m-pullrefresh is-done" style="--kole-m-pullrefresh-offset: 60px">
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">刷新完成</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<ul class="demo-list"><li>订单 20260920-003(新)</li><li>订单 20260920-001</li></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
/* 手势:Pointer Events;位移以纵向为主 —— 横向分量更大时放弃本次下拉(规格 §4.5)。
|
||
达到阈值松手 → 同步进入 refreshing(可断言),随后异步进入 done 并复位。 */
|
||
(function () {
|
||
var root = document.getElementById('pr-gesture');
|
||
if (!root) return;
|
||
var THRESHOLD = 60;
|
||
var startY = 0, startX = 0, dy = 0, active = false;
|
||
|
||
function setOffset(v) { root.style.setProperty('--kole-m-pullrefresh-offset', v + 'px'); }
|
||
function setState(state, text) {
|
||
root.classList.remove('is-pulling', 'is-ready', 'is-refreshing', 'is-done');
|
||
if (state) root.classList.add('is-' + state);
|
||
var t = root.querySelector('.kole-m-pullrefresh__text');
|
||
if (t && text) t.textContent = text;
|
||
}
|
||
|
||
root.addEventListener('pointerdown', function (e) {
|
||
if (root.classList.contains('is-refreshing')) return;
|
||
active = true;
|
||
startY = e.clientY;
|
||
startX = e.clientX;
|
||
dy = 0;
|
||
});
|
||
|
||
root.addEventListener('pointermove', function (e) {
|
||
if (!active) return;
|
||
dy = e.clientY - startY;
|
||
var dx = Math.abs(e.clientX - startX);
|
||
if (dx > Math.abs(dy)) { active = false; return; }
|
||
if (dy <= 0) { setOffset(0); setState(null); return; }
|
||
setOffset(dy);
|
||
if (dy >= THRESHOLD) setState('ready', '松开立即刷新');
|
||
else setState('pulling', '下拉即可刷新');
|
||
});
|
||
|
||
function release() {
|
||
if (!active) return;
|
||
active = false;
|
||
if (dy >= THRESHOLD) refresh();
|
||
else { setOffset(0); setState(null, '下拉即可刷新'); }
|
||
}
|
||
|
||
root.addEventListener('pointerup', release);
|
||
root.addEventListener('pointercancel', release);
|
||
|
||
function refresh() {
|
||
setOffset(THRESHOLD);
|
||
setState('refreshing', '正在刷新…');
|
||
setTimeout(function () {
|
||
setState('done', '刷新完成');
|
||
setTimeout(function () { setOffset(0); setState(null, '下拉即可刷新'); }, 600);
|
||
}, 300);
|
||
}
|
||
|
||
var btn = document.getElementById('pr-refresh');
|
||
if (btn) btn.addEventListener('click', refresh);
|
||
})();
|
||
</script>
|
||
<script>
|
||
/* ?demo=<id> → 只显示该演示块:文档站为每个演示单独起一个 375×640 预览帧。
|
||
无参数时全部显示 —— 测试页与回归走无参数路径,行为不变。 */
|
||
(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');
|
||
/* 单块模式去掉 min-height:100vh —— 否则内容高度随帧高变化(帧高→vh→内容高)形成反馈环,
|
||
自适应量到的永远是视口高度而不是内容高度。 */
|
||
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>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/PullRefresh.jsx · React · 94 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/PullRefresh.jsx</span><button class="m-copy" type="button" data-copy="code-21">复制</button></div>
|
||
<pre id="code-21"><code>import React, { useRef, useState } from 'react';
|
||
import './PullRefresh.css';
|
||
|
||
/* 下拉刷新(移动端)— 规格 §4;Pointer Events 手势,阈值默认 60px,
|
||
松手达阈值进入 refreshing;横向分量更大时放弃本次下拉。 */
|
||
export default function PullRefresh({
|
||
threshold = 60,
|
||
refreshing = false,
|
||
done = false,
|
||
onRefresh,
|
||
fallbackLabel = '刷新列表',
|
||
children,
|
||
}) {
|
||
const [offset, setOffset] = useState(0);
|
||
const [drag, setDrag] = useState('');
|
||
const start = useRef({ y: 0, x: 0 });
|
||
const dy = useRef(0);
|
||
const active = useRef(false);
|
||
|
||
const stateText = refreshing
|
||
? '正在刷新…'
|
||
: done
|
||
? '刷新完成'
|
||
: drag === 'ready'
|
||
? '松开立即刷新'
|
||
: '下拉即可刷新';
|
||
|
||
const cls =
|
||
'kole-m-pullrefresh' +
|
||
(refreshing ? ' is-refreshing' : done ? ' is-done' : drag ? ' is-' + drag : '');
|
||
|
||
function onPointerDown(e) {
|
||
if (refreshing) return;
|
||
active.current = true;
|
||
start.current = { y: e.clientY, x: e.clientX };
|
||
dy.current = 0;
|
||
}
|
||
|
||
function onPointerMove(e) {
|
||
if (!active.current) return;
|
||
dy.current = e.clientY - start.current.y;
|
||
if (Math.abs(e.clientX - start.current.x) > Math.abs(dy.current)) {
|
||
active.current = false;
|
||
return;
|
||
}
|
||
if (dy.current <= 0) {
|
||
setOffset(0);
|
||
setDrag('');
|
||
return;
|
||
}
|
||
setOffset(dy.current);
|
||
setDrag(dy.current >= threshold ? 'ready' : 'pulling');
|
||
}
|
||
|
||
function release() {
|
||
if (!active.current) return;
|
||
active.current = false;
|
||
if (dy.current >= threshold) {
|
||
setOffset(threshold);
|
||
setDrag('');
|
||
if (onRefresh) onRefresh();
|
||
} else {
|
||
setOffset(0);
|
||
setDrag('');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cls}
|
||
style={{ '--kole-m-pullrefresh-offset': offset + 'px' }}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={release}
|
||
onPointerCancel={release}
|
||
>
|
||
<div className="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span className="kole-m-pullrefresh__spinner" aria-hidden="true" />
|
||
<span className="kole-m-pullrefresh__text">{stateText}</span>
|
||
</div>
|
||
<div className="kole-m-pullrefresh__content">
|
||
{children}
|
||
<button
|
||
className="kole-m-pullrefresh__fallback"
|
||
type="button"
|
||
onClick={() => onRefresh && onRefresh()}
|
||
>
|
||
{fallbackLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/PullRefresh.vue2.vue · Vue 2 · 87 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/PullRefresh.vue2.vue</span><button class="m-copy" type="button" data-copy="code-22">复制</button></div>
|
||
<pre id="code-22"><code><template>
|
||
<div
|
||
class="kole-m-pullrefresh"
|
||
:class="stateClass"
|
||
:style="{ '--kole-m-pullrefresh-offset': offset + 'px' }"
|
||
@pointerdown="onDown"
|
||
@pointermove="onMove"
|
||
@pointerup="release"
|
||
@pointercancel="release"
|
||
>
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">{{ stateText }}</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<slot></slot>
|
||
<button class="kole-m-pullrefresh__fallback" type="button" @click="$emit('refresh')">
|
||
{{ fallbackLabel }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: 'KoleMPullRefresh',
|
||
props: {
|
||
threshold: { type: Number, default: 60 },
|
||
refreshing: { type: Boolean, default: false },
|
||
done: { type: Boolean, default: false },
|
||
fallbackLabel: { type: String, default: '刷新列表' }
|
||
},
|
||
data: function () {
|
||
return { offset: 0, drag: '', start: { y: 0, x: 0 }, dy: 0, active: false };
|
||
},
|
||
computed: {
|
||
stateClass: function () {
|
||
if (this.refreshing) return 'is-refreshing';
|
||
if (this.done) return 'is-done';
|
||
return this.drag ? 'is-' + this.drag : '';
|
||
},
|
||
stateText: function () {
|
||
if (this.refreshing) return '正在刷新…';
|
||
if (this.done) return '刷新完成';
|
||
return this.drag === 'ready' ? '松开立即刷新' : '下拉即可刷新';
|
||
}
|
||
},
|
||
methods: {
|
||
onDown: function (e) {
|
||
if (this.refreshing) return;
|
||
this.active = true;
|
||
this.start = { y: e.clientY, x: e.clientX };
|
||
this.dy = 0;
|
||
},
|
||
onMove: function (e) {
|
||
if (!this.active) return;
|
||
this.dy = e.clientY - this.start.y;
|
||
if (Math.abs(e.clientX - this.start.x) > Math.abs(this.dy)) {
|
||
this.active = false;
|
||
return;
|
||
}
|
||
if (this.dy <= 0) {
|
||
this.offset = 0;
|
||
this.drag = '';
|
||
return;
|
||
}
|
||
this.offset = this.dy;
|
||
this.drag = this.dy >= this.threshold ? 'ready' : 'pulling';
|
||
},
|
||
release: function () {
|
||
if (!this.active) return;
|
||
this.active = false;
|
||
if (this.dy >= this.threshold) {
|
||
this.offset = this.threshold;
|
||
this.drag = '';
|
||
this.$emit('refresh');
|
||
} else {
|
||
this.offset = 0;
|
||
this.drag = '';
|
||
}
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style src="./PullRefresh.css"></style>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/PullRefresh.vue3.vue · Vue 3 · 88 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/PullRefresh.vue3.vue</span><button class="m-copy" type="button" data-copy="code-23">复制</button></div>
|
||
<pre id="code-23"><code><template>
|
||
<div
|
||
class="kole-m-pullrefresh"
|
||
:class="stateClass"
|
||
:style="{ '--kole-m-pullrefresh-offset': offset + 'px' }"
|
||
@pointerdown="onDown"
|
||
@pointermove="onMove"
|
||
@pointerup="release"
|
||
@pointercancel="release"
|
||
>
|
||
<div class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<span class="kole-m-pullrefresh__spinner" aria-hidden="true"></span>
|
||
<span class="kole-m-pullrefresh__text">{{ stateText }}</span>
|
||
</div>
|
||
<div class="kole-m-pullrefresh__content">
|
||
<slot></slot>
|
||
<button class="kole-m-pullrefresh__fallback" type="button" @click="emit('refresh')">
|
||
{{ fallbackLabel }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue';
|
||
|
||
const props = defineProps({
|
||
threshold: { type: Number, default: 60 },
|
||
refreshing: { type: Boolean, default: false },
|
||
done: { type: Boolean, default: false },
|
||
fallbackLabel: { type: String, default: '刷新列表' }
|
||
});
|
||
const emit = defineEmits(['refresh']);
|
||
|
||
const offset = ref(0);
|
||
const drag = ref('');
|
||
const start = ref({ y: 0, x: 0 });
|
||
const dy = ref(0);
|
||
const active = ref(false);
|
||
|
||
const stateClass = computed(() => {
|
||
if (props.refreshing) return 'is-refreshing';
|
||
if (props.done) return 'is-done';
|
||
return drag.value ? 'is-' + drag.value : '';
|
||
});
|
||
const stateText = computed(() => {
|
||
if (props.refreshing) return '正在刷新…';
|
||
if (props.done) return '刷新完成';
|
||
return drag.value === 'ready' ? '松开立即刷新' : '下拉即可刷新';
|
||
});
|
||
|
||
function onDown(e) {
|
||
if (props.refreshing) return;
|
||
active.value = true;
|
||
start.value = { y: e.clientY, x: e.clientX };
|
||
dy.value = 0;
|
||
}
|
||
function onMove(e) {
|
||
if (!active.value) return;
|
||
dy.value = e.clientY - start.value.y;
|
||
if (Math.abs(e.clientX - start.value.x) > Math.abs(dy.value)) {
|
||
active.value = false;
|
||
return;
|
||
}
|
||
if (dy.value <= 0) {
|
||
offset.value = 0;
|
||
drag.value = '';
|
||
return;
|
||
}
|
||
offset.value = dy.value;
|
||
drag.value = dy.value >= props.threshold ? 'ready' : 'pulling';
|
||
}
|
||
function release() {
|
||
if (!active.value) return;
|
||
active.value = false;
|
||
if (dy.value >= props.threshold) {
|
||
offset.value = props.threshold;
|
||
drag.value = '';
|
||
emit('refresh');
|
||
} else {
|
||
offset.value = 0;
|
||
drag.value = '';
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style src="./PullRefresh.css"></style>
|
||
</code></pre>
|
||
</div>
|
||
</details>
|
||
<details class="m-details">
|
||
<summary>frameworks-mobile/PullRefresh.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 154 行</summary>
|
||
<div class="m-code">
|
||
<div class="m-code-head"><span>frameworks-mobile/PullRefresh.uniapp.vue</span><button class="m-copy" type="button" data-copy="code-24">复制</button></div>
|
||
<pre id="code-24"><code><template>
|
||
<view
|
||
class="kole-m-pullrefresh"
|
||
:class="stateClass"
|
||
:style="'--kole-m-pullrefresh-offset:' + offset + 'rpx'"
|
||
@touchstart="onStart"
|
||
@touchmove="onMove"
|
||
@touchend="onEnd"
|
||
@touchcancel="onEnd"
|
||
>
|
||
<view class="kole-m-pullrefresh__indicator" role="status" aria-live="polite">
|
||
<view class="kole-m-pullrefresh__spinner" :class="spinClass"></view>
|
||
<text class="kole-m-pullrefresh__text">{{ stateText }}</text>
|
||
</view>
|
||
<view class="kole-m-pullrefresh__content">
|
||
<slot></slot>
|
||
<!-- 规格 §4.6:需保留一个非手势的等价入口 -->
|
||
<view class="kole-m-pullrefresh__fallback" role="button" @tap="emit('refresh')">
|
||
<text>{{ fallbackLabel }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
/* uni-app 端 · 下拉刷新(移动端)— 规格 §4
|
||
跨端差异(重要):小程序 / App 端没有 PointerEvent,手势只能用
|
||
@touchstart / @touchmove / @touchend,位移取 touch 事件的 clientY;
|
||
阈值 60px 在 rpx 下折算为 120rpx(750rpx = 视口宽度)。
|
||
规格 §4.7 明确「与页面整体下拉的竞争规则」未定,故不对 touchmove 调 preventDefault,
|
||
纵向滚动仍由宿主页面的 scroll-view / page 决定。 */
|
||
import { computed, ref } from 'vue';
|
||
|
||
const props = defineProps({
|
||
/* 触发阈值:uni-app 端的单位是 rpx(750rpx = 视口宽度),默认 120rpx ≈ 60px @375pt。
|
||
契约 api.props.threshold 说明里写明了「浏览器端 px / 本端 rpx」的单位差异。 */
|
||
threshold: { type: Number, default: 120 },
|
||
refreshing: { type: Boolean, default: false },
|
||
done: { type: Boolean, default: false },
|
||
fallbackLabel: { type: String, default: '刷新列表' }
|
||
});
|
||
const emit = defineEmits(['refresh']);
|
||
|
||
const offset = ref(0);
|
||
const drag = ref('');
|
||
const startY = ref(0);
|
||
const startX = ref(0);
|
||
const dy = ref(0);
|
||
const active = ref(false);
|
||
|
||
const stateClass = computed(() => {
|
||
if (props.refreshing) return 'is-refreshing';
|
||
if (props.done) return 'is-done';
|
||
return drag.value ? 'is-' + drag.value : '';
|
||
});
|
||
const stateText = computed(() => {
|
||
if (props.refreshing) return '正在刷新…';
|
||
if (props.done) return '刷新完成';
|
||
return drag.value === 'ready' ? '松开立即刷新' : '下拉即可刷新';
|
||
});
|
||
const spinClass = computed(() => 'is-' + (props.refreshing || props.done ? 'spinning' : 'idle'));
|
||
|
||
function touch(e) {
|
||
var t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {};
|
||
return { x: t.clientX != null ? t.clientX : t.pageX, y: t.clientY != null ? t.clientY : t.pageY };
|
||
}
|
||
function onStart(e) {
|
||
if (props.refreshing) return;
|
||
var p = touch(e);
|
||
active.value = true;
|
||
startX.value = p.x;
|
||
startY.value = p.y;
|
||
dy.value = 0;
|
||
}
|
||
function onMove(e) {
|
||
if (!active.value) return;
|
||
var p = touch(e);
|
||
dy.value = p.y - startY.value; /* 单次 touchmove 内用像素判方向,仅用于阈值换算 */
|
||
var dx = Math.abs(p.x - startX.value);
|
||
if (dx > Math.abs(dy.value)) { active.value = false; return; }
|
||
var rpx = dy.value * 2; /* 1px ≈ 2rpx(375pt 视口) */
|
||
if (rpx <= 0) { offset.value = 0; drag.value = ''; return; }
|
||
offset.value = rpx;
|
||
drag.value = rpx >= props.threshold ? 'ready' : 'pulling';
|
||
}
|
||
function onEnd() {
|
||
if (!active.value) return;
|
||
active.value = false;
|
||
if (offset.value >= props.threshold) {
|
||
offset.value = props.threshold;
|
||
drag.value = '';
|
||
emit('refresh');
|
||
} else {
|
||
offset.value = 0;
|
||
drag.value = '';
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
.kole-m-pullrefresh {
|
||
--kole-m-pullrefresh-offset: 0rpx;
|
||
--kole-m-touch-target: 88rpx;
|
||
--kole-m-font-size-label: 28rpx;
|
||
--kole-m-gutter: 32rpx;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.kole-m-pullrefresh__indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: var(--kole-m-pullrefresh-offset);
|
||
overflow: hidden;
|
||
color: var(--kole-color-text-secondary);
|
||
font-size: var(--kole-m-font-size-label);
|
||
transition: height 300ms ease-out;
|
||
}
|
||
|
||
.kole-m-pullrefresh.is-refreshing .kole-m-pullrefresh__indicator,
|
||
.kole-m-pullrefresh.is-done .kole-m-pullrefresh__indicator { height: 120rpx; }
|
||
|
||
.kole-m-pullrefresh__spinner {
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
border: 4rpx solid var(--kole-color-border);
|
||
border-top-color: var(--kole-color-brand);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.kole-m-pullrefresh__spinner.is-spinning { animation: kole-m-pullrefresh-spin 800ms linear infinite; }
|
||
|
||
@keyframes kole-m-pullrefresh-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.kole-m-pullrefresh__content {
|
||
transform: translateY(var(--kole-m-pullrefresh-offset));
|
||
transition: transform 300ms ease-out;
|
||
}
|
||
|
||
.kole-m-pullrefresh__fallback {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: var(--kole-m-touch-target);
|
||
border-top: 1rpx solid var(--kole-color-border);
|
||
background-color: var(--kole-color-card-bg);
|
||
color: var(--kole-color-brand);
|
||
font-size: var(--kole-m-font-size-label);
|
||
}
|
||
</style>
|
||
</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">断言 15 条 · 全部通过</span>
|
||
<span class="m-status">报告 2026-09-22 03:04:00</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 &
|
||
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/pullrefresh.json(点击展开原始 JSON)</summary>
|
||
<div class="m-code"><pre><code>{
|
||
"schemaVersion": 1,
|
||
"sourceKind": "authored-spec",
|
||
"provenance": "authored-in-repo",
|
||
"specFile": "spec/移动端规格.md",
|
||
"specSection": "4 · 下拉刷新 PullRefresh",
|
||
"confidence": "high",
|
||
"slug": "pullrefresh",
|
||
"name": "下拉刷新 PullRefresh",
|
||
"semanticTypeCandidates": [
|
||
"pull-refresh",
|
||
"gesture",
|
||
"list-viewport"
|
||
],
|
||
"variantDimensions": [
|
||
{
|
||
"name": "state",
|
||
"values": [
|
||
"pull",
|
||
"ready",
|
||
"refreshing",
|
||
"done"
|
||
]
|
||
},
|
||
{
|
||
"name": "threshold",
|
||
"values": [
|
||
"60"
|
||
]
|
||
}
|
||
],
|
||
"representativeVariants": [
|
||
{
|
||
"state": "pull",
|
||
"threshold": "60",
|
||
"label": "下拉中"
|
||
},
|
||
{
|
||
"state": "ready",
|
||
"threshold": "60",
|
||
"label": "已达阈值"
|
||
},
|
||
{
|
||
"state": "refreshing",
|
||
"threshold": "60",
|
||
"label": "刷新中"
|
||
},
|
||
{
|
||
"state": "done",
|
||
"threshold": "60",
|
||
"label": "完成提示"
|
||
}
|
||
],
|
||
"anatomy": {
|
||
"viewport": "包裹滚动内容的容器,负责手势",
|
||
"indicator": "下拉指示区,含箭头或旋转图标与状态文字",
|
||
"content": "业务内容"
|
||
},
|
||
"structurePatterns": {
|
||
"state": "pull(下拉中)/ ready(已达阈值)/ refreshing(刷新中)/ done(完成提示)",
|
||
"threshold": "触发阈值,默认 60px"
|
||
},
|
||
"usageHints": [
|
||
"列表顶部下拉手势触发刷新,移动端最常见的列表刷新入口",
|
||
"手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑",
|
||
"达到阈值后松手进入 refreshing;未达阈值松手回弹",
|
||
"刷新期间再次下拉不重复触发",
|
||
"指示区 role=status + aria-live=polite,状态文字变化被读屏播报",
|
||
"需保留一个非手势的等价入口(如列表底部的刷新按钮)"
|
||
],
|
||
"doNotInvent": [
|
||
"惯性与阻尼曲线",
|
||
"与页面整体下拉(浏览器级)的竞争规则"
|
||
],
|
||
"unknowns": [
|
||
"刷新超时的提示形式",
|
||
"完成提示的停留时长"
|
||
],
|
||
"interaction": [
|
||
"手势使用 Pointer Events,位移以纵向为主;横向位移更大时让位给页面横滑",
|
||
"达到阈值后松手进入 refreshing;未达阈值松手回弹",
|
||
"刷新期间再次下拉不重复触发"
|
||
],
|
||
"accessibility": [
|
||
"指示区 role=status + aria-live=polite,状态文字变化被读屏播报",
|
||
"需保留一个非手势的等价入口(如列表底部的刷新按钮)"
|
||
],
|
||
"api": {
|
||
"source": "implementation",
|
||
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
|
||
"props": [
|
||
{
|
||
"name": "threshold",
|
||
"type": "number",
|
||
"default": "60",
|
||
"desc": "触发阈值:浏览器端(css/h5/react/vue2/vue3)单位为 px,默认 60;uni-app 端单位为 rpx,默认 120(≈ 60px @375pt)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "refreshing",
|
||
"type": "boolean",
|
||
"default": "false",
|
||
"desc": "刷新中状态;由业务侧在 refresh 事件后置位(规格 §4.4)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "done",
|
||
"type": "boolean",
|
||
"default": "false",
|
||
"desc": "完成提示状态(规格 §4.4)",
|
||
"required": false
|
||
},
|
||
{
|
||
"name": "fallbackLabel",
|
||
"type": "string",
|
||
"default": "'刷新列表'",
|
||
"desc": "非手势等价入口的文案(规格 §4.6)",
|
||
"required": false
|
||
}
|
||
],
|
||
"events": [
|
||
{
|
||
"name": "refresh",
|
||
"params": "—",
|
||
"desc": "松手达到阈值时触发(手动点等价入口同样触发)"
|
||
}
|
||
],
|
||
"slots": [
|
||
{
|
||
"name": "default",
|
||
"desc": "列表内容;组件在外层包裹手势与指示区(规格 §4.2)"
|
||
}
|
||
],
|
||
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。"
|
||
},
|
||
"variantClasses": {
|
||
"state": {
|
||
"pull": [
|
||
".is-pulling"
|
||
],
|
||
"ready": [
|
||
".is-ready"
|
||
],
|
||
"refreshing": [
|
||
".is-refreshing"
|
||
],
|
||
"done": [
|
||
".is-done"
|
||
]
|
||
},
|
||
"threshold": {
|
||
"60": [
|
||
"--kole-m-pullrefresh-threshold"
|
||
]
|
||
}
|
||
},
|
||
"demos": [
|
||
{
|
||
"id": "pull",
|
||
"group": "01 组件类型",
|
||
"title": "下拉中",
|
||
"desc": "未达阈值:指示器随位移旋转,提示「下拉即可刷新」。",
|
||
"variant": "state=pull"
|
||
},
|
||
{
|
||
"id": "ready",
|
||
"group": "01 组件类型",
|
||
"title": "已达阈值",
|
||
"desc": "达到 60px 提示改为「松开立即刷新」,松手即进入刷新。",
|
||
"variant": "state=ready"
|
||
},
|
||
{
|
||
"id": "refreshing",
|
||
"group": "01 组件类型",
|
||
"title": "刷新中",
|
||
"desc": "指示器持续旋转、下拉不回弹;期间再次下拉不重复触发。",
|
||
"variant": "state=refreshing"
|
||
},
|
||
{
|
||
"id": "done",
|
||
"group": "01 组件类型",
|
||
"title": "完成提示",
|
||
"desc": "完成态短暂停留后复位(停留时长规格未定,见「规格未定」)。",
|
||
"variant": "state=done"
|
||
}
|
||
],
|
||
"related": [
|
||
{
|
||
"slug": "swipecell",
|
||
"why": "同属手势交互:下拉是纵向、滑动是横向,两者靠方向判定让位"
|
||
},
|
||
{
|
||
"slug": "navbar",
|
||
"why": "刷新常与顶栏配合(刷新后更新标题或角标)"
|
||
}
|
||
]
|
||
}</code></pre></div>
|
||
</details>
|
||
</section>
|
||
|
||
<footer class="m-foot">
|
||
<p>生成物:本页由 <code>node tools/build-mobile.mjs</code> 从契约 <code>components/pullrefresh.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>
|