@astratra/native-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/package.json +64 -0
- package/src/components/Chevron.js +31 -0
- package/src/components/CollapsibleHeader.js +253 -0
- package/src/components/FloatingPagination.js +135 -0
- package/src/components/GlassButton.js +117 -0
- package/src/components/GlassSurface.js +61 -0
- package/src/components/MarkdownView.js +322 -0
- package/src/components/PaleCard.js +83 -0
- package/src/components/TabBar.js +346 -0
- package/src/components/runtime.js +88 -0
- package/src/components/useCollapsingBar.js +55 -0
- package/src/index.d.ts +229 -0
- package/src/index.js +20 -0
- package/src/logic/anchorQuestion.js +41 -0
- package/src/logic/collapse.js +77 -0
- package/src/logic/collapsibleHeader.js +103 -0
- package/src/logic/color.js +68 -0
- package/src/logic/glass.js +151 -0
- package/src/logic/index.d.ts +203 -0
- package/src/logic/index.js +16 -0
- package/src/logic/markdown.js +229 -0
- package/src/logic/paleCard.js +66 -0
- package/src/logic/tabBar.js +104 -0
- package/src/logic/tableColumns.js +149 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Astratra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# @astratra/native-ui
|
|
2
|
+
|
|
3
|
+
Le kit d'interface mobile : **le verre liquide d'Apple sur iOS, une surface
|
|
4
|
+
visible sur Android**, des boutons en verre, des cartes pâles, des barres qui se
|
|
5
|
+
replient au défilement, une barre d'onglets façon Instagram, un en-tête
|
|
6
|
+
repliable, et le rendu propre des réponses d'une IA.
|
|
7
|
+
|
|
8
|
+
Pour React Native / Expo. Pas d'étape de build, pas de JSX : le source est du
|
|
9
|
+
CommonJS écrit avec `createElement`, comme `@astratra/react`, et Metro le prend
|
|
10
|
+
tel quel.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @astratra/native-ui
|
|
14
|
+
npx expo install expo-glass-effect expo-blur expo-linear-gradient react-native-reanimated
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Dépendances en `peerDependencies` : `react`, `react-native`,
|
|
18
|
+
`react-native-reanimated`, `expo-glass-effect`, `expo-blur`,
|
|
19
|
+
`expo-linear-gradient`. Une seule dépendance réelle : `@astratra/native`, dont
|
|
20
|
+
la règle `resolveGlassMode` décide du verre — elle n'est pas recopiée ici.
|
|
21
|
+
|
|
22
|
+
Rien n'est écrit en dur : ni texte (les libellés sont des props), ni icône (ce
|
|
23
|
+
sont des nœuds ou des fonctions passés par l'application), ni navigation, ni
|
|
24
|
+
retour haptique (`onHaptic`).
|
|
25
|
+
|
|
26
|
+
## Deux entrées
|
|
27
|
+
|
|
28
|
+
| Import | Contenu | Charge react-native ? |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `@astratra/native-ui` | les composants **et** toutes les règles | oui |
|
|
31
|
+
| `@astratra/native-ui/logic` | les règles seules, fonctions pures | non — Node pur |
|
|
32
|
+
|
|
33
|
+
La logique est séparée des composants pour se tester à sec : ce que fait un
|
|
34
|
+
composant se décide dans une fonction pure (quand replier, quelle largeur de
|
|
35
|
+
colonne, quel poids de teinte), le composant ne fait que la rendre.
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// Un serveur, un script, un test : aucune dépendance mobile.
|
|
39
|
+
const { parseMarkdown, measureColumns } = require('@astratra/native-ui/logic');
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
# Le verre
|
|
45
|
+
|
|
46
|
+
## La règle : le verre d'Apple sur iOS, une surface visible ailleurs
|
|
47
|
+
|
|
48
|
+
`getGlassMode()` interroge `resolveGlassMode` de `@astratra/native`, une fois
|
|
49
|
+
pour toute l'application. Seule la réponse `'native'` (Liquid Glass, iOS 26+)
|
|
50
|
+
donne le `GlassView` d'Apple.
|
|
51
|
+
|
|
52
|
+
Partout ailleurs — Android, un iPhone plus ancien — le kit rend une **surface
|
|
53
|
+
visible**, pas une imitation. Android avait d'abord reçu son flou natif
|
|
54
|
+
(`dimezisBlurView`) : il fonctionnait, mais imitait un matériau qui n'existe pas
|
|
55
|
+
chez lui, et chaque surface sortait d'un gris dense et opaque. Android dit
|
|
56
|
+
« surface posée » avec un fond, un rayon, une élévation : c'est ce qu'il reçoit.
|
|
57
|
+
|
|
58
|
+
**Le poids réel de la teinte.** Mesuré au pixel : un `GlassView` teinté de blanc
|
|
59
|
+
à 0,4 laisse un fond clair… inchangé. Apple ne peint pas la teinte, il en module
|
|
60
|
+
son dépoli. La peindre telle quelle sur Android donnait des panneaux nettement
|
|
61
|
+
plus laiteux qu'iOS. Hors du verre d'Apple, une teinte translucide est donc
|
|
62
|
+
peinte au **quart** de son poids (`tintAtAppleWeight`) ; un bouton (verre
|
|
63
|
+
interactif) reçoit en plus le givre d'Apple, `1 − 0,6 × (1 − α)`. Une couleur
|
|
64
|
+
opaque n'est pas une teinte mais de la peinture : elle est laissée telle quelle.
|
|
65
|
+
|
|
66
|
+
**L'ombre suit la règle d'iOS.** iOS calcule l'ombre sur la silhouette alpha :
|
|
67
|
+
un conteneur presque transparent n'en projette aucune. Android dessine toujours
|
|
68
|
+
le rectangle plein — un cadre fantôme autour de chaque panneau. Hors du verre
|
|
69
|
+
d'Apple, un conteneur perd donc son ombre (`surfaceStyleOffApple`) ; un bouton,
|
|
70
|
+
dont le givre est un vrai fond, la garde.
|
|
71
|
+
|
|
72
|
+
## GlassSurface, GlassGroup
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { GlassSurface, GlassGroup } from '@astratra/native-ui';
|
|
76
|
+
|
|
77
|
+
<GlassSurface tintColor="rgba(255,255,255,0.4)" style={{ borderRadius: 24, padding: 16 }}>
|
|
78
|
+
<Text>Panneau flottant</Text>
|
|
79
|
+
</GlassSurface>
|
|
80
|
+
|
|
81
|
+
<GlassGroup spacing={8} style={{ flexDirection: 'row' }}>
|
|
82
|
+
<GlassButton …/>
|
|
83
|
+
<GlassButton …/>
|
|
84
|
+
</GlassGroup>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`GlassGroup` est un `GlassContainer` sur iOS (les verres voisins fusionnent) ;
|
|
88
|
+
ailleurs, l'espacement devient un `gap` — un `View` nu collait les éléments.
|
|
89
|
+
|
|
90
|
+
## GlassButton
|
|
91
|
+
|
|
92
|
+
Un objet pris dans un glaçon : le verre a un reflet en haut, un pied plus dense,
|
|
93
|
+
et **aucun trait** autour — un filet blanc et un contour d'encre se lisaient
|
|
94
|
+
comme « de la glace en plastique ». Trois couches, dans cet ordre : la teinte,
|
|
95
|
+
le reflet, le contenu. Le contenu passe au-dessus du reflet, sinon le voile
|
|
96
|
+
éteint l'icône.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { GlassButton } from '@astratra/native-ui';
|
|
100
|
+
|
|
101
|
+
<GlassButton accessibilityLabel={t('back')} onPress={router.back}>
|
|
102
|
+
<BackIcon />
|
|
103
|
+
</GlassButton>
|
|
104
|
+
|
|
105
|
+
<GlassButton pill tint="#3b6cf0" accessibilityLabel={t('send')} onPress={send} size={40}>
|
|
106
|
+
<Text style={{ color: '#fff' }}>{t('send')}</Text>
|
|
107
|
+
</GlassButton>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Sur une page claire, le verre est `clear` (le `regular` rendait les boutons gris
|
|
111
|
+
et ternes sur un vrai iPhone) ; en sombre, `regular`. La lueur qui détache
|
|
112
|
+
l'icône n'existe que sous le verre d'Apple : Android en ferait un halo gris.
|
|
113
|
+
|
|
114
|
+
## PaleCard, TappableCard
|
|
115
|
+
|
|
116
|
+
Le verre sur les cartes du **contenu** a été jugé laid sur un vrai téléphone :
|
|
117
|
+
reflets, flou, ombre flottante. La règle : le verre reste à ce qui flotte et à
|
|
118
|
+
ce qu'on touche ; le contenu reçoit des cartes pâles, plates, de leur couleur —
|
|
119
|
+
un mélange **opaque** de la teinte dans le fond de page, en léger dégradé, sans
|
|
120
|
+
filet ni ombre.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { PaleCard, TappableCard, usePaleCardColors } from '@astratra/native-ui';
|
|
124
|
+
|
|
125
|
+
<PaleCard tint={colors.accent} pageBackground={colors.background} ink={colors.text}>
|
|
126
|
+
<Text>Contenu</Text>
|
|
127
|
+
</PaleCard>
|
|
128
|
+
|
|
129
|
+
// Ce qu'on touche : verre interactif sur iOS, carte pâle ailleurs.
|
|
130
|
+
<Pressable onPress={open}>
|
|
131
|
+
<TappableCard tint={colors.accent}>
|
|
132
|
+
<Text>Une suggestion</Text>
|
|
133
|
+
</TappableCard>
|
|
134
|
+
</Pressable>
|
|
135
|
+
|
|
136
|
+
// Le même fond pâle pour autre chose qu'une carte.
|
|
137
|
+
const { fill } = usePaleCardColors({ tint: colors.accent });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
# Les barres qui flottent
|
|
143
|
+
|
|
144
|
+
## useCollapsingBar
|
|
145
|
+
|
|
146
|
+
La règle d'Instagram : on descend, la barre rapetisse ; on remonte, elle revient.
|
|
147
|
+
Un tremblement de moins de 8 points ne compte pas, et tout en haut de la page la
|
|
148
|
+
barre est toujours pleine (le rebond d'iOS ne la replie pas). Le sens est
|
|
149
|
+
comparé au dernier point de décision, pas à l'image précédente : un défilement
|
|
150
|
+
lent finit par compter.
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
import { useCollapsingBar, TabBar } from '@astratra/native-ui';
|
|
154
|
+
|
|
155
|
+
const { collapse, onScroll } = useCollapsingBar();
|
|
156
|
+
|
|
157
|
+
<ScrollView onScroll={onScroll} scrollEventThrottle={16}>…</ScrollView>
|
|
158
|
+
<TabBar collapse={collapse} … />
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`collapse` glisse de 0 (pleine) à 1 (repliée) sur le fil de l'interface, sans
|
|
162
|
+
rerendre l'écran. Avec « Réduire les animations », la barre ne bouge pas.
|
|
163
|
+
|
|
164
|
+
## TabBar
|
|
165
|
+
|
|
166
|
+
La pastille active glisse jusqu'à l'onglet touché, et la page ne s'ouvre qu'une
|
|
167
|
+
fois la pastille presque arrivée (`openDelay`, 180 ms) : ouverte dans la même
|
|
168
|
+
image, elle couvrait la barre et le glissement n'était jamais vu. Un onglet
|
|
169
|
+
central (`centerKey`) monte au-dessus de la barre et **ne prend jamais la
|
|
170
|
+
pastille** : il ouvre. La barre se replie au défilement et grossit jusqu'à 12 %
|
|
171
|
+
sur les grands écrans, jamais en dessous de sa taille de référence. Verre du
|
|
172
|
+
Dock sur iOS, carte claire sur Android.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
import { TabBar } from '@astratra/native-ui';
|
|
176
|
+
import { useFocusEffect } from 'expo-router';
|
|
177
|
+
import * as Haptics from 'expo-haptics';
|
|
178
|
+
|
|
179
|
+
const tabs = [
|
|
180
|
+
{ key: 'home', label: t('tabs.home'), icon: ({ color, size }) => <HomeIcon color={color} size={size} /> },
|
|
181
|
+
{ key: 'inbox', label: t('tabs.inbox'), badge: unread, icon: … },
|
|
182
|
+
{ key: 'assistant', label: t('tabs.assistant'), icon: … },
|
|
183
|
+
{ key: 'more', label: t('tabs.more'), icon: … }
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
<TabBar
|
|
187
|
+
tabs={tabs}
|
|
188
|
+
activeKey="home"
|
|
189
|
+
centerKey="assistant"
|
|
190
|
+
renderCenter={({ size }) => <AssistantOrb size={size} />}
|
|
191
|
+
onSelect={(tab) => router.navigate(`/${tab.key}`)}
|
|
192
|
+
collapse={collapse}
|
|
193
|
+
bottomInset={insets.bottom}
|
|
194
|
+
onHaptic={() => Haptics.selectionAsync()}
|
|
195
|
+
useFocusEffect={useFocusEffect}
|
|
196
|
+
/>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`useFocusEffect` : quand la barre est rendue dans chaque écran, c'est au retour
|
|
200
|
+
sur l'écran que la pastille doit revenir à l'onglet de l'écran. Sans lui, elle se
|
|
201
|
+
recale quand `activeKey` change.
|
|
202
|
+
|
|
203
|
+
## FloatingPagination
|
|
204
|
+
|
|
205
|
+
La pagination d'une liste en pilule flottante, au même endroit et avec le même
|
|
206
|
+
repli que la barre d'onglets. Réserver `paginationReserve(insets.bottom)` sous la
|
|
207
|
+
liste, pour que sa dernière ligne passe au-dessus de la pilule.
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import { FloatingPagination, paginationReserve } from '@astratra/native-ui';
|
|
211
|
+
|
|
212
|
+
<FlatList contentContainerStyle={{ paddingBottom: paginationReserve(insets.bottom) }} … />
|
|
213
|
+
<FloatingPagination
|
|
214
|
+
page={page}
|
|
215
|
+
totalPages={pages}
|
|
216
|
+
canPrevious={page > 1}
|
|
217
|
+
canNext={page < pages}
|
|
218
|
+
onPrevious={() => setPage(page - 1)}
|
|
219
|
+
onNext={() => setPage(page + 1)}
|
|
220
|
+
previousLabel={t('previous_page')}
|
|
221
|
+
nextLabel={t('next_page')}
|
|
222
|
+
collapse={collapse}
|
|
223
|
+
bottomInset={insets.bottom}
|
|
224
|
+
/>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`bottom` la pose au-dessus d'un bouton déjà fixé au pied de la page.
|
|
228
|
+
|
|
229
|
+
## CollapsibleScreen, CollapsibleHeader, useCollapsibleHeader
|
|
230
|
+
|
|
231
|
+
En descendant dans une page, tout partait — le titre, le retour — et on ne
|
|
232
|
+
savait plus où l'on était. Désormais le bouton retour et les actions restent
|
|
233
|
+
fixes, le grand titre défile, et dès qu'il est passé une petite barre floutée
|
|
234
|
+
apparaît avec le nom de la page. Sous la barre, **un fondu et non une ligne**.
|
|
235
|
+
Sur iOS, le flou du système ; sur Android, un dégradé de la couleur de la page,
|
|
236
|
+
plus léger et plus court (son flou sortait « sale »).
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
import { CollapsibleScreen, GlassButton } from '@astratra/native-ui';
|
|
240
|
+
import MaskedView from '@react-native-masked-view/masked-view';
|
|
241
|
+
|
|
242
|
+
<CollapsibleScreen
|
|
243
|
+
title={t('schools.title')}
|
|
244
|
+
largeTitle={<Text style={styles.largeTitle}>{t('schools.title')}</Text>}
|
|
245
|
+
leading={<GlassButton accessibilityLabel={t('back')} onPress={router.back}><BackIcon /></GlassButton>}
|
|
246
|
+
actions={<GlassButton accessibilityLabel={t('add')} onPress={add}><PlusIcon /></GlassButton>}
|
|
247
|
+
topInset={insets.top}
|
|
248
|
+
pageBackground={colors.background}
|
|
249
|
+
MaskedView={MaskedView}
|
|
250
|
+
>
|
|
251
|
+
{rows}
|
|
252
|
+
</CollapsibleScreen>
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`MaskedView` est facultatif : sans lui, le flou d'iOS s'arrête au bord de la
|
|
256
|
+
barre et c'est la couleur de la page qui porte le fondu.
|
|
257
|
+
|
|
258
|
+
Pour une liste virtualisée, poser l'en-tête soi-même :
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
const header = useCollapsibleHeader({ topInset: insets.top });
|
|
262
|
+
|
|
263
|
+
<FlatList
|
|
264
|
+
onScroll={header.onScroll}
|
|
265
|
+
scrollEventThrottle={16}
|
|
266
|
+
contentContainerStyle={{ paddingTop: insets.top + HEADER_BAR_HEIGHT }}
|
|
267
|
+
ListHeaderComponent={<View onLayout={header.onListTitleLayout}>{largeTitle}</View>}
|
|
268
|
+
…
|
|
269
|
+
/>
|
|
270
|
+
<CollapsibleHeader title={title} scrollY={header.scrollY} threshold={header.threshold} topInset={insets.top} />
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
# Les réponses d'une IA
|
|
276
|
+
|
|
277
|
+
## MarkdownView
|
|
278
|
+
|
|
279
|
+
Un modèle écrit du Markdown ; l'écran l'affichait brut, étoiles comprises.
|
|
280
|
+
`parseMarkdown` est une fonction pure (aucune bibliothèque de rendu Markdown ne
|
|
281
|
+
se teste à sec ni ne garantit React 19), et `MarkdownView` n'est qu'un parcours
|
|
282
|
+
de ses blocs.
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
import { MarkdownView } from '@astratra/native-ui';
|
|
286
|
+
import * as Clipboard from 'expo-clipboard';
|
|
287
|
+
|
|
288
|
+
<MarkdownView
|
|
289
|
+
content={message.text}
|
|
290
|
+
plain={message.mine}
|
|
291
|
+
onCopyCode={(code) => Clipboard.setStringAsync(code)}
|
|
292
|
+
copyLabel={t('copy')}
|
|
293
|
+
copyIcon={<CopyIcon />}
|
|
294
|
+
copiedIcon={<CheckIcon />}
|
|
295
|
+
styles={{ body: { color: colors.text } }}
|
|
296
|
+
/>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Ce que l'analyseur garantit, chaque règle née d'un défaut vu à l'écran :
|
|
300
|
+
|
|
301
|
+
- **une liste numérotée continue** malgré les puces glissées sous chaque étape —
|
|
302
|
+
le modèle écrit « 1. » partout, l'écran affichait « 1. 1. 1. » ;
|
|
303
|
+
- **les traits `---` sont ignorés** — ils s'affichaient en trois tirets ;
|
|
304
|
+
- une étoile perdue ne met pas la suite du message en gras ;
|
|
305
|
+
- le tiret bas d'un identifiant (`final_grade`) n'est pas de l'italique ;
|
|
306
|
+
- un bloc de code sans clôture s'arrête en fin de message, sans rien avaler ;
|
|
307
|
+
- le message de la personne elle-même (`plain`) reste tel qu'elle l'a tapé.
|
|
308
|
+
|
|
309
|
+
## Les tableaux : measureColumns
|
|
310
|
+
|
|
311
|
+
Chaque cellule était un texte libre : React Native répartissait la place rangée
|
|
312
|
+
par rangée, et l'en-tête ne tombait plus en face de ses valeurs. Une colonne a
|
|
313
|
+
désormais **une** largeur, calculée une fois pour tout le tableau :
|
|
314
|
+
|
|
315
|
+
- mesurée sur le **texte visible** (« **Starter** » sans ses étoiles) ;
|
|
316
|
+
- jamais sous son mot le plus long — un mot n'est jamais coupé au milieu ;
|
|
317
|
+
- plafonnée, pour qu'une phrase revienne à la ligne au lieu d'étirer la colonne ;
|
|
318
|
+
- agrandie avec la **taille de texte choisie dans les réglages du téléphone** ;
|
|
319
|
+
- arrondie sur la grille de pixels de l'écran, sinon les filets bavent ;
|
|
320
|
+
- étirée au prorata jusqu'à la largeur disponible ;
|
|
321
|
+
- alignée à droite quand toutes ses valeurs sont des nombres (montants en toute
|
|
322
|
+
devise, pourcentages, notes sur 20).
|
|
323
|
+
|
|
324
|
+
```js
|
|
325
|
+
const { widths, numeric } = measureColumns(header, rows, {
|
|
326
|
+
targetWidth: 320,
|
|
327
|
+
fontScale: PixelRatio.getFontScale(),
|
|
328
|
+
round: PixelRatio.roundToNearestPixel
|
|
329
|
+
});
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Un voile en dégradé signale qu'un tableau trop large continue à droite : sans
|
|
333
|
+
lui, une colonne coupée par le bord se lisait comme une colonne vide.
|
|
334
|
+
|
|
335
|
+
## La question en haut, la réponse dessous
|
|
336
|
+
|
|
337
|
+
Quand on envoie un message, le fil remonte pour poser la question en haut de
|
|
338
|
+
l'écran, et la réponse s'écrit dessous — au lieu de suivre le bas pendant
|
|
339
|
+
qu'elle arrive, ce qui faisait sauter l'écran à chaque ligne.
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
import { anchorOffset, reserveBelowQuestion } from '@astratra/native-ui';
|
|
343
|
+
|
|
344
|
+
// La réserve sous la question, mesurée sur le VRAI contenu (réserve exclue).
|
|
345
|
+
const reserve = reserveBelowQuestion({ viewportHeight, contentHeight, anchorY });
|
|
346
|
+
listRef.current.scrollToOffset({ offset: anchorOffset(anchorY) });
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Avec cette réserve, le défilement maximal vaut exactement la position de la
|
|
350
|
+
question, que la réponse s'allonge ou raccourcisse ; elle tombe à zéro dès que la
|
|
351
|
+
réponse remplit l'écran.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
# Tests
|
|
356
|
+
|
|
357
|
+
```sh
|
|
358
|
+
npx jest packages/native-ui
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
- **La logique pure** se teste en Node, sans rien simuler.
|
|
362
|
+
- **Les composants** sont montés avec `react-dom` dans `jsdom`, sur des doublures
|
|
363
|
+
des modules natifs (`test/rn.js`) qui rendent chaque composant hôte en élément
|
|
364
|
+
portant ses props. On vérifie ce que le doigt et le lecteur d'écran
|
|
365
|
+
rencontrent — rôles, états, libellés, ordre des couches, ce qu'un appui
|
|
366
|
+
déclenche — sur iOS 26 et sur Android. Jamais des pixels : un rendu sur
|
|
367
|
+
appareil reste à faire.
|
|
368
|
+
- `jsdom` 30 dépend d'un module ES seul, que le chargeur de jest ne sait pas
|
|
369
|
+
lire : il est chargé par le `require` natif de Node (Node ≥ 22.3 pour les
|
|
370
|
+
tests).
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@astratra/native-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mobile UI kit for React Native / Expo: Apple's Liquid Glass on iOS and a calibrated visible surface on Android, glass buttons, pale cards, bars that fold on scroll, an Instagram-style tab bar, a collapsible header, and a Markdown renderer for AI answers. Pure rules exported separately and tested in plain Node.",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=20"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"types": "src/index.d.ts",
|
|
10
|
+
"type": "commonjs",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
14
|
+
"default": "./src/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./logic": {
|
|
17
|
+
"types": "./src/logic/index.d.ts",
|
|
18
|
+
"default": "./src/logic/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "jest"
|
|
28
|
+
},
|
|
29
|
+
"jest": {
|
|
30
|
+
"testMatch": [
|
|
31
|
+
"**/__tests__/**/*.test.js"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"astratra",
|
|
36
|
+
"react-native",
|
|
37
|
+
"expo",
|
|
38
|
+
"liquid-glass",
|
|
39
|
+
"ui",
|
|
40
|
+
"tab-bar",
|
|
41
|
+
"markdown"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@astratra/native": "^0.2.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"expo-blur": ">=14",
|
|
52
|
+
"expo-glass-effect": ">=0.1",
|
|
53
|
+
"expo-linear-gradient": ">=14",
|
|
54
|
+
"react": ">=18",
|
|
55
|
+
"react-native": ">=0.76",
|
|
56
|
+
"react-native-reanimated": ">=3.16"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@testing-library/react": "^16.3.2",
|
|
60
|
+
"jest": "30.4.2",
|
|
61
|
+
"jsdom": "^30.0.1",
|
|
62
|
+
"react-dom": "^19.2.8"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chevron drawn with two borders — no SVG dependency for an arrow.
|
|
3
|
+
*
|
|
4
|
+
* The corner of a square (its left and bottom borders), turned 45°, points
|
|
5
|
+
* left; turned 225°, right. The rounded ends of an SVG stroke are lost, which
|
|
6
|
+
* at 10 points nobody sees.
|
|
7
|
+
*/
|
|
8
|
+
const { h, RN } = require('./runtime');
|
|
9
|
+
|
|
10
|
+
const ROTATION = { left: '45deg', right: '225deg', up: '135deg', down: '-45deg' };
|
|
11
|
+
|
|
12
|
+
function Chevron({ direction = 'left', color = '#0d1235', size = 9, strokeWidth = 2.4 }) {
|
|
13
|
+
return h(RN.View, {
|
|
14
|
+
pointerEvents: 'none',
|
|
15
|
+
style: {
|
|
16
|
+
width: size,
|
|
17
|
+
height: size,
|
|
18
|
+
borderLeftWidth: strokeWidth,
|
|
19
|
+
borderBottomWidth: strokeWidth,
|
|
20
|
+
borderColor: color,
|
|
21
|
+
borderRadius: 1,
|
|
22
|
+
/* The corner sits at the visual centre only once shifted toward the
|
|
23
|
+
open side by a quarter of its diagonal. */
|
|
24
|
+
marginLeft: direction === 'left' ? size / 4 : 0,
|
|
25
|
+
marginRight: direction === 'right' ? size / 4 : 0,
|
|
26
|
+
transform: [{ rotate: ROTATION[direction] || ROTATION.left }]
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { Chevron };
|