From aa09ba0fd9c65c5b5dc2ef723b2891590332c71e Mon Sep 17 00:00:00 2001 From: chenjiahan Date: Mon, 6 Jul 2020 16:31:59 +0800 Subject: [PATCH] feat: ActionSheet component --- src-next/action-sheet/README.md | 191 ++++++++++++++++ src-next/action-sheet/README.zh-CN.md | 205 ++++++++++++++++++ src-next/action-sheet/demo/index.vue | 154 +++++++++++++ src-next/action-sheet/index.js | 167 ++++++++++++++ src-next/action-sheet/index.less | 89 ++++++++ .../test/__snapshots__/demo.spec.js.snap | 37 ++++ .../test/__snapshots__/index.spec.js.snap | 40 ++++ src-next/action-sheet/test/demo.spec.js | 4 + src-next/action-sheet/test/index.spec.js | 186 ++++++++++++++++ src-next/mixins/popup/index.js | 2 - src-next/popup/index.js | 10 +- vant.config.js | 16 +- 12 files changed, 1090 insertions(+), 11 deletions(-) create mode 100644 src-next/action-sheet/README.md create mode 100644 src-next/action-sheet/README.zh-CN.md create mode 100644 src-next/action-sheet/demo/index.vue create mode 100644 src-next/action-sheet/index.js create mode 100644 src-next/action-sheet/index.less create mode 100644 src-next/action-sheet/test/__snapshots__/demo.spec.js.snap create mode 100644 src-next/action-sheet/test/__snapshots__/index.spec.js.snap create mode 100644 src-next/action-sheet/test/demo.spec.js create mode 100644 src-next/action-sheet/test/index.spec.js diff --git a/src-next/action-sheet/README.md b/src-next/action-sheet/README.md new file mode 100644 index 000000000..a79ac6ccc --- /dev/null +++ b/src-next/action-sheet/README.md @@ -0,0 +1,191 @@ +# ActionSheet + +### Install + +```js +import Vue from 'vue'; +import { ActionSheet } from 'vant'; + +Vue.use(ActionSheet); +``` + +## Usage + +### Basic Usage + +Use `actions` prop to set options of action-sheet. + +```html + + +``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + show: false, + actions: [ + { name: 'Option 1' }, + { name: 'Option 2' }, + { name: 'Option 3' }, + ], + }; + }, + methods: { + onSelect(item) { + this.show = false; + Toast(item.name); + }, + }, +}; +``` + +### Show Cancel Button + +```html + +``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + show: false, + actions: [ + { name: 'Option 1' }, + { name: 'Option 2' }, + { name: 'Option 3' }, + ], + }; + }, + methods: { + onCancel() { + Toast('cancel'); + }, + }, +}; +``` + +### Show Description + +```html + +``` + +```js +export default { + data() { + return { + show: false, + actions: [ + { name: 'Option 1' }, + { name: 'Option 2' }, + { name: 'Option 3', subname: 'Description' }, + ], + }; + }, +}; +``` + +### Option Status + +```html + +``` + +```js +export default { + data() { + return { + show: false, + actions: [ + { name: 'Colored Option', color: '#07c160' }, + { name: 'Disabled Option', disabled: true }, + { name: 'Loading Option', loading: true }, + ], + }; + }, +}; +``` + +### Custom Panel + +```html + +
Content
+
+ + +``` + +## API + +### Props + +| Attribute | Description | Type | Default | +| --- | --- | --- | --- | +| v-model (value) | Whether to show ActionSheet | _boolean_ | `false` | +| actions | Options | _Action[]_ | `[]` | +| title | Title | _string_ | - | +| cancel-text | Text of cancel button | _string_ | - | +| description `v2.2.8` | Description above the options | _string_ | - | +| close-icon `v2.2.13` | Close icon name | _string_ | `cross` | +| duration `v2.0.3` | Transition duration, unit second | _number \| string_ | `0.3` | +| round `v2.0.9` | Whether to show round corner | _boolean_ | `true` | +| overlay | Whether to show overlay | _boolean_ | `true` | +| lock-scroll | Whether to lock background scroll | _boolean_ | `true` | +| lazy-render | Whether to lazy render util appeared | _boolean_ | `true` | +| close-on-popstate `v2.5.3` | Whether to close when popstate | _boolean_ | `false` | +| close-on-click-action | Whether to close when click action | _boolean_ | `false` | +| close-on-click-overlay | Whether to close when click overlay | _boolean_ | `true` | +| safe-area-inset-bottom | Whether to enable bottom safe area adaptation | _boolean_ | `true` | +| get-container | Return the mount node for ActionSheet | _string \| () => Element_ | - | + +### Data Structure of Action + +| Key | Description | Type | +| --------- | ---------------------------- | --------- | +| name | Title | _string_ | +| subname | Subtitle | _string_ | +| color | Text color | _string_ | +| className | className for the option | _any_ | +| loading | Whether to be loading status | _boolean_ | +| disabled | Whether to be disabled | _boolean_ | + +### Events + +| Event | Description | Arguments | +| --- | --- | --- | +| select | Triggered when click option | _action: Action, index: number_ | +| cancel | Triggered when click cancel button | - | +| open | Triggered when open ActionSheet | - | +| close | Triggered when close ActionSheet | - | +| opened | Triggered when opened ActionSheet | - | +| closed | Triggered when closed ActionSheet | - | +| click-overlay | Triggered when click overlay | - | diff --git a/src-next/action-sheet/README.zh-CN.md b/src-next/action-sheet/README.zh-CN.md new file mode 100644 index 000000000..0993e4e71 --- /dev/null +++ b/src-next/action-sheet/README.zh-CN.md @@ -0,0 +1,205 @@ +# ActionSheet 动作面板 + +### 介绍 + +底部弹起的模态面板,包含与当前情境相关的多个选项。 + +### 引入 + +```js +import Vue from 'vue'; +import { ActionSheet } from 'vant'; + +Vue.use(ActionSheet); +``` + +## 代码演示 + +### 基础用法 + +动作面板通过 `actions` 属性来定义选项,`actions` 属性是一个由对象构成的数组,数组中的每个对象配置一列,对象格式见文档下方表格。 + +```html + + +``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + show: false, + actions: [{ name: '选项一' }, { name: '选项二' }, { name: '选项三' }], + }; + }, + methods: { + onSelect(item) { + // 默认情况下点击选项时不会自动收起 + // 可以通过 close-on-click-action 属性开启自动收起 + this.show = false; + Toast(item.name); + }, + }, +}; +``` + +### 展示取消按钮 + +设置 `cancel-text` 属性后,会在底部展示取消按钮,点击后关闭当前面板并触发 `cancel` 事件。 + +```html + +``` + +```js +import { Toast } from 'vant'; + +export default { + data() { + return { + show: false, + actions: [{ name: '选项一' }, { name: '选项二' }, { name: '选项三' }], + }; + }, + methods: { + onCancel() { + Toast('取消'); + }, + }, +}; +``` + +### 展示描述信息 + +通过 `description` 可以在菜单顶部显示描述信息,通过选项的 `subname` 属性可以在选项文字的右侧展示描述信息。 + +```html + +``` + +```js +export default { + data() { + return { + show: false, + actions: [ + { name: '选项一' }, + { name: '选项二' }, + { name: '选项三', subname: '描述信息' }, + ], + }; + }, +}; +``` + +### 选项状态 + +可以通过 `loading` 和 `disabled` 将选项设置为加载状态或禁用状态,或者通过`color`设置选项的颜色 + +```html + +``` + +```js +export default { + data() { + return { + show: false, + actions: [ + { name: '着色选项', color: '#07c160' }, + { name: '禁用选项', disabled: true }, + { name: '加载选项', loading: true }, + ], + }; + }, +}; +``` + +### 自定义面板 + +通过插槽可以自定义面板的展示内容,同时可以使用`title`属性展示标题栏 + +```html + +
内容
+
+ + +``` + +## API + +### Props + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| v-model (value) | 是否显示动作面板 | _boolean_ | `false` | +| actions | 面板选项列表 | _Action[]_ | `[]` | +| title | 顶部标题 | _string_ | - | +| cancel-text | 取消按钮文字 | _string_ | - | +| description `v2.2.8` | 选项上方的描述信息 | _string_ | - | +| close-icon `v2.2.13` | 关闭[图标名称](#/zh-CN/icon)或图片链接 | _string_ | `cross` | +| duration `v2.0.3` | 动画时长,单位秒 | _number \| string_ | `0.3` | +| round `v2.0.9` | 是否显示圆角 | _boolean_ | `true` | +| overlay | 是否显示遮罩层 | _boolean_ | `true` | +| lock-scroll | 是否锁定背景滚动 | _boolean_ | `true` | +| lazy-render | 是否在显示弹层时才渲染节点 | _boolean_ | `true` | +| close-on-popstate `v2.5.3` | 是否在页面回退时自动关闭 | _boolean_ | `false` | +| close-on-click-action | 是否在点击选项后关闭 | _boolean_ | `false` | +| close-on-click-overlay | 是否在点击遮罩层后关闭 | _boolean_ | `true` | +| safe-area-inset-bottom | 是否开启[底部安全区适配](#/zh-CN/quickstart#di-bu-an-quan-qu-gua-pei) | _boolean_ | `true` | +| get-container | 指定挂载的节点,[用法示例](#/zh-CN/popup#zhi-ding-gua-zai-wei-zhi) | _string \| () => Element_ | - | + +### Action 数据结构 + +`actions` 属性是一个由对象构成的数组,数组中的每个对象配置一列,对象可以包含以下值: + +| 键名 | 说明 | 类型 | +| --------- | ------------------------ | --------- | +| name | 标题 | _string_ | +| subname | 二级标题 | _string_ | +| color | 选项文字颜色 | _string_ | +| className | 为对应列添加额外的 class | _any_ | +| loading | 是否为加载状态 | _boolean_ | +| disabled | 是否为禁用状态 | _boolean_ | + +### Events + +| 事件名 | 说明 | 回调参数 | +| --- | --- | --- | +| select | 点击选项时触发,禁用或加载状态下不会触发 | _action: Action, index: number_ | +| cancel | 点击取消按钮时触发 | - | +| open | 打开面板时触发 | - | +| close | 关闭面板时触发 | - | +| opened | 打开面板且动画结束后触发 | - | +| closed | 关闭面板且动画结束后触发 | - | +| click-overlay | 点击遮罩层时触发 | - | + +## 常见问题 + +### 引入时提示 dependencies not found? + +在 1.x 版本中,动作面板的组件名为`Actionsheet`,从 2.0 版本开始更名为`ActionSheet`,请注意区分。 diff --git a/src-next/action-sheet/demo/index.vue b/src-next/action-sheet/demo/index.vue new file mode 100644 index 000000000..24b0e31fe --- /dev/null +++ b/src-next/action-sheet/demo/index.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src-next/action-sheet/index.js b/src-next/action-sheet/index.js new file mode 100644 index 000000000..caf4df4b2 --- /dev/null +++ b/src-next/action-sheet/index.js @@ -0,0 +1,167 @@ +// Utils +import { createNamespace } from '../utils'; + +// Mixins +import { popupMixinProps } from '../mixins/popup'; + +// Components +import Icon from '../icon'; +import Popup from '../popup'; +import Loading from '../loading'; + +const [createComponent, bem] = createNamespace('action-sheet'); + +export default createComponent({ + props: { + ...popupMixinProps, + title: String, + actions: Array, + duration: [Number, String], + cancelText: String, + description: String, + getContainer: [String, Function], + closeOnPopstate: Boolean, + closeOnClickAction: Boolean, + round: { + type: Boolean, + default: true, + }, + closeIcon: { + type: String, + default: 'cross', + }, + safeAreaInsetBottom: { + type: Boolean, + default: true, + }, + overlay: { + type: Boolean, + default: true, + }, + closeOnClickOverlay: { + type: Boolean, + default: true, + }, + }, + + emits: ['close', 'cancel', 'select', 'update:show'], + + setup(props, { slots, emit }) { + function onCancel() { + emit('update:show', false); + emit('cancel'); + } + + function onToggle(show) { + emit('update:show', show); + } + + return function () { + const { title, cancelText } = props; + + function Header() { + if (title) { + return ( +
+ {title} + +
+ ); + } + } + + function Content() { + if (slots.default) { + return
{slots.default()}
; + } + } + + function Option(item, index) { + const { disabled, loading, callback } = item; + + function onClickOption(event) { + event.stopPropagation(); + + if (disabled || loading) { + return; + } + + if (callback) { + callback(item); + } + + emit('select', item, index); + + if (props.closeOnClickAction) { + emit('update:show', false); + } + } + + function OptionContent() { + if (loading) { + return ; + } + + return [ + {item.name}, + item.subname && {item.subname}, + ]; + } + + return ( + + ); + } + + function CancelText() { + if (cancelText) { + return [ +
, + , + ]; + } + } + + const Description = props.description && ( +
{props.description}
+ ); + + return ( + + {Header()} + {Description} + {props.actions && props.actions.map(Option)} + {Content()} + {CancelText()} + + ); + }; + }, +}); diff --git a/src-next/action-sheet/index.less b/src-next/action-sheet/index.less new file mode 100644 index 000000000..4d86492d3 --- /dev/null +++ b/src-next/action-sheet/index.less @@ -0,0 +1,89 @@ +@import '../style/var'; +@import '../style/mixins/hairline'; + +.van-action-sheet { + max-height: @action-sheet-max-height; + color: @action-sheet-item-text-color; + + &__item, + &__cancel { + display: block; + width: 100%; + height: @action-sheet-item-height; + padding: 0; + font-size: @action-sheet-item-font-size; + line-height: 20px; + background-color: @action-sheet-item-background; + border: none; + cursor: pointer; + + &:active { + background-color: @active-color; + } + } + + &__item { + &--loading, + &--disabled { + color: @action-sheet-item-disabled-text-color; + + &:active { + background-color: @action-sheet-item-background; + } + } + + &--disabled { + cursor: not-allowed; + } + + &--loading { + cursor: default; + } + } + + &__subname { + margin-left: @padding-base; + color: @action-sheet-subname-color; + font-size: @action-sheet-subname-font-size; + } + + &__gap { + display: block; + height: @action-sheet-cancel-padding-top; + background-color: @action-sheet-cancel-padding-color; + } + + &__header { + font-weight: @font-weight-bold; + font-size: @action-sheet-header-font-size; + line-height: @action-sheet-header-height; + text-align: center; + } + + &__description { + position: relative; + padding: 20px @padding-md; + color: @action-sheet-description-color; + font-size: @action-sheet-description-font-size; + line-height: @action-sheet-description-line-height; + text-align: center; + + &::after { + .hairline-bottom(@cell-border-color, @padding-md, @padding-md); + } + } + + &__close { + position: absolute; + top: 0; + right: 0; + padding: @action-sheet-close-icon-padding; + color: @action-sheet-close-icon-color; + font-size: @action-sheet-close-icon-size; + line-height: inherit; + + &:active { + color: @action-sheet-close-icon-active-color; + } + } +} diff --git a/src-next/action-sheet/test/__snapshots__/demo.spec.js.snap b/src-next/action-sheet/test/__snapshots__/demo.spec.js.snap new file mode 100644 index 000000000..0b0f77f33 --- /dev/null +++ b/src-next/action-sheet/test/__snapshots__/demo.spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders demo correctly 1`] = ` +
+
+
+
基础用法
+ +
+
+
展示取消按钮
+ +
+
+
展示描述信息
+ +
+
+
+
+
选项状态
+ +
+
+
+
+
自定义面板
+ +
+
+ + + + + +
+`; diff --git a/src-next/action-sheet/test/__snapshots__/index.spec.js.snap b/src-next/action-sheet/test/__snapshots__/index.spec.js.snap new file mode 100644 index 000000000..e28dc8120 --- /dev/null +++ b/src-next/action-sheet/test/__snapshots__/index.spec.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`callback events 1`] = ` +
+
+
+`; + +exports[`close-icon prop 1`] = ` +
+
Title +
+
+`; + +exports[`color option 1`] = `
`; + +exports[`description prop 1`] = ` +
+
This is a description
+
+`; + +exports[`disable lazy-render 1`] = ` + +`; + +exports[`render title and default slot 1`] = ` +
+
Title +
+
Default
+
+`; + +exports[`round prop 1`] = `
`; diff --git a/src-next/action-sheet/test/demo.spec.js b/src-next/action-sheet/test/demo.spec.js new file mode 100644 index 000000000..5c70922b5 --- /dev/null +++ b/src-next/action-sheet/test/demo.spec.js @@ -0,0 +1,4 @@ +import Demo from '../demo'; +import { snapshotDemo } from '../../../test/demo'; + +snapshotDemo(Demo); diff --git a/src-next/action-sheet/test/index.spec.js b/src-next/action-sheet/test/index.spec.js new file mode 100644 index 000000000..e07eeca18 --- /dev/null +++ b/src-next/action-sheet/test/index.spec.js @@ -0,0 +1,186 @@ +import { mount, later } from '../../../test'; +import ActionSheet from '..'; + +test('callback events', () => { + const callback = jest.fn(); + const onInput = jest.fn(); + const onCancel = jest.fn(); + const onSelect = jest.fn(); + + const actions = [ + { name: 'Option', callback }, + { name: 'Option', disabled: true }, + { name: 'Option', loading: true }, + { name: 'Option', subname: 'Subname' }, + ]; + + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + actions, + cancelText: 'Cancel', + }, + context: { + on: { + input: onInput, + cancel: onCancel, + select: onSelect, + }, + }, + }); + + const options = wrapper.findAll('.van-action-sheet__item'); + options.at(0).trigger('click'); + options.at(1).trigger('click'); + wrapper.find('.van-action-sheet__cancel').trigger('click'); + + expect(callback).toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalled(); + expect(onInput).toHaveBeenCalledWith(false); + expect(onSelect).toHaveBeenCalledWith(actions[0], 0); + expect(wrapper).toMatchSnapshot(); +}); + +test('click overlay and close', async () => { + const onInput = jest.fn(); + const onClickOverlay = jest.fn(); + const div = document.createElement('div'); + + mount({ + template: ` +
+ +
+ `, + components: { + ActionSheet, + }, + data() { + return { + getContainer: () => div, + }; + }, + methods: { + onInput, + onClickOverlay, + }, + }); + + await later(); + + div.querySelector('.van-overlay').click(); + expect(onInput).toHaveBeenCalledWith(false); + expect(onClickOverlay).toHaveBeenCalledTimes(1); +}); + +test('disable lazy-render', () => { + const wrapper = mount(ActionSheet, { + propsData: { + lazyRender: false, + actions: [{ name: 'Option' }, { name: 'Option' }], + cancelText: 'Cancel', + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('render title and default slot', () => { + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + title: 'Title', + }, + scopedSlots: { + default() { + return 'Default'; + }, + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('get container', () => { + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + getContainer: 'body', + }, + }); + + expect(wrapper.vm.$el.parentNode).toEqual(document.body); +}); + +test('close-on-click-action prop', () => { + const onInput = jest.fn(); + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + actions: [{ name: 'Option' }], + closeOnClickAction: true, + }, + context: { + on: { + input: onInput, + }, + }, + }); + + const option = wrapper.find('.van-action-sheet__item'); + option.trigger('click'); + + expect(onInput).toHaveBeenCalledWith(false); +}); + +test('round prop', () => { + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + round: true, + actions: [{ name: 'Option' }], + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('color option', () => { + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + actions: [{ name: 'Option', color: 'red' }], + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('description prop', () => { + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + description: 'This is a description', + actions: [{ name: 'Option' }], + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('close-icon prop', () => { + const wrapper = mount(ActionSheet, { + propsData: { + value: true, + title: 'Title', + closeIcon: 'cross', + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src-next/mixins/popup/index.js b/src-next/mixins/popup/index.js index 9a18f9ee4..0b0c7a43d 100644 --- a/src-next/mixins/popup/index.js +++ b/src-next/mixins/popup/index.js @@ -43,8 +43,6 @@ export function PopupMixin(options = {}) { props: popupMixinProps, - emits: ['open', 'close', 'update:show', 'click-overlay'], - data() { return { inited: this.show, diff --git a/src-next/popup/index.js b/src-next/popup/index.js index cb28f8d5e..c77cea282 100644 --- a/src-next/popup/index.js +++ b/src-next/popup/index.js @@ -39,7 +39,15 @@ export default createComponent({ }, }, - emits: ['click', 'opened', 'closed'], + emits: [ + 'open', + 'close', + 'click', + 'opened', + 'closed', + 'update:show', + 'click-overlay', + ], beforeCreate() { const createEmitter = (eventName) => (event) => diff --git a/vant.config.js b/vant.config.js index 5ee9aaf2c..4df589682 100644 --- a/vant.config.js +++ b/vant.config.js @@ -180,10 +180,10 @@ module.exports = { { title: '反馈组件', items: [ - // { - // path: 'action-sheet', - // title: 'ActionSheet 动作面板', - // }, + { + path: 'action-sheet', + title: 'ActionSheet 动作面板', + }, // { // path: 'dialog', // title: 'Dialog 弹出框', @@ -514,10 +514,10 @@ module.exports = { { title: 'Action Components', items: [ - // { - // path: 'action-sheet', - // title: 'ActionSheet', - // }, + { + path: 'action-sheet', + title: 'ActionSheet', + }, // { // path: 'dialog', // title: 'Dialog',