Comportement partagé
Pourquoi
Le mobile ne peut pas réutiliser le rendu du web. Un <span> n'est pas une <View>, une feuille CSS n'est pas une StyleSheet, onPointerEnter n'est pas onPressIn. Écrire react-native en repartant de zéro reviendrait pourtant à réécrire une deuxième fois le calcul d'un slider, l'état d'un drawer et l'empilement des couches flottantes — c'est-à-dire la majorité du code.
Ce qui se partage n'est pas le rendu, c'est le comportement.
La frontière
@sia-ui/headless utilise React — useState et consorts existent des deux côtés — mais jamais le DOM, jamais react-dom, jamais une feuille de style.
Dans headless | Par plateforme |
|---|---|
| Calcul des valeurs, arrondi au pas, bornes | Le JSX |
| Machines à états d'ouverture et de fermeture | Les styles |
| Empilement des couches, qui est au-dessus | Les gestes |
| Placement flottant, retournement, clamp | La mesure des éléments |
La règle pratique : si le code mesure ou rend quelque chose, il reste dans la plateforme; s'il calcule ou décide, il descend dans headless.
Ce qui est livré
computeFloatingPosition(trigger, floating, viewport, placement, options)
Place un panneau par rapport à son déclencheur, retourne le placement si ça déborde, et ramène dans les limites si aucun côté ne tient.
Les rectangles sont de simples nombres. Le web les tire de getBoundingClientRect() et de window.innerWidth; React Native les tirera de onLayout ou de measureInWindow. Aucune mesure n'est faite ici — c'est ce qui rend le calcul commun aux deux plateformes.
overlayStack
Pile des couches ouvertes. acquire(kind) ouvre et renvoie une profondeur, release(kind) referme, isTop(z) répond sans rien modifier.
Cette dernière méthode existe parce que le Drawer appelait acquire() pour savoir qui était au-dessus : chaque touche Escape empilait une couche de plus.
useSliderState(options)
Renvoie values, percents, valueAt(ratio), nearestThumb(value) et moveThumb(index, value). Gère le mode contrôlé et non contrôlé, le pas, les bornes et le non-croisement des poignées.
Le seul point de contact avec la plateforme est valueAt(ratio) : le web calcule ce ratio depuis un clientX et la largeur de la piste, le mobile depuis un geste et une largeur de layout.
useDisclosureState(options)
Renvoie isOpen, present, phase (opening ou closing), open, close, toggle et recentlyOpened.
La demande et l'affichage sont séparés :
| Valeur | Suit | Sert à |
|---|---|---|
isOpen | la demande — open(), close() ou le open du parent | la prévenir tout de suite par onOpenChange |
present | l'affichage : vrai jusqu'à la fin de l'animation de sortie | décider du rendu |
phase | opening à chaque ouverture, closing à chaque fermeture, d'où qu'elles viennent | accrocher les animations |
Deux défauts corrigés par ce découpage. Contrôlée par open, une couche rouverte restait en closing — la phase ne revenait à opening que par open() : chaque ouverture après la première glissait aussitôt hors de l'écran et laissait son voile. Et la fermeture n'était signalée au parent qu'après l'animation, ce qui laissait son état faux 200 ms ; un parent qui fermait lui-même faisait en outre disparaître la couche sans sortie.
recentlyOpened() est vrai pendant openGraceMs (400 ms) après une ouverture. Le Drawer y ignore un clic extérieur, et le Modal applique la même garde à son voile : le second clic d'un double clic sur la ligne qui ouvre le tiroir — ou le rebond d'une souris — tombait sur le fond et refermait aussitôt.
Ce que ça change pour un composant
Slider mesurait la piste, calculait le pas, gérait le croisement des poignées et rendait le tout. Il ne garde plus que la mesure, les événements souris et le JSX :
const slider = useSliderState({ value, min, max, step, range, onChange });
const valueFromClientX = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return slider.values[0] ?? min;
return slider.valueAt((clientX - rect.left) / rect.width);
};Le pendant React Native remplacera ces quatre lignes par une largeur de layout et un geste. Tout le reste est déjà écrit.
Tests
La logique extraite est pure : elle se teste sans simuler de plateforme, ce qui en fait le code le plus facile à couvrir du dépôt. packages/headless porte déjà 8 assertions sur le placement et l'empilement.