feat(simulations): composants TimerDisplay + WordCountBar
This commit is contained in:
parent
24968f542d
commit
41d2eec3f7
2 changed files with 112 additions and 0 deletions
45
src/features/simulations/components/TimerDisplay.tsx
Normal file
45
src/features/simulations/components/TimerDisplay.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Affichage du minuteur de simulation au format MM:SS.
|
||||||
|
*
|
||||||
|
* Règle H : purement présentationnel, reçoit secondesRestantes et isExpired par props.
|
||||||
|
* Règle L : tokens Direction H uniquement (ink-*, danger).
|
||||||
|
*
|
||||||
|
* États visuels :
|
||||||
|
* - Normal : ink-2
|
||||||
|
* - Critique (< 120s) : danger + pulse (motion-safe uniquement — prefers-reduced-motion respecté)
|
||||||
|
* - Expiré : danger + bold
|
||||||
|
*
|
||||||
|
* A11y : role="timer" + aria-live="polite" pour annoncer les changements critiques.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatTimer } from '../lib/simulationConfig'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
secondesRestantes: number
|
||||||
|
isExpired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEUIL_CRITIQUE_SECONDES = 120
|
||||||
|
|
||||||
|
export function TimerDisplay({ secondesRestantes, isExpired }: Props) {
|
||||||
|
const isCritique = !isExpired && secondesRestantes < SEUIL_CRITIQUE_SECONDES
|
||||||
|
|
||||||
|
const base = 'tabular-nums text-sm font-medium'
|
||||||
|
const tone = isExpired
|
||||||
|
? 'text-danger font-bold'
|
||||||
|
: isCritique
|
||||||
|
? 'text-danger motion-safe:animate-pulse'
|
||||||
|
: 'text-ink-2'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-label={isExpired ? 'Temps écoulé' : `Temps restant ${formatTimer(secondesRestantes)}`}
|
||||||
|
className={`${base} ${tone}`}
|
||||||
|
>
|
||||||
|
{formatTimer(secondesRestantes)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/features/simulations/components/WordCountBar.tsx
Normal file
67
src/features/simulations/components/WordCountBar.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Barre de progression du nombre de mots avec compteur contextuel.
|
||||||
|
*
|
||||||
|
* Règle H : purement présentationnel, reçoit count et config par props.
|
||||||
|
* Règle L : tokens Direction H uniquement (success, warning, danger, ink-*, line).
|
||||||
|
*
|
||||||
|
* Couleurs selon le nombre de mots :
|
||||||
|
* - < motsCibleMin : warning (orange) — en dessous de la cible
|
||||||
|
* - motsCibleMin ≤ count ≤ motsCibleMax : success (vert) — dans la cible
|
||||||
|
* - > motsCibleMax : danger (rouge) — au-dessus de la cible
|
||||||
|
*
|
||||||
|
* La barre est graduée par rapport à motsCibleMax (100% = borne haute cible).
|
||||||
|
* Au-dessus on reste à 100% de remplissage en rouge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SimulationConfig } from '../lib/simulationConfig'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
count: number
|
||||||
|
config: SimulationConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tone = 'warning' | 'success' | 'danger'
|
||||||
|
|
||||||
|
function computeTone(count: number, config: SimulationConfig): Tone {
|
||||||
|
if (count > config.motsCibleMax) return 'danger'
|
||||||
|
if (count >= config.motsCibleMin) return 'success'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_CLASSES: Record<Tone, { bar: string; text: string }> = {
|
||||||
|
warning: { bar: 'bg-warning', text: 'text-warning' },
|
||||||
|
success: { bar: 'bg-success', text: 'text-success' },
|
||||||
|
danger: { bar: 'bg-danger', text: 'text-danger' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WordCountBar({ count, config }: Props) {
|
||||||
|
const tone = computeTone(count, config)
|
||||||
|
const classes = TONE_CLASSES[tone]
|
||||||
|
const pct = Math.min(100, Math.round((count / config.motsCibleMax) * 100))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={count}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={config.motsCibleMax}
|
||||||
|
aria-label={`Progression du nombre de mots : ${count} sur une cible de ${config.motsCibleMin} à ${config.motsCibleMax} mots`}
|
||||||
|
className="h-1.5 w-full overflow-hidden rounded-full bg-canvas-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-[width] duration-150 ease-out ${classes.bar}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className={`font-medium tabular-nums ${classes.text}`}>
|
||||||
|
{count.toLocaleString('fr-FR')} mot{count > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-4 tabular-nums">
|
||||||
|
cible {config.motsCibleMin}–{config.motsCibleMax} mots
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue