Composant Circle Choice ReactJS

Découvrez comment créer un composant React innovant pour remplacer select/radio, avec React-hook-form, Tailwind, et NextJS. Intuitif et personnalisable ! 🚀
Hey les développeurs et développeuses, 👋
Aujourd'hui nous allons développer un composant React pour remplacer un composant select ou radio.
Dernièrement je vous parlais d'un composant ReactJS que j'ai designé de manière à rendre plus pratique un choix multiple de 3, 4 ou 5 items:

Surtout dans une situation où on l'on peut mettre une icone ou une image pour un choix, cela rend les choses beaucoup plus simple à visualiser.
Pour réaliser ce composant, j'ai utilisé React, React-hook-form, Tailwind sur NextJS 14.
Composants
Je commence par créer un composant CircleButton.tsx:
export type CircleButtonProps = {
children: React.ReactNode;
onClick: () => void;
}
export const CircleButton = ({ children, onClick }: CircleButtonProps) => {
return (
<div
className="w-28 h-28 bg-white border border-gray-200 rounded-full shadow dark:bg-gray-800 dark:border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-300"
onClick={onClick}
>
{children}
</div>
);
};
Ensuite, je crée un composant CircleChoice.tsx:
import { useState } from "react";
import { Control, Controller } from "react-hook-form";
import { CircleButton } from "./circleButton";
import cn from "classnames";
import styles from "./CircleChoice.module.css"
type Choice = {
value: string;
content: React.ReactNode;
};
export type CircleChoiceProps<T> = {
fieldTitle: string;
fieldName: string;
control: Control<any>;
choices: Choice[];
};
export const CircleChoice = <T,>({ fieldName, control, fieldTitle, choices }: CircleChoiceProps<T>) => {
const [isOpened, setIsOpened] = useState(false);
const radius = 120;
return (
<div className="mb-6 relative flex flex-col items-center justify-center">
<Controller
control={control}
name={fieldName}
render={({ field }) => (
<>
<div
className="fixed top-0 left-0 w-full h-full bg-black opacity-50 z-10"
style={{ display: isOpened ? "block" : "none" }}
onClick={() => setIsOpened(false)}
>
</div>
<div className="relative z-20">
<CircleButton
onClick={() => {
setIsOpened(!isOpened);
field.onChange(field.value);
}}
>
{
field.value ?
choices.find(choice => choice.value === field.value)?.content :
fieldTitle
}
</CircleButton>
{isOpened && (
<div
className={`absolute inset-0 transition-all duration-500 scale-100`}
onClick={() => setIsOpened(false)}
>
<div
onClick={() => setIsOpened(false)}
className="absolute w-12 h-12 bg-gray-300 border border-gray-400 rounded-full shadow dark:bg-gray-800 dark:border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-400"
style={{
transform: `translate(${radius / 4}px, ${radius / 4}px)`,
}}>
✖
</div>
{choices.map((choice, index) => {
return (
<div
key={choice.value}
className={cn("absolute transition-transform duration-500",
styles[`circle-button-anim-${choices.length}-${index}`]
)}
>
<CircleButton
onClick={() => {
field.onChange(choice.value);
setIsOpened(false);
}}
>
{choice.content}
</CircleButton>
</div>
);
})}
</div>
)}
</div>
</>
)}
/>
</div>
);
};
Et enfin j'ajoute mon module pour le CSS dans un fichier CircleChoice.module.css:
@keyframes moveAway-3-0 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(0px, -120px);
opacity: 1;
}
}
@keyframes moveAway-3-1 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(103.92px, 60px);
opacity: 1;
}
}
@keyframes moveAway-3-2 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-103.92px, 60px);
opacity: 1;
}
}
.circle-button-anim-3-0 {
animation: moveAway-3-0 0.5s ease-out forwards;
}
.circle-button-anim-3-1 {
animation: moveAway-3-1 0.5s ease-out forwards;
}
.circle-button-anim-3-2 {
animation: moveAway-3-2 0.5s ease-out forwards;
}
@keyframes moveAway-4-0 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(120px, 0px);
opacity: 1;
}
}
@keyframes moveAway-4-1 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(0px, 120px);
opacity: 1;
}
}
@keyframes moveAway-4-2 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-120px, 0px);
opacity: 1;
}
}
@keyframes moveAway-4-3 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(0px, -120px);
opacity: 1;
}
}
.circle-button-anim-4-0 {
animation: moveAway-4-0 0.5s ease-out forwards;
}
.circle-button-anim-4-1 {
animation: moveAway-4-1 0.5s ease-out forwards;
}
.circle-button-anim-4-2 {
animation: moveAway-4-2 0.5s ease-out forwards;
}
.circle-button-anim-4-3 {
animation: moveAway-4-3 0.5s ease-out forwards;
}
@keyframes moveAway-5-0 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(120px, 0px);
opacity: 1;
}
}
@keyframes moveAway-5-1 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(37.08px, 114.43px);
opacity: 1;
}
}
@keyframes moveAway-5-2 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-97.08px, 70.63px);
opacity: 1;
}
}
@keyframes moveAway-5-3 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(-97.08px, -70.63px);
opacity: 1;
}
}
@keyframes moveAway-5-4 {
from {
transform: translate(0px, 0px);
opacity: 0;
}
to {
transform: translate(37.08px, -114.43px);
opacity: 1;
}
}
.circle-button-anim-5-0 {
animation: moveAway-5-0 0.5s ease-out forwards;
}
.circle-button-anim-5-1 {
animation: moveAway-5-1 0.5s ease-out forwards;
}
.circle-button-anim-5-2 {
animation: moveAway-5-2 0.5s ease-out forwards;
}
.circle-button-anim-5-3 {
animation: moveAway-5-3 0.5s ease-out forwards;
}
.circle-button-anim-5-4 {
animation: moveAway-5-4 0.5s ease-out forwards;
}
Enfin, à l'utilisation, j'ajoute dans mon composant le bout de code suivant:
const VEHICLES = {
"car": "Voiture",
"plane": "Avion",
"train": "Train",
"bus": "Bus"
}
// ...
<CircleChoice
fieldName="vehicle"
control={control}
fieldTitle="Choix du véhicule"
choices={Object.entries(VEHICLES).map(([key, value]) => ({
value,
content: (<>
<img
src={`/vehicles/${value.toLowerCase()}.png`}
alt={key} className="w-10 h-10 mb-2" />
<span className="text-sm">{key}</span>
</>)
}))}
/>
Voilà, c'est assez simple et efficace je trouve, bon code à vous ! 😉
Je vous laisse personnaliser cela selon vos besoins.
FAQ
Est-ce que ce composant fonctionne avec n'importe quel formulaire React ou uniquement avec React-hook-form ?
Tel qu'il est conçu, le composant utilise le Controller de React-hook-form pour gérer la valeur du champ. Il faudrait l'adapter pour l'utiliser avec une autre solution de gestion de formulaire ou un simple useState.
Combien d'options peut-on afficher dans le composant CircleChoice ?
Le CSS fourni gère nativement 3, 4 ou 5 choix, chacun avec ses propres animations positionnées en cercle. Au-delà de 5 options, il faudra ajouter manuellement les keyframes et classes correspondantes dans le fichier CSS.
Peut-on mettre du texte seul dans les boutons, sans image ou icône ?
Oui, le contenu de chaque bouton est un ReactNode libre, donc on peut y passer du texte, une icône, une image ou n'importe quel élément JSX selon les besoins du projet.
Tailwind est-il obligatoire pour utiliser ce composant ?
Tailwind gère tout le style des boutons et du fond semi-transparent. Il est possible de remplacer les classes utilitaires par du CSS classique, mais cela demande de réécrire la mise en forme de CircleButton et CircleChoice.
Comment fonctionne la fermeture du menu quand on clique ailleurs ?
Un overlay noir semi-transparent est affiché en position fixe sur toute la page quand le menu est ouvert. Un clic sur cet overlay déclenche la fermeture du composant via setIsOpened(false).

Alexandre P.
Développeur passionné depuis plus de 20 ans, j'ai une appétence particulière pour les défis techniques et changer de technologie ne me fait pas froid aux yeux.
Poursuivre la lecture


