mirror of
https://gitee.com/vant-contrib/vant.git
synced 2025-04-24 10:20:19 +08:00
feat(Rate): optimize touchmove gesture (#4336)
This commit is contained in:
parent
99cd97febd
commit
390e89fca2
199
src/rate/index.js
Normal file
199
src/rate/index.js
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { createNamespace, addUnit } from '../utils';
|
||||||
|
import { preventDefault } from '../utils/dom/event';
|
||||||
|
import { TouchMixin } from '../mixins/touch';
|
||||||
|
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],
|
||||||
|
|
||||||
|
props: {
|
||||||
|
size: [Number, String],
|
||||||
|
gutter: [Number, String],
|
||||||
|
readonly: Boolean,
|
||||||
|
disabled: Boolean,
|
||||||
|
allowHalf: Boolean,
|
||||||
|
value: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: 'star'
|
||||||
|
},
|
||||||
|
voidIcon: {
|
||||||
|
type: String,
|
||||||
|
default: 'star-o'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#ffd21e'
|
||||||
|
},
|
||||||
|
voidColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#c7c7c7'
|
||||||
|
},
|
||||||
|
disabledColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#bdbdbd'
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: Number,
|
||||||
|
default: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
list() {
|
||||||
|
const list = [];
|
||||||
|
for (let i = 1; i <= this.count; i++) {
|
||||||
|
list.push(getRateStatus(this.value, i, this.allowHalf));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
sizeWithUnit() {
|
||||||
|
return addUnit(this.size);
|
||||||
|
},
|
||||||
|
|
||||||
|
gutterWithUnit() {
|
||||||
|
return addUnit(this.gutter);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
select(index) {
|
||||||
|
if (!this.disabled && !this.readonly && index !== this.value) {
|
||||||
|
this.$emit('input', index);
|
||||||
|
this.$emit('change', index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchStart(event) {
|
||||||
|
if (this.readonly || this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.touchStart(event);
|
||||||
|
|
||||||
|
const rects = this.$refs.items.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) {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderStar(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 (
|
||||||
|
<div
|
||||||
|
ref="items"
|
||||||
|
refInFor
|
||||||
|
key={index}
|
||||||
|
role="radio"
|
||||||
|
style={style}
|
||||||
|
tabindex="0"
|
||||||
|
aria-setsize={count}
|
||||||
|
aria-posinset={score}
|
||||||
|
aria-checked={String(!isVoid)}
|
||||||
|
class={bem('item')}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={this.sizeWithUnit}
|
||||||
|
name={isFull ? icon : voidIcon}
|
||||||
|
class={bem('icon')}
|
||||||
|
data-score={score}
|
||||||
|
color={disabled ? disabledColor : isFull ? color : voidColor}
|
||||||
|
onClick={() => {
|
||||||
|
this.select(score);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{this.allowHalf && (
|
||||||
|
<Icon
|
||||||
|
size={this.sizeWithUnit}
|
||||||
|
name={isVoid ? voidIcon : icon}
|
||||||
|
class={bem('icon', 'half')}
|
||||||
|
data-score={score - 0.5}
|
||||||
|
color={disabled ? disabledColor : isVoid ? voidColor : color}
|
||||||
|
onClick={() => {
|
||||||
|
this.select(score - 0.5);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={bem()}
|
||||||
|
tabindex="0"
|
||||||
|
role="radiogroup"
|
||||||
|
onTouchstart={this.onTouchStart}
|
||||||
|
onTouchmove={this.onTouchMove}
|
||||||
|
>
|
||||||
|
{this.list.map((status, index) => this.renderStar(status, index))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
@ -1,191 +0,0 @@
|
|||||||
import { createNamespace, addUnit } from '../utils';
|
|
||||||
import { emit, inherit } from '../utils/functional';
|
|
||||||
import { preventDefault } from '../utils/dom/event';
|
|
||||||
import Icon from '../icon';
|
|
||||||
|
|
||||||
// Types
|
|
||||||
import { CreateElement, RenderContext } from 'vue/types';
|
|
||||||
import { DefaultSlots } from '../utils/types';
|
|
||||||
|
|
||||||
const [createComponent, bem] = createNamespace('rate');
|
|
||||||
|
|
||||||
export type RateProps = {
|
|
||||||
size: number;
|
|
||||||
icon: string;
|
|
||||||
count: number;
|
|
||||||
color: string;
|
|
||||||
value: number;
|
|
||||||
gutter?: string | number;
|
|
||||||
voidIcon: string;
|
|
||||||
voidColor: string;
|
|
||||||
readonly?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
allowHalf?: boolean;
|
|
||||||
disabledColor: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RateEvents = {
|
|
||||||
input(index: number): void;
|
|
||||||
change(index: number): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RateStatus = 'full' | 'half' | 'void';
|
|
||||||
|
|
||||||
function getRateStatus(value: number, index: number, allowHalf?: boolean): RateStatus {
|
|
||||||
if (value >= index) {
|
|
||||||
return 'full';
|
|
||||||
}
|
|
||||||
if (value + 0.5 >= index && allowHalf) {
|
|
||||||
return 'half';
|
|
||||||
}
|
|
||||||
return 'void';
|
|
||||||
}
|
|
||||||
|
|
||||||
function Rate(
|
|
||||||
h: CreateElement,
|
|
||||||
props: RateProps,
|
|
||||||
slots: DefaultSlots,
|
|
||||||
ctx: RenderContext<RateProps>
|
|
||||||
) {
|
|
||||||
const {
|
|
||||||
icon,
|
|
||||||
color,
|
|
||||||
count,
|
|
||||||
voidIcon,
|
|
||||||
readonly,
|
|
||||||
disabled,
|
|
||||||
voidColor,
|
|
||||||
disabledColor
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const list: RateStatus[] = [];
|
|
||||||
for (let i = 1; i <= count; i++) {
|
|
||||||
list.push(getRateStatus(props.value, i, props.allowHalf));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelect(index: number) {
|
|
||||||
if (!disabled && !readonly) {
|
|
||||||
emit(ctx, 'input', index);
|
|
||||||
emit(ctx, 'change', index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchMove(event: TouchEvent) {
|
|
||||||
if (readonly || disabled || !document.elementFromPoint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
preventDefault(event);
|
|
||||||
const { clientX, clientY } = event.touches[0];
|
|
||||||
const target = document.elementFromPoint(clientX, clientY) as HTMLElement;
|
|
||||||
|
|
||||||
if (target && target.dataset) {
|
|
||||||
const { score } = target.dataset;
|
|
||||||
|
|
||||||
/* istanbul ignore else */
|
|
||||||
if (score) {
|
|
||||||
onSelect(+score);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gutter = addUnit(props.gutter);
|
|
||||||
|
|
||||||
function renderStar(status: RateStatus, index: number) {
|
|
||||||
const isFull = status === 'full';
|
|
||||||
const isVoid = status === 'void';
|
|
||||||
const score = index + 1;
|
|
||||||
const size = addUnit(props.size);
|
|
||||||
|
|
||||||
let style;
|
|
||||||
if (gutter && score !== count) {
|
|
||||||
style = { paddingRight: gutter };
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
role="radio"
|
|
||||||
style={style}
|
|
||||||
tabindex="0"
|
|
||||||
aria-setsize={count}
|
|
||||||
aria-posinset={score}
|
|
||||||
aria-checked={String(!isVoid)}
|
|
||||||
class={bem('item')}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={size}
|
|
||||||
name={isFull ? icon : voidIcon}
|
|
||||||
class={bem('icon')}
|
|
||||||
data-score={score}
|
|
||||||
color={disabled ? disabledColor : isFull ? color : voidColor}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(score);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{props.allowHalf && (
|
|
||||||
<Icon
|
|
||||||
size={size}
|
|
||||||
name={isVoid ? voidIcon : icon}
|
|
||||||
class={bem('icon', 'half')}
|
|
||||||
data-score={score - 0.5}
|
|
||||||
color={disabled ? disabledColor : isVoid ? voidColor : color}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(score - 0.5);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={bem()}
|
|
||||||
tabindex="0"
|
|
||||||
role="radiogroup"
|
|
||||||
{...inherit(ctx)}
|
|
||||||
onTouchmove={onTouchMove}
|
|
||||||
>
|
|
||||||
{list.map((status, index) => renderStar(status, index))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Rate.props = {
|
|
||||||
size: [Number, String],
|
|
||||||
gutter: [Number, String],
|
|
||||||
readonly: Boolean,
|
|
||||||
disabled: Boolean,
|
|
||||||
allowHalf: Boolean,
|
|
||||||
value: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
default: 'star'
|
|
||||||
},
|
|
||||||
voidIcon: {
|
|
||||||
type: String,
|
|
||||||
default: 'star-o'
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: '#ffd21e'
|
|
||||||
},
|
|
||||||
voidColor: {
|
|
||||||
type: String,
|
|
||||||
default: '#c7c7c7'
|
|
||||||
},
|
|
||||||
disabledColor: {
|
|
||||||
type: String,
|
|
||||||
default: '#bdbdbd'
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: Number,
|
|
||||||
default: 5
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default createComponent<RateProps, RateEvents>(Rate);
|
|
@ -1,23 +1,38 @@
|
|||||||
import Rate from '..';
|
import Rate from '..';
|
||||||
import { mount, triggerDrag } from '../../../test/utils';
|
import { mount, triggerDrag } from '../../../test/utils';
|
||||||
|
|
||||||
|
function mockGetBoundingClientRect(items) {
|
||||||
|
items.filter((icon, index) => {
|
||||||
|
icon.element.getBoundingClientRect = () => ({
|
||||||
|
left: index * 25,
|
||||||
|
width: 25
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('change event', () => {
|
test('change event', () => {
|
||||||
const onInput = jest.fn();
|
const onInput = jest.fn();
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
|
|
||||||
const wrapper = mount(Rate, {
|
const wrapper = mount(Rate, {
|
||||||
context: {
|
listeners: {
|
||||||
on: {
|
input: value => {
|
||||||
input: onInput,
|
onInput(value);
|
||||||
|
wrapper.setProps({ value });
|
||||||
|
},
|
||||||
change: onChange
|
change: onChange
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const item4 = wrapper.findAll('.van-rate__icon').at(3);
|
const item4 = wrapper.findAll('.van-rate__icon').at(3);
|
||||||
|
|
||||||
item4.trigger('click');
|
item4.trigger('click');
|
||||||
|
item4.trigger('click');
|
||||||
|
|
||||||
expect(onInput).toHaveBeenCalledWith(4);
|
expect(onInput).toHaveBeenCalledWith(4);
|
||||||
|
expect(onInput).toHaveBeenCalledTimes(1);
|
||||||
expect(onChange).toHaveBeenCalledWith(4);
|
expect(onChange).toHaveBeenCalledWith(4);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allow half', () => {
|
test('allow half', () => {
|
||||||
@ -28,12 +43,10 @@ test('allow half', () => {
|
|||||||
propsData: {
|
propsData: {
|
||||||
allowHalf: true
|
allowHalf: true
|
||||||
},
|
},
|
||||||
context: {
|
listeners: {
|
||||||
on: {
|
|
||||||
input: onInput,
|
input: onInput,
|
||||||
change: onChange
|
change: onChange
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const item4 = wrapper.findAll('.van-rate__icon--half').at(3);
|
const item4 = wrapper.findAll('.van-rate__icon--half').at(3);
|
||||||
|
|
||||||
@ -50,43 +63,59 @@ test('disabled', () => {
|
|||||||
propsData: {
|
propsData: {
|
||||||
disabled: true
|
disabled: true
|
||||||
},
|
},
|
||||||
context: {
|
listeners: {
|
||||||
on: {
|
|
||||||
input: onInput,
|
input: onInput,
|
||||||
change: onChange
|
change: onChange
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const item4 = wrapper.findAll('.van-rate__item').at(3);
|
const item4 = wrapper.findAll('.van-rate__item').at(3);
|
||||||
|
|
||||||
|
triggerDrag(wrapper, 100, 0);
|
||||||
item4.trigger('click');
|
item4.trigger('click');
|
||||||
|
|
||||||
expect(onInput).toHaveBeenCalledTimes(0);
|
expect(onInput).toHaveBeenCalledTimes(0);
|
||||||
expect(onChange).toHaveBeenCalledTimes(0);
|
expect(onChange).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('touchmove', () => {
|
test('touchmove to select item', () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
const wrapper = mount(Rate, {
|
const wrapper = mount(Rate, {
|
||||||
context: {
|
listeners: {
|
||||||
on: {
|
|
||||||
change: onChange
|
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);
|
triggerDrag(wrapper, 100, 0);
|
||||||
|
|
||||||
const icons = wrapper.findAll('.van-icon');
|
expect(onChange).toHaveBeenNthCalledWith(1, 1);
|
||||||
document.elementFromPoint = function (x) {
|
expect(onChange).toHaveBeenNthCalledWith(2, 1.5);
|
||||||
const index = Math.round(x / 20);
|
expect(onChange).toHaveBeenNthCalledWith(3, 2);
|
||||||
if (index < icons.length) {
|
expect(onChange).toHaveBeenNthCalledWith(4, 4);
|
||||||
return icons.at(index).element;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
triggerDrag(wrapper, 100, 0);
|
|
||||||
expect(onChange).toHaveBeenNthCalledWith(1, 2);
|
|
||||||
expect(onChange).toHaveBeenNthCalledWith(2, 3);
|
|
||||||
expect(onChange).toHaveBeenNthCalledWith(3, 4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gutter prop', () => {
|
test('gutter prop', () => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user