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.
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 « à ta portée » : 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 : 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ément | Rôle |
|---|---|---|
| 1 | Bouton | Le « i » rond, nommé pour les lecteurs d’écran. |
| 2 | Fenêtre | Une carte par niveau (débutant, intermédiaire, confirmé, expert), pour la pratique choisie et changeable dans la fenêtre. |
Guidelines
- SHOULDDécrire chaque niveau par ce que la personne sait déjà faire, pas par une note abstraite.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
practice | Practice | requis |