Skip to content

Commit

Permalink
feat(rate): rate组件升级
Browse files Browse the repository at this point in the history
rate组件升级

Tencent#462
  • Loading branch information
ming680 authored and anlyyao committed Nov 11, 2024
1 parent 4b92263 commit a735355
Show file tree
Hide file tree
Showing 31 changed files with 759 additions and 390 deletions.
2 changes: 1 addition & 1 deletion site/mobile/mobile.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default {
{
title: 'Rate 评分',
name: 'rate',
component: () => import('tdesign-mobile-react/rate/_example/index.jsx'),
component: () => import('tdesign-mobile-react/rate/_example/index.tsx'),
},
{
title: 'Search 搜索框',
Expand Down
356 changes: 251 additions & 105 deletions src/rate/Rate.tsx
Original file line number Diff line number Diff line change
@@ -1,119 +1,265 @@
import React, { FC, forwardRef } from 'react';
import { StarFilledIcon, StarIcon } from 'tdesign-icons-react';
import isEmpty from 'lodash/isEmpty';
import useConfig from '../_util/useConfig';
import type { TdRateProps } from './type';
import useDefault from '../_util/useDefault';
import useColor from '../_util/useColor';
import withNativeProps, { NativeProps } from '../_util/withNativeProps';
import { useSize } from 'ahooks';
import cx from 'classnames';
import React, { FC, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useConfig from 'tdesign-mobile-react/_util/useConfig';
import useDefault from 'tdesign-mobile-react/_util/useDefault';
import { StyledProps } from 'tdesign-mobile-react/common';
import useDefaultProps from 'tdesign-mobile-react/hooks/useDefaultProps';
import { rateDefaultProps } from './defaultProps';
import { RateIcon } from './RateIcon';
import { RateText } from './RateText';
import { RateTips } from './RateTips';
import type { TdRateProps } from './type';

export interface RateProps extends TdRateProps, NativeProps {}
export interface RateProps extends TdRateProps, StyledProps {}

const Star = (props) => {
const { size, style, variant } = props;
if (variant === 'outline') {
return <StarIcon size={size} style={{ ...style }} />;
}
return <StarFilledIcon size={size} style={{ ...style }} />;
const converToNumber = (str: string | number, defaultValue = 0) => {
const value = parseFloat(String(str));
return isNaN(value) ? defaultValue : value;
};

const defaultUnCheck = '#E3E6EB';
const defaultCheck = '#ED7B2F';

const Rate: FC<RateProps> = forwardRef((props, ref: React.LegacyRef<HTMLInputElement>) => {
const { allowHalf, color, count, gap, showText, size, texts, value, onChange, variant, defaultValue, disabled } =
props;
const Rate: FC<RateProps> = forwardRef<HTMLDivElement, RateProps>((props, ref) => {
const { classPrefix } = useConfig();
const name = `${classPrefix}-rate`;

const [refValue, setRefValue] = useDefault(value, defaultValue, onChange);
const starClickHandle = (number) => {
setRefValue(refValue === number ? 0 : number);
};

const [checkColor, unCheckColor] = useColor(color, defaultCheck, defaultUnCheck);

const getHalfCheckColor = (number) => (number <= refValue ? checkColor : 'transparent');

const getCheckColor = (number) => (number <= refValue ? checkColor : unCheckColor);

const getVariant = (number) => (number <= refValue ? 'filled' : variant);

const RateLi = (props) => {
const { number } = props;
if (allowHalf) {
const leftStarNumber = number - 0.5;
return (
<>
<li className={`${name}--item ${name}-half`} style={{ marginRight: `${count - number > 0 ? gap : 0}px` }}>
<span className={`${name}--placeholder`}>
<Star size={size} variant={getVariant(leftStarNumber)} style={{ color: unCheckColor }} />
</span>
<span
className={`${name}--icon-left`}
onClick={() => {
!disabled && starClickHandle(leftStarNumber);
}}
>
<Star
size={size}
variant={getVariant(leftStarNumber)}
style={{ color: getHalfCheckColor(leftStarNumber) }}
/>
</span>
<span
className={`${name}--icon-right`}
onClick={() => {
!disabled && starClickHandle(number);
}}
>
<Star size={size} variant={getVariant(number)} style={{ color: getHalfCheckColor(number) }} />
</span>
</li>
</>
);
}
return (
<li className={`${name}--item ${name}-full`} style={{ marginRight: `${count - number > 0 ? gap : 0}px` }}>
<span
className={`${name}--icon`}
onClick={() => {
!disabled && starClickHandle(number);
}}
>
<Star size={size} variant={getVariant(number)} style={{ color: getCheckColor(number) }} />
</span>
</li>
);
};

const starList = [];
for (let i = 0; i < count; i++) {
starList.push(<RateLi key={i} number={i + 1} />);
}

const getText = () => {
if (!refValue) {
return '';
const rateClass = `${classPrefix}-rate`;

const {
style,
className,
count,
gap,
size,
color,
icon,
allowHalf,
placement,
value,
defaultValue,
onChange,
showText,
texts,
disabled,
} = useDefaultProps<RateProps>(props, rateDefaultProps);

const [innerValue, setInnerValue] = useDefault(value, defaultValue, onChange);

const wrapRef = useRef<HTMLDivElement>(null);

const [currentValue, setCurrentValue] = useState(-1);
const [tipsVisible, setTipsVisible] = useState(false);
const [isDragging, setIsDragging] = useState(false);

const controlRef = useRef({
timer: 0,
enableClick: true,
touchStartX: 0,
enableTouch: false,
currentValue,
});

controlRef.current.enableClick = true;

const onShowTips = useCallback(() => {
clearTimeout(controlRef.current.timer);
setTipsVisible(true);
}, []);

const onHideTips = useCallback(() => {
clearTimeout(controlRef.current.timer);
setTipsVisible(false);
}, []);

// 组件销毁的时候 清除定时器
useEffect(
() => () => {
clearTimeout(controlRef.current.timer);
},
[],
);

const onTouchStart = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
controlRef.current.enableTouch = false;
const event = e.touches[0];
if (!event || disabled) {
return;
}
controlRef.current.touchStartX = event.clientX;
},
[disabled],
);

const onTouchMove = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
const event = e.touches[0];
const wrapEle = wrapRef.current;
if (!event || count < 1 || !wrapEle || disabled) {
return;
}

if (Math.abs(event.clientX - controlRef.current.touchStartX) > 5) {
controlRef.current.enableTouch = true;
setIsDragging(true);
onShowTips();
}

if (!controlRef.current.enableTouch) {
return;
}

// 计算
const wrapRect = wrapEle.getBoundingClientRect();
const gapNum = converToNumber(gap);
const perWidth = (wrapRect.width + gapNum) / count;
// 左边 - gap / 2 右边 + gap / 2
const x = event.clientX - wrapRect.x + gapNum / 2;

let value = Math.min(Math.max(Math.floor(x / perWidth / 0.5) * 0.5 + 0.5, 0), count);
if (!allowHalf) {
value = Math.floor(value);
}

setCurrentValue(value);
setTipsVisible(true);
controlRef.current.currentValue = value;
},
[gap, count, allowHalf, onShowTips, disabled],
);

const onTouchEnd = useCallback(() => {
setIsDragging(false);
if (!controlRef.current.enableTouch || disabled) {
return;
}
if (isEmpty(texts)) {
return refValue;
controlRef.current.enableTouch = false;
controlRef.current.enableClick = false;
// 根据记录去修改数据
setInnerValue(controlRef.current.currentValue);
onHideTips();
}, [onHideTips, setInnerValue, disabled]);

const wrapSize = useSize(wrapRef);

const tipsLeft = useMemo(() => {
if (count < 1 || !wrapSize) {
return 0;
}
return texts?.[Math.ceil(refValue) - 1] ?? 'undefined';
};

return withNativeProps(
props,
<div className={`${name}`}>
<input type="hidden" ref={ref} defaultValue={refValue} />
<ul className={`${name}--list`}>{starList}</ul>
{showText && <span className={`${name}--text`}>{getText()}</span>}
</div>,

const gapNum = converToNumber(gap);
const perWidth = (wrapSize.width - (count - 1) * gapNum) / count;
const index = Math.max(Math.min(Math.ceil(currentValue), count), 1) - 1;

return (index + 1) * perWidth - perWidth / 2 + index * gapNum;
}, [wrapSize, count, currentValue, gap]);

const [clickTime, setClickTime] = useState(0);

const doubleTips = allowHalf && !isDragging;

return (
<div
style={style}
className={cx(rateClass, className, {
[`${rateClass}--disabled`]: disabled,
})}
ref={ref}
>
<div
ref={wrapRef}
className={`${rateClass}__wrapper`}
style={{ gap: `${gap}px` }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
{Array(count)
.fill('')
.map((_, index) => {
const itemValue = index + 1;

const compareValue = isDragging ? currentValue : innerValue;

return (
<RateIcon
key={index}
color={color}
size={converToNumber(size)}
icon={icon}
isCurrent={currentValue === itemValue && tipsVisible}
// 整个 和 半个 都要选中
isSelected={itemValue < compareValue + 1}
isHalf={itemValue > compareValue && itemValue < compareValue + 1}
onClick={(placement) => {
if (!controlRef.current.enableClick || disabled) {
return;
}
const value = placement === 'left' && allowHalf ? itemValue - 0.5 : itemValue;
setClickTime(Date.now());
setCurrentValue(value);
onShowTips();
controlRef.current.timer = setTimeout(onHideTips, 3000) as any as number;
setInnerValue(value);
}}
/>
);
})}
</div>
{showText ? <RateText texts={texts} value={isDragging ? currentValue : innerValue} /> : null}
{/* 增加一个时间戳作为 key 保证每次点击的时候 组件都重新创建 防止重复利用 触发 onClickOutSide */}
{tipsVisible && placement && !disabled ? (
<RateTips
key={clickTime}
left={tipsLeft}
placement={placement}
onClickOutside={onHideTips}
data={new Array(doubleTips ? 2 : 1).fill(1).map((_, index) => {
let isHalf = false;
if (doubleTips) {
isHalf = index === 0;
} else {
isHalf = Math.ceil(currentValue) !== currentValue;
}

let value = currentValue;
if (doubleTips) {
if (index === 0) {
value = Math.ceil(currentValue) - 0.5;
} else {
value = Math.ceil(currentValue);
}
}

const actived = doubleTips ? value === currentValue : false;

return {
icon: (
<RateIcon
key={index}
icon={icon}
color={color}
isCurrent={false}
isSelected={true}
isHalf={isHalf}
size={converToNumber(size)}
/>
),
text: value,
actived,
onClick: () => {
if (value === innerValue) {
return;
}
setInnerValue(value);
onHideTips();
},
};
})}
/>
) : null}
</div>
);
});

Rate.defaultProps = rateDefaultProps;
Rate.displayName = 'Rate';

export default Rate;
Loading

0 comments on commit a735355

Please sign in to comment.