-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rating): adding the rating component
- Loading branch information
Maharshi Alpesh
authored and
Maharshi Alpesh
committed
Sep 26, 2024
1 parent
3f8b63e
commit 27bdafc
Showing
27 changed files
with
1,211 additions
and
4 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
# @nextui-org/rating | ||
|
||
Rating Component allows the user to select a value with the help of icons. | ||
|
||
Please refer to the [documentation](https://nextui.org/docs/components/rating) for more information. | ||
|
||
## Installation | ||
|
||
```sh | ||
yarn add @nextui-org/rating | ||
# or | ||
npm i @nextui-org/rating | ||
``` | ||
|
||
## Contribution | ||
|
||
Yes please! See the | ||
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) | ||
for details. | ||
|
||
## License | ||
|
||
This project is licensed under the terms of the | ||
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). |
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 |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import * as React from "react"; | ||
import {fireEvent, render, renderHook} from "@testing-library/react"; | ||
import {useForm} from "react-hook-form"; | ||
import userEvent from "@testing-library/user-event"; | ||
|
||
import {Rating} from "../src"; | ||
|
||
describe("Rating", () => { | ||
it("should render correctly", () => { | ||
const wrapper = render(<Rating length={5} />); | ||
|
||
expect(() => wrapper.unmount()).not.toThrow(); | ||
}); | ||
|
||
it("ref should be forwarded", () => { | ||
const ref = React.createRef<HTMLInputElement>(); | ||
|
||
render(<Rating ref={ref} length={5} />); | ||
expect(ref.current).not.toBeNull(); | ||
}); | ||
|
||
it("should have description when added", async () => { | ||
const description = "description message"; | ||
|
||
render(<Rating description={description} length={5} />); | ||
|
||
const input = document.querySelector("[data-slot=base]")!; | ||
|
||
expect(input).toHaveTextContent(description); | ||
}); | ||
|
||
it("should have the icons according to the length", async () => { | ||
render(<Rating length={3} />); | ||
|
||
const icons = document.querySelectorAll("[data-slot=icon]"); | ||
|
||
expect(icons.length).toBe(3); | ||
}); | ||
}); | ||
|
||
describe("Rating with React Hook Form", () => { | ||
let rating1: Element; | ||
let rating2: Element; | ||
let rating3: Element; | ||
let submitButton: HTMLButtonElement; | ||
let onSubmit: () => void; | ||
|
||
beforeEach(() => { | ||
const {result} = renderHook(() => | ||
useForm({ | ||
defaultValues: { | ||
withDefaultValue: "2", | ||
withoutDefaultValue: "", | ||
requiredField: "", | ||
}, | ||
}), | ||
); | ||
|
||
const {handleSubmit, register} = result.current; | ||
|
||
onSubmit = jest.fn(); | ||
|
||
render( | ||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> | ||
<Rating data-testid="input-1" {...register("withDefaultValue")} length={5} /> | ||
<Rating data-testid="input-2" {...register("withoutDefaultValue")} length={5} /> | ||
<Rating | ||
data-testid="input-3" | ||
label="Required" | ||
{...register("requiredField", {required: true})} | ||
length={5} | ||
/> | ||
<button type="submit">Submit</button> | ||
</form>, | ||
); | ||
|
||
rating1 = document.querySelectorAll("[data-slot=input]")[0]!; | ||
rating2 = document.querySelectorAll("[data-slot=input]")[1]!; | ||
rating3 = document.querySelectorAll("[data-slot=input]")[2]!; | ||
submitButton = document.querySelector("button")!; | ||
}); | ||
|
||
it("should work with defaultValues", () => { | ||
expect(rating1).toHaveValue(2); | ||
expect(rating2).toHaveValue(null); | ||
expect(rating3).toHaveValue(null); | ||
}); | ||
|
||
it("should not submit form when required field is empty", async () => { | ||
const user = userEvent.setup(); | ||
|
||
await user.click(submitButton); | ||
|
||
expect(onSubmit).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
it("should submit form when required field is not empty", async () => { | ||
fireEvent.change(rating3, {target: {value: "2"}}); | ||
|
||
const user = userEvent.setup(); | ||
|
||
await user.click(submitButton); | ||
|
||
expect(onSubmit).toHaveBeenCalledTimes(1); | ||
}); | ||
}); |
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 |
---|---|---|
@@ -0,0 +1,64 @@ | ||
{ | ||
"name": "@nextui-org/rating", | ||
"version": "2.0.0", | ||
"description": "Icon selection based rating component", | ||
"keywords": [ | ||
"rating" | ||
], | ||
"author": "Maharshi Alpesh <[email protected]>", | ||
"homepage": "https://nextui.org", | ||
"license": "MIT", | ||
"main": "src/index.ts", | ||
"sideEffects": false, | ||
"files": [ | ||
"dist" | ||
], | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/nextui-org/nextui.git", | ||
"directory": "packages/components/rating" | ||
}, | ||
"bugs": { | ||
"url": "https://github.com/nextui-org/nextui/issues" | ||
}, | ||
"scripts": { | ||
"build": "tsup src --dts", | ||
"build:fast": "tsup src", | ||
"dev": "pnpm build:fast --watch", | ||
"clean": "rimraf dist .turbo", | ||
"typecheck": "tsc --noEmit", | ||
"prepack": "clean-package", | ||
"postpack": "clean-package restore" | ||
}, | ||
"peerDependencies": { | ||
"react": ">=18", | ||
"react-dom": ">=18", | ||
"@nextui-org/theme": ">=2.0.0", | ||
"@nextui-org/system": ">=2.0.0" | ||
}, | ||
"dependencies": { | ||
"@nextui-org/shared-icons": "workspace:*", | ||
"@nextui-org/shared-utils": "workspace:*", | ||
"@nextui-org/react-utils": "workspace:*", | ||
"@react-aria/utils": "3.24.1", | ||
"@react-stately/slider": "3.5.4", | ||
"@react-aria/visually-hidden": "3.8.12", | ||
"@react-aria/i18n": "3.11.1", | ||
"@react-aria/focus": "3.17.1", | ||
"@react-types/textfield": "3.9.3", | ||
"@react-aria/textfield": "3.14.5", | ||
"@react-stately/utils": "3.10.1" | ||
}, | ||
"devDependencies": { | ||
"@nextui-org/theme": "workspace:*", | ||
"@nextui-org/system": "workspace:*", | ||
"clean-package": "2.2.0", | ||
"react": "^18.0.0", | ||
"react-dom": "^18.0.0", | ||
"react-hook-form": "^7.51.3" | ||
}, | ||
"clean-package": "../../../clean-package.config.json" | ||
} |
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 |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import Rating from "./rating"; | ||
import RatingSegment from "./rating-segment"; | ||
|
||
// export types | ||
export type {RatingProps} from "./rating"; | ||
|
||
// export hooks | ||
export {useRating} from "./use-rating"; | ||
|
||
// export component | ||
export {Rating}; | ||
export {RatingSegment}; |
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 |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import {createContext} from "@nextui-org/react-utils"; | ||
|
||
import {UseRatingReturn} from "./use-rating"; | ||
|
||
export const [RatingProvider, useRatingContext] = createContext<UseRatingReturn>({ | ||
name: "RatingContext", | ||
errorMessage: | ||
"useRatingContext: `context` is undefined. Seems like you forgot to wrap all rating components within `<Rating />`", | ||
}); |
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 |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import {clsx} from "@nextui-org/shared-utils"; | ||
|
||
import {useRatingContext} from "./rating-context"; | ||
|
||
interface RatingIconProps { | ||
offset: number; | ||
icon?: React.ReactNode; | ||
fillColor?: string; | ||
} | ||
|
||
export const RatingIcon = ({offset, icon, fillColor}: RatingIconProps) => { | ||
const id = Math.random().toString(36); | ||
const context = useRatingContext(); | ||
const {slots, isRTL, classNames, opacity, selectedOpacity} = context; | ||
|
||
icon = icon ?? context.icon; | ||
fillColor = fillColor ?? context.fillColor; | ||
const strokeColor = context.strokeColor ?? fillColor; | ||
const iconStyles = slots.icon({class: clsx(classNames?.icon)}); | ||
|
||
return ( | ||
<svg className={iconStyles} data-slot="icon"> | ||
<defs> | ||
<linearGradient id={"grad" + id}> | ||
<stop | ||
offset={offset} | ||
stopColor={fillColor} | ||
stopOpacity={isRTL ? opacity : selectedOpacity} | ||
/> | ||
<stop stopColor={fillColor} stopOpacity={isRTL ? selectedOpacity : opacity} /> | ||
</linearGradient> | ||
</defs> | ||
<g fill={`url(#${"grad" + id})`} stroke={strokeColor}> | ||
{icon} | ||
</g> | ||
</svg> | ||
); | ||
}; |
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 |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import {mergeRefs} from "@nextui-org/react-utils"; | ||
import {useRef} from "react"; | ||
import {clsx, dataAttr} from "@nextui-org/shared-utils"; | ||
import {useHover} from "@react-aria/interactions"; | ||
|
||
import {useRatingContext} from "./rating-context"; | ||
import {RatingIcon} from "./rating-icon"; | ||
|
||
interface RatingSegmentProps { | ||
index: number; | ||
icon?: React.ReactNode; | ||
fillColor?: string; | ||
} | ||
|
||
const RatingSegment = ({index, icon, fillColor}: RatingSegmentProps) => { | ||
const context = useRatingContext(); | ||
const { | ||
ratingValue, | ||
isRTL, | ||
isIconWrapperHovered, | ||
shouldConsiderHover, | ||
precision, | ||
slots, | ||
classNames, | ||
isSingleSelection, | ||
setRatingValue, | ||
onChange, | ||
name, | ||
onBlur, | ||
} = context; | ||
|
||
const iconRef = useRef<HTMLElement>(null); | ||
|
||
const onPointerUp = (e: React.MouseEvent<HTMLDivElement>) => { | ||
if (!iconRef || !iconRef.current) return; | ||
if (isSingleSelection) { | ||
setRatingValue({selectedValue: index + 1, hoveredValue: ratingValue.hoveredValue}); | ||
|
||
return; | ||
} | ||
|
||
const clientX = e.clientX; | ||
const {x, width} = iconRef.current.getBoundingClientRect(); | ||
const sweepedWidth = isRTL ? x + width - clientX : clientX - x; | ||
const updatedSelectedValue = sweepedWidth / width; | ||
let precisedSelectedValue = Math.floor(updatedSelectedValue / precision) * precision; | ||
|
||
if (precisedSelectedValue < updatedSelectedValue) | ||
precisedSelectedValue = precisedSelectedValue + precision; | ||
if (Math.floor(precisedSelectedValue) > Math.floor(updatedSelectedValue)) | ||
precisedSelectedValue = Math.floor(precisedSelectedValue); | ||
|
||
precisedSelectedValue += index; | ||
setRatingValue({selectedValue: precisedSelectedValue, hoveredValue: ratingValue.hoveredValue}); | ||
}; | ||
|
||
let value = ratingValue.selectedValue; | ||
|
||
if (isIconWrapperHovered && shouldConsiderHover) { | ||
value = ratingValue.hoveredValue; | ||
} | ||
|
||
let offset = Number(Math.floor(value) - 1 == index); | ||
|
||
if (!isSingleSelection) { | ||
offset = Math.floor(value) > index ? 1 : 0; | ||
offset = Math.floor(value) == index ? value - Math.floor(value) : offset; | ||
} | ||
|
||
let offsetRTL = 1 - Number(Math.floor(value) - 1 == index); | ||
|
||
if (!isSingleSelection) { | ||
offsetRTL = Math.floor(value) > index ? 0 : 1; | ||
offsetRTL = Math.floor(value) == index ? (offsetRTL = 1 - (value - Math.floor(value))) : offset; | ||
} | ||
|
||
const segmentStyles = slots.iconSegment({class: clsx(classNames?.iconSegment)}); | ||
const {isHovered, hoverProps} = useHover({}); | ||
|
||
return ( | ||
<div | ||
ref={mergeRefs(iconRef)} | ||
className={segmentStyles} | ||
data-hovered={dataAttr(isHovered)} | ||
data-slot="segment" | ||
onPointerUp={onPointerUp} | ||
{...hoverProps} | ||
> | ||
<RatingIcon fillColor={fillColor} icon={icon} offset={isRTL ? offsetRTL : offset} /> | ||
<input | ||
className={`absolute top-0 inset-0 opacity-0`} | ||
name={name} | ||
type="radio" | ||
onBlur={onBlur} | ||
onChange={onChange} | ||
/> | ||
</div> | ||
); | ||
}; | ||
|
||
export default RatingSegment; |
Oops, something went wrong.