diff --git a/src-next/rate/README.md b/src-next/rate/README.md new file mode 100644 index 000000000..46396f819 --- /dev/null +++ b/src-next/rate/README.md @@ -0,0 +1,123 @@ +# Rate + +### Install + +```js +import Vue from 'vue'; +import { Rate } from 'vant'; + +Vue.use(Rate); +``` + +## Usage + +### Basic Usage + +```html + +``` + +```js +export default { + data() { + return { + value: 3, + }; + }, +}; +``` + +### Custom Icon + +```html + +``` + +### Custom Style + +```html + +``` + +### Half Star + +```html + +``` + +```js +export default { + data() { + return { + value: 2.5, + }; + }, +}; +``` + +### Custom Count + +```html + +``` + +### Disabled + +```html + +``` + +### Readonly + +```html + +``` + +### Change Event + +```html + +``` + +```javascript +export default { + method: { + onChange(value) { + Toast('current value:' + value); + }, + }, +}; +``` + +## API + +### Props + +| Attribute | Description | Type | Default | +| --- | --- | --- | --- | +| v-model | Current rate | _number_ | - | +| count | Count | _number \| string_ | `5` | +| size | Icon size | _number \| string_ | `20px` | +| gutter | Icon gutter | _number \| string_ | `4px` | +| color | Selected color | _string_ | `#ee0a24` | +| void-color | Void color | _string_ | `#c8c9cc` | +| disabled-color | Disabled color | _string_ | `#c8c9cc` | +| icon | Selected icon | _string_ | `star` | +| void-icon | Void icon | _string_ | `star-o` | +| icon-prefix `v2.5.3` | Icon className prefix | _string_ | `van-icon` | +| allow-half | Whether to allow half star | _boolean_ | `false` | +| readonly | Whether to be readonly | _boolean_ | `false` | +| disabled | Whether to disable rate | _boolean_ | `false` | +| touchable `v2.2.0` | Whether to allow select rate by touch gesture | _boolean_ | `true` | + +### Events + +| Event | Description | Parameters | +| ------ | --------------------------- | ------------ | +| change | Triggered when rate changed | current rate | diff --git a/src-next/rate/README.zh-CN.md b/src-next/rate/README.zh-CN.md new file mode 100644 index 000000000..869edec83 --- /dev/null +++ b/src-next/rate/README.zh-CN.md @@ -0,0 +1,123 @@ +# Rate 评分 + +### 引入 + +```js +import Vue from 'vue'; +import { Rate } from 'vant'; + +Vue.use(Rate); +``` + +## 代码演示 + +### 基础用法 + +```html + +``` + +```js +export default { + data() { + return { + value: 3, + }; + }, +}; +``` + +### 自定义图标 + +```html + +``` + +### 自定义样式 + +```html + +``` + +### 半星 + +```html + +``` + +```js +export default { + data() { + return { + value: 2.5, + }; + }, +}; +``` + +### 自定义数量 + +```html + +``` + +### 禁用状态 + +```html + +``` + +### 只读状态 + +```html + +``` + +### 监听 change 事件 + +```html + +``` + +```javascript +export default { + method: { + onChange(value) { + Toast('当前值:' + value); + }, + }, +}; +``` + +## API + +### Props + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| v-model | 当前分值 | _number_ | - | +| count | 图标总数 | _number \| string_ | `5` | +| size | 图标大小,默认单位为`px` | _number \| string_ | `20px` | +| gutter | 图标间距,默认单位为`px` | _number \| string_ | `4px` | +| color | 选中时的颜色 | _string_ | `#ee0a24` | +| void-color | 未选中时的颜色 | _string_ | `#c8c9cc` | +| disabled-color | 禁用时的颜色 | _string_ | `#c8c9cc` | +| icon | 选中时的[图标名称](#/zh-CN/icon)或图片链接 | _string_ | `star` | +| void-icon | 未选中时的[图标名称](#/zh-CN/icon)或图片链接 | _string_ | `star-o` | +| icon-prefix `v2.5.3` | 图标类名前缀,同 Icon 组件的 [class-prefix 属性](#/zh-CN/icon#props) | _string_ | `van-icon` | +| allow-half | 是否允许半选 | _boolean_ | `false` | +| readonly | 是否为只读状态  | _boolean_ | `false` | +| disabled | 是否禁用评分 | _boolean_ | `false` | +| touchable `v2.2.0` | 是否可以通过滑动手势选择评分 | _boolean_ | `true` | + +### Events + +| 事件名 | 说明 | 回调参数 | +| ------ | ------------------------ | -------- | +| change | 当前分值变化时触发的事件 | 当前分值 | diff --git a/src-next/rate/demo/index.vue b/src-next/rate/demo/index.vue new file mode 100644 index 000000000..b66911d0b --- /dev/null +++ b/src-next/rate/demo/index.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src-next/rate/index.js b/src-next/rate/index.js new file mode 100644 index 000000000..8c366f581 --- /dev/null +++ b/src-next/rate/index.js @@ -0,0 +1,221 @@ +// Utils +import { createNamespace, addUnit } from '../utils'; +import { preventDefault } from '../utils/dom/event'; + +// Mixins +import { TouchMixin } from '../mixins/touch'; +import { FieldMixin } from '../mixins/field'; + +// Components +import Icon from '../icon'; + +const [createComponent, bem] = createNamespace('rate'); + +function getRateStatus(value, index, allowHalf) { + if (value >= index) { + return 'full'; + } + + if (value + 0.5 >= index && allowHalf) { + return 'half'; + } + + return 'void'; +} + +export default createComponent({ + mixins: [TouchMixin, FieldMixin], + + props: { + size: [Number, String], + color: String, + gutter: [Number, String], + readonly: Boolean, + disabled: Boolean, + allowHalf: Boolean, + voidColor: String, + iconPrefix: String, + disabledColor: String, + modelValue: { + type: Number, + default: 0, + }, + icon: { + type: String, + default: 'star', + }, + voidIcon: { + type: String, + default: 'star-o', + }, + count: { + type: [Number, String], + default: 5, + }, + touchable: { + type: Boolean, + default: true, + }, + }, + + created() { + this.itemRefs = []; + }, + + computed: { + list() { + const list = []; + for (let i = 1; i <= this.count; i++) { + list.push(getRateStatus(this.modelValue, i, this.allowHalf)); + } + + return list; + }, + + sizeWithUnit() { + return addUnit(this.size); + }, + + gutterWithUnit() { + return addUnit(this.gutter); + }, + }, + + mounted() { + this.bindTouchEvent(this.$el); + }, + + methods: { + select(index) { + if (!this.disabled && !this.readonly && index !== this.modelValue) { + this.$emit('update:modelValue', index); + this.$emit('change', index); + } + }, + + onTouchStart(event) { + if (this.readonly || this.disabled || !this.touchable) { + return; + } + + this.touchStart(event); + + const rects = this.itemRefs.map((item) => item.getBoundingClientRect()); + const ranges = []; + + rects.forEach((rect, index) => { + if (this.allowHalf) { + ranges.push( + { score: index + 0.5, left: rect.left }, + { score: index + 1, left: rect.left + rect.width / 2 } + ); + } else { + ranges.push({ score: index + 1, left: rect.left }); + } + }); + + this.ranges = ranges; + }, + + onTouchMove(event) { + if (this.readonly || this.disabled || !this.touchable) { + return; + } + + this.touchMove(event); + + if (this.direction === 'horizontal') { + preventDefault(event); + + const { clientX } = event.touches[0]; + this.select(this.getScoreByPosition(clientX)); + } + }, + + getScoreByPosition(x) { + for (let i = this.ranges.length - 1; i > 0; i--) { + if (x > this.ranges[i].left) { + return this.ranges[i].score; + } + } + + return this.allowHalf ? 0.5 : 1; + }, + + genStar(status, index) { + const { + icon, + color, + count, + voidIcon, + disabled, + voidColor, + disabledColor, + } = this; + const score = index + 1; + const isFull = status === 'full'; + const isVoid = status === 'void'; + + let style; + if (this.gutterWithUnit && score !== +count) { + style = { paddingRight: this.gutterWithUnit }; + } + + return ( +
{ + this.itemRefs[index] = val; + }} + role="radio" + style={style} + tabindex="0" + aria-setsize={count} + aria-posinset={score} + aria-checked={String(!isVoid)} + class={bem('item')} + > + { + this.select(score); + }} + /> + {this.allowHalf && ( + { + this.select(score - 0.5); + }} + /> + )} +
+ ); + }, + }, + + render() { + return ( +
+ {this.list.map((status, index) => this.genStar(status, index))} +
+ ); + }, +}); diff --git a/src-next/rate/index.less b/src-next/rate/index.less new file mode 100644 index 000000000..27012144f --- /dev/null +++ b/src-next/rate/index.less @@ -0,0 +1,46 @@ +@import '../style/var'; + +.van-rate { + display: inline-flex; + cursor: pointer; + user-select: none; + + &__item { + position: relative; + + &:not(:last-child) { + padding-right: @rate-icon-gutter; + } + } + + &__icon { + display: block; + width: 1em; + color: @rate-icon-void-color; + font-size: @rate-icon-size; + + &--half { + position: absolute; + top: 0; + left: 0; + width: 0.5em; + overflow: hidden; + } + + &--full { + color: @rate-icon-full-color; + } + + &--disabled { + color: @rate-icon-disabled-color; + } + } + + &--disabled { + cursor: not-allowed; + } + + &--readonly { + cursor: default; + } +} diff --git a/src-next/rate/test/__snapshots__/demo.spec.js.snap b/src-next/rate/test/__snapshots__/demo.spec.js.snap new file mode 100644 index 000000000..7e13e68b1 --- /dev/null +++ b/src-next/rate/test/__snapshots__/demo.spec.js.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders demo correctly 1`] = ` +
+
+
+ + + + + +
+
+
+
+ + + + + +
+
+
+
+ + + + + +
+
+
+
+ + + + + +
+
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+
+ + + + + +
+
+
+
+ + + + + +
+
+
+`; diff --git a/src-next/rate/test/__snapshots__/index.spec.js.snap b/src-next/rate/test/__snapshots__/index.spec.js.snap new file mode 100644 index 000000000..921034480 --- /dev/null +++ b/src-next/rate/test/__snapshots__/index.spec.js.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gutter prop 1`] = ` +
+ + + + + +
+`; + +exports[`size prop 1`] = ` +
+ + + + + +
+`; diff --git a/src-next/rate/test/demo.spec.js b/src-next/rate/test/demo.spec.js new file mode 100644 index 000000000..5c70922b5 --- /dev/null +++ b/src-next/rate/test/demo.spec.js @@ -0,0 +1,4 @@ +import Demo from '../demo'; +import { snapshotDemo } from '../../../test/demo'; + +snapshotDemo(Demo); diff --git a/src-next/rate/test/index.spec.js b/src-next/rate/test/index.spec.js new file mode 100644 index 000000000..3e2bd51fe --- /dev/null +++ b/src-next/rate/test/index.spec.js @@ -0,0 +1,154 @@ +import Rate from '..'; +import { mount, triggerDrag } from '../../../test'; + +function mockGetBoundingClientRect(items) { + items.filter((icon, index) => { + icon.element.getBoundingClientRect = () => ({ + left: index * 25, + width: 25, + }); + return true; + }); +} + +test('change event', () => { + const onInput = jest.fn(); + const onChange = jest.fn(); + + const wrapper = mount(Rate, { + listeners: { + input: (value) => { + onInput(value); + wrapper.setProps({ value }); + }, + change: onChange, + }, + }); + const item4 = wrapper.findAll('.van-rate__icon').at(3); + + item4.trigger('click'); + item4.trigger('click'); + + expect(onInput).toHaveBeenCalledWith(4); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(4); + expect(onChange).toHaveBeenCalledTimes(1); +}); + +test('allow half', () => { + const onInput = jest.fn(); + const onChange = jest.fn(); + + const wrapper = mount(Rate, { + propsData: { + allowHalf: true, + }, + listeners: { + input: onInput, + change: onChange, + }, + }); + const item4 = wrapper.findAll('.van-rate__icon--half').at(3); + + item4.trigger('click'); + expect(onInput).toHaveBeenCalledWith(3.5); + expect(onChange).toHaveBeenCalledWith(3.5); +}); + +test('disabled', () => { + const onInput = jest.fn(); + const onChange = jest.fn(); + + const wrapper = mount(Rate, { + propsData: { + disabled: true, + }, + listeners: { + input: onInput, + change: onChange, + }, + }); + const item4 = wrapper.findAll('.van-rate__item').at(3); + + triggerDrag(wrapper, 100, 0); + item4.trigger('click'); + + expect(onInput).toHaveBeenCalledTimes(0); + expect(onChange).toHaveBeenCalledTimes(0); +}); + +test('touchmove to select item', () => { + const onChange = jest.fn(); + const wrapper = mount(Rate, { + listeners: { + change: onChange, + }, + }); + + const icons = wrapper.findAll('.van-rate__item'); + + mockGetBoundingClientRect(icons); + triggerDrag(wrapper, 100, 0); + + expect(onChange).toHaveBeenNthCalledWith(1, 1); + expect(onChange).toHaveBeenNthCalledWith(2, 2); + expect(onChange).toHaveBeenNthCalledWith(3, 2); + expect(onChange).toHaveBeenNthCalledWith(4, 4); +}); + +test('touchmove to select half item', () => { + const onChange = jest.fn(); + const wrapper = mount(Rate, { + propsData: { + allowHalf: true, + }, + listeners: { + change: onChange, + }, + }); + + const icons = wrapper.findAll('.van-rate__item'); + + mockGetBoundingClientRect(icons); + triggerDrag(wrapper, 100, 0); + + expect(onChange).toHaveBeenNthCalledWith(1, 1); + expect(onChange).toHaveBeenNthCalledWith(2, 1.5); + expect(onChange).toHaveBeenNthCalledWith(3, 2); + expect(onChange).toHaveBeenNthCalledWith(4, 4); +}); + +test('gutter prop', () => { + const wrapper = mount(Rate, { + propsData: { + gutter: 10, + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('size prop', () => { + const wrapper = mount(Rate, { + propsData: { + size: '2rem', + }, + }); + + expect(wrapper).toMatchSnapshot(); +}); + +test('untouchable', () => { + const onChange = jest.fn(); + const wrapper = mount(Rate, { + propsData: { + touchable: false, + }, + listeners: { + change: onChange, + }, + }); + + triggerDrag(wrapper, 100, 0); + expect(onChange).toHaveBeenCalledTimes(0); +}); diff --git a/vant.config.js b/vant.config.js index 4df589682..44c0a1233 100644 --- a/vant.config.js +++ b/vant.config.js @@ -151,10 +151,10 @@ module.exports = { // path: 'radio', // title: 'Radio 单选框', // }, - // { - // path: 'rate', - // title: 'Rate 评分', - // }, + { + path: 'rate', + title: 'Rate 评分', + }, // { // path: 'search', // title: 'Search 搜索', @@ -485,10 +485,10 @@ module.exports = { // path: 'radio', // title: 'Radio', // }, - // { - // path: 'rate', - // title: 'Rate', - // }, + { + path: 'rate', + title: 'Rate', + }, // { // path: 'search', // title: 'Search',