///
/**
* Rating Component - Lynx 版 MUI Rating
* 100% 一比一复刻 MUI Rating
*
* 星级评分组件,支持小数评分和自定义图标
*
* 对应 MUI: packages/mui-material/src/Rating/Rating.js
*/
import './Rating.css'
import ratingClasses, { getRatingUtilityClass } from './ratingClasses'
export { ratingClasses, getRatingUtilityClass }
// =============================================
// 类型定义
// =============================================
export type RatingSize = 'small' | 'medium' | 'large'
export interface RatingProps {
/** 自定义类名 */
className?: string
/** 样式类覆盖 */
classes?: Partial
/** 默认评分 */
defaultValue?: number
/** 是否禁用 */
disabled?: boolean
/** 空图标 */
emptyIcon?: any
/** 空值标签 */
emptyLabelText?: string
/** 最大评分 */
max?: number
/** 当前评分 */
value?: number
/** 精度 */
precision?: number
/** 是否只读 */
readOnly?: boolean
/** 尺寸 */
size?: RatingSize
/** 获取标签文本 */
getLabelText?: (value: number) => string
/** 高精度图标 */
icon?: any
/** 活动图标 */
activeIcon?: any
/** 内联样式 */
style?: Record
/** sx 属性 */
sx?: Record
/** 点击事件 */
bindtap?: (event?: any) => void
onChange?: (event: any, value: number | null) => void
onChangeActive?: (event: any, value: number) => void
onHover?: (event: any, value: number) => void
}
// =============================================
// 辅助函数
// =============================================
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
function composeClasses(
slots: Record,
getUtilityClass: (slot: string) => string,
classes?: Record
): Record {
const output: Record = {}
Object.keys(slots).forEach((slot) => {
output[slot] = slots[slot]
.filter(Boolean)
.map((key) => {
if (classes && classes[key as string]) {
return `${getUtilityClass(key as string)} ${classes[key as string]}`
}
return getUtilityClass(key as string)
})
.join(' ')
})
return output
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
function getDecimalPrecision(num: number): number {
const decimalPart = num.toString().split('.')[1]
return decimalPart ? decimalPart.length : 0
}
function roundValueToPrecision(value: number, precision: number): number {
if (value == null) {
return value
}
const nearest = Math.round(value / precision) * precision
return Number(nearest.toFixed(getDecimalPrecision(precision)))
}
// =============================================
// useUtilityClasses
// =============================================
interface OwnerState extends RatingProps {
emptyValueFocused?: boolean
focusVisible?: boolean
}
function useUtilityClasses(ownerState: OwnerState) {
const {
classes,
size = 'medium',
readOnly,
disabled,
emptyValueFocused,
focusVisible
} = ownerState
const slots = {
root: [
'root',
`size${capitalize(size)}`,
disabled && 'disabled',
focusVisible && 'focusVisible',
readOnly && 'readOnly',
],
label: ['label', 'pristine'],
labelEmptyValue: [emptyValueFocused && 'labelEmptyValueActive'],
icon: ['icon'],
iconEmpty: ['iconEmpty'],
iconFilled: ['iconFilled'],
iconHover: ['iconHover'],
iconFocus: ['iconFocus'],
iconActive: ['iconActive'],
decimal: ['decimal'],
visuallyHidden: ['visuallyHidden'],
}
return composeClasses(slots, getRatingUtilityClass, classes)
}
// =============================================
// Rating 组件
// =============================================
export function Rating(props: RatingProps) {
const {
className,
classes: classesProp,
defaultValue = 0,
disabled = false,
emptyIcon,
emptyLabelText = 'Empty',
max = 5,
value: valueProp,
precision = 1,
readOnly = false,
size = 'medium',
getLabelText = (value: number) => `${value} Star${value !== 1 ? 's' : ''}`,
icon,
activeIcon,
style,
sx,
bindtap,
onChange,
onChangeActive,
onHover,
...other
} = props
// 在 Lynx 环境下,组件必须是受控的
// 如果没有提供 value 属性,使用默认值
const value = valueProp !== undefined ? valueProp : defaultValue
const ownerState: OwnerState = {
...props,
value,
disabled,
readOnly,
size,
}
const classes = useUtilityClasses(ownerState)
// 处理点击事件
const handleTap = (event?: any, newValue?: number) => {
if (disabled || readOnly) return
if (newValue !== undefined) {
const clampedValue = clamp(newValue, 0, max)
const roundedValue = roundValueToPrecision(clampedValue, precision)
if (onChange) {
onChange(event, roundedValue === 0 ? null : roundedValue)
}
}
if (bindtap) bindtap(event)
}
// 处理悬停事件
const handleHover = (event?: any, newValue?: number) => {
if (disabled || readOnly) return
if (onHover && newValue !== undefined) {
onHover(event, newValue)
}
}
// 渲染星星图标
const renderStars = () => {
const stars = []
const clampedValue = clamp(value, 0, max)
for (let index = 0; index < max; index += 1) {
const itemValue = index + 1
// 判断是否填充
const isFilled = clampedValue >= itemValue
// 判断是否部分填充(小数)
const isFraction = clampedValue > index && clampedValue < itemValue
stars.push(
handleTap(e, itemValue)}
style={{ cursor: disabled || readOnly ? 'default' : 'pointer' }}
>
{/* 使用 CSS 实现星星图标 */}
)
}
return stars
}
// 构建类名
const rootClasses = [
classes.root,
ratingClasses.root,
className,
ratingClasses[`size${capitalize(size)}` as keyof typeof ratingClasses],
disabled && ratingClasses.disabled,
readOnly && ratingClasses.readOnly,
].filter(Boolean).join(' ')
return (
{getLabelText(value)}
{renderStars()}
)
}
export default Rating