Chargement…

Composant Circle Choice ReactJS

Composant Circle Choice ReactJS

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:

cs2strats-after.gif

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).


Poursuivre la lecture

Comment formater son texte sur des composants React Dev
#react#text#library

Comment formater son texte sur des composants React

Apprenez à formater proprement vos templates string en React et améliorer la lisibilité de vos composants frontend.

Alexandre P.
Un template frontend React Typescript Dev
#react#template#typescript

Un template frontend React Typescript

Je vous donne mon template perso pour déployer un projet React Typescript en moins de temps qu'il ne faut pour lire le nombre de techno que ça embarque. 😂

Alexandre P.