forked from Tencent/tdesign-mobile-react
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
rate组件升级 Tencent#462
- Loading branch information
Showing
31 changed files
with
759 additions
and
390 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.