diff --git a/src-next/swipe-cell/README.md b/src-next/swipe-cell/README.md
new file mode 100644
index 000000000..25a979899
--- /dev/null
+++ b/src-next/swipe-cell/README.md
@@ -0,0 +1,139 @@
+# SwipeCell
+
+### Install
+
+```js
+import Vue from 'vue';
+import { SwipeCell } from 'vant';
+
+Vue.use(SwipeCell);
+```
+
+## Usage
+
+### Basic Usage
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+### Custom Content
+
+```html
+
+
+
+
+
+
+
+
+```
+
+### Before Close
+
+```html
+
+
+
+
+
+
+
+
+
+```
+
+```js
+export default {
+ methods: {
+ beforeClose({ position, instance }) {
+ switch (position) {
+ case 'left':
+ case 'cell':
+ case 'outside':
+ instance.close();
+ break;
+ case 'right':
+ Dialog.confirm({
+ message: 'Are you sure to delete?',
+ }).then(() => {
+ instance.close();
+ });
+ break;
+ }
+ },
+ },
+};
+```
+
+## API
+
+### Props
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| name `v2.0.4` | Identifier of SwipeCell | _number \| string_ | - |
+| left-width | Width of the left swipe area | _number \| string_ | `auto` |
+| right-width | Width of the right swipe area | _number \| string_ | `auto` |
+| before-close `v2.3.0` | Callback function before close | _Function_ | - |
+| disabled | Whether to disabled swipe | _boolean_ | `false` |
+| stop-propagation `v2.1.0` | Whether to stop touchmove event propagation | _boolean_ | `false` |
+
+### Slots
+
+| Name | Description |
+| ------- | ------------------------------- |
+| default | custom content |
+| left | content of left scrollable area |
+| right | content of right scrollabe area |
+
+### Events
+
+| Event | Description | Arguments |
+| --- | --- | --- |
+| click | Triggered when clicked | Click positon (`left` `right` `cell` `outside`) |
+| open | Triggered when opened | { position: 'left' \| 'right' , name: string } |
+| close | Triggered when closed | { position: string , name: string } |
+
+### beforeClose Params
+
+| Attribute | Description | Type |
+| --------- | ----------------------------------------------- | ----------- |
+| name | Name | _string_ |
+| position | Click positon (`left` `right` `cell` `outside`) | _string_ |
+| instance | SwipeCell instance | _SwipeCell_ |
+
+### Methods
+
+Use [ref](https://vuejs.org/v2/api/#ref) to get SwipeCell instance and call instance methods
+
+| Name | Description | Attribute | Return value |
+| ----- | --------------- | ------------------------ | ------------ |
+| open | open SwipeCell | position: `left | right` | - |
+| close | close SwipeCell | - | - |
diff --git a/src-next/swipe-cell/README.zh-CN.md b/src-next/swipe-cell/README.zh-CN.md
new file mode 100644
index 000000000..ccaf901ec
--- /dev/null
+++ b/src-next/swipe-cell/README.zh-CN.md
@@ -0,0 +1,155 @@
+# SwipeCell 滑动单元格
+
+### 引入
+
+```js
+import Vue from 'vue';
+import { SwipeCell } from 'vant';
+
+Vue.use(SwipeCell);
+```
+
+## 代码演示
+
+### 基础用法
+
+`SwipeCell`组件提供了`left`和`right`两个插槽,用于定义两侧滑动区域的内容
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+### 自定义内容
+
+`SwipeCell`内容可以嵌套任意内容,比如嵌套一个商品卡片
+
+```html
+
+
+
+
+
+
+
+
+```
+
+### 异步关闭
+
+通过传入`before-close`回调函数,可以自定义两侧滑动内容关闭时的行为
+
+```html
+
+
+
+
+
+
+
+
+
+```
+
+```js
+export default {
+ methods: {
+ // position 为关闭时点击的位置
+ // instance 为对应的 SwipeCell 实例
+ beforeClose({ position, instance }) {
+ switch (position) {
+ case 'left':
+ case 'cell':
+ case 'outside':
+ instance.close();
+ break;
+ case 'right':
+ Dialog.confirm({
+ message: '确定删除吗?',
+ }).then(() => {
+ instance.close();
+ });
+ break;
+ }
+ },
+ },
+};
+```
+
+## API
+
+### Props
+
+| 参数 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| name `v2.0.4` | 标识符,可以在事件参数中获取到 | _number \| string_ | - |
+| left-width | 指定左侧滑动区域宽度,单位为`px` | _number \| string_ | `auto` |
+| right-width | 指定右侧滑动区域宽度,单位为`px` | _number \| string_ | `auto` |
+| before-close `v2.3.0` | 关闭前的回调函数 | _Function_ | - |
+| disabled | 是否禁用滑动 | _boolean_ | `false` |
+| stop-propagation `v2.1.0` | 是否阻止滑动事件冒泡 | _boolean_ | `false` |
+
+### Slots
+
+| 名称 | 说明 |
+| ------- | -------------- |
+| default | 自定义显示内容 |
+| left | 左侧滑动内容 |
+| right | 右侧滑动内容 |
+
+### Events
+
+| 事件名 | 说明 | 回调参数 |
+| ------ | ---------- | -------------------------------------------------- |
+| click | 点击时触发 | 关闭时的点击位置 (`left` `right` `cell` `outside`) |
+| open | 打开时触发 | { position: 'left' \| 'right' , name: string } |
+| close | 关闭时触发 | { position: string , name: string } |
+
+### beforeClose 参数
+
+beforeClose 的第一个参数为对象,对象中包含以下属性:
+
+| 参数名 | 说明 | 类型 |
+| -------- | -------------------------------------------------- | ----------- |
+| name | 标识符 | _string_ |
+| position | 关闭时的点击位置 (`left` `right` `cell` `outside`) | _string_ |
+| instance | SwipeCell 实例,用于调用实例方法 | _SwipeCell_ |
+
+### 方法
+
+通过 ref 可以获取到 SwipeCell 实例并调用实例方法,详见[组件实例方法](#/zh-CN/quickstart#zu-jian-shi-li-fang-fa)
+
+| 方法名 | 说明 | 参数 | 返回值 |
+| ------ | ---------------- | ------------------------ | ------ |
+| open | 打开单元格侧边栏 | position: `left | right` | - |
+| close | 收起单元格侧边栏 | - | - |
+
+## 常见问题
+
+### 在桌面端无法操作组件?
+
+参见[在桌面端使用](#/zh-CN/quickstart#zai-zhuo-mian-duan-shi-yong)。
diff --git a/src-next/swipe-cell/demo/index.vue b/src-next/swipe-cell/demo/index.vue
new file mode 100644
index 000000000..d7ce0629f
--- /dev/null
+++ b/src-next/swipe-cell/demo/index.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-next/swipe-cell/index.js b/src-next/swipe-cell/index.js
new file mode 100644
index 000000000..b7a31aab3
--- /dev/null
+++ b/src-next/swipe-cell/index.js
@@ -0,0 +1,246 @@
+// Utils
+import { createNamespace } from '../utils';
+import { range } from '../utils/format/number';
+import { preventDefault } from '../utils/dom/event';
+
+// Mixins
+import { TouchMixin } from '../mixins/touch';
+import { ClickOutsideMixin } from '../mixins/click-outside';
+
+const [createComponent, bem] = createNamespace('swipe-cell');
+const THRESHOLD = 0.15;
+
+export default createComponent({
+ mixins: [
+ TouchMixin,
+ ClickOutsideMixin({
+ event: 'touchstart',
+ method: 'onClick',
+ }),
+ ],
+
+ props: {
+ // @deprecated
+ // should be removed in next major version, use beforeClose instead
+ onClose: Function,
+ disabled: Boolean,
+ leftWidth: [Number, String],
+ rightWidth: [Number, String],
+ beforeClose: Function,
+ stopPropagation: Boolean,
+ name: {
+ type: [Number, String],
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ offset: 0,
+ dragging: false,
+ };
+ },
+
+ computed: {
+ computedLeftWidth() {
+ return +this.leftWidth || this.getWidthByRef('left');
+ },
+
+ computedRightWidth() {
+ return +this.rightWidth || this.getWidthByRef('right');
+ },
+ },
+
+ mounted() {
+ this.bindTouchEvent(this.$el);
+ },
+
+ methods: {
+ getWidthByRef(ref) {
+ if (this.$refs[ref]) {
+ const rect = this.$refs[ref].getBoundingClientRect();
+ return rect.width;
+ }
+
+ return 0;
+ },
+
+ // @exposed-api
+ open(position) {
+ const offset =
+ position === 'left' ? this.computedLeftWidth : -this.computedRightWidth;
+
+ this.opened = true;
+ this.offset = offset;
+
+ this.$emit('open', {
+ position,
+ name: this.name,
+ // @deprecated
+ // should be removed in next major version
+ detail: this.name,
+ });
+ },
+
+ // @exposed-api
+ close(position) {
+ this.offset = 0;
+
+ if (this.opened) {
+ this.opened = false;
+ this.$emit('close', {
+ position,
+ name: this.name,
+ });
+ }
+ },
+
+ onTouchStart(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ this.startOffset = this.offset;
+ this.touchStart(event);
+ },
+
+ onTouchMove(event) {
+ if (this.disabled) {
+ return;
+ }
+
+ this.touchMove(event);
+
+ if (this.direction === 'horizontal') {
+ this.dragging = true;
+ this.lockClick = true;
+
+ const isPrevent = !this.opened || this.deltaX * this.startOffset < 0;
+
+ if (isPrevent) {
+ preventDefault(event, this.stopPropagation);
+ }
+
+ this.offset = range(
+ this.deltaX + this.startOffset,
+ -this.computedRightWidth,
+ this.computedLeftWidth
+ );
+ }
+ },
+
+ onTouchEnd() {
+ if (this.disabled) {
+ return;
+ }
+
+ if (this.dragging) {
+ this.toggle(this.offset > 0 ? 'left' : 'right');
+ this.dragging = false;
+
+ // compatible with desktop scenario
+ setTimeout(() => {
+ this.lockClick = false;
+ }, 0);
+ }
+ },
+
+ toggle(direction) {
+ const offset = Math.abs(this.offset);
+ const threshold = this.opened ? 1 - THRESHOLD : THRESHOLD;
+ const { computedLeftWidth, computedRightWidth } = this;
+
+ if (
+ computedRightWidth &&
+ direction === 'right' &&
+ offset > computedRightWidth * threshold
+ ) {
+ this.open('right');
+ } else if (
+ computedLeftWidth &&
+ direction === 'left' &&
+ offset > computedLeftWidth * threshold
+ ) {
+ this.open('left');
+ } else {
+ this.close();
+ }
+ },
+
+ onClick(position = 'outside') {
+ this.$emit('click', position);
+
+ if (this.opened && !this.lockClick) {
+ if (this.beforeClose) {
+ this.beforeClose({
+ position,
+ name: this.name,
+ instance: this,
+ });
+ } else if (this.onClose) {
+ this.onClose(position, this, { name: this.name });
+ } else {
+ this.close(position);
+ }
+ }
+ },
+
+ getClickHandler(position, stop) {
+ return (event) => {
+ if (stop) {
+ event.stopPropagation();
+ }
+ this.onClick(position);
+ };
+ },
+
+ genLeftPart() {
+ const content = this.$slots.left?.();
+
+ if (content) {
+ return (
+
+ {content}
+
+ );
+ }
+ },
+
+ genRightPart() {
+ const content = this.$slots.right?.();
+
+ if (content) {
+ return (
+
+ {content}
+
+ );
+ }
+ },
+ },
+
+ render() {
+ const wrapperStyle = {
+ transform: `translate3d(${this.offset}px, 0, 0)`,
+ transitionDuration: this.dragging ? '0s' : '.6s',
+ };
+
+ return (
+
+
+ {this.genLeftPart()}
+ {this.$slots.default?.()}
+ {this.genRightPart()}
+
+
+ );
+ },
+});
diff --git a/src-next/swipe-cell/index.less b/src-next/swipe-cell/index.less
new file mode 100644
index 000000000..64a62c058
--- /dev/null
+++ b/src-next/swipe-cell/index.less
@@ -0,0 +1,29 @@
+@import '../style/var';
+
+.van-swipe-cell {
+ position: relative;
+ overflow: hidden;
+ cursor: grab;
+
+ &__wrapper {
+ transition-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1);
+ transition-property: transform;
+ }
+
+ &__left,
+ &__right {
+ position: absolute;
+ top: 0;
+ height: 100%;
+ }
+
+ &__left {
+ left: 0;
+ transform: translate3d(-100%, 0, 0);
+ }
+
+ &__right {
+ right: 0;
+ transform: translate3d(100%, 0, 0);
+ }
+}
diff --git a/src-next/swipe-cell/test/__snapshots__/demo.spec.js.snap b/src-next/swipe-cell/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..2e1757d61
--- /dev/null
+++ b/src-next/swipe-cell/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,72 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders demo correctly 1`] = `
+
+`;
diff --git a/src-next/swipe-cell/test/__snapshots__/index.spec.js.snap b/src-next/swipe-cell/test/__snapshots__/index.spec.js.snap
new file mode 100644
index 000000000..def90a113
--- /dev/null
+++ b/src-next/swipe-cell/test/__snapshots__/index.spec.js.snap
@@ -0,0 +1,63 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`auto calc width 1`] = `
+
+`;
+
+exports[`drag and show left part 1`] = `
+
+`;
+
+exports[`drag and show left part 2`] = `
+
+`;
+
+exports[`drag and show left part 3`] = `
+
+`;
+
+exports[`drag and show left part 4`] = `
+
+`;
+
+exports[`drag and show right part 1`] = `
+
+`;
+
+exports[`render one side 1`] = `
+
+`;
diff --git a/src-next/swipe-cell/test/demo.spec.js b/src-next/swipe-cell/test/demo.spec.js
new file mode 100644
index 000000000..5c70922b5
--- /dev/null
+++ b/src-next/swipe-cell/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import { snapshotDemo } from '../../../test/demo';
+
+snapshotDemo(Demo);
diff --git a/src-next/swipe-cell/test/index.spec.js b/src-next/swipe-cell/test/index.spec.js
new file mode 100644
index 000000000..0f1d11693
--- /dev/null
+++ b/src-next/swipe-cell/test/index.spec.js
@@ -0,0 +1,229 @@
+import SwipeCell from '..';
+import {
+ mount,
+ triggerDrag,
+ later,
+ mockGetBoundingClientRect,
+} from '../../../test';
+
+const THRESHOLD = 0.15;
+const defaultProps = {
+ propsData: {
+ leftWidth: 100,
+ rightWidth: 100,
+ },
+ scopedSlots: {
+ left: () => 'Left',
+ right: () => 'Right',
+ },
+};
+
+test('drag and show left part', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ triggerDrag(wrapper, 10, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ triggerDrag(wrapper, 50, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ triggerDrag(wrapper, 500, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ triggerDrag(wrapper, 0, 100);
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('drag and show right part', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ triggerDrag(wrapper, -50, 0);
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('on-close prop', () => {
+ let position;
+ let instance;
+
+ const wrapper = mount(SwipeCell, {
+ ...defaultProps,
+ propsData: {
+ ...defaultProps.propsData,
+ onClose(pos, ins) {
+ position = pos;
+ instance = ins;
+ },
+ },
+ });
+
+ wrapper.trigger('click');
+ expect(position).toEqual(undefined);
+
+ wrapper.vm.open('left');
+ wrapper.trigger('click');
+ expect(position).toEqual('cell');
+
+ wrapper.find('.van-swipe-cell__left').trigger('click');
+ expect(position).toEqual('left');
+
+ wrapper.find('.van-swipe-cell__right').trigger('click');
+ expect(position).toEqual('right');
+
+ instance.close();
+ expect(instance.offset).toEqual(0);
+
+ instance.open('left');
+ wrapper.setData({ onClose: null });
+ wrapper.trigger('click');
+ expect(wrapper.vm.offset).toEqual(0);
+});
+
+test('before-close prop', () => {
+ let position;
+ let instance;
+
+ const wrapper = mount(SwipeCell, {
+ ...defaultProps,
+ propsData: {
+ ...defaultProps.propsData,
+ beforeClose(params) {
+ ({ position } = params);
+ ({ instance } = params);
+ },
+ },
+ });
+
+ wrapper.trigger('click');
+ expect(position).toEqual(undefined);
+
+ wrapper.vm.open('left');
+ wrapper.trigger('click');
+ expect(position).toEqual('cell');
+
+ wrapper.find('.van-swipe-cell__left').trigger('click');
+ expect(position).toEqual('left');
+
+ wrapper.find('.van-swipe-cell__right').trigger('click');
+ expect(position).toEqual('right');
+
+ instance.close();
+ expect(wrapper.vm.offset).toEqual(0);
+
+ instance.open('left');
+ wrapper.setData({ beforeClose: null });
+ wrapper.trigger('click');
+ expect(wrapper.vm.offset).toEqual(0);
+});
+
+test('name prop', (done) => {
+ const wrapper = mount(SwipeCell, {
+ ...defaultProps,
+ propsData: {
+ ...defaultProps.propsData,
+ name: 'test',
+ onClose(position, instance, detail) {
+ expect(detail.name).toEqual('test');
+ done();
+ },
+ },
+ });
+
+ wrapper.vm.open('left');
+ wrapper.trigger('click');
+});
+
+test('should reset after drag', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ triggerDrag(wrapper, defaultProps.leftWidth * THRESHOLD - 1, 0);
+ expect(wrapper.vm.offset).toEqual(0);
+});
+
+test('disabled prop', () => {
+ const wrapper = mount(SwipeCell, {
+ propsData: {
+ ...defaultProps.propsData,
+ disabled: true,
+ },
+ });
+
+ triggerDrag(wrapper, 50, 0);
+ expect(wrapper.vm.offset).toEqual(0);
+});
+
+test('auto calc width', async () => {
+ const restoreMock = mockGetBoundingClientRect({
+ width: 50,
+ });
+
+ const wrapper = mount(SwipeCell, {
+ scopedSlots: defaultProps.scopedSlots,
+ });
+
+ await later();
+ triggerDrag(wrapper, 100, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ restoreMock();
+});
+
+test('render one side', async () => {
+ const restoreMock = mockGetBoundingClientRect({
+ width: 50,
+ });
+
+ const wrapper = mount(SwipeCell, {
+ scopedSlots: {
+ left: defaultProps.scopedSlots.left,
+ },
+ });
+
+ await later();
+ triggerDrag(wrapper, 100, 0);
+ expect(wrapper).toMatchSnapshot();
+
+ restoreMock();
+});
+
+test('trigger open event when open left side', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ triggerDrag(wrapper, 50, 0);
+ expect(wrapper.emitted('open')[0][0]).toEqual({
+ name: '',
+ detail: '',
+ position: 'left',
+ });
+});
+
+test('trigger open event when open right side', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ triggerDrag(wrapper, -50, 0);
+ expect(wrapper.emitted('open')[0][0]).toEqual({
+ name: '',
+ detail: '',
+ position: 'right',
+ });
+});
+
+test('trigger close event when closed', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ wrapper.vm.open('left');
+ wrapper.vm.close();
+
+ expect(wrapper.emitted('close')[0][0]).toEqual({
+ name: '',
+ position: undefined,
+ });
+});
+
+test('should not trigger close event again when already closed', () => {
+ const wrapper = mount(SwipeCell, defaultProps);
+
+ wrapper.vm.open('left');
+ wrapper.vm.close();
+ wrapper.vm.close();
+ expect(wrapper.emitted('close').length).toEqual(1);
+});
diff --git a/vant.config.js b/vant.config.js
index 0c9d2eb25..467ff4759 100644
--- a/vant.config.js
+++ b/vant.config.js
@@ -212,10 +212,10 @@ module.exports = {
// path: 'share-sheet',
// title: 'ShareSheet 分享面板',
// },
- // {
- // path: 'swipe-cell',
- // title: 'SwipeCell 滑动单元格',
- // },
+ {
+ path: 'swipe-cell',
+ title: 'SwipeCell 滑动单元格',
+ },
],
},
{
@@ -360,7 +360,7 @@ module.exports = {
// title: 'Sku 商品规格',
// },
],
- }
+ },
],
},
'en-US': {
@@ -546,10 +546,10 @@ module.exports = {
// path: 'share-sheet',
// title: 'ShareSheet',
// },
- // {
- // path: 'swipe-cell',
- // title: 'SwipeCell',
- // },
+ {
+ path: 'swipe-cell',
+ title: 'SwipeCell',
+ },
],
},
{
@@ -694,7 +694,7 @@ module.exports = {
// title: 'Sku',
// },
],
- }
+ },
],
},
},