Skip to content

Commit 4a3d666

Browse files
authored
New Component: Rating (#8182)
* refactor: enhance Rating component with allowHalf feature and introduce RatingOption component * feat: add rating demos and docs * refactor: rename modelValue to value in useRating props and update related logic * refactor: update Rating component to use 'value' prop & add ControlledDemo
1 parent caab41c commit 4a3d666

20 files changed

Lines changed: 454 additions & 143 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Rating } from 'primereact/rating';
2+
3+
function AllowHalfDemo() {
4+
return (
5+
<div className="card flex justify-center">
6+
<Rating value={3} allowHalf={false}>
7+
<Rating.Option />
8+
</Rating>
9+
</div>
10+
);
11+
}
12+
13+
export default AllowHalfDemo;

apps/showcase/demo/rating/basic-demo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { Rating } from 'primereact/rating';
33
function BasicDemo() {
44
return (
55
<div className="card flex justify-center">
6-
<Rating />
6+
<Rating value={3.5}>
7+
<Rating.Option />
8+
</Rating>
79
</div>
810
);
911
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useRatingChangeEvent } from '@primereact/types/shared/rating';
2+
import { Button } from 'primereact/button';
3+
import { Rating } from 'primereact/rating';
4+
import React from 'react';
5+
6+
function ControlledDemo() {
7+
const [value, setValue] = React.useState<number | undefined>(4);
8+
9+
return (
10+
<div className="card flex flex-col justify-center gap-4">
11+
<div className="flex items-center gap-2">
12+
<Button onClick={() => setValue(2.5)} severity="secondary" variant="outlined">
13+
2.5 Star
14+
</Button>
15+
<Button onClick={() => setValue(3)} severity="secondary" variant="outlined">
16+
3 Star
17+
</Button>
18+
<Button onClick={() => setValue(3.5)} severity="secondary" variant="outlined">
19+
3.5 Star
20+
</Button>
21+
</div>
22+
<Rating value={value} onValueChange={(e: useRatingChangeEvent) => setValue(e.value)}>
23+
<Rating.Option />
24+
</Rating>
25+
</div>
26+
);
27+
}
28+
29+
export default ControlledDemo;

apps/showcase/demo/rating/disabled-demo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { Rating } from 'primereact/rating';
33
function DisabledDemo() {
44
return (
55
<div className="card flex justify-center">
6-
<Rating modelValue={3} disabled />
6+
<Rating value={3} disabled>
7+
<Rating.Option />
8+
</Rating>
79
</div>
810
);
911
}

apps/showcase/demo/rating/readonly-demo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { Rating } from 'primereact/rating';
33
function ReadOnlyDemo() {
44
return (
55
<div className="card flex justify-center">
6-
<Rating modelValue={3} readOnly />
6+
<Rating value={3} readOnly>
7+
<Rating.Option />
8+
</Rating>
79
</div>
810
);
911
}

apps/showcase/demo/rating/stars-demo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { Rating } from 'primereact/rating';
33
function StarsDemo() {
44
return (
55
<div className="card flex justify-center">
6-
<Rating stars={10} />
6+
<Rating stars={10}>
7+
<Rating.Option />
8+
</Rating>
79
</div>
810
);
911
}

apps/showcase/demo/rating/template-demo.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@ import { Rating } from 'primereact/rating';
22

33
function TemplateDemo() {
44
return (
5-
<div className="card flex justify-center">
6-
<Rating
7-
modelValue={3}
8-
onIcon={<img src="https://primefaces.org/cdn/primevue/images/rating/custom-onicon.png" height="24" width="24" />}
9-
offIcon={<img src="https://primefaces.org/cdn/primevue/images/rating/custom-officon.png" height="24" width="24" />}
10-
/>
5+
<div className="card flex flex-col gap-6 justify-center">
6+
<Rating value={3}>
7+
<Rating.Option onIcon={<span className="text-surface-950 dark:text-surface-0 text-2xl select-none">A</span>} offIcon={<span className="text-surface-300 dark:text-surface-700 text-2xl select-none">A</span>} />
8+
</Rating>
9+
<Rating value={3} allowHalf={false}>
10+
<Rating.Option
11+
onIcon={
12+
<span className="w-6 h-6">
13+
<img src="https://primefaces.org/cdn/primevue/images/rating/custom-onicon.png" className="w-6 h-6" />
14+
</span>
15+
}
16+
offIcon={
17+
<span className="w-6 h-6">
18+
<img src="https://primefaces.org/cdn/primevue/images/rating/custom-officon.png" />
19+
</span>
20+
}
21+
/>
22+
</Rating>
1123
</div>
1224
);
1325
}

apps/showcase/docs/components/rating/features.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,31 @@ import { Rating } from 'primereact/rating';
1111
```
1212

1313
```tsx
14-
<Rating />
14+
<Rating>
15+
<Rating.Option value={1} />
16+
</Rating>
1517
```
1618

1719
## Examples
1820

1921
### Basic
2022

21-
Rating is used with the `modelValue` property.
23+
Rating is used with the `value` property.
2224

2325
<DocDemoViewer name="rating:basic-demo" />
2426

27+
### Half Stars
28+
29+
Use `allowHalf` property to allow half stars.
30+
31+
<DocDemoViewer name="rating:allow-half-demo" />
32+
33+
### Controlled
34+
35+
Use `onValueChange` to listen to value changes.
36+
37+
<DocDemoViewer name="rating:controlled-demo" />
38+
2539
### Number of Stars
2640

2741
Number of stars to display is defined with `stars` property.
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { useRatingProps } from '@primereact/types/shared/rating';
22

33
export const defaultProps: useRatingProps = {
4-
modelValue: undefined,
4+
value: undefined,
55
defaultValue: undefined,
66
stars: 5,
77
disabled: false,
88
readOnly: false,
9-
onChange: undefined
9+
allowHalf: true,
10+
onValueChange: undefined
1011
};

packages/headless/src/rating/useRating.ts

Lines changed: 72 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,97 +8,120 @@ export const useRating = withHeadless({
88
defaultProps,
99
setup: ({ props, elementRef }) => {
1010
const { readOnly, disabled } = props;
11-
const [valueState, setValueState] = React.useState(props.modelValue);
12-
const focusedOptionIndexRef = React.useRef(-1);
13-
const isFocusVisibleItemRef = React.useRef(false);
14-
const [focusedOptionIndex, setFocusedOptionIndex] = React.useState(-1);
11+
const [valueState, setValueState] = React.useState(props.allowHalf ? (props.defaultValue ?? props.value) : Math.ceil(props.defaultValue ?? props.value ?? 0));
12+
const [hoverValueState, setHoverValueState] = React.useState<number | undefined>(undefined);
13+
const [focusedOptionIndex, setFocusedOptionIndex] = React.useState<number | undefined>(undefined);
1514
const [isFocusVisibleItem, setIsFocusVisibleItem] = React.useState(false);
1615

16+
const hoverValueRef = React.useRef<number | undefined>(undefined);
17+
1718
const state = {
1819
value: valueState,
20+
hoverValue: hoverValueState,
1921
focusedOptionIndex,
2022
isFocusVisibleItem
2123
};
2224

2325
// methods
24-
const setFocusedOption = (val: number) => {
25-
focusedOptionIndexRef.current = val;
26-
setFocusedOptionIndex(val);
27-
};
2826

29-
const setIsFocusVisible = (val: boolean) => {
30-
isFocusVisibleItemRef.current = val;
31-
setIsFocusVisibleItem(val);
32-
};
27+
const onInputFocus = (event: React.FocusEvent<HTMLInputElement>) => {
28+
const inputValue = parseFloat(event.target.value);
29+
const starIndex = Math.ceil(inputValue);
3330

34-
const onOptionClick = (event: React.MouseEvent<HTMLDivElement>, value: number) => {
35-
if (!readOnly && !disabled) {
36-
onOptionSelect(value);
37-
setIsFocusVisible(false);
31+
setFocusedOptionIndex(starIndex);
3832

39-
const firstFocusableEl = getFirstFocusableElement(event.currentTarget);
33+
const native = event.nativeEvent as FocusEvent & {
34+
sourceCapabilities?: {
35+
firesTouchEvents: boolean;
36+
};
37+
};
4038

41-
if (firstFocusableEl && firstFocusableEl instanceof HTMLElement) {
42-
focus(firstFocusableEl);
43-
}
44-
}
39+
setIsFocusVisibleItem(native.sourceCapabilities?.firesTouchEvents === false);
4540
};
4641

47-
const onOptionSelect = (value: number) => {
48-
if (readOnly || disabled) return;
42+
const onInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
43+
const relatedTarget = event.relatedTarget as HTMLElement | null;
4944

50-
if (focusedOptionIndexRef.current === value || valueState === value) {
51-
setFocusedOption(-1);
52-
setIsFocusVisible(false);
53-
setValueState(undefined);
54-
} else {
55-
setFocusedOption(value);
56-
setIsFocusVisible(true);
57-
setValueState(value);
45+
if (relatedTarget && elementRef.current?.contains(relatedTarget)) {
46+
return;
5847
}
48+
49+
setFocusedOptionIndex(undefined);
5950
};
6051

61-
const onFocus = (event: React.FocusEvent<HTMLInputElement>, value: number) => {
62-
if (readOnly || disabled) return;
52+
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
53+
setValueState(Number(event.target.value));
54+
const inputValue = parseFloat(event.target.value);
55+
const starIndex = Math.ceil(inputValue);
6356

64-
setFocusedOption(value);
65-
setIsFocusVisible((event.nativeEvent as FocusEvent & { sourceCapabilities?: { firesTouchEvents: boolean } | null }).sourceCapabilities?.firesTouchEvents === false);
57+
setFocusedOptionIndex(starIndex);
58+
setIsFocusVisibleItem(true);
6659
};
6760

68-
const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
61+
const onOptionClick = (event: React.MouseEvent<HTMLDivElement>, value: number | undefined) => {
6962
if (readOnly || disabled) return;
7063

71-
const relatedTarget = event.relatedTarget as HTMLElement | null;
64+
const effectiveValue = props.allowHalf ? value : Math.ceil(value ?? 0);
7265

73-
if (relatedTarget && elementRef.current?.contains(relatedTarget)) {
74-
return;
66+
if (hoverValueRef.current === effectiveValue) {
67+
setHoverValueState(undefined);
7568
}
7669

77-
setFocusedOption(-1);
78-
setIsFocusVisible(false);
79-
// formField.onBlur?.();
70+
setValueState((prev) => (prev === effectiveValue ? undefined : effectiveValue));
71+
setIsFocusVisibleItem(false);
72+
73+
const firstFocusableEl = getFirstFocusableElement(event.currentTarget);
74+
75+
if (firstFocusableEl && firstFocusableEl instanceof HTMLElement) {
76+
focus(firstFocusableEl);
77+
}
8078
};
8179

82-
const onChange = (event: React.ChangeEvent<HTMLInputElement>, value: number) => {
80+
const onOptionHover = (event: React.PointerEvent<HTMLDivElement>, value: number | undefined) => {
8381
if (readOnly || disabled) return;
8482

85-
onOptionSelect(value);
86-
setIsFocusVisible(true);
83+
setFocusedOptionIndex(undefined);
84+
const newValue = value ? (props.allowHalf ? value : Math.ceil(value ?? 0)) : undefined;
85+
86+
setHoverValueState(newValue);
87+
hoverValueRef.current = newValue;
88+
};
89+
90+
const getOptionState = (value: number) => {
91+
const effectiveValue = hoverValueState ?? valueState ?? 0;
92+
93+
const floor = Math.floor(effectiveValue);
94+
95+
let state = 'empty';
96+
97+
if (value <= floor) {
98+
state = 'filled';
99+
} else if (value === floor + 1 && !Number.isInteger(effectiveValue)) {
100+
state = 'half';
101+
}
102+
103+
return state;
87104
};
88105

89106
// effects
90107

91108
React.useEffect(() => {
92-
props?.onChange?.({ value: valueState, originalEvent: null });
109+
props?.onValueChange?.({ value: valueState, originalEvent: null });
93110
}, [valueState]);
94111

112+
React.useEffect(() => {
113+
setValueState(props.allowHalf ? (props.defaultValue ?? props.value) : Math.ceil(props.defaultValue ?? props.value ?? 0));
114+
}, [props.value, props.defaultValue, props.allowHalf]);
115+
95116
return {
96117
state,
97118
// methods
119+
onInputFocus,
120+
onInputBlur,
121+
onInputChange,
122+
getOptionState,
98123
onOptionClick,
99-
onFocus,
100-
onBlur,
101-
onChange
124+
onOptionHover
102125
};
103126
}
104127
});

0 commit comments

Comments
 (0)