LevelInfo

←→

Bouton « i » à côté du choix du niveau, qui ouvre l’explication des quatre niveaux pour la pratique choisie.

stableÀ valider
// Titre : Bouton « i » à côté du niveau
import LevelInfo from '@pathou/components/LevelInfo';

export default function Exemple() {
  return (
    <div style={{ minHeight: 60 }}>
      <LevelInfo practice="vtt" />
    </div>
  );
}

Installation

Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.

src/components/LevelInfo.tsx
import { useState } from 'react';
import Modal from './Modal';
import { DOWNHILL_LIMIT, FLAT_SPEED, LEVELS, LEVEL_LABEL, PRACTICE_LABEL, RIDE_HOURS, TECH_CAP, UPHILL_LIMIT, VAM } from '../lib/difficulty/rules';
import { fmt } from '../lib/format';
import { formatHours } from '../lib/time';
import type { Level, Practice } from '../lib/types';

export const PRACTICES: Practice[] = ['vtt', 'gravel', 'route'];

/** Rythme de base d'un niveau, nombres à la française (« 15,5 km/h »). */
export const paceText = (practice: Practice, li: number) =>
  `${fmt(FLAT_SPEED[practice][li], Number.isInteger(FLAT_SPEED[practice][li]) ? 0 : 1)}\u00a0km/h sur le plat · ${fmt(VAM[practice][li])}\u00a0m/h en montée`;

/** Portrait de chaque niveau, commun aux pratiques. */
export const LEVEL_PROFILE: Record<Level, string> = {
  debutant: 'Sorties occasionnelles. À l’aise sur les pistes larges\u00a0; pied à terre dès que ça devient raide ou technique.',
  intermediaire: 'Sorties régulières. Gère les racines et les pierres, enchaîne les montées sur piste sans trop forcer.',
  confirme: 'Pratique assidue et bonne technique\u00a0: épingles, marches, longues montées raides.',
  expert: 'Très bonne condition et pilotage\u00a0: passages très techniques, descentes raides et engagées.',
};

export const techText = (practice: Practice, li: number) => {
  const cap = TECH_CAP[practice][li];
  if (cap < 0) return 'Bitume uniquement';
  return cap === 0 ? 'S0 (sans obstacle)' : `Jusqu’à S${cap}`;
};

/** Bouton « i » à côté du choix du niveau, qui ouvre l'explication des niveaux. */
export default function LevelInfo({ practice }: { practice: Practice }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button className="info-btn" onClick={() => setOpen(true)} aria-label="Comprendre les niveaux" title="Comprendre les niveaux">
        <svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
          <circle cx="10" cy="10" r="7.5" />
          <path d="M10 9v5" strokeLinecap="round" />
          <circle cx="10" cy="6.3" r="0.6" fill="currentColor" />
        </svg>
      </button>
      {open && <LevelModal practice={practice} onClose={() => setOpen(false)} />}
    </>
  );
}

function LevelModal({ practice: initial, onClose }: { practice: Practice; onClose: () => void }) {
  const [practice, setPractice] = useState(initial);
  return (
    <Modal title="Comprendre les niveaux" onClose={onClose}>
      <p className="modal-lead">
        Ton niveau fixe ce que l’app considère comme «&nbsp;à ta portée&nbsp;»&nbsp;: la technicité des sentiers, les pentes que tu montes sans pousser, les descentes où
        tu restes à l’aise et ta vitesse pour estimer le temps. Au-delà, les passages sont cotés difficiles, très difficiles ou inadaptés.
      </p>

      <div
        className="segmented modal-tabs"
        role="tablist"
        aria-label="Pratique"
        onKeyDown={(e) => {
          // flèches : onglet voisin, comme un groupe d'onglets standard
          const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
          if (!step) return;
          e.preventDefault();
          const j = (PRACTICES.indexOf(practice) + step + PRACTICES.length) % PRACTICES.length;
          setPractice(PRACTICES[j]);
          (e.currentTarget.children[j] as HTMLElement | undefined)?.focus();
        }}
      >
        {PRACTICES.map((p) => (
          <button
            key={p}
            id={`level-tab-${p}`}
            role="tab"
            aria-selected={p === practice}
            aria-controls="level-panel"
            tabIndex={p === practice ? 0 : -1}
            className={p === practice ? 'on' : ''}
            onClick={() => setPractice(p)}
          >
            {PRACTICE_LABEL[p]}
          </button>
        ))}
      </div>

      <div className="level-cards" id="level-panel" role="tabpanel" aria-labelledby={`level-tab-${practice}`}>
        {LEVELS.map((level, li) => (
          <article key={level} className="level-card">
            <h3>{LEVEL_LABEL[level]}</h3>
            <p>{LEVEL_PROFILE[level]}</p>
            <dl>
              <div>
                <dt>Technicité à l’aise</dt>
                <dd>{techText(practice, li)}</dd>
              </div>
              <div>
                <dt>Montée sans pousser</dt>
                <dd>
                  {UPHILL_LIMIT[practice].compact[li]} % sur piste · {UPHILL_LIMIT[practice].sentier[li]} % sur sentier
                </dd>
              </div>
              <div>
                <dt>Descente engagée dès</dt>
                <dd>
                  {DOWNHILL_LIMIT[practice].compact[li]} % sur piste · {DOWNHILL_LIMIT[practice].sentier[li]} % sur sentier
                </dd>
              </div>
              <div>
                <dt>Sortie confortable</dt>
                <dd>
                  Jusqu’à {formatHours(RIDE_HOURS[li][0])} en roulant · très longue au-delà de {formatHours(RIDE_HOURS[li][1])}
                </dd>
              </div>
              <div>
                <dt>Rythme de base</dt>
                <dd>
                  {paceText(practice, li)}
                </dd>
              </div>
            </dl>
          </article>
        ))}
      </div>

      <p className="muted small">
        Pentes moyennes sur 100 m. La technicité suit l’échelle VTT S0–S5 d’OpenStreetMap (voir la légende dans le panneau de la trace). Ces repères sont
        indicatifs&nbsp;: l’état du sol, la météo et la fatigue comptent autant.
      </p>
    </Modal>
  );
}

Tokens : --panel --text --muted --chip --ease-out

Utilisation

import LevelInfo from './components/LevelInfo';

<LevelInfo practice={practice} />

Anatomie

#ÉlémentRôle
1BoutonLe « i » rond, nommé pour les lecteurs d’écran.
2FenêtreUne carte par niveau (débutant, intermédiaire, confirmé, expert), pour la pratique choisie et changeable dans la fenêtre.

Guidelines

  • SHOULD
    Décrire chaque niveau par ce que la personne sait déjà faire, pas par une note abstraite.

Référence de l’API

PropTypeDéfaut
practicePracticerequis