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