@nexub/react-native 0.0.0-stage → 2.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.
Files changed (124) hide show
  1. package/README.md +111 -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 +292 -0
  5. package/lib/module/components/containers.js.map +1 -0
  6. package/lib/module/components/display.js +266 -0
  7. package/lib/module/components/display.js.map +1 -0
  8. package/lib/module/components/feedback.js +464 -0
  9. package/lib/module/components/feedback.js.map +1 -0
  10. package/lib/module/components/inputs.js +746 -0
  11. package/lib/module/components/inputs.js.map +1 -0
  12. package/lib/module/components/mobile.js +811 -0
  13. package/lib/module/components/mobile.js.map +1 -0
  14. package/lib/module/components/overlays.js +418 -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 +30 -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/index.js +7 -0
  28. package/lib/module/patterns/index.js.map +1 -0
  29. package/lib/module/patterns/money.js +419 -0
  30. package/lib/module/patterns/money.js.map +1 -0
  31. package/lib/module/primitives/Icon.js +17 -0
  32. package/lib/module/primitives/Icon.js.map +1 -0
  33. package/lib/module/primitives/Layout.js +79 -0
  34. package/lib/module/primitives/Layout.js.map +1 -0
  35. package/lib/module/primitives/Pressable.js +77 -0
  36. package/lib/module/primitives/Pressable.js.map +1 -0
  37. package/lib/module/primitives/ScreenContainer.js +58 -0
  38. package/lib/module/primitives/ScreenContainer.js.map +1 -0
  39. package/lib/module/primitives/Surface.js +57 -0
  40. package/lib/module/primitives/Surface.js.map +1 -0
  41. package/lib/module/primitives/Text.js +70 -0
  42. package/lib/module/primitives/Text.js.map +1 -0
  43. package/lib/module/theme/ThemeProvider.js +127 -0
  44. package/lib/module/theme/ThemeProvider.js.map +1 -0
  45. package/lib/module/theme/fonts.js +56 -0
  46. package/lib/module/theme/fonts.js.map +1 -0
  47. package/lib/module/theme/haptics.js +56 -0
  48. package/lib/module/theme/haptics.js.map +1 -0
  49. package/lib/module/theme/navigation.js +99 -0
  50. package/lib/module/theme/navigation.js.map +1 -0
  51. package/lib/typescript/components/actions.d.ts +53 -0
  52. package/lib/typescript/components/actions.d.ts.map +1 -0
  53. package/lib/typescript/components/containers.d.ts +59 -0
  54. package/lib/typescript/components/containers.d.ts.map +1 -0
  55. package/lib/typescript/components/display.d.ts +62 -0
  56. package/lib/typescript/components/display.d.ts.map +1 -0
  57. package/lib/typescript/components/feedback.d.ts +93 -0
  58. package/lib/typescript/components/feedback.d.ts.map +1 -0
  59. package/lib/typescript/components/inputs.d.ts +141 -0
  60. package/lib/typescript/components/inputs.d.ts.map +1 -0
  61. package/lib/typescript/components/mobile.d.ts +173 -0
  62. package/lib/typescript/components/mobile.d.ts.map +1 -0
  63. package/lib/typescript/components/overlays.d.ts +74 -0
  64. package/lib/typescript/components/overlays.d.ts.map +1 -0
  65. package/lib/typescript/components/shared.d.ts +20 -0
  66. package/lib/typescript/components/shared.d.ts.map +1 -0
  67. package/lib/typescript/hooks/useControllableState.d.ts +6 -0
  68. package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
  69. package/lib/typescript/index.d.ts +37 -0
  70. package/lib/typescript/index.d.ts.map +1 -0
  71. package/lib/typescript/package.json +1 -0
  72. package/lib/typescript/patterns/app.d.ts +139 -0
  73. package/lib/typescript/patterns/app.d.ts.map +1 -0
  74. package/lib/typescript/patterns/auth.d.ts +54 -0
  75. package/lib/typescript/patterns/auth.d.ts.map +1 -0
  76. package/lib/typescript/patterns/index.d.ts +7 -0
  77. package/lib/typescript/patterns/index.d.ts.map +1 -0
  78. package/lib/typescript/patterns/money.d.ts +106 -0
  79. package/lib/typescript/patterns/money.d.ts.map +1 -0
  80. package/lib/typescript/primitives/Icon.d.ts +12 -0
  81. package/lib/typescript/primitives/Icon.d.ts.map +1 -0
  82. package/lib/typescript/primitives/Layout.d.ts +26 -0
  83. package/lib/typescript/primitives/Layout.d.ts.map +1 -0
  84. package/lib/typescript/primitives/Pressable.d.ts +21 -0
  85. package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
  86. package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
  87. package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
  88. package/lib/typescript/primitives/Surface.d.ts +23 -0
  89. package/lib/typescript/primitives/Surface.d.ts.map +1 -0
  90. package/lib/typescript/primitives/Text.d.ts +21 -0
  91. package/lib/typescript/primitives/Text.d.ts.map +1 -0
  92. package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
  93. package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
  94. package/lib/typescript/theme/fonts.d.ts +25 -0
  95. package/lib/typescript/theme/fonts.d.ts.map +1 -0
  96. package/lib/typescript/theme/haptics.d.ts +3 -0
  97. package/lib/typescript/theme/haptics.d.ts.map +1 -0
  98. package/lib/typescript/theme/navigation.d.ts +158 -0
  99. package/lib/typescript/theme/navigation.d.ts.map +1 -0
  100. package/package.json +138 -3
  101. package/src/components/actions.tsx +259 -0
  102. package/src/components/containers.tsx +341 -0
  103. package/src/components/display.tsx +314 -0
  104. package/src/components/feedback.tsx +522 -0
  105. package/src/components/inputs.tsx +851 -0
  106. package/src/components/mobile.tsx +879 -0
  107. package/src/components/overlays.tsx +485 -0
  108. package/src/components/shared.ts +54 -0
  109. package/src/hooks/useControllableState.ts +27 -0
  110. package/src/index.ts +175 -0
  111. package/src/patterns/app.tsx +715 -0
  112. package/src/patterns/auth.tsx +266 -0
  113. package/src/patterns/index.ts +57 -0
  114. package/src/patterns/money.tsx +458 -0
  115. package/src/primitives/Icon.tsx +15 -0
  116. package/src/primitives/Layout.tsx +71 -0
  117. package/src/primitives/Pressable.tsx +94 -0
  118. package/src/primitives/ScreenContainer.tsx +65 -0
  119. package/src/primitives/Surface.tsx +75 -0
  120. package/src/primitives/Text.tsx +79 -0
  121. package/src/theme/ThemeProvider.tsx +210 -0
  122. package/src/theme/fonts.ts +52 -0
  123. package/src/theme/haptics.ts +57 -0
  124. package/src/theme/navigation.ts +68 -0
@@ -0,0 +1,879 @@
1
+ import * as React from 'react';
2
+ import {
3
+ AppState,
4
+ FlatList,
5
+ KeyboardAvoidingView,
6
+ PanResponder,
7
+ Platform,
8
+ RefreshControl as RNRefreshControl,
9
+ StyleSheet,
10
+ TextInput,
11
+ View,
12
+ useWindowDimensions,
13
+ type ListRenderItem,
14
+ type RefreshControlProps as RNRefreshControlProps,
15
+ type StyleProp,
16
+ type ViewStyle,
17
+ } from 'react-native';
18
+ import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
19
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
20
+ import { currencySymbol, fractionDigits, parseMoney } from '@nexub/core';
21
+ import { Calendar, Delete, Eye, EyeOff, type NativeIconProps } from '@nexub/icons/native';
22
+ import { useControllableState } from '../hooks/useControllableState';
23
+ import { Pressable } from '../primitives/Pressable';
24
+ import { Surface } from '../primitives/Surface';
25
+ import { Text } from '../primitives/Text';
26
+ import { fontStyle } from '../theme/fonts';
27
+ import { useHaptics, useTheme, type RoleName } from '../theme/ThemeProvider';
28
+ import { Button, IconButton } from './actions';
29
+ import { Banner } from './feedback';
30
+ import { BottomSheet } from './overlays';
31
+
32
+ // ─── SwipeableRow ────────────────────────────────────────────────────────────
33
+
34
+ export interface SwipeAction {
35
+ label: string;
36
+ icon?: React.ComponentType<NativeIconProps>;
37
+ onPress: () => void;
38
+ /** @default 'neutral' */
39
+ tone?: 'neutral' | 'danger' | 'primary';
40
+ }
41
+
42
+ export interface SwipeableRowProps {
43
+ /** Accessible name of the whole row, e.g. "Amazon, minus $42.50, today". */
44
+ label: string;
45
+ /** Tap / double-tap action of the row (open details). */
46
+ onPress?: () => void;
47
+ /** Revealed by swiping toward the start edge. Each one is also an accessibility action. */
48
+ actions: SwipeAction[];
49
+ children: React.ReactNode;
50
+ style?: StyleProp<ViewStyle>;
51
+ }
52
+
53
+ const SWIPE_ACTION_WIDTH = 80;
54
+
55
+ /**
56
+ * Swipe a row to reveal actions. Never the only way to an action: they are
57
+ * custom accessibility actions too (VoiceOver rotor / TalkBack actions).
58
+ */
59
+ export function SwipeableRow({ label, onPress, actions, children, style }: SwipeableRowProps) {
60
+ const { roles, dir } = useTheme();
61
+ const play = useHaptics();
62
+ const width = actions.length * SWIPE_ACTION_WIDTH;
63
+ const sign = dir === 'rtl' ? 1 : -1; // open = content moves toward the start edge
64
+ const x = useSharedValue(0);
65
+ const open = React.useRef(false);
66
+ const spring = useTheme().tokens.motion.spring.spatial.default;
67
+ const settle = (to: boolean) => {
68
+ if (to !== open.current) play('snap');
69
+ open.current = to;
70
+ x.value = withSpring(to ? sign * width : 0, spring);
71
+ };
72
+ const responder = React.useMemo(
73
+ () =>
74
+ PanResponder.create({
75
+ onMoveShouldSetPanResponder: (_, g) =>
76
+ Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5,
77
+ onPanResponderMove: (_, g) => {
78
+ const base = open.current ? sign * width : 0;
79
+ const next = base + g.dx;
80
+ x.value =
81
+ sign < 0
82
+ ? Math.min(0, Math.max(-width * 1.2, next))
83
+ : Math.max(0, Math.min(width * 1.2, next));
84
+ },
85
+ onPanResponderRelease: (_, g) =>
86
+ settle(Math.abs((open.current ? sign * width : 0) + g.dx) > width / 2),
87
+ onPanResponderTerminate: () => settle(open.current),
88
+ }),
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ [width, sign],
91
+ );
92
+ const slide = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }));
93
+ const bg: Record<NonNullable<SwipeAction['tone']>, [RoleName, RoleName]> = {
94
+ neutral: ['surface-container-highest', 'on-surface'],
95
+ danger: ['error', 'on-error'],
96
+ primary: ['primary', 'on-primary'],
97
+ };
98
+ return (
99
+ <View
100
+ style={[{ overflow: 'hidden' }, style]}
101
+ // One screen-reader element for the row: its name, its tap, and every swipe action.
102
+ accessible
103
+ accessibilityRole={onPress ? 'button' : undefined}
104
+ accessibilityLabel={label}
105
+ accessibilityActions={[
106
+ ...(onPress ? [{ name: 'activate' }] : []),
107
+ ...actions.map((a) => ({ name: a.label, label: a.label })),
108
+ ]}
109
+ onAccessibilityAction={(e) => {
110
+ const name = e.nativeEvent.actionName;
111
+ if (name === 'activate') onPress?.();
112
+ else actions.find((a) => a.label === name)?.onPress();
113
+ }}
114
+ >
115
+ <View
116
+ style={[StyleSheet.absoluteFill, { flexDirection: 'row', justifyContent: 'flex-end' }]}
117
+ importantForAccessibility="no-hide-descendants"
118
+ accessibilityElementsHidden
119
+ >
120
+ {actions.map((a) => {
121
+ const [b, f] = bg[a.tone ?? 'neutral'];
122
+ const Glyph = a.icon;
123
+ return (
124
+ <Pressable
125
+ key={a.label}
126
+ accessibilityLabel={a.label}
127
+ onPress={() => {
128
+ settle(false);
129
+ a.onPress();
130
+ }}
131
+ pressScale={false}
132
+ stateLayer={f}
133
+ style={{
134
+ width: SWIPE_ACTION_WIDTH,
135
+ backgroundColor: roles[b],
136
+ alignItems: 'center',
137
+ justifyContent: 'center',
138
+ gap: 4,
139
+ }}
140
+ >
141
+ {Glyph && <Glyph size="md" color={roles[f]} />}
142
+ <Text variant="label-md" color={f}>
143
+ {a.label}
144
+ </Text>
145
+ </Pressable>
146
+ );
147
+ })}
148
+ </View>
149
+ <Animated.View
150
+ style={[{ backgroundColor: roles['surface-container-lowest'] }, slide]}
151
+ {...responder.panHandlers}
152
+ >
153
+ {children}
154
+ </Animated.View>
155
+ </View>
156
+ );
157
+ }
158
+
159
+ // ─── RefreshControl ──────────────────────────────────────────────────────────
160
+
161
+ /** The platform pull-to-refresh in the primary role. Pass to a ScrollView/FlatList `refreshControl`. */
162
+ export function RefreshControl(props: RNRefreshControlProps) {
163
+ const { roles } = useTheme();
164
+ return (
165
+ <RNRefreshControl
166
+ tintColor={roles.primary}
167
+ colors={[roles.primary]}
168
+ progressBackgroundColor={roles['surface-container-high']}
169
+ {...props}
170
+ />
171
+ );
172
+ }
173
+
174
+ // ─── Carousel + PageIndicator ────────────────────────────────────────────────
175
+
176
+ export interface PageIndicatorProps {
177
+ count: number;
178
+ index: number;
179
+ style?: StyleProp<ViewStyle>;
180
+ }
181
+
182
+ /** Dots for paged content: "Page 2 of 4" to screen readers. */
183
+ export function PageIndicator({ count, index, style }: PageIndicatorProps) {
184
+ const { roles, messages } = useTheme();
185
+ return (
186
+ <View
187
+ accessible
188
+ accessibilityLabel={messages.page(index + 1, count)}
189
+ style={[{ flexDirection: 'row', gap: 6, alignSelf: 'center' }, style]}
190
+ >
191
+ {Array.from({ length: count }, (_, i) => (
192
+ <View
193
+ key={i}
194
+ style={{
195
+ width: i === index ? 20 : 6,
196
+ height: 6,
197
+ borderRadius: 3,
198
+ backgroundColor: roles[i === index ? 'primary' : 'outline-variant'],
199
+ }}
200
+ />
201
+ ))}
202
+ </View>
203
+ );
204
+ }
205
+
206
+ export interface CarouselProps<T> {
207
+ data: readonly T[];
208
+ renderItem: ListRenderItem<T>;
209
+ /** Page width. @default the window width */
210
+ width?: number;
211
+ onIndexChange?: (index: number) => void;
212
+ style?: StyleProp<ViewStyle>;
213
+ }
214
+
215
+ /** Full-width paging (onboarding, card stacks) with a page indicator. */
216
+ export function Carousel<T>({
217
+ data,
218
+ renderItem,
219
+ width: w,
220
+ onIndexChange,
221
+ style,
222
+ }: CarouselProps<T>) {
223
+ const { width: window } = useWindowDimensions();
224
+ const width = w ?? window;
225
+ const [index, setIndex] = React.useState(0);
226
+ const { tokens } = useTheme();
227
+ return (
228
+ <View style={[{ gap: tokens.spacing[3] }, style]}>
229
+ <FlatList
230
+ data={data}
231
+ horizontal
232
+ pagingEnabled
233
+ showsHorizontalScrollIndicator={false}
234
+ renderItem={(info) => <View style={{ width }}>{renderItem(info)}</View>}
235
+ keyExtractor={(_, i) => String(i)}
236
+ onMomentumScrollEnd={(e) => {
237
+ const i = Math.round(e.nativeEvent.contentOffset.x / width);
238
+ setIndex(i);
239
+ onIndexChange?.(i);
240
+ }}
241
+ />
242
+ <PageIndicator count={data.length} index={index} />
243
+ </View>
244
+ );
245
+ }
246
+
247
+ // ─── StickyFooter ────────────────────────────────────────────────────────────
248
+
249
+ export interface StickyFooterProps {
250
+ /** Scrollable content above the footer. */
251
+ children: React.ReactNode;
252
+ /** The flow's primary action(s), kept above the keyboard and the home indicator. */
253
+ footer: React.ReactNode;
254
+ style?: StyleProp<ViewStyle>;
255
+ }
256
+
257
+ /** Screen body + a footer that stays above the keyboard: the primary button of every flow. */
258
+ export function StickyFooter({ children, footer, style }: StickyFooterProps) {
259
+ const { roles, tokens } = useTheme();
260
+ const insets = useSafeAreaInsets();
261
+ return (
262
+ <KeyboardAvoidingView
263
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
264
+ style={[{ flex: 1, backgroundColor: roles.surface }, style]}
265
+ >
266
+ <View style={{ flex: 1 }}>{children}</View>
267
+ <View
268
+ style={{
269
+ paddingHorizontal: tokens.spacing[4],
270
+ paddingTop: tokens.spacing[3],
271
+ paddingBottom: Math.max(insets.bottom, tokens.spacing[4]),
272
+ gap: tokens.spacing[2],
273
+ backgroundColor: roles.surface,
274
+ borderTopWidth: StyleSheet.hairlineWidth,
275
+ borderTopColor: roles['outline-variant'],
276
+ }}
277
+ >
278
+ {footer}
279
+ </View>
280
+ </KeyboardAvoidingView>
281
+ );
282
+ }
283
+
284
+ // ─── OTPInput ────────────────────────────────────────────────────────────────
285
+
286
+ export interface OTPInputProps {
287
+ /** Accessible name, e.g. "Verification code". */
288
+ label: string;
289
+ /** @default 6 */
290
+ length?: number;
291
+ value?: string;
292
+ onValueChange?: (code: string) => void;
293
+ /** Called once all digits are entered. */
294
+ onComplete?: (code: string) => void;
295
+ /** Dots instead of digits (PIN). Turns off one-time-code autofill. */
296
+ secure?: boolean;
297
+ error?: string;
298
+ autoFocus?: boolean;
299
+ style?: StyleProp<ViewStyle>;
300
+ }
301
+
302
+ /**
303
+ * One-time code / PIN entry: one hidden field (so paste and SMS autofill work:
304
+ * iOS oneTimeCode, Android sms-otp) drawn as boxes.
305
+ */
306
+ export function OTPInput({
307
+ label,
308
+ length = 6,
309
+ value,
310
+ onValueChange,
311
+ onComplete,
312
+ secure,
313
+ error,
314
+ autoFocus,
315
+ style,
316
+ }: OTPInputProps) {
317
+ const { roles, tokens, fonts } = useTheme();
318
+ const ref = React.useRef<React.ComponentRef<typeof TextInput>>(null);
319
+ const [focused, setFocused] = React.useState(false);
320
+ const [code, setCode] = useControllableState({
321
+ value,
322
+ defaultValue: '',
323
+ onChange: onValueChange,
324
+ });
325
+ return (
326
+ <View style={[{ gap: tokens.spacing[2] }, style]}>
327
+ <Pressable
328
+ onPress={() => ref.current?.focus()}
329
+ stateLayer={false}
330
+ pressScale={false}
331
+ accessible={false}
332
+ style={{ flexDirection: 'row', gap: tokens.spacing[2], justifyContent: 'center' }}
333
+ >
334
+ {Array.from({ length }, (_, i) => {
335
+ const ch = code[i];
336
+ const active = focused && i === Math.min(code.length, length - 1);
337
+ return (
338
+ <View
339
+ key={i}
340
+ style={{
341
+ width: 44,
342
+ height: 52,
343
+ borderRadius: tokens.shape.field,
344
+ borderWidth: active || error ? 2 : 1,
345
+ borderColor: error ? roles.error : active ? roles.primary : roles.outline,
346
+ backgroundColor: roles['surface-container-lowest'],
347
+ alignItems: 'center',
348
+ justifyContent: 'center',
349
+ }}
350
+ >
351
+ <Text variant="title-lg" tabular>
352
+ {ch ? (secure ? '•' : ch) : ''}
353
+ </Text>
354
+ </View>
355
+ );
356
+ })}
357
+ </Pressable>
358
+ <TextInput
359
+ ref={ref}
360
+ accessibilityLabel={label}
361
+ accessibilityHint={error}
362
+ value={code}
363
+ onChangeText={(t) => {
364
+ const next = t.replace(/\D/g, '').slice(0, length);
365
+ setCode(next);
366
+ if (next.length === length) onComplete?.(next);
367
+ }}
368
+ onFocus={() => setFocused(true)}
369
+ onBlur={() => setFocused(false)}
370
+ autoFocus={autoFocus}
371
+ keyboardType="number-pad"
372
+ inputMode="numeric"
373
+ maxLength={length}
374
+ secureTextEntry={secure}
375
+ textContentType={secure ? 'password' : 'oneTimeCode'}
376
+ autoComplete={secure ? 'off' : 'sms-otp'}
377
+ caretHidden
378
+ style={{
379
+ position: 'absolute',
380
+ opacity: 0.011,
381
+ width: 1,
382
+ height: 1,
383
+ ...fontStyle('sans', 400, fonts),
384
+ }}
385
+ />
386
+ {error && (
387
+ <Text
388
+ variant="body-sm"
389
+ color="error"
390
+ style={{ textAlign: 'center' }}
391
+ accessibilityLiveRegion="polite"
392
+ >
393
+ {error}
394
+ </Text>
395
+ )}
396
+ </View>
397
+ );
398
+ }
399
+
400
+ // ─── NumericKeypad + ExpressiveMoneyInput ────────────────────────────────────
401
+
402
+ export interface NumericKeypadProps {
403
+ /** Called with '0'–'9', the decimal key or 'delete'. */
404
+ onKey: (key: string) => void;
405
+ /** Show the decimal key (money). @default false */
406
+ decimal?: boolean;
407
+ locale?: string;
408
+ style?: StyleProp<ViewStyle>;
409
+ }
410
+
411
+ /** On-screen digits for amounts and PINs (no system keyboard covering the screen). */
412
+ export function NumericKeypad({ onKey, decimal, locale, style }: NumericKeypadProps) {
413
+ const { roles, messages, locale: loc } = useTheme();
414
+ const sep = new Intl.NumberFormat(locale ?? loc).format(1.5).replace(/\p{Nd}/gu, '') || '.';
415
+ const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', decimal ? sep : '', '0', 'delete'];
416
+ return (
417
+ <View style={[{ flexDirection: 'row', flexWrap: 'wrap' }, style]}>
418
+ {keys.map((k, i) =>
419
+ k === '' ? (
420
+ <View key={i} style={{ width: '33.333%', height: 64 }} />
421
+ ) : (
422
+ <Pressable
423
+ key={i}
424
+ accessibilityLabel={k === 'delete' ? messages.deleteDigit : k}
425
+ haptic="selection"
426
+ onPress={() => onKey(k)}
427
+ style={{
428
+ width: '33.333%',
429
+ height: 64,
430
+ alignItems: 'center',
431
+ justifyContent: 'center',
432
+ borderRadius: 16,
433
+ }}
434
+ >
435
+ {k === 'delete' ? (
436
+ <Delete size="lg" color={roles['on-surface']} />
437
+ ) : (
438
+ <Text variant="headline-md">{k}</Text>
439
+ )}
440
+ </Pressable>
441
+ ),
442
+ )}
443
+ </View>
444
+ );
445
+ }
446
+
447
+ export interface ExpressiveMoneyInputProps {
448
+ currency: string;
449
+ value?: number | null;
450
+ onValueChange?: (value: number | null) => void;
451
+ /** Accessible name, e.g. "Amount to send". */
452
+ label: string;
453
+ locale?: string;
454
+ /** Shown under the amount, e.g. "Balance $1,240.00" or an error. */
455
+ caption?: string;
456
+ error?: boolean;
457
+ style?: StyleProp<ViewStyle>;
458
+ }
459
+
460
+ /** Big amount in the brand face + keypad (Wise-style money entry). */
461
+ export function ExpressiveMoneyInput({
462
+ currency,
463
+ value,
464
+ onValueChange,
465
+ label,
466
+ locale,
467
+ caption,
468
+ error,
469
+ style,
470
+ }: ExpressiveMoneyInputProps) {
471
+ const theme = useTheme();
472
+ const loc = locale ?? theme.locale;
473
+ const digits = fractionDigits(currency, loc);
474
+ const sep = new Intl.NumberFormat(loc).format(1.5).replace(/\p{Nd}/gu, '') || '.';
475
+ const [text, setText] = React.useState(value == null ? '' : String(value).replace('.', sep));
476
+ const press = (k: string) => {
477
+ let next = text;
478
+ if (k === 'delete') next = text.slice(0, -1);
479
+ else if (k === sep) next = digits === 0 || text.includes(sep) ? text : (text || '0') + sep;
480
+ else {
481
+ const [, frac] = text.split(sep);
482
+ if (frac !== undefined && frac.length >= digits) return;
483
+ next = text === '0' ? k : text + k;
484
+ }
485
+ setText(next);
486
+ onValueChange?.(next ? parseMoney(next, currency, loc) : null);
487
+ };
488
+ return (
489
+ <View style={[{ gap: theme.tokens.spacing[4] }, style]}>
490
+ <View
491
+ accessible
492
+ accessibilityLabel={`${label}, ${text || '0'} ${currency}`}
493
+ accessibilityLiveRegion="polite"
494
+ style={{ alignItems: 'center', gap: 4 }}
495
+ >
496
+ <Text
497
+ variant="display-md"
498
+ color={error ? 'error' : 'on-surface'}
499
+ tabular
500
+ >
501
+ <Text variant="headline-md" color="on-surface-variant">
502
+ {currencySymbol(currency, loc)}
503
+ </Text>
504
+ {text || '0'}
505
+ </Text>
506
+ {caption && (
507
+ <Text variant="body-md" color={error ? 'error' : 'on-surface-variant'}>
508
+ {caption}
509
+ </Text>
510
+ )}
511
+ </View>
512
+ <NumericKeypad onKey={press} decimal={digits > 0} locale={loc} />
513
+ </View>
514
+ );
515
+ }
516
+
517
+ // ─── SensitiveValue ──────────────────────────────────────────────────────────
518
+
519
+ export interface SensitiveValueProps {
520
+ /** What it is, e.g. "Balance" (used for the toggle and the masked reading). */
521
+ label: string;
522
+ /** The formatted value, e.g. "$12,480.50". */
523
+ children: string;
524
+ /** @default 'title-lg' */
525
+ variant?: React.ComponentProps<typeof Text>['variant'];
526
+ revealed?: boolean;
527
+ defaultRevealed?: boolean;
528
+ onRevealedChange?: (revealed: boolean) => void;
529
+ }
530
+
531
+ /**
532
+ * A value that can be hidden (balances, card numbers). Masked text reads as
533
+ * "Balance, hidden", never as dots, and it re-masks when the app backgrounds.
534
+ */
535
+ export function SensitiveValue({
536
+ label,
537
+ children,
538
+ variant = 'title-lg',
539
+ revealed,
540
+ defaultRevealed = false,
541
+ onRevealedChange,
542
+ }: SensitiveValueProps) {
543
+ const { messages } = useTheme();
544
+ const [shown, setShown] = useControllableState({
545
+ value: revealed,
546
+ defaultValue: defaultRevealed,
547
+ onChange: onRevealedChange,
548
+ });
549
+ React.useEffect(() => {
550
+ const sub = AppState.addEventListener('change', (s) => s !== 'active' && setShown(false));
551
+ return () => sub.remove();
552
+ }, [setShown]);
553
+ return (
554
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
555
+ <Text
556
+ variant={variant}
557
+ tabular
558
+ accessibilityLabel={`${label}, ${shown ? children : messages.hidden}`}
559
+ >
560
+ {shown ? children : '••••••'}
561
+ </Text>
562
+ <IconButton
563
+ icon={shown ? EyeOff : Eye}
564
+ label={shown ? messages.hide(label) : messages.show(label)}
565
+ size="sm"
566
+ onPress={() => setShown(!shown)}
567
+ />
568
+ </View>
569
+ );
570
+ }
571
+
572
+ // ─── PrivacyShield ───────────────────────────────────────────────────────────
573
+
574
+ let ScreenCapture: typeof import('expo-screen-capture') | undefined;
575
+ try {
576
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer, must not fail when absent
577
+ ScreenCapture = require('expo-screen-capture');
578
+ } catch {
579
+ ScreenCapture = undefined;
580
+ }
581
+
582
+ export interface PrivacyShieldProps {
583
+ children: React.ReactNode;
584
+ /** Shown over the app in the app switcher (e.g. your logo). */
585
+ cover?: React.ReactNode;
586
+ /** Block screenshots and recording (Android FLAG_SECURE; iOS hides the content in captures). Needs expo-screen-capture. @default false */
587
+ preventCapture?: boolean;
588
+ }
589
+
590
+ /**
591
+ * Covers the app when it leaves the foreground, so the app-switcher snapshot
592
+ * shows no balances. Wrap screens with sensitive data (or the whole app).
593
+ */
594
+ export function PrivacyShield({ children, cover, preventCapture }: PrivacyShieldProps) {
595
+ const { roles } = useTheme();
596
+ const [covered, setCovered] = React.useState(false);
597
+ React.useEffect(() => {
598
+ const sub = AppState.addEventListener('change', (s) => setCovered(s !== 'active'));
599
+ return () => sub.remove();
600
+ }, []);
601
+ React.useEffect(() => {
602
+ if (!preventCapture || !ScreenCapture) return;
603
+ const key = 'nexub-privacy-shield';
604
+ ScreenCapture.preventScreenCaptureAsync(key).catch(() => {});
605
+ return () => {
606
+ ScreenCapture?.allowScreenCaptureAsync(key).catch(() => {});
607
+ };
608
+ }, [preventCapture]);
609
+ return (
610
+ <View style={{ flex: 1 }}>
611
+ {children}
612
+ {covered && (
613
+ <View
614
+ style={[
615
+ StyleSheet.absoluteFill,
616
+ { backgroundColor: roles.surface, alignItems: 'center', justifyContent: 'center' },
617
+ ]}
618
+ accessibilityElementsHidden
619
+ >
620
+ {cover}
621
+ </View>
622
+ )}
623
+ </View>
624
+ );
625
+ }
626
+
627
+ // ─── OfflineBanner ───────────────────────────────────────────────────────────
628
+
629
+ /** Slim notice while the app has no connection. Pass your network state (e.g. NetInfo). */
630
+ export function OfflineBanner({
631
+ offline,
632
+ style,
633
+ }: {
634
+ offline: boolean;
635
+ style?: StyleProp<ViewStyle>;
636
+ }) {
637
+ const { messages } = useTheme();
638
+ if (!offline) return null;
639
+ return (
640
+ <Banner
641
+ tone="warning"
642
+ style={[{ borderRadius: 0, paddingVertical: 10 }, style]}
643
+ accessibilityLiveRegion="polite"
644
+ >
645
+ {messages.offline}
646
+ </Banner>
647
+ );
648
+ }
649
+
650
+ // ─── Coachmark ───────────────────────────────────────────────────────────────
651
+
652
+ export interface CoachmarkProps {
653
+ title?: string;
654
+ children: string;
655
+ /** Which side the arrow is on (towards the thing it explains). @default 'top' */
656
+ arrow?: 'top' | 'bottom';
657
+ onDismiss: () => void;
658
+ /** Dismiss label. @default messages.close */
659
+ dismissLabel?: string;
660
+ style?: StyleProp<ViewStyle>;
661
+ }
662
+
663
+ /** A one-time hint next to a new feature. Place it in your layout; it doesn't measure targets. */
664
+ export function Coachmark({
665
+ title,
666
+ children,
667
+ arrow = 'top',
668
+ onDismiss,
669
+ dismissLabel,
670
+ style,
671
+ }: CoachmarkProps) {
672
+ const { roles, tokens, messages } = useTheme();
673
+ const tip = (
674
+ <View
675
+ style={{
676
+ width: 14,
677
+ height: 14,
678
+ marginStart: 24,
679
+ transform: [{ rotate: '45deg' }],
680
+ backgroundColor: roles['inverse-surface'],
681
+ marginVertical: -7,
682
+ }}
683
+ />
684
+ );
685
+ return (
686
+ <View accessibilityRole="alert" style={style}>
687
+ {arrow === 'top' && tip}
688
+ <View
689
+ style={{
690
+ padding: tokens.spacing[4],
691
+ gap: tokens.spacing[2],
692
+ borderRadius: tokens.shape.container,
693
+ backgroundColor: roles['inverse-surface'],
694
+ }}
695
+ >
696
+ {title && (
697
+ <Text variant="title-sm" color="inverse-on-surface">
698
+ {title}
699
+ </Text>
700
+ )}
701
+ <Text variant="body-sm" color="inverse-on-surface">
702
+ {children}
703
+ </Text>
704
+ <Button variant="text" size="sm" onPress={onDismiss} style={{ alignSelf: 'flex-end' }}>
705
+ {dismissLabel ?? messages.close}
706
+ </Button>
707
+ </View>
708
+ {arrow === 'bottom' && tip}
709
+ </View>
710
+ );
711
+ }
712
+
713
+ // ─── FAB ─────────────────────────────────────────────────────────────────────
714
+
715
+ export interface FABProps {
716
+ icon: React.ComponentType<NativeIconProps>;
717
+ /** Accessible name; also shown when `extended`. */
718
+ label: string;
719
+ onPress: () => void;
720
+ extended?: boolean;
721
+ /** Platforms that render it (a FAB reads as Material; iOS uses a toolbar or nav-bar button). @default ['android'] */
722
+ platforms?: ('ios' | 'android')[];
723
+ /** Extra bottom offset, e.g. the tab bar. @default 0 */
724
+ bottomOffset?: number;
725
+ }
726
+
727
+ /** Floating primary action, bottom end. Android only by default (MOBILE_PLAN.md D7). */
728
+ export function FAB({
729
+ icon: Glyph,
730
+ label,
731
+ onPress,
732
+ extended,
733
+ platforms = ['android'],
734
+ bottomOffset = 0,
735
+ }: FABProps) {
736
+ const { roles, tokens } = useTheme();
737
+ const insets = useSafeAreaInsets();
738
+ if (!platforms.includes(Platform.OS as 'ios')) return null;
739
+ return (
740
+ <Pressable
741
+ accessibilityLabel={label}
742
+ onPress={onPress}
743
+ stateLayer="on-primary-container"
744
+ style={{
745
+ position: 'absolute',
746
+ end: 16,
747
+ bottom: insets.bottom + 16 + bottomOffset,
748
+ height: 56,
749
+ minWidth: 56,
750
+ paddingHorizontal: extended ? 20 : 0,
751
+ borderRadius: 16,
752
+ flexDirection: 'row',
753
+ alignItems: 'center',
754
+ justifyContent: 'center',
755
+ gap: 12,
756
+ backgroundColor: roles['primary-container'],
757
+ boxShadow: tokens.elevation[3].light,
758
+ }}
759
+ >
760
+ <Glyph size="lg" color={roles['on-primary-container']} />
761
+ {extended && (
762
+ <Text variant="label-lg" color="on-primary-container">
763
+ {label}
764
+ </Text>
765
+ )}
766
+ </Pressable>
767
+ );
768
+ }
769
+
770
+ // ─── DateTimeField ───────────────────────────────────────────────────────────
771
+
772
+ let Picker: typeof import('@react-native-community/datetimepicker') | undefined;
773
+ try {
774
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer, must not fail when absent
775
+ Picker = require('@react-native-community/datetimepicker');
776
+ } catch {
777
+ Picker = undefined;
778
+ }
779
+
780
+ export interface DateTimeFieldProps {
781
+ label: string;
782
+ value: Date | null;
783
+ onValueChange: (value: Date) => void;
784
+ /** @default 'date' */
785
+ mode?: 'date' | 'time';
786
+ minimumDate?: Date;
787
+ maximumDate?: Date;
788
+ placeholder?: string;
789
+ style?: StyleProp<ViewStyle>;
790
+ }
791
+
792
+ /**
793
+ * A field that opens the platform picker (Android dialog, iOS inline calendar
794
+ * in a sheet). Needs @react-native-community/datetimepicker.
795
+ */
796
+ export function DateTimeField({
797
+ label,
798
+ value,
799
+ onValueChange,
800
+ mode = 'date',
801
+ minimumDate,
802
+ maximumDate,
803
+ placeholder,
804
+ style,
805
+ }: DateTimeFieldProps) {
806
+ const theme = useTheme();
807
+ const { roles, tokens, messages, locale } = theme;
808
+ const [open, setOpen] = React.useState(false);
809
+ if (!Picker)
810
+ throw new Error(
811
+ '@nexub/react-native: DateTimeField needs @react-native-community/datetimepicker (npx expo install @react-native-community/datetimepicker).',
812
+ );
813
+ const { default: RNDateTimePicker, DateTimePickerAndroid } = Picker;
814
+ const text = value
815
+ ? new Intl.DateTimeFormat(
816
+ locale,
817
+ mode === 'date' ? { dateStyle: 'medium' } : { timeStyle: 'short' },
818
+ ).format(value)
819
+ : (placeholder ?? messages.pickDate);
820
+ const show = () => {
821
+ if (Platform.OS === 'android') {
822
+ DateTimePickerAndroid.open({
823
+ value: value ?? new Date(),
824
+ mode,
825
+ minimumDate,
826
+ maximumDate,
827
+ onChange: (e, d) => e.type === 'set' && d && onValueChange(d),
828
+ });
829
+ } else setOpen(true);
830
+ };
831
+ return (
832
+ <View style={[{ gap: 6 }, style]}>
833
+ <Text variant="label-lg" color="on-surface-variant" importantForAccessibility="no">
834
+ {label}
835
+ </Text>
836
+ <Pressable
837
+ accessibilityLabel={`${label}, ${text}`}
838
+ onPress={show}
839
+ pressScale={false}
840
+ style={{
841
+ minHeight: tokens.size.control.lg,
842
+ paddingHorizontal: 14,
843
+ flexDirection: 'row',
844
+ alignItems: 'center',
845
+ gap: 8,
846
+ borderRadius: tokens.shape.field,
847
+ borderWidth: 1,
848
+ borderColor: roles.outline,
849
+ backgroundColor: roles['surface-container-lowest'],
850
+ }}
851
+ >
852
+ <Calendar size="sm" color={roles['on-surface-variant']} />
853
+ <Text variant="body-lg" color={value ? 'on-surface' : 'on-surface-muted'}>
854
+ {text}
855
+ </Text>
856
+ </Pressable>
857
+ {Platform.OS === 'ios' && (
858
+ <BottomSheet open={open} onOpenChange={setOpen} title={label}>
859
+ <Surface tone="surface-elevated">
860
+ <RNDateTimePicker
861
+ value={value ?? new Date()}
862
+ mode={mode}
863
+ display={mode === 'date' ? 'inline' : 'spinner'}
864
+ minimumDate={minimumDate}
865
+ maximumDate={maximumDate}
866
+ accentColor={roles.primary}
867
+ themeVariant={theme.scheme}
868
+ locale={locale}
869
+ onChange={(_, d) => d && onValueChange(d)}
870
+ />
871
+ </Surface>
872
+ <Button size="lg" fullWidth onPress={() => setOpen(false)}>
873
+ {messages.close}
874
+ </Button>
875
+ </BottomSheet>
876
+ )}
877
+ </View>
878
+ );
879
+ }