Skip to content

Commit

Permalink
chore(rating): adding suggestions of ryo and wkw
Browse files Browse the repository at this point in the history
  • Loading branch information
macci001 committed Oct 17, 2024
1 parent 25b051f commit 3f9efd9
Show file tree
Hide file tree
Showing 19 changed files with 352 additions and 180 deletions.
8 changes: 8 additions & 0 deletions .changeset/smooth-masks-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nextui-org/rating": minor
"@nextui-org/react": minor
"@nextui-org/theme": minor
"@nextui-org/shared-icons": minor
---

Add rating component(#3807)
2 changes: 1 addition & 1 deletion apps/docs/content/components/rating/disabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const App = `import {Rating} from "@nextui-org/react";
export default function App() {
return (
<div className="flex w-full flex-wrap md:flex-nowrap gap-4">
<Rating length={5} isDisabled />
<Rating length={5} isDisabled value={2} />
</div>
);
}`;
Expand Down
65 changes: 33 additions & 32 deletions apps/docs/content/docs/components/rating.mdx

Large diffs are not rendered by default.

129 changes: 113 additions & 16 deletions packages/components/rating/__tests__/rating.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";
import {fireEvent, render, renderHook} from "@testing-library/react";
import {act, render, renderHook} from "@testing-library/react";
import {focus} from "@nextui-org/test-utils";
import {useForm} from "react-hook-form";
import userEvent from "@testing-library/user-event";

Expand Down Expand Up @@ -36,12 +37,106 @@ describe("Rating", () => {

expect(icons.length).toBe(3);
});

it("should be able to reset the rating value after being selected once", async () => {
render(<Rating length={3} />);

const input = document.querySelectorAll("[data-slot=input]")[0];
const radioButtonForValueZero = document.querySelectorAll("[data-slot=radio]")[0];
const radioButtonForValueOne = document.querySelectorAll("[data-slot=radio]")[1];

const user = userEvent.setup();

await user.click(radioButtonForValueOne);
expect(input).toHaveValue(1);

await user.click(radioButtonForValueZero);
expect(input).toHaveValue(0);
});

it("should be able to change the rating value on keypress", async () => {
render(<Rating length={3} />);

const input = document.querySelectorAll("[data-slot=input]")[0];
const radioButtonForValueOne = document.querySelectorAll("[data-slot=radio]")[1] as HTMLElement;
const radioButtonForValueTwo = document.querySelectorAll("[data-slot=radio]")[2] as HTMLElement;

const user = userEvent.setup();

await user.click(radioButtonForValueOne);
expect(input).toHaveValue(1);

act(() => {
focus(radioButtonForValueOne);
});
await user.keyboard("[ArrowRight]");
expect(input).toHaveValue(2);

act(() => {
focus(radioButtonForValueTwo);
});
await user.keyboard("[ArrowLeft]");
expect(input).toHaveValue(1);
});
});

describe("validation", () => {
let user = userEvent.setup();

beforeAll(() => {
user = userEvent.setup();
});

it("should support native validationBehaviour", async () => {
const {getAllByRole, getByTestId} = render(
<form data-testid="form">
<Rating isRequired length={5} validationBehavior="native" />
</form>,
);

const radios = getAllByRole("radio") as HTMLInputElement[];

for (let input of radios) {
expect(input).toHaveAttribute("required");
expect(input).not.toHaveAttribute("aria-required");
expect(input.validity.valid).toBe(false);
}

act(() => {
(getByTestId("form") as HTMLFormElement).checkValidity();
});
expect(document.activeElement).toBe(radios[0]);

await user.click(radios[0]);
for (let input of radios) {
expect(input.validity.valid).toBe(true);
}
});

it("should support aria validationBehaviour", async () => {
const {getByRole, getAllByRole} = render(
<form data-testid="form">
<Rating isRequired defaultValue="1" length={5} validationBehavior="aria" />
</form>,
);

const group = getByRole("radiogroup");

expect(group).toHaveAttribute("aria-required", "true");

const radios = getAllByRole("radio") as HTMLInputElement[];

for (let input of radios) {
expect(input.validity.valid).toBe(true);
}
});
});

describe("Rating with React Hook Form", () => {
let rating1: Element;
let rating2: Element;
let rating3: Element;
let radioButtonRating3: Element;
let submitButton: HTMLButtonElement;
let onSubmit: () => void;

Expand All @@ -61,29 +156,32 @@ describe("Rating with React Hook Form", () => {
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>,
<>
<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]!;
radioButtonRating3 = document.querySelectorAll("[data-slot=radio]")[13]!;
submitButton = document.querySelector("button")!;
});

it("should work with defaultValues", () => {
expect(rating1).toHaveValue(2);
expect(rating2).toHaveValue(0);
expect(rating3).toHaveValue(0);
expect(rating2).toHaveValue(null);
expect(rating3).toHaveValue(null);
});

it("should not submit form when required field is empty", async () => {
Expand All @@ -95,10 +193,9 @@ describe("Rating with React Hook Form", () => {
});

it("should submit form when required field is not empty", async () => {
fireEvent.change(rating3, {target: {value: "2"}});

const user = userEvent.setup();

await user.click(radioButtonRating3);
await user.click(submitButton);

expect(onSubmit).toHaveBeenCalledTimes(1);
Expand Down
1 change: 1 addition & 0 deletions packages/components/rating/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"devDependencies": {
"@nextui-org/theme": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/test-utils": "workspace:*",
"clean-package": "2.2.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
Expand Down
16 changes: 11 additions & 5 deletions packages/components/rating/src/rating-segment.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {mergeRefs} from "@nextui-org/react-utils";
import {useMemo, useRef} from "react";
import {clsx, dataAttr} from "@nextui-org/shared-utils";
import {useHover} from "@react-aria/interactions";
Expand Down Expand Up @@ -29,7 +28,7 @@ const RatingSegment = ({index, icon, fillColor}: RatingSegmentProps) => {
onBlur,
} = context;

const iconRef = useRef<HTMLElement>(null);
const iconRef = useRef<HTMLDivElement>(null);

let value = ratingValue.selectedValue;

Expand Down Expand Up @@ -70,13 +69,20 @@ const RatingSegment = ({index, icon, fillColor}: RatingSegmentProps) => {
};

return (
<div className="absolute inset-0 top-0 flex" style={gridStyle}>
<div
className={slots.radioButtonsWrapper({class: classNames?.radioButtonsWrapper})}
style={gridStyle}
>
{Array.from(Array(numButtons)).map((_, idx) => {
return (
<div key={idx} className="col-span-1 inset-0 overflow-hidden opacity-0">
<div
key={idx}
className={slots.radioButtonWrapper({class: classNames?.radioButtonWrapper})}
>
<Radio
key={idx}
classNames={{base: "w-full h-full m-0"}}
data-slot="radio"
name={name}
value={
idx === numButtons - 1
Expand All @@ -95,7 +101,7 @@ const RatingSegment = ({index, icon, fillColor}: RatingSegmentProps) => {

return (
<div
ref={mergeRefs(iconRef)}
ref={iconRef}
className={segmentStyles}
data-hovered={dataAttr(isHovered)}
data-slot="segment"
Expand Down
88 changes: 44 additions & 44 deletions packages/components/rating/src/rating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {forwardRef} from "@nextui-org/system";
import {useMemo} from "react";
import {Radio, RadioGroup} from "@nextui-org/radio";
import {VisuallyHidden} from "@react-aria/visually-hidden";
import {StarIcon} from "@nextui-org/shared-icons";

import {UseRatingProps, useRating} from "./use-rating";
import RatingSegment from "./rating-segment";
Expand All @@ -16,85 +17,85 @@ const Rating = forwardRef<"div", RatingProps>((props, ref) => {
Component,
children,
length,
hasHelper,
isInvalid,
isRequired,
ratingValue,
defaultValue,
value,
name,
description,
errorMessage,
classNames,
slots,
validationBehavior,
icon = <StarIcon />,
onChange,
onBlur,
setRatingValue,
getBaseProps,
getMainWrapperProps,
getIconWrapperProps,
getHelperWrapperProps,
getInputProps,
getDescriptionProps,
getErrorMessageProps,
ratingValue,
value,
name,
onBlur,
onChange,
validate,
} = context;

const IconList = useMemo(() => {
if (children) {
return <div {...getIconWrapperProps()}>{children}</div>;
}

return (
<div {...getIconWrapperProps()}>
<RadioGroup
data-selected-value={ratingValue.selectedValue.toString()}
classNames={{
errorMessage: slots.errorMessage({class: classNames?.errorMessage}),
description: slots.description({class: classNames?.description}),
}}
data-slot="radio-group"
defaultValue={defaultValue}
description={description}
errorMessage={errorMessage}
isInvalid={isInvalid}
isRequired={isRequired}
name={name}
orientation="horizontal"
value={ratingValue.selectedValue.toString()}
onBlur={onBlur}
validate={validate}
validationBehavior={validationBehavior}
value={ratingValue.selectedValue != -1 ? ratingValue.selectedValue.toString() : null}
onChange={onChange}
onValueChange={(e) => {
setRatingValue({selectedValue: Number(e), hoveredValue: Number(e)});
}}
>
<Radio
className={`absolute top-0 inset-0 opacity-0 cursor-pointer`}
className={"absolute inset-0 top-0 opacity-0"}
data-slot="radio"
name={name}
value={"0"}
onBlur={onBlur}
onChange={onChange}
/>
{Array.from(Array(length)).map((_, idx) => (
<RatingSegment key={"segment-" + idx} index={idx} />
))}
{children ??
Array.from(Array(length)).map((_, idx) => (
<RatingSegment key={"segment-" + idx} icon={icon} index={idx} />
))}
</RadioGroup>
</div>
);
}, [children, length, getIconWrapperProps, name, onBlur, onChange]);

const Helper = useMemo(() => {
if (!hasHelper) {
return null;
}
if (isInvalid && !!errorMessage) {
return (
<div {...getHelperWrapperProps()}>
<div {...getErrorMessageProps()}>{errorMessage}</div>
</div>
);
}

return (
<div {...getHelperWrapperProps()}>
<div {...getDescriptionProps()}>{description}</div>
</div>
);
}, [
hasHelper,
children,
length,
getIconWrapperProps,
name,
defaultValue,
ratingValue,
setRatingValue,
isInvalid,
isRequired,
description,
errorMessage,
getHelperWrapperProps,
getDescriptionProps,
getErrorMessageProps,
slots,
classNames,
validationBehavior,
onBlur,
onChange,
validate,
]);

const Input = useMemo(
Expand All @@ -111,7 +112,6 @@ const Rating = forwardRef<"div", RatingProps>((props, ref) => {
<RatingProvider value={context}>
<div {...getMainWrapperProps()}>
{IconList}
{Helper}
{Input}
</div>
</RatingProvider>
Expand Down
Loading

0 comments on commit 3f9efd9

Please sign in to comment.