<Attente
mot="Traitement"
motif="chasse"
/>AttenteLoading state
Neuf pixels, un mot et un chronomètre, et pas une seule trame demandée.
"use client";
import { useEffect, useRef, useState } from "react";
/* ------------------------------------------------------------------
Attente · une grille de neuf pixels, un mot, et un chronomètre.
Le sablier, la roue qui tourne, la barre qui n'avance pas : trois
façons de dire « patientez » et aucune de dire combien de temps. Ce
composant en dit trois choses, et c'est ce qui le distingue :
CE QUI TRAVAILLE : le mot. Pas « Chargement », mais le verbe de
ce qui se passe. Un mot qui change pendant
l'attente dit qu'on avance ; un mot fixe dit
seulement qu'on n'a pas planté.
QUE ÇA VIT : la grille. Une onde traverse trois colonnes,
et son cycle est PLUS COURT que la traversée :
deux fronts sont toujours en vol, donc la
grille n'est jamais éteinte.
DEPUIS QUAND : le chronomètre. C'est la seule information
honnête qu'on puisse donner sans connaître la
fin, et c'est celle qu'on cherche des yeux au
bout de dix secondes.
NEUF ÉLÉMENTS, ZÉRO TRAME. Les neuf cellules sont animées par une
`@keyframes` CSS avec neuf retards différents : le compositeur s'en
occupe, le fil principal n'est réveillé par rien. Le chronomètre est
la seule chose qui tourne, dix fois par seconde, pas soixante, parce
qu'un dixième de seconde ne change pas plus vite que ça.
LE CHRONOMÈTRE N'EST PAS UN COMPTEUR. Il ne s'incrémente pas de cent
millisecondes à chaque réveil : il relit l'horloge. Un intervalle en
retard (onglet en arrière-plan, fil principal occupé, machine
chargée) ferait dériver un compteur de plusieurs secondes sur une
longue attente, et un chronomètre qui ment sur une attente longue
ment exactement quand on le regarde.
------------------------------------------------------------------- */
export type Motif = "chasse" | "points" | "orbite";
export type AttenteProps = {
/** Le verbe de ce qui travaille. Change-le pendant l'attente. */
mot?: string;
motif?: Motif;
/** Affiche le temps écoulé depuis le montage. */
chrono?: boolean;
className?: string;
};
/* La chasse : un front en chevron qui traverse de gauche à droite. Le
retard d'une cellule est sa colonne, plus son écart à la ligne du
milieu, ce qui incline le front au lieu de le laisser droit. */
const CHASSE = Array.from({ length: 9 }, (_, i) =>
((i % 3) + Math.abs(Math.floor(i / 3) - 1)) * 90
);
/* L'orbite : une comète fait le tour du bord. Le centre ne s'allume
jamais : il reste à l'état sourd, et c'est ce vide au milieu qui fait
lire une rotation plutôt qu'un scintillement. */
const TOUR = [0, 1, 2, 5, 8, 7, 6, 3];
const ORBITE = Array.from({ length: 9 }, (_, i) => {
const k = TOUR.indexOf(i);
return k === -1 ? null : k * 110;
});
const MOTIFS: Record<Motif, { retards: (number | null)[]; duree: number; rond: boolean }> = {
chasse: { retards: CHASSE, duree: 650, rond: false },
points: { retards: CHASSE, duree: 650, rond: true },
orbite: { retards: ORBITE, duree: 950, rond: false },
};
/** Le temps écoulé, relu sur l'horloge et jamais accumulé. */
function useEcoule(actif: boolean) {
const depart = useRef(0);
const [ms, setMs] = useState(0);
useEffect(() => {
if (!actif) return;
depart.current = performance.now();
setMs(0);
const t = window.setInterval(
() => setMs(performance.now() - depart.current),
100
);
return () => window.clearInterval(t);
}, [actif]);
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)} s`;
return `${Math.floor(s / 60)} min ${(s % 60).toFixed(0).padStart(2, "0")} s`;
}
function Grille({ retards, duree, rond }: (typeof MOTIFS)[Motif]) {
return (
<span className="att__grille" aria-hidden="true" data-rond={rond || undefined}>
{retards.map((r, i) => (
<span
key={i}
className="att__px"
data-morte={r === null || undefined}
style={
r === null
? undefined
: ({ "--r": `${r}ms`, "--t": `${duree}ms` } as React.CSSProperties)
}
/>
))}
</span>
);
}
export default function Attente({
mot = "Traitement",
motif = "chasse",
chrono = true,
className,
}: AttenteProps) {
const ecoule = useEcoule(chrono);
const m = MOTIFS[motif] ?? MOTIFS.chasse;
return (
/* `role="status"` et pas `aria-live="assertive"` : une attente
s'annonce quand le lecteur d'écran a fini sa phrase, elle ne
l'interrompt pas. Le chronomètre en est retiré : il changerait
dix fois par seconde, et personne ne veut entendre ça. */
<div className={className ? `att ${className}` : "att"} role="status">
<Grille {...m} />
<span className="att__mot">{mot}</span>
{chrono && (
<span className="att__chrono" aria-hidden="true">
{ecoule}
</span>
)}
</div>
);
}
/* Attente · src/components/book/kit/Attente.tsx */
.att {
display: inline-flex;
align-items: center;
gap: 0.7em;
--att-px: 4px;
--att-jour: 1.5px;
}
/* ---------- La grille ----------
Trois colonnes de quatre pixels. Les neuf cellules sont animées par
une seule `@keyframes`, avec neuf retards : rien n'est piloté en
JavaScript, et le fil principal n'est réveillé par aucune trame.
Les cellules mortes de l'orbite gardent leur place et leur teinte la
plus sourde. Les retirer ferait s'effondrer la grille sur huit
cellules ; les allumer supprimerait le vide central qui fait lire une
rotation. */
.att__grille {
display: grid;
flex: none;
grid-template-columns: repeat(3, var(--att-px));
gap: var(--att-jour);
}
.att__px {
width: var(--att-px);
height: var(--att-px);
border-radius: 1px;
background: var(--fg);
opacity: 0.16;
animation: att-pixel var(--t, 650ms) ease-in-out var(--r, 0ms) infinite;
}
.att__grille[data-rond] .att__px {
border-radius: 50%;
}
.att__px[data-morte] {
opacity: 0.07;
animation: none;
}
@keyframes att-pixel {
0%,
100% {
opacity: 0.16;
}
38% {
opacity: 1;
}
}
/* ---------- Le mot ----------
Le reflet passe DANS la lettre, pas derrière : le dégradé est peint
sur le texte lui-même. Une bande claire qui traverse un bloc de texte
se lit comme un balayage de chargement générique ; la même bande dans
le tracé des lettres se lit comme une matière. */
.att__mot {
font-size: 0.92em;
font-weight: 500;
color: var(--muted-strong);
background-image: linear-gradient(
90deg,
var(--muted-strong) 34%,
var(--fg) 50%,
var(--muted-strong) 66%
);
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: att-reflet 1.5s linear infinite;
}
@keyframes att-reflet {
from {
background-position: 130% 0;
}
to {
background-position: -30% 0;
}
}
/* Chiffres TABULAIRES, et c'est tout l'intérêt. Sans eux, la largeur du
nombre change à chaque dixième de seconde, et la ligne entière
tressaute : un chronomètre qui gigote se lit comme une erreur. */
.att__chrono {
font-family: var(--mono);
font-size: 0.82em;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Mouvement réduit : la grille se fige à son état sourd et le reflet
s'en va. Le chronomètre, lui, continue : c'est de l'information, pas
du mouvement, et c'est justement celle qu'on est venu chercher. */
@media (prefers-reduced-motion: reduce) {
.att__px,
.att__mot {
animation: none;
}
.att__mot {
-webkit-text-fill-color: var(--muted-strong);
}
}
/* En contraste forcé, un texte peint par un dégradé disparaît : le
système impose ses couleurs et le remplissage transparent gagne. */
@media (forced-colors: active) {
.att__mot {
background: none;
-webkit-text-fill-color: CanvasText;
}
}