Utilitaires, hooks et mode de couleur
Ce que la phase règle
Les trois choses que chaque projet réécrivait et que le design system ne fournissait pas : les fonctions utilitaires du quotidien, les hooks de base, et le basculement clair / sombre.
@sia-ui/utils passe de six modules à quatorze.
| Module | Ce qu'il apporte |
|---|---|
query | toQueryString, fromQueryString, removeEmptyParams, mergeSearchParams |
async | sleep, debounce, throttle, createRetry, toAsyncState |
collection | groupBy, keyBy, chunk, partition, sortBy, sumBy, range, move, toggleItem |
object | pick, omit, isEmpty, deepMerge, getPath, setPath, isEqual, diff |
validation | règles composables et createValidator |
file | formatBytes, fileKind, matchesAccept, readAsDataUrl, downloadBlob |
clipboard | copyText avec son repli pour les contextes non sécurisés |
locale | resolveLocale, compareStrings, looseIncludes, formatList, countLabel |
Chacun s'importe seul — @sia-ui/utils/date — pour maîtriser ce qui entre dans le bundle.
validationest absent du barrel racine, volontairement. Ses règles s'appellentmin,max,url,required: sorties de leur module, ces noms ne veulent plus rien dire et entrent en collision avec tout. Elles s'importent de@sia-ui/utils/validation.
Les doublons supprimés
Le point de cette phase n'est pas seulement d'ajouter. Quatre implémentations qui coexistaient ont été ramenées à une.
buildQueryStringde@sia-ui/apiappelletoQueryString. Il garde son typageQueryParams, qui est une information du module API — mais pas sa propre sérialisation. Deux sérialisations qui divergent d'un espace produisent des caches qui ne se recoupent jamais.isDefined,unique,AsyncStatus,AsyncStatevivent dans@sia-ui/utils: ce sont des fonctions et des contrats sans rapport avec le rendu.delay()du client HTTP devientsleep(), qui sait en plus honorer unAbortSignal.Paginationne calcule plus ses ellipses :paginationItemsde@sia-ui/headlesssert le composant web et le hookusePagination.
@sia-ui/api dépend de @sia-ui/utils, qui n'a lui-même aucune dépendance. La pile reste acyclique :
tokens ─┬─> headless ─┬─> react-web
utils ──┴─> api │
react ─────┘Les hooks : pas de dixième paquet
La roadmap prévoyait un paquet @sia-ui/hooks. Il n'a pas été créé, et c'est délibéré : @sia-ui/react est déjà le paquet des hooks React sans rendu, et @sia-ui/headless celui du comportement sans plateforme. Ajouter un troisième aurait obligé, à chaque nouveau hook, à trancher entre trois endroits qui se ressemblent.
La règle appliquée est la même que partout ailleurs dans le dépôt :
| Hook | Où | Pourquoi |
|---|---|---|
useDebouncedValue, useDebouncedState | headless | Du temps et de l'état. Aucun DOM. |
usePagination, paginationItems | headless | Du calcul. Une liste native en a autant besoin. |
useToggle | headless | Un booléen. |
useMediaQuery, useBreakpoint, useIsMobile, usePrefersDark, usePrefersReducedMotion | react | window.matchMedia. |
useLocalStorage | react | localStorage. |
useCopyToClipboard | react | navigator.clipboard. |
Deux détails qui comptent
useMediaQuery passe par useSyncExternalStore. Un useState plus un useEffect rendrait false au premier affichage puis la vraie valeur : la mise en page saute une fois montée. useSyncExternalStore dit à React que la source est extérieure, et le rendu serveur reçoit la valeur de repli qu'on lui donne.
useLocalStorage hydrate au second rendu. Lire le stockage pendant le rendu ferait diverger le serveur et le client — l'erreur d'hydratation classique. Chaque accès est de plus protégé : en navigation privée, ou avec les données de site bloquées, la simple lecture lève. Une valeur absente est un cas normal; une page qui tombe pour cette raison ne l'est pas.
Le mode de couleur
Beaucoup d'applications ajoutent next-themes pour cette seule fonction. Elle tient maintenant dans SiaProvider, sans dépendance.
<SiaProvider defaultColorMode="system">
<App />
</SiaProvider>const { colorMode, resolvedColorMode, setColorMode, toggleColorMode } =
useColorMode();colorModeest ce qui est demandé :light,darkousystem.resolvedColorModeest ce qui est appliqué,systemrésolu.- Le choix est enregistré sous
sia-ui.color-mode;storageKey={false}désactive la persistance. toggleColorModebascule depuis ce qui est affiché, pas depuis ce qui est demandé : en mode système, la personne voit du sombre et attend du clair.
color-scheme est posé sur <html> en plus de l'attribut de thème. C'est ce qui fait suivre au navigateur ce qu'il dessine lui-même — barres de défilement, champs natifs, menus du système. Sans elle, un thème sombre garde des ascenseurs blancs.
La feuille globale de @sia-ui/react-web affine également les barres de défilement de la page et de toutes les zones scrollables : piste transparente, curseur fin et arrondi, puis contraste renforcé au survol. Les couleurs suivent les tokens du thème. Les sélecteurs utilisent :where(*), donc une application peut surcharger ce défaut sans combattre une forte spécificité CSS.
Le bouton de bascule
ColorModeToggle est ce bouton, prêt à poser dans une barre : il lit et écrit le mode de SiaProvider — persistance comprise —, montre le soleil en mode sombre et la lune en mode clair (l'icône annonce où l'on va, comme le libellé), et porte aria-pressed. Toutes les props d'IconButton passent, sauf l'icône et le clic qu'il pilote. SunIcon et MoonIcon rejoignent le jeu d'icônes pour qui préfère composer le sien.
Le clignotement au chargement
Entre le chargement du document et le premier rendu de React, la page s'affiche dans le mode par défaut : un éclair blanc sur un thème sombre. colorModeScript() rend le script à poser dans le <head>, qui lit le choix enregistré et pose l'attribut avant la première peinture.
<head>
<script dangerouslySetInnerHTML={{ __html: colorModeScript() }} />
</head>La validation, branchée au formulaire
createValidator rend exactement ce que useLocalForm attend. C'est le point de rencontre entre @sia-ui/utils et le contrat de 35 — Le contrat de formulaire, sans que l'un importe l'autre.
import {
createValidator,
email,
min,
required,
} from "@sia-ui/utils/validation";
const validate = createValidator<Valeurs>({
nom: [required()],
email: [required(), email()],
montant: [required(), min(0)],
});
const form = useLocalForm({ defaultValues, validate, onSubmit });Une règle rend un message ou undefined. Un champ vide facultatif passe : seule required juge le vide — afficher « au moins huit caractères » sur un champ qu'on n'a pas encore rempli n'apprend rien à personne. Le marqueur judgesEmpty porte cette distinction, plutôt qu'une comparaison de nom qui disparaîtrait à la minification.
Deux composants qui en profitent
SearchInput reçoit searchDelay. Au-delà de zéro, onSearch part quand la frappe s'arrête — ce que chaque projet réécrivait autour de ce champ. Le champ lui-même reste immédiat : retarder la valeur affichée donnerait un champ qui paraît figé. La validation et l'effacement annulent le délai en cours.
Pagination reçoit jumpTo et showTotal. Sur quarante pages, atteindre la vingt-septième demandait de cliquer dans les ellipses jusqu'à ce qu'elle apparaisse. Une page hors bornes est ramenée dedans plutôt que refusée : taper 99 sur 40 pages veut dire « la dernière ».
Vérification
- 34 assertions sur les utilitaires (
packages/utils/src/phase6.test.ts), 22 sur les hooks, le thème et les deux composants (packages/react-web/src/phase6.test.tsx). - Les cas retenus sont ceux qui cassent en vrai :
0etfalseperdus par un nettoyage de paramètres, « Émile » rangé après « Zoé » faute delocaleCompare, uneDatefusionnée champ par champ, unlocalStoragequi lève, untoggleColorModequi part du mode demandé plutôt que de l'affiché. - 272 assertions sur le dépôt.