Star Rating

0

Introduction

Fooderia, the vibrant, dynamic recipe portal that you work for, is looking at launching a star rating component on their website to cultivate a more dynamic and interactive community experience. This addition is aimed at benefiting both users and recipe contributors. By allowing users to rate recipes from one to five stars, they're providing a simple yet powerful tool for feedback and engagement. This will not only help other users make informed decisions based on community preferences but also offer valuable insights to the recipe contributors, empowering them to refine and tailor their creations based on community feedback. The star rating system is a step towards making Fooderia a more user-centric platform, where the quality of content is continuously uplifted through active community participation.

Tutorial

A star rater, right? You have seen them everywhere, it lets you grade something by chosing a star in a range of stars, and generally the stars up and including the one you have chosen gets highlighted. You get assigned the task of creating the UI for this component, what an honor! Well, nothing else to do than to get on with it. You start by creating the necessary files to be able to implement and showcase the component:

  • app
  • star-rating
  • page.jsx
  • components
  • StarRating
  • StarRating.jsx
  • index.js
components/StarRating/StarRating.jsx
const FILLED = ''
const UNFILLED = ''

export default function StarRating () {
// For starters, hardcoded stars displayed as
// an ordered list in a row:
return (
<ol className="flex gap-2">
<li>{FILLED}</li>
<li>{FILLED}</li>
<li>{FILLED}</li>
<li>{UNFILLED}</li>
<li>{UNFILLED}</li>
</ol>
)
}
components/StarRating/index.js
export { default } from './StarRating'
app/star-rating/page.jsx
import StarRating from '@/components/StarRating'

export default function StarRatingPage () {
return (
<main className="flex flex-col gap-8 p-4 sm:p-24">
<h1 className="text-xl font-bold">Star Rating</h1>
<StarRating />
</main>
)
}

Wow, it almost feels like you're done already! If it were solely a gadget to display ratings, you might have finished. However, this component is also expected to collect user input and give the application a chance to process the submitted rating, which is crucial for its operation. Currently, the component is highly visual without alternative methods for all users to understand the displayed rating.

After some thought, you figure that a range input element closely aligns with the input dynamics required by a star-rating system, due to its semantic appropriateness and inherent accessibility benefits. You outline different rating states: Zero stars indicating an unrated item, and one to five stars for rated items. The decision to use the input element assures compatibility with accessibility standards and browser functionality. Your intention is to visually replace this input with a graphical star system, enhancing user engagement without compromising accessibility. This approach ensures the component is both appealing and adheres to inclusive design principles, including keyboard and screen reader support.

Ok, enough with the rambling, you begin with adding the visual representation of the component:

components/StarRating/StarRating.jsx
import { useState } from 'react'

const FILLED = ''
const UNFILLED = ''

// give the input an initial value
export default function StarRating ({ initValue = 0 }) {
const [rating, setRating] = useState (initValue)
return (
<ol className="flex gap-2">
{/*
Array (5) - creates an empty array with the length 5: you
cannot iterate over it
[...Array (5)] - results in an array with 5 'undefined'
items: you can iterate over it

For each possible rating level create a list item that
is either FILLED or UNFILLED depending on the current
rating value, and set the rating value when clicked on.
*/}
{[...Array (5)].map ((_, index) => {
return (
<li
key={index}
// cursor-pointer: Change the mouse cursor to indicate
// that the element is clickable.
// active:opacity-70: When the element is clicked,
// reduce the opacity.
className="cursor-pointer active:opacity-70"
onClick={() => setRating (index + 1)}
>{index < rating ? FILLED : UNFILLED}</li>
)
})}
</ol>
)
}

Before trying it out, you need to make sure that the component is exported as a client component (since you are using useState):

components/StarRating/index.js
'use client' // <- The magic word!
export { default } from './StarRating'

Now, you can test the component:

app/star-rating/page.jsx
import StarRating from "@/components/StarRating"

export default function StarRatingPage () {
return (
<main className="flex flex-col gap-8 p-4 sm:p-24">
<h1 className="text-xl font-bold">Star Rating</h1>
<h2>Default</h2>
<StarRating />
<h2>Rating 3</h2>
<StarRating initRate={3} />
</main>
)
}

Improvement! However, after inspecting the component in the browser, you see that the stars container is very wide, you rather not have it take up more space than the stars themselves. There is also a gap between making the mouse pointer flicker when moving between the stars, you want to fix that as well. You also take the opportunity to prepare for the upcomint input element that you are planning for:

components/StarRating/StarRating.jsx
// ...
export default function StarRating ({ initRate = 0 }) {
const [rating, setRating] = useState (initRate)
// Prepare the component for the input element and wrap
// the list in a div! Make the div a flex container and
// center the items horizontally and vertically. Also
// make the stars bigger by increasing the font size:
return (
<div className="flex w-fit justify-center items-center text-xl">
<ol className="flex justify-between">
{/* <ol className="flex gap-2"> <- replaced */}
{[...Array (5)].map ((_, index) => {
return (
<li
key={index}
// Make the items have the flex-grow property
// of 1 to fill the available space and add
// padding to the left and right of the items
// (to compensate for the removal of gap-2):
className="cursor-pointer active:opacity-70 flex-1 px-1"
onClick={() => setRating (index + 1)}
>{index < rating ? FILLED : UNFILLED}</li>
)
})}
</ol>
</div>
)
}

Hm, not big of a difference but well worth it. A feature you are looking for is that when a user hovers over the component, deciding what the rating should be, the stars would be highlighted to match the star that is currently hovered. So if a user hovers over the fifth star, all stars would be highlighted, but if the user lingers over the first star only that star would be highlighted, no matter the actual current rating. You realise that you have to create a temporal universe here when the mouse is over the stars. That is you need a temporary state that is used when the mouse pointer enters the component and stop using it when the mouse pointer leaves it:

components/StarRating/StarRating.jsx
// ...
export default function StarRating ({ initRate = 0 }) {
const [rating, setRating] = useState (initRate)

// Create a temporary state from the current:
const [tmpRating, setTmpRating] = useState (rating)

return (
<div className="flex w-fit justify-center items-center text-xl">
<ol className="flex justify-between">
{[...Array (5)].map ((_, index) => {
return (
<li
key={index}
className="cursor-pointer active:opacity-70 flex-1 px-1"
onClick={() => setRating (index + 1)}

// When the mouse enters a star, set tmpRating to
// the current index + 1 (the rating the star represents),
// and when it leaves, set it back to the current rating.
onMouseEnter={() => setTmpRating (index + 1)}
onMouseLeave={() => setTmpRating (rating)}

// and use tmpRating instead of rating to fill the stars.
>{index < tmpRating ? FILLED : UNFILLED}</li>
)
})}
</ol>
</div>
)
}

Easy peasy! Ok, but you are eager to make the final touch: bolstering accessibility. Your plan involves leveraging a non-visible, yet functionally integral, range input element. This hidden interface allows the browser and assistive technologies to interact with and modify the rating, significantly enhancing component usability for all users. By connecting keyboard inputs directly to this element, users can adjust the rating more effortless:

components/StarRating/StarRating.jsx
// ...
export default function StarRating ({ initRate = 0 }) {
const [rating, setRating] = useState (initRate)
const [tmpRating, setTmpRating] = useState (rating)

// Since we are now able to set the rating from the
// input element, we need to update the tmpRating
// when the rating changes, which is the displayed value.
useEffect (() => {
setTmpRating (rating)
}, [rating])

return (
// Make the container relative to position the input
// element absolutely within it. Add a focus ring that
// is shown when the input element is focused.
// focus-within makes it visible when the input is tabbed
// to rather than clicked on.
<div className="relative focus-within:ring flex w-fit justify-center items-center text-xl">
<input
// Make the input element absolute and position it right
// on top of the stars. Make the input element invisible
// by setting the opacity to 0, its appearance to none and
// make it not respond to mouse events at all.
className="absolute top-0 left-0 opacity-0 appearance-none pointer-events-none"
// The title attribute is used to provide an accessible
// name for the input element.
title="Rating"
// Make it a range input with a minimum value of 0 and a
// maximum value of 5.
type="range"
min="0"
max="5"
// Connect the value of the input element to the rating
// state variable. This way, the input element will always
// reflect the current rating.
value={rating}
// When the input element changes, update the rating state
onChange={(event) => setRating (event.target.value)}
/>
<ol className="flex justify-between">
{[...Array (5)].map ((_, index) => {
return (
<li
key={index}
className="cursor-pointer active:opacity-70 flex-1 px-1"
onClick={() => setRating (index + 1)}
onMouseEnter={() => setTmpRating (index + 1)}
onMouseLeave={() => setTmpRating (rating)}
>{index < tmpRating ? FILLED : UNFILLED}</li>
)
})}
</ol>
</div>
)
}

Hm, you are not sure it works, it looks just the same. But then you use the Tab key to navigate to the element. And, voilà, it focuses and you can even change the rating by using the arrow keys! Your star rater is set apart, from average into exceptional!

There is one more thing though, you have to be able to use whatever rating is set in parent components. You are just about to finish it up when the backend guys sends you a message. It turns out that they are using multiple ratings providers and those sometimes differ in the number of levels that are used, some use 10 and one even uses 7. They ask you to make sure the component can handle that. You answer back quickly - No problem guys, no problem at all! See you on friday.

Alright, you put, what you believe is the final touch on the component: support for arbitary levels and a way to use the rating being set, as a bonus you make it possible to set an element id to be able to associate labels with the component:

components/StarRating/StarRating.jsx
import { useEffect, useState } from 'react'

const FILLED = ''
const UNFILLED = ''

// adding a levels and an onChange prop to be able to change
// the number of stars and provide callback to be called when
// the rating changes, and an id prop to be able to, for example,
// associate a label to the component
export default function StarRating ({ levels = 5, initRate = 0, onChange, id }) {
const [rating, setRating] = useState (initRate)
const [tmpRating, setTmpRating] = useState (rating)

useEffect (() => {
setTmpRating (rating)

// call the onChange callback when the rating changes
onChange && onChange (rating)
}, [rating, onChange])

return (
<div className="relative focus-within:ring flex w-fit justify-center items-center text-xl">
<input
className="absolute top-0 left-0 appearance-none pointer-events-none opacity-0"
title="Rating"
type="range"
min="0"

// set the id of the input element to the id prop
// this way, we can associate a label with the input
id={id}
// max is now levels
max={levels}

value={rating}
onChange={(event) => setRating (event.target.value)}
/>
<ol className="flex justify-between">

{/* change the number of stars to levels */}
{[...Array (levels)].map ((_, index) => {
return (
<li
key={index}
className="cursor-pointer active:opacity-70 flex-1 px-1"
onClick={() => setRating (index + 1)}
onMouseEnter={() => setTmpRating (index + 1)}
onMouseLeave={() => setTmpRating (rating)}
>{index < tmpRating ? FILLED : UNFILLED}</li>
)
})}
</ol>
</div>
)
}

To test it out you realise it is best to create a demo component that can hold state, for easy testing and showcasing:

components/StarRating/Demo.jsx
import { useState } from 'react'
import StarRating from './StarRating'

export default function Demo () {
const [rating1, setRating1] = useState (0)
const [rating2, setRating2] = useState (5)
return (
<>
<label for="star-rater1">[ {rating1}/5 ]</label>
<StarRating id="star-rater1" onChange={setRating1} />
<h2>[ {rating2}/10 ]</h2>
<StarRating onChange={setRating2} levels={10} initRate={5} />
</>
)
}

Export the demo component and use it on the page:

components/StarRating/index.js
'use client'
export { default } from './StarRating'
export { default as Demo } from './Demo'
app/star-rating/page.jsx
import { Demo } from "@/components/StarRating"

export default function StarRatingPage () {
return (
<main className="flex flex-col gap-8 p-4 sm:p-24">
<h1 className="text-xl font-bold">Star Rating</h1>
<Demo />
</main>
)
}

And there you have it, a fully functional, accessible star rating component. The team is impressed by your skills and eager to hand over the next task to you, it is about images...