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',