移动端导航滑动单元格

滑动单元格SwipeCell

列表行左滑露出操作按钮,用于删除、标记等单行操作

反馈 规格 5 · 滑动单元格 SwipeCell 6 端实现 触摸优先

引入(H5 原生;其余 5 端见「快速开始」)
<!-- ① 令牌:PC 令牌 + 移动端 --kole-m-* 合成单文件,引一次 -->
<link rel="stylesheet" href="kole-ui/mobile/tokens.css">

<!-- ② 本组件样式(全量则用 kole-ui/mobile/components/index.css) -->
<link rel="stylesheet" href="kole-ui/mobile/components/swipecell.css">

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

演示

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

01 组件类型

左滑 · 单个操作direction=left / actions=1

最常用的形态:左滑露出一个操作;位移超过操作区一半即展开。

查看代码(演示页原文 · 15 行)
frameworks-mobile/SwipeCell.html · left-single
<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>
左滑 · 两个操作actions=2

两个操作为上限;破坏性操作放最右(拇指行程最短,也最不易误触)。

查看代码(演示页原文 · 16 行)
frameworks-mobile/SwipeCell.html · left-double
<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>
右滑 · 单个操作direction=right

右滑露出左侧操作,适合「标记已读」这类非破坏性动作。

查看代码(演示页原文 · 15 行)
frameworks-mobile/SwipeCell.html · right-single
<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 组件状态

已展开态open=true

展开后操作按钮可键盘聚焦 —— 滑动不是唯一路径(无障碍要求)。

查看代码(演示页原文 · 16 行)
frameworks-mobile/SwipeCell.html · open
<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
actionsAction[][]操作项(最多 2 个):{ key, label, tone?, ariaLabel? }(规格 §5.2/§5.3)N
openbooleanfalse受控展开态(closed / open,规格 §5.4)N

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

事件

名称参数说明
actionkey, action点击某个操作按钮时触发

插槽

名称说明
default行内容(标题、描述;规格 §5.2)

CSS 变量

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

名称默认值说明
--kole-m-swipecell-offset0px组件内部默认值,可在业务侧覆盖
--kole-m-swipecell-action-width72px组件内部默认值,可在业务侧覆盖

何时使用

  • 列表行左滑露出操作按钮,用于删除、标记等单行操作
  • 横向位移超过 10px 判定为滑动,纵向位移更大时取消滑动
  • 松手后按位移是否超过操作区宽度的一半决定展开或回弹
  • 展开状态下点击内容区先收起,不触发内容点击
  • 操作按钮为原生 button,具备 aria-label
  • 滑动不可作为唯一路径:操作按钮在展开后必须可键盘聚焦

交互与触控

  • 横向位移超过 10px 判定为滑动,纵向位移更大时取消滑动
  • 松手后按位移是否超过操作区宽度的一半决定展开或回弹
  • 展开状态下点击内容区先收起,不触发内容点击

无障碍

  • 操作按钮为原生 button,具备 aria-label
  • 滑动不可作为唯一路径:操作按钮在展开后必须可键盘聚焦

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

组件何时用它而不是本组件
动作面板ActionSheet需要标题与统一取消的批量选择用动作面板;行内单点操作用滑动单元格
底部标签栏TabBar长列表底部常带标签栏,滑动时要保证列表可滚、标签栏不动

规格未定 / 禁止发明

类别条目
禁止发明多行同时展开的互斥规则
禁止发明滑动与纵向滚动的竞争阈值细节
规格未定操作区宽度是否有标准档位
规格未定拖拽中的阴影表现

结构(anatomy)

字段说明
cell可滑动的行容器
content行内容(标题、描述)
actions右侧操作区,随滑动露出
action单个操作按钮,可标记危险

变体维度与类名映射

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

维度取值对应类名 / 变量
directionleft / right
left (由数据驱动,无专属类)
right .kole-m-swipecell--right
actions1 / 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 令牌(改一处两端生效)。

--kole-m-duration-slide --kole-m-font-size-body --kole-m-font-size-label --kole-m-gutter --kole-color-card-bg --kole-color-error --kole-color-focus-ring --kole-color-table-header-bg --kole-color-text-body --kole-color-text-inverse --kole-color-text-secondary --kole-color-text-title --kole-font-family --kole-space-12 --kole-space-4 --kole-ease-slide --kole-m-swipecell-action-width --kole-m-swipecell-offset

6 端源码

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

frameworks-mobile/SwipeCell.css · 纯样式(CSS) · 84 行
frameworks-mobile/SwipeCell.css
/* 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 行
frameworks-mobile/SwipeCell.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Kole UI Mobile · 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 行
frameworks-mobile/SwipeCell.jsx
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 行
frameworks-mobile/SwipeCell.vue2.vue
<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 行
frameworks-mobile/SwipeCell.vue3.vue
<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 行
frameworks-mobile/SwipeCell.uniapp.vue
<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": "长列表底部常带标签栏,滑动时要保证列表可滚、标签栏不动"
    }
  ]
}