滑动单元格SwipeCell
列表行左滑露出操作按钮,用于删除、标记等单行操作
反馈 规格 5 · 滑动单元格 SwipeCell 6 端实现 触摸优先
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">
<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/swipecell.css">
<!-- ③ 结构照抄下方任一演示块(类名与 6 端实现一致) -->
演示
每个演示都是真实渲染:预览帧加载 frameworks-mobile/SwipeCell.html?demo=<id>(只显示该演示块),代码是该演示块在演示页里的原文,可复制。全部演示同屏可看 演示页 ↗。
01 组件类型
最常用的形态:左滑露出一个操作;位移超过操作区一半即展开。
查看代码(演示页原文 · 15 行)
<section class="demo-block" data-demo="left-single">
<p class="demo-label">左滑 · 单个操作(direction=left / actions=1,在行上向左拖动)</p>
<div class="demo-frame" data-assert="swipecell-left-single">
<div class="kole-m-swipecell" id="sc-left-1" data-state="closed"
data-behavior="swipe-sets-class:#sc-left-1|is-open">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已完成">完成</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-001</span>
<span class="kole-m-swipecell__desc">2026-09-20 09:12 · 待处理</span>
</div>
</div>
</div>
</section>两个操作为上限;破坏性操作放最右(拇指行程最短,也最不易误触)。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="left-double">
<p class="demo-label">左滑 · 两个操作(actions=2,data-state 由 closed 变 open)</p>
<div class="demo-frame" data-assert="swipecell-left-double">
<div class="kole-m-swipecell" id="sc-left-2" data-state="closed"
data-behavior="swipe-sets-attr:#sc-left-2|data-state|open">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已完成">完成</button>
<button class="kole-m-swipecell__action kole-m-swipecell__action--danger" type="button" aria-label="删除订单">删除</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-002</span>
<span class="kole-m-swipecell__desc">2026-09-20 10:40 · 待处理</span>
</div>
</div>
</div>
</section>右滑露出左侧操作,适合「标记已读」这类非破坏性动作。
查看代码(演示页原文 · 15 行)
<section class="demo-block" data-demo="right-single">
<p class="demo-label">右滑 · 单个操作(direction=right,在行上向右拖动)</p>
<div class="demo-frame" data-assert="swipecell-right-single">
<div class="kole-m-swipecell kole-m-swipecell--right" id="sc-right-1" data-state="closed"
data-behavior="swipe-sets-class:#sc-right-1|is-open|right">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已读">已读</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-003</span>
<span class="kole-m-swipecell__desc">2026-09-20 11:05 · 未读</span>
</div>
</div>
</div>
</section>02 组件状态
展开后操作按钮可键盘聚焦 —— 滑动不是唯一路径(无障碍要求)。
查看代码(演示页原文 · 16 行)
<section class="demo-block" data-demo="open">
<p class="demo-label">已展开态(is-open:操作按钮可键盘聚焦,滑动不是唯一路径)</p>
<div class="demo-frame" data-assert="swipecell-open">
<div class="kole-m-swipecell is-open" id="sc-open" data-state="open" style="--kole-m-swipecell-offset: -144px">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已完成">完成</button>
<button class="kole-m-swipecell__action kole-m-swipecell__action--danger" type="button" aria-label="删除订单">删除</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-004</span>
<span class="kole-m-swipecell__desc">已滑出操作区,点击内容区先收起</span>
</div>
</div>
</div>
</div>
</section>API
props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs
Props
| 名称 | 类型 | 默认值 | 说明 | 必传 |
|---|---|---|---|---|
direction | 'left' | 'right' | 'left' | 变体 direction:左滑露出右侧操作或右滑露出左侧操作(规格 §5.3) | N |
actions | Action[] | [] | 操作项(最多 2 个):{ key, label, tone?, ariaLabel? }(规格 §5.2/§5.3) | N |
open | boolean | false | 受控展开态(closed / open,规格 §5.4) | N |
「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。
事件
| 名称 | 参数 | 说明 |
|---|---|---|
action | key, action | 点击某个操作按钮时触发 |
插槽
| 名称 | 说明 |
|---|---|
default | 行内容(标题、描述;规格 §5.2) |
CSS 变量
组件级变量(在组件样式表里定义)。业务侧可在自己的作用域内覆盖,不必改组件源码。
| 名称 | 默认值 | 说明 |
|---|---|---|
--kole-m-swipecell-offset | 0px | 组件内部默认值,可在业务侧覆盖 |
--kole-m-swipecell-action-width | 72px | 组件内部默认值,可在业务侧覆盖 |
何时使用
- 列表行左滑露出操作按钮,用于删除、标记等单行操作
- 横向位移超过 10px 判定为滑动,纵向位移更大时取消滑动
- 松手后按位移是否超过操作区宽度的一半决定展开或回弹
- 展开状态下点击内容区先收起,不触发内容点击
- 操作按钮为原生 button,具备 aria-label
- 滑动不可作为唯一路径:操作按钮在展开后必须可键盘聚焦
交互与触控
- 横向位移超过 10px 判定为滑动,纵向位移更大时取消滑动
- 松手后按位移是否超过操作区宽度的一半决定展开或回弹
- 展开状态下点击内容区先收起,不触发内容点击
无障碍
- 操作按钮为原生 button,具备 aria-label
- 滑动不可作为唯一路径:操作按钮在展开后必须可键盘聚焦
相似组件
从「该用哪一个」的角度区分;PC 端的对应实现见 PC 文档站。
| 组件 | 何时用它而不是本组件 |
|---|---|
| 动作面板ActionSheet | 需要标题与统一取消的批量选择用动作面板;行内单点操作用滑动单元格 |
| 底部标签栏TabBar | 长列表底部常带标签栏,滑动时要保证列表可滚、标签栏不动 |
规格未定 / 禁止发明
| 类别 | 条目 |
|---|---|
| 禁止发明 | 多行同时展开的互斥规则 |
| 禁止发明 | 滑动与纵向滚动的竞争阈值细节 |
| 规格未定 | 操作区宽度是否有标准档位 |
| 规格未定 | 拖拽中的阴影表现 |
结构(anatomy)
| 字段 | 说明 |
|---|---|
cell | 可滑动的行容器 |
content | 行内容(标题、描述) |
actions | 右侧操作区,随滑动露出 |
action | 单个操作按钮,可标记危险 |
变体维度与类名映射
类名映射由构建脚本从契约 variantClasses 生成,并被 verify:mobile-docs 逐条对照组件 CSS 校验(类/变量必须真实存在)。
| 维度 | 取值 | 对应类名 / 变量 |
|---|---|---|
direction | left / right | left (由数据驱动,无专属类) right .kole-m-swipecell--right |
actions | 1 / 2 | 1 .kole-m-swipecell__action 2 .kole-m-swipecell__action |
代表变体
| 变体 | 标签 |
|---|---|
direction=left · actions=1 | 左滑 · 单个操作 |
direction=left · actions=2 | 左滑 · 两个操作 |
direction=right · actions=1 | 右滑 · 单个操作 |
direction=left · actions=2 | 已展开态 |
用到的令牌
构建时从本组件样式表扫描得出。蓝色为移动端自有令牌,绿色为继承的 PC 令牌(改一处两端生效)。
6 端源码
同一组件的六份实现(生产环境的类名与结构一致,差异只在技术栈写法与单位)。点开查看,右侧可复制。
frameworks-mobile/SwipeCell.css · 纯样式(CSS) · 84 行
/* Kole UI Mobile · SwipeCell 样式 — 对齐移动端规格 §5
滑动单元格:左滑露出右侧操作;横向位移 > 10px 判定为滑动,纵向更大时取消;
松手按是否超过操作区宽度一半决定展开或回弹。 */
.kole-m-swipecell {
/* 操作区宽度:规格 §5.8 明确「是否有标准档位」未知,这里是实现默认值,可被业务覆盖 */
--kole-m-swipecell-offset: 0px;
--kole-m-swipecell-action-width: 72px;
position: relative;
overflow: hidden;
background: var(--kole-color-card-bg);
font-family: var(--kole-font-family);
}
.kole-m-swipecell__actions {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
display: flex;
}
/* 变体 direction=right:操作区在左侧,内容向右滑出 */
.kole-m-swipecell--right .kole-m-swipecell__actions {
inset-inline-end: auto;
inset-inline-start: 0;
}
.kole-m-swipecell__action {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: var(--kole-m-swipecell-action-width);
border: 0;
background: var(--kole-color-table-header-bg);
color: var(--kole-color-text-body);
font-family: inherit;
font-size: var(--kole-m-font-size-label);
cursor: pointer;
touch-action: manipulation;
}
.kole-m-swipecell__action:focus-visible {
outline: 2px solid var(--kole-color-focus-ring);
outline-offset: -2px;
}
.kole-m-swipecell__action:active { filter: brightness(.94); }
.kole-m-swipecell__action--danger {
background: var(--kole-color-error);
color: var(--kole-color-text-inverse);
}
.kole-m-swipecell__content {
position: relative;
z-index: 1;
display: block;
padding: var(--kole-space-12) var(--kole-m-gutter);
background: var(--kole-color-card-bg);
text-align: start;
transform: translateX(var(--kole-m-swipecell-offset));
transition: transform var(--kole-m-duration-slide) var(--kole-ease-slide);
touch-action: pan-y;
}
/* 拖拽中不跟手会有延迟感:取消过渡 */
.kole-m-swipecell.is-dragging .kole-m-swipecell__content { transition: none; }
.kole-m-swipecell__title {
display: block;
font-size: var(--kole-m-font-size-body);
color: var(--kole-color-text-title);
line-height: 1.4;
}
.kole-m-swipecell__desc {
display: block;
margin-top: var(--kole-space-4);
font-size: var(--kole-m-font-size-label);
color: var(--kole-color-text-secondary);
line-height: 1.4;
}
frameworks-mobile/SwipeCell.html · H5 原生(无框架) · 168 行
<!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 · SwipeCell(H5)</title>
<link rel="stylesheet" href="../.design_library/kole-ui-mobile/colors_and_type.css">
<link rel="stylesheet" href="SwipeCell.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; overflow: hidden;
background: var(--kole-color-card-bg); border-block: 1px solid var(--kole-color-border); }
</style>
</head>
<body>
<div class="demo">
<section class="demo-block" data-demo="left-single">
<p class="demo-label">左滑 · 单个操作(direction=left / actions=1,在行上向左拖动)</p>
<div class="demo-frame" data-assert="swipecell-left-single">
<div class="kole-m-swipecell" id="sc-left-1" data-state="closed"
data-behavior="swipe-sets-class:#sc-left-1|is-open">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已完成">完成</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-001</span>
<span class="kole-m-swipecell__desc">2026-09-20 09:12 · 待处理</span>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="left-double">
<p class="demo-label">左滑 · 两个操作(actions=2,data-state 由 closed 变 open)</p>
<div class="demo-frame" data-assert="swipecell-left-double">
<div class="kole-m-swipecell" id="sc-left-2" data-state="closed"
data-behavior="swipe-sets-attr:#sc-left-2|data-state|open">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已完成">完成</button>
<button class="kole-m-swipecell__action kole-m-swipecell__action--danger" type="button" aria-label="删除订单">删除</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-002</span>
<span class="kole-m-swipecell__desc">2026-09-20 10:40 · 待处理</span>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="right-single">
<p class="demo-label">右滑 · 单个操作(direction=right,在行上向右拖动)</p>
<div class="demo-frame" data-assert="swipecell-right-single">
<div class="kole-m-swipecell kole-m-swipecell--right" id="sc-right-1" data-state="closed"
data-behavior="swipe-sets-class:#sc-right-1|is-open|right">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已读">已读</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-003</span>
<span class="kole-m-swipecell__desc">2026-09-20 11:05 · 未读</span>
</div>
</div>
</div>
</section>
<section class="demo-block" data-demo="open">
<p class="demo-label">已展开态(is-open:操作按钮可键盘聚焦,滑动不是唯一路径)</p>
<div class="demo-frame" data-assert="swipecell-open">
<div class="kole-m-swipecell is-open" id="sc-open" data-state="open" style="--kole-m-swipecell-offset: -144px">
<div class="kole-m-swipecell__actions">
<button class="kole-m-swipecell__action" type="button" aria-label="标记为已完成">完成</button>
<button class="kole-m-swipecell__action kole-m-swipecell__action--danger" type="button" aria-label="删除订单">删除</button>
</div>
<div class="kole-m-swipecell__content">
<span class="kole-m-swipecell__title">订单 20260920-004</span>
<span class="kole-m-swipecell__desc">已滑出操作区,点击内容区先收起</span>
</div>
</div>
</div>
</div>
</section>
<script>
/* 手势:纵向位移更大时取消滑动(让位给页面滚动);横向位移 > 10px 才算滑动。
松手按是否超过操作区宽度一半决定展开/回弹;is-open 与 data-state 同一函数写入。 */
(function () {
var THRESHOLD = 10;
function apply(cell, open) {
var right = cell.classList.contains('kole-m-swipecell--right');
var width = cell.querySelector('.kole-m-swipecell__actions').offsetWidth || 72;
var offset = open ? (right ? width : -width) : 0;
cell.style.setProperty('--kole-m-swipecell-offset', offset + 'px');
cell.classList.toggle('is-open', open);
cell.setAttribute('data-state', open ? 'open' : 'closed');
}
document.querySelectorAll('.kole-m-swipecell').forEach(function (cell) {
var startX = 0, startY = 0, dx = 0, dragging = false, decided = false, width = 72;
cell.addEventListener('pointerdown', function (e) {
startX = e.clientX;
startY = e.clientY;
dx = 0;
decided = false;
width = cell.querySelector('.kole-m-swipecell__actions').offsetWidth || 72;
});
cell.addEventListener('pointermove', function (e) {
dx = e.clientX - startX;
var dy = e.clientY - startY;
if (!decided) {
if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return;
decided = true;
if (Math.abs(dy) > Math.abs(dx)) return; /* 纵向为主:交给页面滚动 */
dragging = true;
cell.classList.add('is-dragging');
}
if (!dragging) return;
var clamped = Math.max(-width, Math.min(width, dx));
cell.style.setProperty('--kole-m-swipecell-offset', clamped + 'px');
});
function end() {
if (dragging) {
dragging = false;
cell.classList.remove('is-dragging');
apply(cell, Math.abs(dx) > width / 2);
} else if (cell.classList.contains('is-open')) {
apply(cell, false); /* 展开态点击内容区先收起 */
}
decided = false;
}
cell.addEventListener('pointerup', end);
cell.addEventListener('pointercancel', end);
});
})();
</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>
frameworks-mobile/SwipeCell.jsx · React · 110 行
import React, { useRef, useState } from 'react';
import './SwipeCell.css';
/* 与 CSS 的 --kole-m-swipecell-action-width 默认值保持一致:
展开位移 = 操作区宽度 × 操作数量(规格 §5.8 明确该宽度无标准档位,这里是实现默认值) */
const ACTION_WIDTH = 72;
/* 滑动单元格(移动端)— 规格 §5;横向位移 > 10px 判定为滑动,
纵向位移更大时取消滑动(让位给页面滚动);松手按是否超过操作区宽度一半决定展开/回弹。 */
export default function SwipeCell({
direction = 'left',
actions = [],
open = false,
onAction,
children,
}) {
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const gestures = useRef({ startX: 0, startY: 0, dx: 0, width: ACTION_WIDTH, decided: false, dragging: false });
const rootRef = useRef(null);
/** 展开态位移:右侧操作向左滑(负),左侧操作向右滑(正) */
const openOffset = (direction === 'right' ? 1 : -1) * ACTION_WIDTH * Math.max(1, actions.length);
function measure() {
const el = rootRef.current && rootRef.current.querySelector('.kole-m-swipecell__actions');
return (el && el.offsetWidth) || ACTION_WIDTH;
}
function settle(isOpen) {
const w = measure();
setOffset(isOpen ? (direction === 'right' ? w : -w) : 0);
}
function onPointerDown(e) {
const g = gestures.current;
g.startX = e.clientX;
g.startY = e.clientY;
g.dx = 0;
g.decided = false;
g.dragging = false;
g.width = measure();
}
function onPointerMove(e) {
const g = gestures.current;
g.dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
if (!g.decided) {
if (Math.abs(g.dx) < 10 && Math.abs(dy) < 10) return;
g.decided = true;
if (Math.abs(dy) > Math.abs(g.dx)) return;
g.dragging = true;
setDragging(true);
}
if (!g.dragging) return;
setOffset(Math.max(-g.width, Math.min(g.width, g.dx)));
}
function onPointerEnd() {
const g = gestures.current;
if (g.dragging) {
g.dragging = false;
setDragging(false);
settle(Math.abs(g.dx) > g.width / 2);
} else if (open) {
settle(false);
}
g.decided = false;
}
const appliedOffset = dragging || offset !== 0 ? offset : open ? openOffset : 0;
return (
<div
ref={rootRef}
className={
'kole-m-swipecell' +
(direction === 'right' ? ' kole-m-swipecell--right' : '') +
(dragging ? ' is-dragging' : '') +
(open ? ' is-open' : '')
}
style={{ '--kole-m-swipecell-offset': appliedOffset + 'px' }}
data-state={open ? 'open' : 'closed'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
>
<div className="kole-m-swipecell__actions">
{actions.map((a, i) => (
<button
key={a.key || i}
className={
'kole-m-swipecell__action' +
(a.tone === 'danger' ? ' kole-m-swipecell__action--danger' : '')
}
type="button"
aria-label={a.ariaLabel || a.label}
onClick={() => onAction && onAction(a.key, a)}
>
{a.label}
</button>
))}
</div>
<div className="kole-m-swipecell__content">{children}</div>
</div>
);
}
frameworks-mobile/SwipeCell.vue2.vue · Vue 2 · 105 行
<template>
<div
ref="root"
class="kole-m-swipecell"
:class="[{ 'kole-m-swipecell--right': direction === 'right' }, { 'is-dragging': dragging }, { 'is-open': open }]"
:style="{ '--kole-m-swipecell-offset': appliedOffset + 'px' }"
:data-state="open ? 'open' : 'closed'"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onEnd"
@pointercancel="onEnd"
>
<div class="kole-m-swipecell__actions">
<button
v-for="(a, i) in actions"
:key="a.key || i"
class="kole-m-swipecell__action"
:class="{ 'kole-m-swipecell__action--danger': a.tone === 'danger' }"
type="button"
:aria-label="a.ariaLabel || a.label"
@click="$emit('action', a.key, a)"
>
{{ a.label }}
</button>
</div>
<div class="kole-m-swipecell__content">
<slot></slot>
</div>
</div>
</template>
<script>
/* 与 CSS 默认 --kole-m-swipecell-action-width 一致(规格 §5.8:该宽度无标准档位) */
var ACTION_WIDTH = 72;
export default {
name: 'KoleMSwipeCell',
props: {
direction: { type: String, default: 'left' },
actions: { type: Array, default: function () { return []; } },
open: { type: Boolean, default: false }
},
data: function () {
return {
offset: 0,
dragging: false,
g: { startX: 0, startY: 0, dx: 0, width: ACTION_WIDTH, decided: false, dragging: false }
};
},
computed: {
openOffset: function () {
var sign = this.direction === 'right' ? 1 : -1;
return sign * ACTION_WIDTH * Math.max(1, this.actions.length);
},
appliedOffset: function () {
if (this.dragging || this.offset !== 0) return this.offset;
return this.open ? this.openOffset : 0;
}
},
methods: {
measure: function () {
var el = this.$refs.root && this.$refs.root.querySelector('.kole-m-swipecell__actions');
return (el && el.offsetWidth) || ACTION_WIDTH;
},
settle: function (isOpen) {
var w = this.measure();
this.offset = isOpen ? (this.direction === 'right' ? w : -w) : 0;
},
onDown: function (e) {
this.g.startX = e.clientX;
this.g.startY = e.clientY;
this.g.dx = 0;
this.g.decided = false;
this.g.dragging = false;
this.g.width = this.measure();
},
onMove: function (e) {
this.g.dx = e.clientX - this.g.startX;
var dy = e.clientY - this.g.startY;
if (!this.g.decided) {
if (Math.abs(this.g.dx) < 10 && Math.abs(dy) < 10) return;
this.g.decided = true;
if (Math.abs(dy) > Math.abs(this.g.dx)) return;
this.g.dragging = true;
this.dragging = true;
}
if (!this.g.dragging) return;
this.offset = Math.max(-this.g.width, Math.min(this.g.width, this.g.dx));
},
onEnd: function () {
if (this.g.dragging) {
this.g.dragging = false;
this.dragging = false;
this.settle(Math.abs(this.g.dx) > this.g.width / 2);
} else if (this.open) {
this.settle(false);
}
this.g.decided = false;
}
}
};
</script>
<style src="./SwipeCell.css"></style>
frameworks-mobile/SwipeCell.vue3.vue · Vue 3 · 95 行
<template>
<div
ref="root"
class="kole-m-swipecell"
:class="[{ 'kole-m-swipecell--right': direction === 'right' }, { 'is-dragging': dragging }, { 'is-open': open }]"
:style="{ '--kole-m-swipecell-offset': appliedOffset + 'px' }"
:data-state="open ? 'open' : 'closed'"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onEnd"
@pointercancel="onEnd"
>
<div class="kole-m-swipecell__actions">
<button
v-for="(a, i) in actions"
:key="a.key || i"
class="kole-m-swipecell__action"
:class="{ 'kole-m-swipecell__action--danger': a.tone === 'danger' }"
type="button"
:aria-label="a.ariaLabel || a.label"
@click="emit('action', a.key, a)"
>
{{ a.label }}
</button>
</div>
<div class="kole-m-swipecell__content">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
/* 与 CSS 默认 --kole-m-swipecell-action-width 一致(规格 §5.8:该宽度无标准档位) */
const ACTION_WIDTH = 72;
const props = defineProps({
direction: { type: String, default: 'left' },
actions: { type: Array, default: () => [] },
open: { type: Boolean, default: false }
});
const emit = defineEmits(['action']);
const root = ref(null);
const offset = ref(0);
const dragging = ref(false);
const g = { startX: 0, startY: 0, dx: 0, width: ACTION_WIDTH, decided: false, dragging: false };
const openOffset = computed(() => (props.direction === 'right' ? 1 : -1) * ACTION_WIDTH * Math.max(1, props.actions.length));
const appliedOffset = computed(() => (dragging.value || offset.value !== 0 ? offset.value : props.open ? openOffset.value : 0));
function measure() {
const el = root.value && root.value.querySelector('.kole-m-swipecell__actions');
return (el && el.offsetWidth) || ACTION_WIDTH;
}
function settle(isOpen) {
const w = measure();
offset.value = isOpen ? (props.direction === 'right' ? w : -w) : 0;
}
function onDown(e) {
g.startX = e.clientX;
g.startY = e.clientY;
g.dx = 0;
g.decided = false;
g.dragging = false;
g.width = measure();
}
function onMove(e) {
g.dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
if (!g.decided) {
if (Math.abs(g.dx) < 10 && Math.abs(dy) < 10) return;
g.decided = true;
if (Math.abs(dy) > Math.abs(g.dx)) return;
g.dragging = true;
dragging.value = true;
}
if (!g.dragging) return;
offset.value = Math.max(-g.width, Math.min(g.width, g.dx));
}
function onEnd() {
if (g.dragging) {
g.dragging = false;
dragging.value = false;
settle(Math.abs(g.dx) > g.width / 2);
} else if (props.open) {
settle(false);
}
g.decided = false;
}
</script>
<style src="./SwipeCell.css"></style>
frameworks-mobile/SwipeCell.uniapp.vue · uni-app(跨端:小程序 / App / H5) · 157 行
<template>
<view
class="kole-m-swipecell"
:class="[direction === 'right' ? 'kole-m-swipecell--right' : '', dragging ? 'is-dragging' : '', open ? 'is-open' : '']"
:style="'--kole-m-swipecell-offset:' + appliedOffset + 'rpx'"
:data-state="open ? 'open' : 'closed'"
@touchstart="onStart"
@touchmove="onMove"
@touchend="onEnd"
@touchcancel="onEnd"
>
<view class="kole-m-swipecell__actions">
<view
v-for="(a, i) in actions"
:key="a.key || i"
class="kole-m-swipecell__action"
:class="a.tone === 'danger' ? 'kole-m-swipecell__action--danger' : ''"
role="button"
:aria-label="a.ariaLabel || a.label"
@tap="emit('action', a.key, a)"
>
<text>{{ a.label }}</text>
</view>
</view>
<view class="kole-m-swipecell__content" role="button" @tap="onContentTap">
<slot></slot>
</view>
</view>
</template>
<script setup>
/* uni-app 端 · 滑动单元格(移动端)— 规格 §5
跨端差异:手势用 @touchstart/@touchmove/@touchend(小程序与 App 端无 PointerEvent);
位移以 px 判定(阈值 10px),展开位移换算为 rpx(2rpx = 1px @375pt)。
规格 §5.4「多行同时展开的互斥规则」未定,故本组件只管自身开合。 */
import { computed, ref } from 'vue';
const ACTION_WIDTH_RPX = 144; /* 72px × 2 */
const OPEN_THRESHOLD_PX = 10;
const props = defineProps({
direction: { type: String, default: 'left' },
actions: { type: Array, default: () => [] },
open: { type: Boolean, default: false }
});
const emit = defineEmits(['action']);
const offset = ref(0); /* 单位 rpx */
const dragging = ref(false);
const startX = ref(0);
const startY = ref(0);
const dx = ref(0);
const decided = ref(false);
const isDragging = ref(false);
const openOffset = computed(() => {
var sign = props.direction === 'right' ? 1 : -1;
return sign * ACTION_WIDTH_RPX * Math.max(1, props.actions.length);
});
const appliedOffset = computed(() => (isDragging.value || offset.value !== 0 ? offset.value : props.open ? openOffset.value : 0));
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) {
var p = touch(e);
startX.value = p.x;
startY.value = p.y;
dx.value = 0;
decided.value = false;
isDragging.value = false;
}
function onMove(e) {
var p = touch(e);
dx.value = p.x - startX.value;
var dy = p.y - startY.value;
if (!decided.value) {
if (Math.abs(dx.value) < OPEN_THRESHOLD_PX && Math.abs(dy) < OPEN_THRESHOLD_PX) return;
decided.value = true;
if (Math.abs(dy) > Math.abs(dx.value)) return; /* 纵向为主:让位给页面滚动 */
isDragging.value = true;
dragging.value = true;
}
if (!isDragging.value) return;
var limit = ACTION_WIDTH_RPX * Math.max(1, props.actions.length);
offset.value = Math.max(-limit, Math.min(limit, dx.value * 2));
}
function onEnd() {
if (isDragging.value) {
isDragging.value = false;
dragging.value = false;
var limit = ACTION_WIDTH_RPX * Math.max(1, props.actions.length);
var isOpen = Math.abs(offset.value) > limit / 2;
offset.value = isOpen ? (props.direction === 'right' ? limit : -limit) : 0;
}
decided.value = false;
}
function onContentTap() {
/* 规格 §5.5:展开状态下点击内容区先收起,不触发内容点击 */
if (props.open || offset.value !== 0) offset.value = 0;
}
</script>
<style>
.kole-m-swipecell {
--kole-m-swipecell-offset: 0rpx;
--kole-m-touch-target: 88rpx;
--kole-m-font-size-body: 32rpx;
--kole-m-font-size-label: 28rpx;
--kole-m-gutter: 32rpx;
position: relative;
overflow: hidden;
background-color: var(--kole-color-card-bg);
}
.kole-m-swipecell__actions {
position: absolute;
top: 0;
bottom: 0;
right: 0;
display: flex;
}
.kole-m-swipecell--right .kole-m-swipecell__actions {
right: auto;
left: 0;
}
.kole-m-swipecell__action {
display: flex;
align-items: center;
justify-content: center;
width: 144rpx;
background-color: var(--kole-color-table-header-bg);
color: var(--kole-color-text-body);
font-size: var(--kole-m-font-size-label);
}
.kole-m-swipecell__action--danger {
background-color: var(--kole-color-error);
color: var(--kole-color-text-inverse);
}
.kole-m-swipecell__content {
position: relative;
z-index: 1;
display: block;
padding: 24rpx var(--kole-m-gutter);
background-color: var(--kole-color-card-bg);
transform: translateX(var(--kole-m-swipecell-offset));
transition: transform 240ms cubic-bezier(.32, .72, 0, 1);
}
.kole-m-swipecell.is-dragging .kole-m-swipecell__content { transition: none; }
</style>
测试与回归
断言在真实的 375×640 设备帧里跑(引擎与 PC 侧共用 tests/_runtime.js,触控行为动词来自移动端 tests/mobile/_behaviors.js)。
断言 17 条 · 全部通过 报告 2026-09-20 16:50:33
node site/dev-server.js &
REG_BASE=http://127.0.0.1:3311 node tools/run-mobile-regression.mjs # 全量 5 个组件
npm run verify:mobile-docs # 本页内容完整性 + API 与源码一致性
设计契约
components/swipecell.json(点击展开原始 JSON)
{
"schemaVersion": 1,
"sourceKind": "authored-spec",
"provenance": "authored-in-repo",
"specFile": "spec/移动端规格.md",
"specSection": "5 · 滑动单元格 SwipeCell",
"confidence": "high",
"slug": "swipecell",
"name": "滑动单元格 SwipeCell",
"semanticTypeCandidates": [
"swipe-cell",
"list-item",
"gesture"
],
"variantDimensions": [
{
"name": "direction",
"values": [
"left",
"right"
]
},
{
"name": "actions",
"values": [
"1",
"2"
]
}
],
"representativeVariants": [
{
"direction": "left",
"actions": "1",
"label": "左滑 · 单个操作"
},
{
"direction": "left",
"actions": "2",
"label": "左滑 · 两个操作"
},
{
"direction": "right",
"actions": "1",
"label": "右滑 · 单个操作"
},
{
"direction": "left",
"actions": "2",
"label": "已展开态"
}
],
"anatomy": {
"cell": "可滑动的行容器",
"content": "行内容(标题、描述)",
"actions": "右侧操作区,随滑动露出",
"action": "单个操作按钮,可标记危险"
},
"structurePatterns": {
"direction": "left(左滑露出右侧操作,默认)/ right(右滑露出左侧操作)",
"actions": "1 / 2(操作数量,最多 2 个)"
},
"usageHints": [
"列表行左滑露出操作按钮,用于删除、标记等单行操作",
"横向位移超过 10px 判定为滑动,纵向位移更大时取消滑动",
"松手后按位移是否超过操作区宽度的一半决定展开或回弹",
"展开状态下点击内容区先收起,不触发内容点击",
"操作按钮为原生 button,具备 aria-label",
"滑动不可作为唯一路径:操作按钮在展开后必须可键盘聚焦"
],
"doNotInvent": [
"多行同时展开的互斥规则",
"滑动与纵向滚动的竞争阈值细节"
],
"unknowns": [
"操作区宽度是否有标准档位",
"拖拽中的阴影表现"
],
"interaction": [
"横向位移超过 10px 判定为滑动,纵向位移更大时取消滑动",
"松手后按位移是否超过操作区宽度的一半决定展开或回弹",
"展开状态下点击内容区先收起,不触发内容点击"
],
"accessibility": [
"操作按钮为原生 button,具备 aria-label",
"滑动不可作为唯一路径:操作按钮在展开后必须可键盘聚焦"
],
"api": {
"source": "implementation",
"note": "props / events / slots 为 6 端实现的公共接口(说明文字取自规格对应小节)。字段名与各端源码逐名核对:node tools/verify-mobile-docs.mjs",
"props": [
{
"name": "direction",
"type": "'left' | 'right'",
"default": "'left'",
"desc": "变体 direction:左滑露出右侧操作或右滑露出左侧操作(规格 §5.3)",
"required": false
},
{
"name": "actions",
"type": "Action[]",
"default": "[]",
"desc": "操作项(最多 2 个):{ key, label, tone?, ariaLabel? }(规格 §5.2/§5.3)",
"required": false
},
{
"name": "open",
"type": "boolean",
"default": "false",
"desc": "受控展开态(closed / open,规格 §5.4)",
"required": false
}
],
"events": [
{
"name": "action",
"params": "key, action",
"desc": "点击某个操作按钮时触发"
}
],
"slots": [
{
"name": "default",
"desc": "行内容(标题、描述;规格 §5.2)"
}
],
"requiredNote": "「必传」按严格定义:实现里**没有默认值**时才为 Y(本门禁逐条核对 props 与各端源码的默认值,防止契约与实现脱节)。"
},
"variantClasses": {
"direction": {
"left": [],
"right": [
".kole-m-swipecell--right"
]
},
"actions": {
"1": [
".kole-m-swipecell__action"
],
"2": [
".kole-m-swipecell__action"
]
}
},
"demos": [
{
"id": "left-single",
"group": "01 组件类型",
"title": "左滑 · 单个操作",
"desc": "最常用的形态:左滑露出一个操作;位移超过操作区一半即展开。",
"variant": "direction=left / actions=1"
},
{
"id": "left-double",
"group": "01 组件类型",
"title": "左滑 · 两个操作",
"desc": "两个操作为上限;破坏性操作放最右(拇指行程最短,也最不易误触)。",
"variant": "actions=2"
},
{
"id": "right-single",
"group": "01 组件类型",
"title": "右滑 · 单个操作",
"desc": "右滑露出左侧操作,适合「标记已读」这类非破坏性动作。",
"variant": "direction=right"
},
{
"id": "open",
"group": "02 组件状态",
"title": "已展开态",
"desc": "展开后操作按钮可键盘聚焦 —— 滑动不是唯一路径(无障碍要求)。",
"variant": "open=true"
}
],
"related": [
{
"slug": "actionsheet",
"why": "需要标题与统一取消的批量选择用动作面板;行内单点操作用滑动单元格"
},
{
"slug": "tabbar",
"why": "长列表底部常带标签栏,滑动时要保证列表可滚、标签栏不动"
}
]
}