Skip to content

Commit

Permalink
feat(rating): adding the rating component
Browse files Browse the repository at this point in the history
  • Loading branch information
Maharshi Alpesh authored and Maharshi Alpesh committed Sep 26, 2024
1 parent 3f8b63e commit 27bdafc
Show file tree
Hide file tree
Showing 27 changed files with 1,211 additions and 4 deletions.
24 changes: 24 additions & 0 deletions packages/components/rating/README.md
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).
106 changes: 106 additions & 0 deletions packages/components/rating/__tests__/rating.test.tsx
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);
});
});
64 changes: 64 additions & 0 deletions packages/components/rating/package.json
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"
}
12 changes: 12 additions & 0 deletions packages/components/rating/src/index.ts
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};
9 changes: 9 additions & 0 deletions packages/components/rating/src/rating-context.tsx
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 />`",
});
38 changes: 38 additions & 0 deletions packages/components/rating/src/rating-icon.tsx
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>
);
};
101 changes: 101 additions & 0 deletions packages/components/rating/src/rating-segment.tsx
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;
Loading

0 comments on commit 27bdafc

Please sign in to comment.