@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.
- package/README.md +113 -2
- package/lib/module/components/actions.js +222 -0
- package/lib/module/components/actions.js.map +1 -0
- package/lib/module/components/containers.js +300 -0
- package/lib/module/components/containers.js.map +1 -0
- package/lib/module/components/display.js +299 -0
- package/lib/module/components/display.js.map +1 -0
- package/lib/module/components/feedback.js +484 -0
- package/lib/module/components/feedback.js.map +1 -0
- package/lib/module/components/inputs.js +792 -0
- package/lib/module/components/inputs.js.map +1 -0
- package/lib/module/components/mobile.js +818 -0
- package/lib/module/components/mobile.js.map +1 -0
- package/lib/module/components/overlays.js +421 -0
- package/lib/module/components/overlays.js.map +1 -0
- package/lib/module/components/shared.js +57 -0
- package/lib/module/components/shared.js.map +1 -0
- package/lib/module/hooks/useControllableState.js +23 -0
- package/lib/module/hooks/useControllableState.js.map +1 -0
- package/lib/module/index.js +31 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/patterns/app.js +709 -0
- package/lib/module/patterns/app.js.map +1 -0
- package/lib/module/patterns/auth.js +224 -0
- package/lib/module/patterns/auth.js.map +1 -0
- package/lib/module/patterns/home.js +201 -0
- package/lib/module/patterns/home.js.map +1 -0
- package/lib/module/patterns/index.js +8 -0
- package/lib/module/patterns/index.js.map +1 -0
- package/lib/module/patterns/money.js +419 -0
- package/lib/module/patterns/money.js.map +1 -0
- package/lib/module/primitives/Icon.js +17 -0
- package/lib/module/primitives/Icon.js.map +1 -0
- package/lib/module/primitives/Layout.js +79 -0
- package/lib/module/primitives/Layout.js.map +1 -0
- package/lib/module/primitives/Pressable.js +79 -0
- package/lib/module/primitives/Pressable.js.map +1 -0
- package/lib/module/primitives/ScreenContainer.js +58 -0
- package/lib/module/primitives/ScreenContainer.js.map +1 -0
- package/lib/module/primitives/Surface.js +57 -0
- package/lib/module/primitives/Surface.js.map +1 -0
- package/lib/module/primitives/Text.js +70 -0
- package/lib/module/primitives/Text.js.map +1 -0
- package/lib/module/theme/ThemeProvider.js +127 -0
- package/lib/module/theme/ThemeProvider.js.map +1 -0
- package/lib/module/theme/fonts.js +56 -0
- package/lib/module/theme/fonts.js.map +1 -0
- package/lib/module/theme/haptics.js +56 -0
- package/lib/module/theme/haptics.js.map +1 -0
- package/lib/module/theme/motion.js +89 -0
- package/lib/module/theme/motion.js.map +1 -0
- package/lib/module/theme/navigation.js +99 -0
- package/lib/module/theme/navigation.js.map +1 -0
- package/lib/typescript/components/actions.d.ts +53 -0
- package/lib/typescript/components/actions.d.ts.map +1 -0
- package/lib/typescript/components/containers.d.ts +59 -0
- package/lib/typescript/components/containers.d.ts.map +1 -0
- package/lib/typescript/components/display.d.ts +68 -0
- package/lib/typescript/components/display.d.ts.map +1 -0
- package/lib/typescript/components/feedback.d.ts +96 -0
- package/lib/typescript/components/feedback.d.ts.map +1 -0
- package/lib/typescript/components/inputs.d.ts +141 -0
- package/lib/typescript/components/inputs.d.ts.map +1 -0
- package/lib/typescript/components/mobile.d.ts +173 -0
- package/lib/typescript/components/mobile.d.ts.map +1 -0
- package/lib/typescript/components/overlays.d.ts +74 -0
- package/lib/typescript/components/overlays.d.ts.map +1 -0
- package/lib/typescript/components/shared.d.ts +20 -0
- package/lib/typescript/components/shared.d.ts.map +1 -0
- package/lib/typescript/hooks/useControllableState.d.ts +6 -0
- package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +39 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/patterns/app.d.ts +139 -0
- package/lib/typescript/patterns/app.d.ts.map +1 -0
- package/lib/typescript/patterns/auth.d.ts +54 -0
- package/lib/typescript/patterns/auth.d.ts.map +1 -0
- package/lib/typescript/patterns/home.d.ts +50 -0
- package/lib/typescript/patterns/home.d.ts.map +1 -0
- package/lib/typescript/patterns/index.d.ts +9 -0
- package/lib/typescript/patterns/index.d.ts.map +1 -0
- package/lib/typescript/patterns/money.d.ts +106 -0
- package/lib/typescript/patterns/money.d.ts.map +1 -0
- package/lib/typescript/primitives/Icon.d.ts +12 -0
- package/lib/typescript/primitives/Icon.d.ts.map +1 -0
- package/lib/typescript/primitives/Layout.d.ts +26 -0
- package/lib/typescript/primitives/Layout.d.ts.map +1 -0
- package/lib/typescript/primitives/Pressable.d.ts +21 -0
- package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
- package/lib/typescript/primitives/Surface.d.ts +23 -0
- package/lib/typescript/primitives/Surface.d.ts.map +1 -0
- package/lib/typescript/primitives/Text.d.ts +21 -0
- package/lib/typescript/primitives/Text.d.ts.map +1 -0
- package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
- package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/theme/fonts.d.ts +25 -0
- package/lib/typescript/theme/fonts.d.ts.map +1 -0
- package/lib/typescript/theme/haptics.d.ts +3 -0
- package/lib/typescript/theme/haptics.d.ts.map +1 -0
- package/lib/typescript/theme/motion.d.ts +79 -0
- package/lib/typescript/theme/motion.d.ts.map +1 -0
- package/lib/typescript/theme/navigation.d.ts +158 -0
- package/lib/typescript/theme/navigation.d.ts.map +1 -0
- package/package.json +138 -3
- package/src/components/actions.tsx +259 -0
- package/src/components/containers.tsx +352 -0
- package/src/components/display.tsx +345 -0
- package/src/components/feedback.tsx +541 -0
- package/src/components/inputs.tsx +899 -0
- package/src/components/mobile.tsx +883 -0
- package/src/components/overlays.tsx +488 -0
- package/src/components/shared.ts +54 -0
- package/src/hooks/useControllableState.ts +27 -0
- package/src/index.ts +177 -0
- package/src/patterns/app.tsx +715 -0
- package/src/patterns/auth.tsx +266 -0
- package/src/patterns/home.tsx +211 -0
- package/src/patterns/index.ts +59 -0
- package/src/patterns/money.tsx +458 -0
- package/src/primitives/Icon.tsx +15 -0
- package/src/primitives/Layout.tsx +71 -0
- package/src/primitives/Pressable.tsx +97 -0
- package/src/primitives/ScreenContainer.tsx +65 -0
- package/src/primitives/Surface.tsx +75 -0
- package/src/primitives/Text.tsx +79 -0
- package/src/theme/ThemeProvider.tsx +210 -0
- package/src/theme/fonts.ts +52 -0
- package/src/theme/haptics.ts +57 -0
- package/src/theme/motion.ts +105 -0
- 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
|
+
}
|