@nexub/react-native 0.0.0-stage → 2.2.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.
Files changed (134) hide show
  1. package/README.md +113 -2
  2. package/lib/module/components/actions.js +222 -0
  3. package/lib/module/components/actions.js.map +1 -0
  4. package/lib/module/components/containers.js +300 -0
  5. package/lib/module/components/containers.js.map +1 -0
  6. package/lib/module/components/display.js +299 -0
  7. package/lib/module/components/display.js.map +1 -0
  8. package/lib/module/components/feedback.js +484 -0
  9. package/lib/module/components/feedback.js.map +1 -0
  10. package/lib/module/components/inputs.js +792 -0
  11. package/lib/module/components/inputs.js.map +1 -0
  12. package/lib/module/components/mobile.js +818 -0
  13. package/lib/module/components/mobile.js.map +1 -0
  14. package/lib/module/components/overlays.js +421 -0
  15. package/lib/module/components/overlays.js.map +1 -0
  16. package/lib/module/components/shared.js +57 -0
  17. package/lib/module/components/shared.js.map +1 -0
  18. package/lib/module/hooks/useControllableState.js +23 -0
  19. package/lib/module/hooks/useControllableState.js.map +1 -0
  20. package/lib/module/index.js +31 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/package.json +1 -0
  23. package/lib/module/patterns/app.js +709 -0
  24. package/lib/module/patterns/app.js.map +1 -0
  25. package/lib/module/patterns/auth.js +224 -0
  26. package/lib/module/patterns/auth.js.map +1 -0
  27. package/lib/module/patterns/home.js +201 -0
  28. package/lib/module/patterns/home.js.map +1 -0
  29. package/lib/module/patterns/index.js +8 -0
  30. package/lib/module/patterns/index.js.map +1 -0
  31. package/lib/module/patterns/money.js +419 -0
  32. package/lib/module/patterns/money.js.map +1 -0
  33. package/lib/module/primitives/Icon.js +17 -0
  34. package/lib/module/primitives/Icon.js.map +1 -0
  35. package/lib/module/primitives/Layout.js +79 -0
  36. package/lib/module/primitives/Layout.js.map +1 -0
  37. package/lib/module/primitives/Pressable.js +79 -0
  38. package/lib/module/primitives/Pressable.js.map +1 -0
  39. package/lib/module/primitives/ScreenContainer.js +58 -0
  40. package/lib/module/primitives/ScreenContainer.js.map +1 -0
  41. package/lib/module/primitives/Surface.js +57 -0
  42. package/lib/module/primitives/Surface.js.map +1 -0
  43. package/lib/module/primitives/Text.js +70 -0
  44. package/lib/module/primitives/Text.js.map +1 -0
  45. package/lib/module/theme/ThemeProvider.js +127 -0
  46. package/lib/module/theme/ThemeProvider.js.map +1 -0
  47. package/lib/module/theme/fonts.js +56 -0
  48. package/lib/module/theme/fonts.js.map +1 -0
  49. package/lib/module/theme/haptics.js +56 -0
  50. package/lib/module/theme/haptics.js.map +1 -0
  51. package/lib/module/theme/motion.js +89 -0
  52. package/lib/module/theme/motion.js.map +1 -0
  53. package/lib/module/theme/navigation.js +99 -0
  54. package/lib/module/theme/navigation.js.map +1 -0
  55. package/lib/typescript/components/actions.d.ts +53 -0
  56. package/lib/typescript/components/actions.d.ts.map +1 -0
  57. package/lib/typescript/components/containers.d.ts +59 -0
  58. package/lib/typescript/components/containers.d.ts.map +1 -0
  59. package/lib/typescript/components/display.d.ts +68 -0
  60. package/lib/typescript/components/display.d.ts.map +1 -0
  61. package/lib/typescript/components/feedback.d.ts +96 -0
  62. package/lib/typescript/components/feedback.d.ts.map +1 -0
  63. package/lib/typescript/components/inputs.d.ts +141 -0
  64. package/lib/typescript/components/inputs.d.ts.map +1 -0
  65. package/lib/typescript/components/mobile.d.ts +173 -0
  66. package/lib/typescript/components/mobile.d.ts.map +1 -0
  67. package/lib/typescript/components/overlays.d.ts +74 -0
  68. package/lib/typescript/components/overlays.d.ts.map +1 -0
  69. package/lib/typescript/components/shared.d.ts +20 -0
  70. package/lib/typescript/components/shared.d.ts.map +1 -0
  71. package/lib/typescript/hooks/useControllableState.d.ts +6 -0
  72. package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
  73. package/lib/typescript/index.d.ts +39 -0
  74. package/lib/typescript/index.d.ts.map +1 -0
  75. package/lib/typescript/package.json +1 -0
  76. package/lib/typescript/patterns/app.d.ts +139 -0
  77. package/lib/typescript/patterns/app.d.ts.map +1 -0
  78. package/lib/typescript/patterns/auth.d.ts +54 -0
  79. package/lib/typescript/patterns/auth.d.ts.map +1 -0
  80. package/lib/typescript/patterns/home.d.ts +50 -0
  81. package/lib/typescript/patterns/home.d.ts.map +1 -0
  82. package/lib/typescript/patterns/index.d.ts +9 -0
  83. package/lib/typescript/patterns/index.d.ts.map +1 -0
  84. package/lib/typescript/patterns/money.d.ts +106 -0
  85. package/lib/typescript/patterns/money.d.ts.map +1 -0
  86. package/lib/typescript/primitives/Icon.d.ts +12 -0
  87. package/lib/typescript/primitives/Icon.d.ts.map +1 -0
  88. package/lib/typescript/primitives/Layout.d.ts +26 -0
  89. package/lib/typescript/primitives/Layout.d.ts.map +1 -0
  90. package/lib/typescript/primitives/Pressable.d.ts +21 -0
  91. package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
  92. package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
  93. package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
  94. package/lib/typescript/primitives/Surface.d.ts +23 -0
  95. package/lib/typescript/primitives/Surface.d.ts.map +1 -0
  96. package/lib/typescript/primitives/Text.d.ts +21 -0
  97. package/lib/typescript/primitives/Text.d.ts.map +1 -0
  98. package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
  99. package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
  100. package/lib/typescript/theme/fonts.d.ts +25 -0
  101. package/lib/typescript/theme/fonts.d.ts.map +1 -0
  102. package/lib/typescript/theme/haptics.d.ts +3 -0
  103. package/lib/typescript/theme/haptics.d.ts.map +1 -0
  104. package/lib/typescript/theme/motion.d.ts +79 -0
  105. package/lib/typescript/theme/motion.d.ts.map +1 -0
  106. package/lib/typescript/theme/navigation.d.ts +158 -0
  107. package/lib/typescript/theme/navigation.d.ts.map +1 -0
  108. package/package.json +138 -3
  109. package/src/components/actions.tsx +259 -0
  110. package/src/components/containers.tsx +352 -0
  111. package/src/components/display.tsx +345 -0
  112. package/src/components/feedback.tsx +541 -0
  113. package/src/components/inputs.tsx +899 -0
  114. package/src/components/mobile.tsx +883 -0
  115. package/src/components/overlays.tsx +488 -0
  116. package/src/components/shared.ts +54 -0
  117. package/src/hooks/useControllableState.ts +27 -0
  118. package/src/index.ts +177 -0
  119. package/src/patterns/app.tsx +715 -0
  120. package/src/patterns/auth.tsx +266 -0
  121. package/src/patterns/home.tsx +211 -0
  122. package/src/patterns/index.ts +59 -0
  123. package/src/patterns/money.tsx +458 -0
  124. package/src/primitives/Icon.tsx +15 -0
  125. package/src/primitives/Layout.tsx +71 -0
  126. package/src/primitives/Pressable.tsx +97 -0
  127. package/src/primitives/ScreenContainer.tsx +65 -0
  128. package/src/primitives/Surface.tsx +75 -0
  129. package/src/primitives/Text.tsx +79 -0
  130. package/src/theme/ThemeProvider.tsx +210 -0
  131. package/src/theme/fonts.ts +52 -0
  132. package/src/theme/haptics.ts +57 -0
  133. package/src/theme/motion.ts +105 -0
  134. package/src/theme/navigation.ts +68 -0
@@ -0,0 +1,458 @@
1
+ import * as React from 'react';
2
+ import { SectionList, View } from 'react-native';
3
+ import { moneyFormatter } from '@nexub/core';
4
+ import { CheckCircle2, ShieldCheck, type NativeIconProps } from '@nexub/icons/native';
5
+ import { Button } from '../components/actions';
6
+ import { ListItem, ListSection } from '../components/containers';
7
+ import { Amount, Avatar, Badge } from '../components/display';
8
+ import { EmptyState, Progress } from '../components/feedback';
9
+ import {
10
+ ExpressiveMoneyInput,
11
+ RefreshControl,
12
+ StickyFooter,
13
+ SwipeableRow,
14
+ type SwipeAction,
15
+ } from '../components/mobile';
16
+ import { ScreenContainer } from '../primitives/ScreenContainer';
17
+ import { Heading, Text } from '../primitives/Text';
18
+ import { useTheme } from '../theme/ThemeProvider';
19
+
20
+ // ─── Flow step frame ─────────────────────────────────────────────────────────
21
+
22
+ export interface FlowStepProps {
23
+ /** 1-based. */
24
+ step: number;
25
+ total: number;
26
+ title: string;
27
+ children: React.ReactNode;
28
+ footer: React.ReactNode;
29
+ }
30
+
31
+ /** One step of a multi-step flow: progress, title, content, sticky primary action. */
32
+ export function FlowStep({ step, total, title, children, footer }: FlowStepProps) {
33
+ const { tokens, messages } = useTheme();
34
+ return (
35
+ <StickyFooter footer={footer}>
36
+ <ScreenContainer gap="5">
37
+ <View style={{ gap: tokens.spacing[2] }}>
38
+ <Progress value={step / total} label={messages.pageOf(step, total)} />
39
+ <Text variant="label-md" color="on-surface-variant">
40
+ {messages.pageOf(step, total)}
41
+ </Text>
42
+ <Heading variant="headline-sm">{title}</Heading>
43
+ </View>
44
+ {children}
45
+ </ScreenContainer>
46
+ </StickyFooter>
47
+ );
48
+ }
49
+
50
+ // ─── Send money ──────────────────────────────────────────────────────────────
51
+
52
+ export interface Quote {
53
+ fee: number;
54
+ /** Units of `to` per unit of `from`. */
55
+ rate: number;
56
+ /** e.g. "In seconds", "By Friday". */
57
+ arrives: string;
58
+ }
59
+
60
+ export interface AmountStepProps {
61
+ from: string;
62
+ to: string;
63
+ /** Available balance in `from`. */
64
+ balance: number;
65
+ /** Quote for an amount (fees and rate shown before anything else, like Wise). */
66
+ quote: (amount: number) => Quote;
67
+ onContinue: (amount: number) => void;
68
+ step?: number;
69
+ total?: number;
70
+ }
71
+
72
+ /** Amount entry with the fee, rate and arrival shown live. */
73
+ export function AmountStep({
74
+ from,
75
+ to,
76
+ balance,
77
+ quote,
78
+ onContinue,
79
+ step = 1,
80
+ total = 3,
81
+ }: AmountStepProps) {
82
+ const { locale } = useTheme();
83
+ const [amount, setAmount] = React.useState<number | null>(null);
84
+ const fmt = (n: number, c: string) => moneyFormatter(c, locale).format(n);
85
+ const over = amount != null && amount > balance;
86
+ const q = amount ? quote(amount) : undefined;
87
+ return (
88
+ <FlowStep
89
+ step={step}
90
+ total={total}
91
+ title="How much?"
92
+ footer={
93
+ <Button
94
+ size="lg"
95
+ fullWidth
96
+ disabled={!amount || over}
97
+ onPress={() => amount && onContinue(amount)}
98
+ >
99
+ Continue
100
+ </Button>
101
+ }
102
+ >
103
+ <ExpressiveMoneyInput
104
+ label="Amount to send"
105
+ currency={from}
106
+ value={amount}
107
+ onValueChange={setAmount}
108
+ error={over}
109
+ caption={
110
+ over ? `More than your balance (${fmt(balance, from)})` : `Balance ${fmt(balance, from)}`
111
+ }
112
+ />
113
+ {q && amount && !over && (
114
+ <ListSection>
115
+ <ListItem variant="value" title="Fee" value={fmt(q.fee, from)} />
116
+ <ListItem variant="value" title="Rate" value={`1 ${from} = ${q.rate} ${to}`} />
117
+ <ListItem
118
+ variant="value"
119
+ title="Recipient gets"
120
+ value={fmt((amount - q.fee) * q.rate, to)}
121
+ />
122
+ <ListItem variant="value" title="Arrives" value={q.arrives} />
123
+ </ListSection>
124
+ )}
125
+ </FlowStep>
126
+ );
127
+ }
128
+
129
+ export interface ReviewRow {
130
+ label: string;
131
+ value: string;
132
+ }
133
+
134
+ export interface ReviewStepProps {
135
+ rows: ReviewRow[];
136
+ /** "Confirm and send". */
137
+ confirmLabel: string;
138
+ onConfirm: () => void;
139
+ /** Biometric confirmation, named: "Confirm with Face ID". Shown as the hint under the button. */
140
+ confirmHint?: string;
141
+ loading?: boolean;
142
+ step?: number;
143
+ total?: number;
144
+ }
145
+
146
+ /** Summary before money moves: every number visible, one confirming action. */
147
+ export function ReviewStep({
148
+ rows,
149
+ confirmLabel,
150
+ onConfirm,
151
+ confirmHint,
152
+ loading,
153
+ step = 2,
154
+ total = 3,
155
+ }: ReviewStepProps) {
156
+ return (
157
+ <FlowStep
158
+ step={step}
159
+ total={total}
160
+ title="Check the details"
161
+ footer={
162
+ <>
163
+ <Button
164
+ size="lg"
165
+ fullWidth
166
+ loading={loading}
167
+ leadingIcon={confirmHint ? ShieldCheck : undefined}
168
+ onPress={onConfirm}
169
+ >
170
+ {confirmLabel}
171
+ </Button>
172
+ {confirmHint && (
173
+ <Text variant="body-sm" color="on-surface-variant" style={{ textAlign: 'center' }}>
174
+ {confirmHint}
175
+ </Text>
176
+ )}
177
+ </>
178
+ }
179
+ >
180
+ <ListSection>
181
+ {rows.map((r) => (
182
+ <ListItem key={r.label} variant="value" title={r.label} value={r.value} />
183
+ ))}
184
+ </ListSection>
185
+ </FlowStep>
186
+ );
187
+ }
188
+
189
+ // ─── Activity ────────────────────────────────────────────────────────────────
190
+
191
+ export interface Transaction {
192
+ id: string;
193
+ title: string;
194
+ subtitle?: string;
195
+ /** Negative = money out. */
196
+ amount: number;
197
+ currency: string;
198
+ date: Date;
199
+ status?: 'pending' | 'failed';
200
+ /** Avatar image URL. */
201
+ image?: string;
202
+ }
203
+
204
+ export interface ActivityListProps {
205
+ transactions: Transaction[];
206
+ onPress?: (t: Transaction) => void;
207
+ onRefresh?: () => void;
208
+ refreshing?: boolean;
209
+ onEndReached?: () => void;
210
+ /** Swipe actions per row (also accessibility actions). */
211
+ actions?: (t: Transaction) => SwipeAction[];
212
+ header?: React.ReactElement;
213
+ empty?: React.ReactElement;
214
+ }
215
+
216
+ /** Transactions grouped by day, with pull-to-refresh, paging and swipe actions (web ActivityFeed). */
217
+ export function ActivityList({
218
+ transactions,
219
+ onPress,
220
+ onRefresh,
221
+ refreshing = false,
222
+ onEndReached,
223
+ actions,
224
+ header,
225
+ empty,
226
+ }: ActivityListProps) {
227
+ const { roles, tokens, locale } = useTheme();
228
+ const day = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
229
+ const sections = React.useMemo(() => {
230
+ const map = new Map<string, Transaction[]>();
231
+ for (const t of [...transactions].sort((a, b) => +b.date - +a.date)) {
232
+ const key = day.format(t.date);
233
+ map.set(key, [...(map.get(key) ?? []), t]);
234
+ }
235
+ return [...map].map(([title, data]) => ({ title, data }));
236
+ // eslint-disable-next-line react-hooks/exhaustive-deps
237
+ }, [transactions, locale]);
238
+
239
+ const row = (t: Transaction) => {
240
+ const label = `${t.title}, ${moneyFormatter(t.currency, locale, { signDisplay: 'exceptZero' }).format(t.amount)}${t.status ? `, ${t.status}` : ''}`;
241
+ const item = (
242
+ <ListItem
243
+ title={t.title}
244
+ subtitle={t.subtitle}
245
+ leading={<Avatar name={t.title} src={t.image} />}
246
+ trailing={
247
+ <View style={{ alignItems: 'flex-end', gap: 2 }}>
248
+ <Amount
249
+ value={t.amount}
250
+ currency={t.currency}
251
+ locale={locale}
252
+ tone="auto"
253
+ signDisplay="exceptZero"
254
+ />
255
+ {t.status && (
256
+ <Badge tone={t.status === 'failed' ? 'danger' : 'warning'} size="sm">
257
+ {t.status === 'failed' ? 'Failed' : 'Pending'}
258
+ </Badge>
259
+ )}
260
+ </View>
261
+ }
262
+ variant={onPress ? 'navigation' : 'static'}
263
+ onPress={onPress && (() => onPress(t))}
264
+ />
265
+ );
266
+ return actions ? (
267
+ <SwipeableRow label={label} onPress={onPress && (() => onPress(t))} actions={actions(t)}>
268
+ {item}
269
+ </SwipeableRow>
270
+ ) : (
271
+ item
272
+ );
273
+ };
274
+
275
+ return (
276
+ <SectionList
277
+ sections={sections}
278
+ keyExtractor={(t) => t.id}
279
+ contentInsetAdjustmentBehavior="automatic"
280
+ style={{ backgroundColor: roles.surface }}
281
+ contentContainerStyle={{ padding: tokens.spacing[4], gap: 0 }}
282
+ ListHeaderComponent={header}
283
+ ListEmptyComponent={
284
+ empty ?? (
285
+ <EmptyState
286
+ title="No activity yet"
287
+ description="Your payments and transfers will show up here."
288
+ />
289
+ )
290
+ }
291
+ renderSectionHeader={({ section }) => (
292
+ <Text
293
+ variant="title-sm"
294
+ color="on-surface-variant"
295
+ accessibilityRole="header"
296
+ style={{
297
+ paddingTop: tokens.spacing[4],
298
+ paddingBottom: tokens.spacing[2],
299
+ paddingHorizontal: tokens.spacing[4],
300
+ backgroundColor: roles.surface,
301
+ }}
302
+ >
303
+ {section.title}
304
+ </Text>
305
+ )}
306
+ renderItem={({ item, index, section }) => (
307
+ <View
308
+ style={{
309
+ backgroundColor: roles['surface-container-lowest'],
310
+ borderTopStartRadius: index === 0 ? tokens.shape.container : 0,
311
+ borderTopEndRadius: index === 0 ? tokens.shape.container : 0,
312
+ borderBottomStartRadius: index === section.data.length - 1 ? tokens.shape.container : 0,
313
+ borderBottomEndRadius: index === section.data.length - 1 ? tokens.shape.container : 0,
314
+ overflow: 'hidden',
315
+ }}
316
+ >
317
+ {row(item)}
318
+ </View>
319
+ )}
320
+ refreshControl={
321
+ onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> : undefined
322
+ }
323
+ onEndReached={onEndReached}
324
+ onEndReachedThreshold={0.5}
325
+ stickySectionHeadersEnabled={false}
326
+ />
327
+ );
328
+ }
329
+
330
+ export interface TransactionDetailProps {
331
+ transaction: Transaction;
332
+ /** Extra rows: reference, account, category… */
333
+ rows?: ReviewRow[];
334
+ actions?: React.ReactNode;
335
+ }
336
+
337
+ /** One transaction: hero amount, status, summary rows (web InfoList). */
338
+ export function TransactionDetail({ transaction: t, rows = [], actions }: TransactionDetailProps) {
339
+ const { tokens, locale } = useTheme();
340
+ const when = new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' }).format(
341
+ t.date,
342
+ );
343
+ return (
344
+ <ScreenContainer gap="5">
345
+ <View
346
+ style={{ alignItems: 'center', gap: tokens.spacing[2], paddingVertical: tokens.spacing[4] }}
347
+ >
348
+ <Avatar name={t.title} src={t.image} size="xl" />
349
+ <Text variant="title-md">{t.title}</Text>
350
+ <Amount
351
+ value={t.amount}
352
+ currency={t.currency}
353
+ locale={locale}
354
+ size="display"
355
+ tone="auto"
356
+ signDisplay="exceptZero"
357
+ />
358
+ {t.status ? (
359
+ <Badge tone={t.status === 'failed' ? 'danger' : 'warning'} dot>
360
+ {t.status === 'failed' ? 'Failed' : 'Pending'}
361
+ </Badge>
362
+ ) : (
363
+ <Badge tone="success" dot>
364
+ Completed
365
+ </Badge>
366
+ )}
367
+ </View>
368
+ <ListSection>
369
+ <ListItem variant="value" title="Date" value={when} />
370
+ {t.subtitle && <ListItem variant="value" title="Details" value={t.subtitle} />}
371
+ {rows.map((r) => (
372
+ <ListItem key={r.label} variant="value" title={r.label} value={r.value} />
373
+ ))}
374
+ </ListSection>
375
+ {actions}
376
+ </ScreenContainer>
377
+ );
378
+ }
379
+
380
+ // ─── Result screens ──────────────────────────────────────────────────────────
381
+
382
+ export interface ResultScreenProps {
383
+ /** @default 'success' */
384
+ kind?: 'success' | 'error' | 'offline' | 'maintenance' | 'update';
385
+ title: string;
386
+ description?: string;
387
+ /** Hero figure (e.g. the amount sent). */
388
+ amount?: { value: number; currency: string };
389
+ primary: { label: string; onPress: () => void };
390
+ secondary?: { label: string; onPress: () => void };
391
+ icon?: React.ComponentType<NativeIconProps>;
392
+ }
393
+
394
+ /** Full-screen outcome: success, error, offline, maintenance, or "update required". */
395
+ export function ResultScreen({
396
+ kind = 'success',
397
+ title,
398
+ description,
399
+ amount,
400
+ primary,
401
+ secondary,
402
+ icon,
403
+ }: ResultScreenProps) {
404
+ const { roles, tokens, locale } = useTheme();
405
+ const tone = kind === 'success' ? 'success' : kind === 'error' ? 'error' : 'primary';
406
+ const Glyph = icon ?? CheckCircle2;
407
+ return (
408
+ <StickyFooter
409
+ footer={
410
+ <>
411
+ <Button size="lg" fullWidth onPress={primary.onPress}>
412
+ {primary.label}
413
+ </Button>
414
+ {secondary && (
415
+ <Button size="lg" fullWidth variant="text" onPress={secondary.onPress}>
416
+ {secondary.label}
417
+ </Button>
418
+ )}
419
+ </>
420
+ }
421
+ >
422
+ <View
423
+ accessibilityLiveRegion="polite"
424
+ style={{
425
+ flex: 1,
426
+ alignItems: 'center',
427
+ justifyContent: 'center',
428
+ gap: tokens.spacing[4],
429
+ padding: tokens.spacing[6],
430
+ }}
431
+ >
432
+ <View
433
+ style={{
434
+ width: 88,
435
+ height: 88,
436
+ borderRadius: 44,
437
+ alignItems: 'center',
438
+ justifyContent: 'center',
439
+ backgroundColor: roles[`${tone}-container`],
440
+ }}
441
+ >
442
+ <Glyph size={44} color={roles[tone]} />
443
+ </View>
444
+ <Heading variant="headline-md" style={{ textAlign: 'center' }}>
445
+ {title}
446
+ </Heading>
447
+ {amount && (
448
+ <Amount value={amount.value} currency={amount.currency} locale={locale} size="display" />
449
+ )}
450
+ {description && (
451
+ <Text variant="body-md" color="on-surface-variant" style={{ textAlign: 'center' }}>
452
+ {description}
453
+ </Text>
454
+ )}
455
+ </View>
456
+ </StickyFooter>
457
+ );
458
+ }
@@ -0,0 +1,15 @@
1
+ import * as React from 'react';
2
+ import type { NativeIconProps } from '@nexub/icons/native';
3
+ import { useTheme, type RoleName } from '../theme/ThemeProvider';
4
+
5
+ export interface IconProps extends Omit<NativeIconProps, 'color'> {
6
+ /** An icon from `@nexub/icons/native`, e.g. `Search`. */
7
+ icon: React.ComponentType<NativeIconProps>;
8
+ /** Colour role. @default 'on-surface-variant' */
9
+ color?: RoleName;
10
+ }
11
+
12
+ /** An `@nexub/icons/native` icon coloured by a role. Decorative unless `label` is set. */
13
+ export function Icon({ icon: Glyph, color = 'on-surface-variant', ...rest }: IconProps) {
14
+ return <Glyph color={useTheme().roles[color]} {...rest} />;
15
+ }
@@ -0,0 +1,71 @@
1
+ import * as React from 'react';
2
+ import { View, type ViewProps, type ViewStyle } from 'react-native';
3
+ import type { NativeTokens } from '@nexub/tokens/native';
4
+ import { useTheme, type RoleName } from '../theme/ThemeProvider';
5
+ import type { ShapeName } from './Surface';
6
+
7
+ export type Space = keyof NativeTokens['spacing'];
8
+
9
+ export interface BoxProps extends ViewProps {
10
+ /** Padding on every side (spacing token). */
11
+ padding?: Space;
12
+ /** Horizontal padding (start + end, so RTL is free). */
13
+ paddingX?: Space;
14
+ paddingY?: Space;
15
+ /** Gap between children. */
16
+ gap?: Space;
17
+ background?: RoleName;
18
+ radius?: ShapeName;
19
+ align?: ViewStyle['alignItems'];
20
+ justify?: ViewStyle['justifyContent'];
21
+ flex?: number;
22
+ }
23
+
24
+ function useBoxStyle({
25
+ padding,
26
+ paddingX,
27
+ paddingY,
28
+ gap,
29
+ background,
30
+ radius,
31
+ align,
32
+ justify,
33
+ flex,
34
+ }: BoxProps): ViewStyle {
35
+ const { tokens, roles } = useTheme();
36
+ const s = (k?: Space) => (k === undefined ? undefined : tokens.spacing[k]);
37
+ return {
38
+ padding: s(padding),
39
+ paddingHorizontal: s(paddingX),
40
+ paddingVertical: s(paddingY),
41
+ gap: s(gap),
42
+ backgroundColor: background && roles[background],
43
+ borderRadius: radius && tokens.shape[radius],
44
+ alignItems: align,
45
+ justifyContent: justify,
46
+ flex,
47
+ };
48
+ }
49
+
50
+ export function Box({ style, ...props }: BoxProps) {
51
+ return <View style={[useBoxStyle(props), style]} {...props} />;
52
+ }
53
+
54
+ /** Children in a column. @default gap '3' */
55
+ export function Stack({ gap = '3', style, ...props }: BoxProps) {
56
+ return <View style={[useBoxStyle({ gap, ...props }), style]} {...props} />;
57
+ }
58
+
59
+ /** Children in a row that wraps, centred on the cross axis. @default gap '2' */
60
+ export function Inline({ gap = '2', align = 'center', style, ...props }: BoxProps) {
61
+ return (
62
+ <View
63
+ style={[
64
+ useBoxStyle({ gap, align, ...props }),
65
+ { flexDirection: 'row', flexWrap: 'wrap' },
66
+ style,
67
+ ]}
68
+ {...props}
69
+ />
70
+ );
71
+ }
@@ -0,0 +1,97 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Pressable as RNPressable,
4
+ StyleSheet,
5
+ View,
6
+ type GestureResponderEvent,
7
+ type PressableProps as RNPressableProps,
8
+ type StyleProp,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+ import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
12
+ import type { HapticToken } from '../theme/haptics';
13
+ import { useHaptics, useTheme, type RoleName } from '../theme/ThemeProvider';
14
+ import { nxSpring } from '../theme/motion';
15
+
16
+ const PRESS_SPRING = nxSpring('spatial', 'fast');
17
+
18
+ // The touchable itself animates and takes `style`, so layout props (flex,
19
+ // position, margins) apply to the element that sits in the parent's layout.
20
+ const AnimatedPressable = Animated.createAnimatedComponent(RNPressable);
21
+ const AnimatedView = Animated.createAnimatedComponent(View);
22
+
23
+ export interface PressableProps extends Omit<RNPressableProps, 'style' | 'children'> {
24
+ style?: StyleProp<ViewStyle>;
25
+ children?: React.ReactNode;
26
+ /** Haptic played on press (Apple: not for plain taps — toggles, selections, outcomes). */
27
+ haptic?: HapticToken;
28
+ /** On-colour of the state layer, or false for none. @default 'on-surface' */
29
+ stateLayer?: RoleName | false;
30
+ /** Press-scale target, or false for none. @default 0.97 */
31
+ pressScale?: number | false;
32
+ }
33
+
34
+ /**
35
+ * The base of every Nexub control: a state layer (on-colour at the pressed
36
+ * opacity) and a 0.97 press-scale on the spatial-fast spring. No ripple.
37
+ * Reduce Motion is honoured by Reanimated (ReduceMotion.System).
38
+ */
39
+ export function Pressable({
40
+ style,
41
+ children,
42
+ haptic,
43
+ stateLayer = 'on-surface',
44
+ pressScale = 0.97,
45
+ disabled,
46
+ onPress,
47
+ onPressIn,
48
+ onPressOut,
49
+ ...rest
50
+ }: PressableProps) {
51
+ const { roles, tokens } = useTheme();
52
+ const play = useHaptics();
53
+ const pressed = useSharedValue(0);
54
+ const spring = PRESS_SPRING;
55
+ const opacity = tokens.state.pressed;
56
+ const scale = pressScale === false ? 1 : pressScale;
57
+
58
+ const container = useAnimatedStyle(() => ({
59
+ transform: [{ scale: 1 - (1 - scale) * pressed.value }],
60
+ }));
61
+ const layer = useAnimatedStyle(() => ({ opacity: pressed.value * opacity }));
62
+ const radius = StyleSheet.flatten(style)?.borderRadius;
63
+
64
+ return (
65
+ <AnimatedPressable
66
+ accessibilityRole="button"
67
+ accessibilityState={{ disabled: !!disabled }}
68
+ disabled={disabled}
69
+ onPressIn={(e: GestureResponderEvent) => {
70
+ pressed.value = withSpring(1, spring);
71
+ onPressIn?.(e);
72
+ }}
73
+ onPressOut={(e: GestureResponderEvent) => {
74
+ pressed.value = withSpring(0, spring);
75
+ onPressOut?.(e);
76
+ }}
77
+ onPress={(e: GestureResponderEvent) => {
78
+ if (haptic) play(haptic);
79
+ onPress?.(e);
80
+ }}
81
+ style={[style, container]}
82
+ {...rest}
83
+ >
84
+ {children}
85
+ {stateLayer && (
86
+ <AnimatedView
87
+ pointerEvents="none"
88
+ style={[
89
+ StyleSheet.absoluteFill,
90
+ { backgroundColor: roles[stateLayer], borderRadius: radius },
91
+ layer,
92
+ ]}
93
+ />
94
+ )}
95
+ </AnimatedPressable>
96
+ );
97
+ }
@@ -0,0 +1,65 @@
1
+ import * as React from 'react';
2
+ import { ScrollView, View, type ScrollViewProps, type ViewStyle } from 'react-native';
3
+ import { useSafeAreaInsets, type Edge } from 'react-native-safe-area-context';
4
+ import { useTheme, type RoleName } from '../theme/ThemeProvider';
5
+ import type { Space } from './Layout';
6
+
7
+ export interface ScreenContainerProps extends Omit<ScrollViewProps, 'contentContainerStyle'> {
8
+ /** Scrolls (and lets a native large title collapse). @default true */
9
+ scroll?: boolean;
10
+ /** Safe-area edges to pad. A native header already covers 'top'. @default ['left', 'right', 'bottom'] */
11
+ edges?: Edge[];
12
+ /** @default 'surface' */
13
+ background?: RoleName;
14
+ /** Content padding. @default '4' */
15
+ padding?: Space;
16
+ /** Gap between children. @default '4' */
17
+ gap?: Space;
18
+ contentStyle?: ViewStyle;
19
+ }
20
+
21
+ /**
22
+ * The root of every screen: background, edge-to-edge safe-area insets and
23
+ * content padding. With `scroll`, iOS adjusts insets itself
24
+ * (contentInsetAdjustmentBehavior) so native large titles collapse.
25
+ */
26
+ export function ScreenContainer({
27
+ scroll = true,
28
+ edges = ['left', 'right', 'bottom'],
29
+ background = 'surface',
30
+ padding = '4',
31
+ gap = '4',
32
+ contentStyle,
33
+ children,
34
+ style,
35
+ ...rest
36
+ }: ScreenContainerProps) {
37
+ const { roles, tokens } = useTheme();
38
+ const insets = useSafeAreaInsets();
39
+ const pad = tokens.spacing[padding];
40
+ const inner: ViewStyle = {
41
+ paddingTop: pad + (edges.includes('top') ? insets.top : 0),
42
+ paddingBottom: pad + (edges.includes('bottom') ? insets.bottom : 0),
43
+ paddingStart: pad + (edges.includes('left') ? insets.left : 0),
44
+ paddingEnd: pad + (edges.includes('right') ? insets.right : 0),
45
+ gap: tokens.spacing[gap],
46
+ };
47
+ if (!scroll) {
48
+ return (
49
+ <View style={[{ flex: 1, backgroundColor: roles[background] }, inner, contentStyle, style]}>
50
+ {children}
51
+ </View>
52
+ );
53
+ }
54
+ return (
55
+ <ScrollView
56
+ contentInsetAdjustmentBehavior="automatic"
57
+ keyboardShouldPersistTaps="handled"
58
+ style={[{ flex: 1, backgroundColor: roles[background] }, style]}
59
+ contentContainerStyle={[inner, contentStyle]}
60
+ {...rest}
61
+ >
62
+ {children}
63
+ </ScrollView>
64
+ );
65
+ }