reactjstailwind-cssheadless-ui

Trying to make 5 star rating using React, tailwind CSS, and Headless UI


Trying to build a 5-star rating component just like the example link.

peer-hover seems to work, yet peer-checked doesn't work as peer-hover does.

(items contain an array [1,2,3,4,5], by the way)

Could you point out the reason why this problem happens?

import { RadioGroup } from '@headlessui/react'
import { useController } from "react-hook-form";

import { classNames } from '../libs/frontend/utils'

import { StarIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/20/solid';

export const RadioGroupStars = (props) => {
  const {
    field: { value, onChange }
  } = useController(props);

  const { items } = props;

  return (
    <>
      <RadioGroup
        value={value}
        onChange={onChange}
        className="w-full my-1">
        <RadioGroup.Label className="sr-only"> Choose a option </RadioGroup.Label>
        <div className="flex flex-row-reverse justify-center gap-1">
          {items.map((item) => (
            <RadioGroup.Option
              key={item}
              value={item}
              className={({ active, checked }) =>
                classNames(
                  'cursor-pointer text-gray-200',
                  'flex-1 hover:text-yellow-600',
                  'peer',
                  'peer-hover:text-yellow-600 peer-checked:text-yellow-500',
                  active ? 'text-yellow-500' : '',
                  checked ? 'text-yellow-500' : '',
                )
              }
            >
              <RadioGroup.Label as={StarIconSolid} className='' />
            </RadioGroup.Option>
          ))}
        </div>
      </RadioGroup>
    </>
  );
}

Solution

  • I think the reason peer-checked not working could be because RadioGroup.Option outputs a div wrapping the icon as Label (instead of a input), therefore the pseudo-class :checked is not applied, while peer-hover does work.

    Because the component has access to selected value and the rating values are comparable:

    items contain an array [1,2,3,4,5]

    RadioGroup.Option could compare own value with the selected value as a condition to render with different classes (or equivalently, compare the index).

    Because this list also uses flex-row-reverse to implement the siblings hover, consider to reverse() the items before map() to keep the iterated items in correct order.

    Tested the example in live on: stackblitz (omitted logic for react-hook-form for simplicity):

    <div className="flex flex-row-reverse justify-center gap-1">
      {[...items].reverse().map((item) => (
        <RadioGroup.Option
          key={item}
          value={item}
          className={({ active, checked }) =>
            classNames(
              "cursor-pointer text-gray-200",
              "flex-1 hover:text-yellow-400",
              "peer",
              "peer-hover:text-yellow-400",
              active ? "text-yellow-500" : "",
              checked ? "text-yellow-500" : "",
              // 👇 Add a compare with selected value here
              value >= item ? "text-yellow-500" : ""
            )
          }
        >
          <RadioGroup.Label as={BsStarFill} className="w-6 h-6" />
        </RadioGroup.Option>
      ))}
    </div>
    

    On a side note, because RadioGroup requires a setValue (a state set function) for its onChange prop, not too sure if the field.onChange returned by useController() would work with it.

    If not, perhaps consider to host a state in the component and sync with useController, so that its functions could be still be used.