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`] = ` +
+
+
+
+
+
+
+
单元格
+
内容
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
商品标题
+
描述信息
+
+
+
+
¥2.00
+
+
x2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
单元格
+
内容
+
+
+
+
+
+
+
+`; 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`] = ` +
+
+
Left
+
Right
+
+
+`; + +exports[`drag and show left part 1`] = ` +
+
+
Left
+
Right
+
+
+`; + +exports[`drag and show left part 2`] = ` +
+
+
Left
+
Right
+
+
+`; + +exports[`drag and show left part 3`] = ` +
+
+
Left
+
Right
+
+
+`; + +exports[`drag and show left part 4`] = ` +
+
+
Left
+
Right
+
+
+`; + +exports[`drag and show right part 1`] = ` +
+
+
Left
+
Right
+
+
+`; + +exports[`render one side 1`] = ` +
+
+
Left
+
+
+`; 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', // }, ], - } + }, ], }, },