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