Skip to content

Présentation ​

Pas une bibliothèque de composants de plus ​

SIA UI ne cherche pas à avoir le plus beau bouton ni cent variantes de carte. D'autres bibliothèques le font très bien, et ce n'est pas là qu'une application de gestion perd son temps.

Elle le perd à réécrire, projet après projet, les mêmes briques métier :

  • la connexion, le jeton qui expire, la déconnexion, les droits ;
  • le client HTTP, ses en-têtes, ses erreurs, ses nouvelles tentatives ;
  • la page de liste avec sa recherche, ses filtres, sa pagination et ses quatre boîtes de dialogue ;
  • les montants en devise, les dates, les téléphones, les fichiers ;
  • les écrans qui se mettent à jour quand le serveur change quelque chose ;
  • les textes, en français, en anglais ou dans la langue du client.

SIA UI livre ces briques, écrites une fois, testées et branchées entre elles. Les composants visuels sont là, soignés et accessibles, mais ils servent ces briques : ils ne sont pas le but.

Chaque section ci-dessous part d'un besoin concret, dit ce qui est livré et renvoie au guide qui le détaille. Les composants ont leur propre référence et leur catalogue.

La session de l'utilisateur ​

Le besoin. Au chargement, on ne sait pas encore si la personne est connectée : il faut attendre le serveur sans la renvoyer à tort vers la page de connexion. Elle se connecte, parfois en deux temps (un code reçu par SMS, un profil à compléter). Elle travaille, son jeton expire au milieu d'une saisie : il faut le renouveler sans qu'elle s'en aperçoive, et si c'est impossible lui proposer de se reconnecter sans perdre ce qu'elle a tapé. Puis elle se déconnecte, et tout doit être remis à zéro.

Ce qui est livré. createSessionStore (@sia-ui/headless) est une machine à états qui couvre tout ce parcours. Il vit hors de React : le client d'API peut le prévenir d'une expiration.

ts
const session = createSessionStore({
  hydrate: (signal) => api.get<Utilisateur | null>("/auth/moi", { signal }),
  signIn: (identifiants) => auth.connecter(identifiants),
  signOut: () => jetons.clear(),
  permissionsOf: (u) => u.permissions,
  rolesOf: (u) => u.roles,
  tenantOf: (u) => u.organisation, // multi-organisation
  isComplete: (u) => u.telephoneVerifie,
});
session.start();
  • checking n'est pas anonymous : RequireSession affiche une attente et ne redirige personne tant que le serveur n'a pas répondu.
  • expired n'est pas une déconnexion : l'état dit que la session a expiré. L'application choisit d'ouvrir une boîte de reconnexion par-dessus la saisie en cours ou de repartir à l'accueil.
  • La déconnexion vide toujours la session locale, même si l'appel au serveur échoue : rester connecté à l'écran après avoir demandé à partir serait pire. L'échec reste lisible dans error.
  • Une vérification relancée annule la précédente (reload(), reset()) : une réponse en retard n'écrase pas la plus récente.

Les droits. useCan(session, "facture.valider"), <Can>, PermissionGate et RequireSession masquent ce qui n'est pas permis. L'évaluateur est injecté, selon la forme que le serveur renvoie :

ÉvaluateurRègle
matchRules (défaut)"facture.valider", { anyOf }, { allOf }, { not }, jokers credit.*
matchExpression"facture.lire & (role:admin | facture.valider)"
matchExactégalité stricte

Les rôles se testent comme des permissions (role("admin")), et la navigation latérale se filtre par les droits (filterNavTree). Voir Session et droits.

Cela protège l'interface, pas les données : le serveur doit toujours refuser l'appel.

Le client d'API et ses greffons ​

Le besoin. Chaque requête porte le jeton et la langue. Un 401 déclenche le renouvellement du jeton ; pendant ce temps, les autres requêtes attendent au lieu d'échouer, puis repartent avec le nouveau jeton. Si le renouvellement échoue, la session expire. Les jetons survivent au rechargement, sauf en navigation privée où le stockage lève une erreur. Une lecture qui échoue sur une coupure réseau est rejouée, jamais une écriture. Les erreurs de validation du serveur s'affichent sous les bons champs.

Ce qui est livré. createApiClient (@sia-ui/api) s'appuie sur fetch et ne dépend d'aucune librairie. Les greffons s'enchaînent avec use(). Voici le branchement complet d'une application, de bout en bout :

ts
import {
  createApiClient,
  createAuthPlugin,
  createBrowserTokenStorage,
  createRefreshTokenPlugin,
  createUnauthorizedPlugin,
  createIdempotencyPlugin,
  FRENCH_STATUS_MESSAGES,
} from "@sia-ui/api";

// 1. Où vivent les jetons : localStorage, sessionStorage, ou un trousseau natif.
const jetons = createBrowserTokenStorage(localStorage);

const api = createApiClient({
  baseURL: "/api",
  getLanguage: () => "fr-FR",
  statusMessages: FRENCH_STATUS_MESSAGES, // « Conflict » devient une phrase lisible
});

api
  // 2. Le jeton d'accès sur chaque requête.
  .use(createAuthPlugin({ getToken: jetons.getAccessToken }))
  // 3. 401 : renouvelle le jeton une seule fois, met les autres requêtes en
  //    file, les rejoue, et enregistre les nouveaux jetons.
  .use(
    createRefreshTokenPlugin(
      {
        enabled: true,
        refreshEndpoint: "/api/auth/refresh",
        getRefreshToken: jetons.getRefreshToken,
        saveTokens: jetons.saveTokens,
        onRefreshFailure: () => session.expire(),
      },
      (ctx) => api.replay(ctx),
    ),
  )
  // 4. Un 401 qui reste un 401 : la session expire, l'application décide.
  .use(createUnauthorizedPlugin({ onUnauthorized: () => session.expire() }))
  // 5. Pas de double facture si un POST est rejoué après une coupure.
  .use(createIdempotencyPlugin());
Greffon ou moduleCe qu'il règle
createAuthPluginle jeton d'accès (Bearer ou un autre schéma) sur chaque requête
createRefreshTokenPluginle renouvellement du jeton : une seule demande, file d'attente, rejeu, échec signalé
createUnauthorizedPluginla réaction à un 401 : expiration, déconnexion, redirection
createBrowserTokenStoragejetons dans localStorage / sessionStorage, protégé en navigation privée
createMemoryTokenStoragejetons en mémoire, pour les tests
createIdempotencyPluginIdempotency-Key sur POST, PUT, PATCH
createReadOnlyPlugincoupe toute écriture avant l'envoi : démonstration, maintenance
createHeaderSignalPluginle serveur signale par un en-tête (« permissions changées »), le client réagit une seule fois
createLoggerPluginméthode, URL, statut, durée ; la sortie est injectable
createFetchTransport, createAxiosTransportle transport, remplaçable par le sien

Ce que le client fait sans greffon :

  • nouvelles tentatives sur erreur réseau ou 408, 429, 502, 503, 504, avec délai croissant, pour GET et HEAD seulement ;
  • délai maximal et annulation (signal) sur chaque appel ;
  • HttpError normalisée : statut, message lisible, toFormErrors() pour placer les erreurs du serveur sous les champs d'un Form ou d'une CrudPage (formats errors, fieldErrors, violations, class-validator) ;
  • messages lisibles à la place de « Conflict » ou « Forbidden resource », en français (FRENCH_STATUS_MESSAGES) ou en anglais (ENGLISH_STATUS_MESSAGES), sans jamais toucher un message métier ;
  • pagination page / limit (getPaginated) ou par curseur (getCursorPage) ;
  • SWR et RTK Query branchés sans les importer (swrFetcher, rtkBaseQuery).

Voir Module API.

Les ressources et le CRUD ​

Le besoin. Pour chaque entité (factures, clients, utilisateurs), on écrit le service HTTP, les clés de cache, l'invalidation après chaque écriture, les colonnes du tableau, les champs du formulaire, la vue de détail, la validation, la recherche et les règles de droits. Ces descriptions divergent toujours.

Ce qui est livré. Trois étages, chacun utilisable seul :

ts
// Le service HTTP : list, getById, create, update, patch, remove, search.
const factures = createResourceService<Facture>(api, "/factures");
await factures.search({ $or: [{ statut: "payee" }, { montant: { gte: 1_000_000 } }] });

// Le cache : clés emboîtées et invalidation des listes après écriture.
const facturesQuery = createQueryResource(factures, { updateMethod: "PATCH" });
useQuery(facturesQuery.pageQuery({ page, limit: 20 }));
const enregistrer = useMutation(facturesQuery.saveMutation(queryClient));

// L'écran : décrit une fois, tout le reste est dérivé.
const FACTURE = defineResource<Facture>({
  name: "facture",
  label: { singular: "Facture", plural: "Factures" },
  permissions: "auto", // facture.creer, facture.lire, facture.modifier, facture.supprimer
  fields: {
    reference: { required: true },
    montant: { type: "currency", currency: "XOF", required: true },
    echeance: { type: "date" },
    statut: { type: "select", options: STATUTS, tones: { payee: "success" } },
  },
});

<CrudPage
  resource={FACTURE}
  data={donnees}
  can={can}
  onSubmit={(valeurs, { row }) => enregistrer.mutateAsync({ id: row?.id, values: valeurs })}
  onDelete={(f) => supprimer.mutateAsync(f.id)}
/>

Sans rien écrire de plus, on obtient : les montants alignés dans leur devise, les dates formatées, les badges de statut, les boîtes de création, de modification, de consultation et de suppression, la validation, les erreurs du serveur sous les champs, la recherche, l'état vide, le chargement, les boutons masqués selon les droits et la vue en cartes sur mobile.

L'état du tableau (page, tri, filtres, recherche) se synchronise avec l'URL (createUrlTableStorage, createTanStackRouterStorage) et se traduit pour l'API (toServerParams) : un lien partagé rouvre la même vue. Voir Déclarer une ressource et La page de liste.

Les formulaires et la validation ​

Le besoin. Valider sans imposer de librairie, vérifier auprès du serveur qu'un identifiant est libre sans l'interroger à chaque frappe, afficher les erreurs renvoyées par le serveur, ne pas perdre une saisie en quittant la page, ne jamais réafficher un secret.

Ce qui est livré.

  • un contrat unique, FormAdapter, avec useLocalForm livré et un adaptateur react-hook-form. Zod se branche sans dépendance ;
  • des règles prêtes (required, email, phone, min, max, matches, sameAs, oneOf…) et createValidator ;
  • validateAsync par champ : la vérification serveur attend la fin de la frappe, annule la précédente, affiche « Vérification… » et bloque l'envoi ;
  • Form, le formulaire déclaratif, et les 23 types de champ de Field ;
  • UnsavedChangesGuard : un avertissement avant de quitter une saisie ;
  • SecretFields : des secrets en écriture seule, qu'on remplace sans jamais les relire.

Voir Le contrat de formulaire et Les types de champ.

Le temps réel ​

Le besoin. Quand une opération change sur le serveur, les écrans ouverts doivent le montrer : la fiche se met à jour, les listes se rechargent, une notification apparaît. Après une coupure, ce qui a changé pendant l'absence doit revenir.

Ce qui est livré. createRealtimeBinding relie un flux Socket.IO ou SSE au cache de requêtes, sans dépendre de l'un ni de l'autre :

ts
const liaison = createRealtimeBinding({
  source: socketIoSource(socket),
  cache: queryClient,
  refetchOnReconnect: [facturesQuery.keys.lists()],
  on: {
    "facture.payee": (f: Facture, { patch, invalidate }) => {
      patch(facturesQuery.keys.detail(f.id), (avant) => avant && { ...avant, ...f });
      invalidate(facturesQuery.keys.lists());
      toast.success(`${f.reference} est payée.`);
    },
  },
});

useRealtimeStatus et LiveIndicator affichent l'état de la connexion. JobProgress et LogStream suivent une opération longue. Voir Module API et Opérations longues et journaux.

Les langues ​

Le besoin. Livrer la même application en français et en anglais, ou dans la langue d'un client, sans chercher les textes cachés dans chaque composant.

Ce qui est livré. FRENCH et ENGLISH, au même format typé. Aucun composant n'a de texte en dur : un test le vérifie en montant chacun en anglais. Dates, nombres et pluriels suivent la langue. Une application qui a sa propre i18n (i18next ou autre) remplit ce format et le passe :

tsx
<SiaProvider locale={ENGLISH}>
  <App />
</SiaProvider>

Voir Langues, libellés et réglages.

Les utilitaires ​

Le besoin. Les mêmes fonctions recopiées de projet en projet, chacune avec son petit défaut : un arrondi de montant faux, un tri qui place « Émile » après « Zoé », une recherche qui ne trouve pas « Côte » en tapant « cote ».

Ce qui est livré. @sia-ui/utils, sans aucune dépendance, utilisable côté serveur comme dans le navigateur :

DomaineExemples
MontantsformatCurrency, parseCurrency, majorToMinor, minorToMajor, roundCurrency
DatesformatDate, formatRelativeDate, formatTimeAgo, formatDateRange, addMonths
NombresformatNumber, formatPercent, parseDecimal, safeDivide, clamp
Textesinitials, slugify, maskMiddle, truncate, looseIncludes, compareStrings
CollectionsgroupBy, keyBy, sumBy, partition, uniqueBy, sortBy
ObjetsdeepMerge, diff, pick, omit, getPath, isEqual
RequêtestoQueryString, fromQueryString, removeEmptyParams
FichiersformatBytes, fileKind, matchesAccept, downloadBlob
ValidationisEmail, isPhone, required, minLength, createValidator
Asynchronedebounce, throttle, createRetry, sleep

Et dans @sia-ui/headless, les comportements sans DOM : usePagination, useTableQuery, useDisclosureState, useDebouncedValue, useAsyncValidation, toast, overlayStack (Échap et clic extérieur ne ferment que la couche du dessus). Voir Utilitaires partagés et Comportement partagé.

Le thème ​

Des jetons de couleur, d'espacement et de rayon, un mode clair et un mode sombre qui suit le poste ou se choisit, sans clignotement au chargement. Le texte posé sur une couleur de marque est calculé pour rester lisible. Une marque se change par un objet, pas par une feuille de style. Voir Theming.

Des défauts, jamais des obligations ​

Tout marche sans configuration, et tout se remplace :

  • un défaut raisonnable partout : le français, le fetch natif, useLocalForm, les droits <ressource>.<verbe>, les messages d'erreur lisibles ;
  • rien n'est imposé : aucun routeur, aucun store, aucune librairie de requêtes ni de traduction. On injecte les siens, et pnpm check:deps vérifie qu'aucun paquet publié n'embarque de dépendance tierce ;
  • une prop gagne toujours : SiaProvider fixe les défauts de toute l'application (textes, variantes), l'appel les remplace ponctuellement ;
  • chaque composant composé expose ses morceaux : searchInputProps, emptyStateProps, confirmDialogProps… On règle un détail sans réécrire le composant.

Deux façons de l'installer ​

  • Par paquet : pnpm add @sia-ui/react-web. Les mises à jour arrivent par version.
  • Par copie : sia-ui add crud-page copie le composant et ce dont il dépend dans le projet, qui peut ensuite le modifier. Chaque entrée du registre a sa version et son journal. Voir Registre et CLI.

Les deux se combinent : on importe la plupart des composants et on copie celui qu'on veut modifier. @sia-ui/api et @sia-ui/utils s'utilisent aussi sans React.

Principes ​

  • Les états complets : chargement, erreur, vide, désactivé, mobile. Un composant ne livre pas seulement son cas nominal.
  • L'accessibilité dans l'API : clavier, focus, ARIA, contrastes vérifiés en clair comme en sombre.
  • La mécanique, pas les règles : SIA UI fournit les droits, la validation et le cache. Les règles métier restent celles de l'application.
  • Aucune duplication : un comportement vit à un seul endroit. @sia-ui/headless le porte sans DOM, prêt pour un rendu mobile.

Cette page s'enrichit à chaque brique ajoutée.

Publié sous licence MIT.