@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,488 @@
1
+ import * as React from 'react';
2
+ import {
3
+ ActionSheetIOS,
4
+ KeyboardAvoidingView,
5
+ Modal,
6
+ PanResponder,
7
+ Platform,
8
+ Pressable as RNPressable,
9
+ ScrollView,
10
+ View,
11
+ useWindowDimensions,
12
+ type StyleProp,
13
+ type ViewStyle,
14
+ } from 'react-native';
15
+ import Animated, {
16
+ useAnimatedStyle,
17
+ useSharedValue,
18
+ withSpring,
19
+ withTiming,
20
+ } from 'react-native-reanimated';
21
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
22
+ import { ChevronsUpDown } from '@nexub/icons/native';
23
+ import { useControllableState } from '../hooks/useControllableState';
24
+ import { Pressable } from '../primitives/Pressable';
25
+ import { Text } from '../primitives/Text';
26
+ import { useHaptics, useTheme } from '../theme/ThemeProvider';
27
+ import { nxSpring } from '../theme/motion';
28
+ import { ListItem, ListSection } from './containers';
29
+
30
+ const SHEET_SPRING = nxSpring('spatial', 'default');
31
+
32
+ // ─── BottomSheet ─────────────────────────────────────────────────────────────
33
+
34
+ export type SheetDetent = 'content' | number;
35
+
36
+ /** Height of the grabber row above the sheet body. */
37
+ const GRABBER = 24;
38
+
39
+ export interface BottomSheetProps {
40
+ open: boolean;
41
+ onOpenChange: (open: boolean) => void;
42
+ /** Heights the sheet rests at: 'content' (fits its content) or a 0–1 fraction of the screen. @default ['content'] */
43
+ detents?: SheetDetent[];
44
+ title?: string;
45
+ children: React.ReactNode;
46
+ /** Drag down, tap the scrim or Android back closes it. @default true */
47
+ dismissable?: boolean;
48
+ /** Let the content scroll (long lists). @default false */
49
+ scroll?: boolean;
50
+ }
51
+
52
+ /**
53
+ * A modal sheet from the bottom edge (one at a time, per Apple and Material).
54
+ * Drag the grabber to resize or dismiss; tapping it cycles the detents, which
55
+ * also works with VoiceOver/TalkBack.
56
+ */
57
+ export function BottomSheet({
58
+ open,
59
+ onOpenChange,
60
+ detents = ['content'],
61
+ title,
62
+ children,
63
+ dismissable = true,
64
+ scroll,
65
+ }: BottomSheetProps) {
66
+ const theme = useTheme();
67
+ const { roles, tokens, messages } = theme;
68
+ const play = useHaptics();
69
+ const insets = useSafeAreaInsets();
70
+ const { height: screen } = useWindowDimensions();
71
+ const max = screen - insets.top - 12;
72
+ const [content, setContent] = React.useState(0);
73
+ const heights = detents
74
+ .map((d) =>
75
+ Math.min(max, d === 'content' ? (content ? content + GRABBER : max * 0.5) : d * screen),
76
+ )
77
+ .sort((a, b) => a - b);
78
+ const [index, setIndex] = React.useState(0);
79
+ const [visible, setVisible] = React.useState(open);
80
+ const translate = useSharedValue(screen);
81
+ const scrim = useSharedValue(0);
82
+ const spring = SHEET_SPRING;
83
+ const height = heights[Math.min(index, heights.length - 1)]!;
84
+ const tallest = heights[heights.length - 1]!;
85
+
86
+ // Sheet sits at translateY = tallest - height (0 = fully up).
87
+ const snapTo = React.useCallback(
88
+ (i: number) => {
89
+ setIndex(i);
90
+ translate.value = withSpring(tallest - heights[i]!, spring);
91
+ },
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ [tallest, heights.join(), spring],
94
+ );
95
+
96
+ React.useEffect(() => {
97
+ if (open) {
98
+ setVisible(true);
99
+ setIndex(0);
100
+ translate.value = screen;
101
+ translate.value = withSpring(tallest - heights[0]!, spring);
102
+ scrim.value = withTiming(1, { duration: tokens.motion.duration.normal });
103
+ } else if (visible) {
104
+ translate.value = withTiming(screen, { duration: tokens.motion.duration.normal });
105
+ scrim.value = withTiming(0, { duration: tokens.motion.duration.normal });
106
+ const t = setTimeout(() => setVisible(false), tokens.motion.duration.normal);
107
+ return () => clearTimeout(t);
108
+ }
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, [open]);
111
+
112
+ // Re-fit when 'content' is measured.
113
+ React.useEffect(() => {
114
+ if (open && content) translate.value = withSpring(tallest - height, spring);
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ }, [content]);
117
+
118
+ const start = React.useRef(0);
119
+ const responder = React.useMemo(
120
+ () =>
121
+ PanResponder.create({
122
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 4,
123
+ onPanResponderGrant: () => {
124
+ start.current = translate.value;
125
+ },
126
+ onPanResponderMove: (_, g) => {
127
+ translate.value = Math.max(0, start.current + g.dy);
128
+ },
129
+ onPanResponderRelease: (_, g) => {
130
+ const y = start.current + g.dy;
131
+ const lowest = tallest - heights[0]!;
132
+ if (dismissable && (y > lowest + 80 || g.vy > 1.2)) return onOpenChange(false);
133
+ // Nearest detent, biased by fling direction.
134
+ const target = y - g.vy * 120;
135
+ let best = 0;
136
+ heights.forEach((h, i) => {
137
+ if (Math.abs(tallest - h - target) < Math.abs(tallest - heights[best]! - target))
138
+ best = i;
139
+ });
140
+ if (best !== index) play('snap');
141
+ snapTo(best);
142
+ },
143
+ }),
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ [tallest, heights.join(), index, dismissable],
146
+ );
147
+
148
+ const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translate.value }] }));
149
+ const scrimStyle = useAnimatedStyle(() => ({ opacity: scrim.value * 0.32 }));
150
+ const close = () => {
151
+ if (dismissable) onOpenChange(false);
152
+ };
153
+
154
+ const body = (
155
+ <View
156
+ onLayout={(e) => {
157
+ const h = e.nativeEvent.layout.height;
158
+ if (detents.includes('content') && Math.abs(h - content) > 1) setContent(h);
159
+ }}
160
+ style={{
161
+ paddingHorizontal: tokens.spacing[4],
162
+ paddingBottom: insets.bottom + tokens.spacing[4],
163
+ gap: tokens.spacing[3],
164
+ }}
165
+ >
166
+ {title && (
167
+ <Text variant="title-lg" accessibilityRole="header" style={{ textAlign: 'center' }}>
168
+ {title}
169
+ </Text>
170
+ )}
171
+ {children}
172
+ </View>
173
+ );
174
+
175
+ return (
176
+ <Modal
177
+ transparent
178
+ visible={visible}
179
+ animationType="none"
180
+ statusBarTranslucent
181
+ navigationBarTranslucent
182
+ onRequestClose={close}
183
+ >
184
+ <Animated.View
185
+ style={[
186
+ {
187
+ position: 'absolute',
188
+ top: 0,
189
+ bottom: 0,
190
+ start: 0,
191
+ end: 0,
192
+ backgroundColor: roles.scrim,
193
+ },
194
+ scrimStyle,
195
+ ]}
196
+ >
197
+ <RNPressable
198
+ accessibilityElementsHidden
199
+ importantForAccessibility="no-hide-descendants"
200
+ style={{ flex: 1 }}
201
+ onPress={close}
202
+ />
203
+ </Animated.View>
204
+ <KeyboardAvoidingView
205
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
206
+ style={{ flex: 1, justifyContent: 'flex-end' }}
207
+ pointerEvents="box-none"
208
+ >
209
+ <Animated.View
210
+ accessibilityViewIsModal
211
+ style={[
212
+ {
213
+ height: tallest,
214
+ borderTopStartRadius: tokens.shape.overlay,
215
+ borderTopEndRadius: tokens.shape.overlay,
216
+ backgroundColor: roles['surface-elevated'],
217
+ boxShadow: tokens.elevation[3][theme.scheme],
218
+ overflow: 'hidden',
219
+ },
220
+ sheetStyle,
221
+ ]}
222
+ >
223
+ <View {...responder.panHandlers}>
224
+ <Pressable
225
+ accessibilityLabel={messages.resize}
226
+ accessibilityHint={title}
227
+ disabled={heights.length < 2}
228
+ onPress={() => snapTo((index + 1) % heights.length)}
229
+ stateLayer={false}
230
+ pressScale={false}
231
+ style={{ height: GRABBER, alignItems: 'center', justifyContent: 'center' }}
232
+ >
233
+ <View
234
+ style={{
235
+ width: 36,
236
+ height: 5,
237
+ borderRadius: 3,
238
+ backgroundColor: roles['outline-variant'],
239
+ }}
240
+ />
241
+ </Pressable>
242
+ </View>
243
+ {scroll ? (
244
+ <ScrollView style={{ maxHeight: height - GRABBER }} keyboardShouldPersistTaps="handled">
245
+ {body}
246
+ </ScrollView>
247
+ ) : (
248
+ body
249
+ )}
250
+ </Animated.View>
251
+ </KeyboardAvoidingView>
252
+ </Modal>
253
+ );
254
+ }
255
+
256
+ // ─── ActionSheet ─────────────────────────────────────────────────────────────
257
+
258
+ export interface SheetAction {
259
+ label: string;
260
+ onPress: () => void;
261
+ destructive?: boolean;
262
+ disabled?: boolean;
263
+ }
264
+
265
+ export interface ActionSheetProps {
266
+ open: boolean;
267
+ onOpenChange: (open: boolean) => void;
268
+ title?: string;
269
+ message?: string;
270
+ actions: SheetAction[];
271
+ }
272
+
273
+ /**
274
+ * Choices about an action the user started. iOS: the system action sheet
275
+ * (destructive in red, Cancel at the bottom). Android: a bottom sheet list.
276
+ */
277
+ export function ActionSheet({ open, onOpenChange, title, message, actions }: ActionSheetProps) {
278
+ const { roles, messages, scheme } = useTheme();
279
+ React.useEffect(() => {
280
+ if (!open || Platform.OS !== 'ios') return;
281
+ const enabled = actions.filter((a) => !a.disabled);
282
+ ActionSheetIOS.showActionSheetWithOptions(
283
+ {
284
+ title,
285
+ message,
286
+ options: [...enabled.map((a) => a.label), messages.cancel],
287
+ cancelButtonIndex: enabled.length,
288
+ destructiveButtonIndex: enabled.flatMap((a, i) => (a.destructive ? [i] : [])),
289
+ tintColor: roles.primary,
290
+ userInterfaceStyle: scheme,
291
+ },
292
+ (i) => {
293
+ onOpenChange(false);
294
+ enabled[i]?.onPress();
295
+ },
296
+ );
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
298
+ }, [open]);
299
+ if (Platform.OS === 'ios') return null;
300
+ return (
301
+ <BottomSheet open={open} onOpenChange={onOpenChange} title={title}>
302
+ {message && (
303
+ <Text variant="body-sm" color="on-surface-variant" style={{ textAlign: 'center' }}>
304
+ {message}
305
+ </Text>
306
+ )}
307
+ <ListSection>
308
+ {actions.map((a) => (
309
+ <ListItem
310
+ key={a.label}
311
+ variant="button"
312
+ title={a.label}
313
+ destructive={a.destructive}
314
+ disabled={a.disabled}
315
+ onPress={() => {
316
+ onOpenChange(false);
317
+ a.onPress();
318
+ }}
319
+ />
320
+ ))}
321
+ </ListSection>
322
+ <ListSection>
323
+ <ListItem variant="button" title={messages.cancel} onPress={() => onOpenChange(false)} />
324
+ </ListSection>
325
+ </BottomSheet>
326
+ );
327
+ }
328
+
329
+ // ─── ContextMenu ─────────────────────────────────────────────────────────────
330
+
331
+ export interface ContextMenuProps {
332
+ actions: SheetAction[];
333
+ title?: string;
334
+ children: React.ReactNode;
335
+ /** Tap action of the wrapped content (long-press opens the menu). */
336
+ onPress?: () => void;
337
+ accessibilityLabel?: string;
338
+ style?: StyleProp<ViewStyle>;
339
+ }
340
+
341
+ /**
342
+ * Long-press menu. The same actions are custom accessibility actions, so
343
+ * VoiceOver/TalkBack users reach them from the actions rotor. Always keep a
344
+ * visible way to every action too (HIG).
345
+ */
346
+ export function ContextMenu({
347
+ actions,
348
+ title,
349
+ children,
350
+ onPress,
351
+ accessibilityLabel,
352
+ style,
353
+ }: ContextMenuProps) {
354
+ const play = useHaptics();
355
+ const [open, setOpen] = React.useState(false);
356
+ return (
357
+ <>
358
+ <Pressable
359
+ accessibilityLabel={accessibilityLabel}
360
+ accessibilityActions={actions.map((a) => ({ name: a.label, label: a.label }))}
361
+ onAccessibilityAction={(e) =>
362
+ actions.find((a) => a.label === e.nativeEvent.actionName)?.onPress()
363
+ }
364
+ onPress={onPress}
365
+ onLongPress={() => {
366
+ play('snap');
367
+ setOpen(true);
368
+ }}
369
+ style={style}
370
+ >
371
+ {children}
372
+ </Pressable>
373
+ <ActionSheet open={open} onOpenChange={setOpen} title={title} actions={actions} />
374
+ </>
375
+ );
376
+ }
377
+
378
+ // ─── Select ──────────────────────────────────────────────────────────────────
379
+
380
+ export interface SelectOption<T extends string> {
381
+ value: T;
382
+ label: string;
383
+ description?: string;
384
+ }
385
+
386
+ export interface SelectProps<T extends string> {
387
+ label: string;
388
+ options: readonly SelectOption<T>[];
389
+ value?: T;
390
+ defaultValue?: T;
391
+ onValueChange?: (value: T) => void;
392
+ placeholder?: string;
393
+ helperText?: string;
394
+ error?: string;
395
+ disabled?: boolean;
396
+ style?: StyleProp<ViewStyle>;
397
+ }
398
+
399
+ /** A field that opens a sheet of choices (web Select / Combobox on mobile). */
400
+ export function Select<T extends string>({
401
+ label,
402
+ options,
403
+ value,
404
+ defaultValue,
405
+ onValueChange,
406
+ placeholder,
407
+ helperText,
408
+ error,
409
+ disabled,
410
+ style,
411
+ }: SelectProps<T>) {
412
+ const theme = useTheme();
413
+ const { roles, tokens } = theme;
414
+ const [open, setOpen] = React.useState(false);
415
+ const [v, setV] = useControllableState<T | undefined>({
416
+ value,
417
+ defaultValue,
418
+ onChange: onValueChange as (v: T | undefined) => void,
419
+ });
420
+ const current = options.find((o) => o.value === v);
421
+ return (
422
+ <View style={[{ gap: 6 }, style]}>
423
+ <Text
424
+ variant="label-lg"
425
+ color={error ? 'error' : 'on-surface-variant'}
426
+ importantForAccessibility="no"
427
+ >
428
+ {label}
429
+ </Text>
430
+ <Pressable
431
+ accessibilityLabel={`${label}, ${current?.label ?? placeholder ?? ''}`}
432
+ accessibilityHint={error ?? helperText}
433
+ disabled={disabled}
434
+ onPress={() => setOpen(true)}
435
+ pressScale={false}
436
+ style={{
437
+ minHeight: tokens.size.control.lg,
438
+ paddingHorizontal: 14,
439
+ flexDirection: 'row',
440
+ alignItems: 'center',
441
+ borderRadius: tokens.shape.field,
442
+ borderWidth: error ? 2 : 1,
443
+ borderColor: error ? roles.error : roles.outline,
444
+ backgroundColor: roles['surface-container-lowest'],
445
+ opacity: disabled ? tokens.state['disabled-content'] : 1,
446
+ }}
447
+ >
448
+ <Text
449
+ variant="body-lg"
450
+ color={current ? 'on-surface' : 'on-surface-muted'}
451
+ style={{ flex: 1 }}
452
+ numberOfLines={1}
453
+ >
454
+ {current?.label ?? placeholder ?? ''}
455
+ </Text>
456
+ <ChevronsUpDown size="sm" color={roles['on-surface-variant']} />
457
+ </Pressable>
458
+ {(error || helperText) && (
459
+ <Text variant="body-sm" color={error ? 'error' : 'on-surface-variant'}>
460
+ {error ?? helperText}
461
+ </Text>
462
+ )}
463
+ <BottomSheet
464
+ open={open}
465
+ onOpenChange={setOpen}
466
+ title={label}
467
+ detents={options.length > 6 ? [0.6, 0.92] : ['content']}
468
+ scroll={options.length > 6}
469
+ >
470
+ <ListSection>
471
+ {options.map((o) => (
472
+ <ListItem
473
+ key={o.value}
474
+ variant="radio"
475
+ title={o.label}
476
+ subtitle={o.description}
477
+ checked={o.value === v}
478
+ onCheckedChange={() => {
479
+ setV(o.value);
480
+ setOpen(false);
481
+ }}
482
+ />
483
+ ))}
484
+ </ListSection>
485
+ </BottomSheet>
486
+ </View>
487
+ );
488
+ }
@@ -0,0 +1,54 @@
1
+ import type { ControlSize, ToneValue } from '@nexub/core';
2
+ import type { NexubTheme, RoleName } from '../theme/ThemeProvider';
3
+
4
+ /** Container + on-colour role per tone (same mapping as web Button `tone`). */
5
+ export const TONE_ROLES: Record<ToneValue, [RoleName, RoleName]> = {
6
+ neutral: ['surface-container-high', 'on-surface'],
7
+ info: ['info', 'on-info'],
8
+ success: ['success', 'on-success'],
9
+ warning: ['warning', 'on-warning'],
10
+ danger: ['error', 'on-error'],
11
+ leap: ['leap', 'on-leap'],
12
+ bookub: ['bookub', 'on-bookub'],
13
+ payub: ['payub', 'on-payub'],
14
+ founderub: ['founderub', 'on-founderub'],
15
+ };
16
+
17
+ /** Soft container variant per tone (banners, tags, badges). */
18
+ export const TONE_CONTAINERS: Record<ToneValue, [RoleName, RoleName]> = {
19
+ neutral: ['surface-container-high', 'on-surface-variant'],
20
+ info: ['info-container', 'on-info-container'],
21
+ success: ['success-container', 'on-success-container'],
22
+ warning: ['warning-container', 'on-warning-container'],
23
+ danger: ['error-container', 'on-error-container'],
24
+ leap: ['leap-container', 'on-leap-container'],
25
+ bookub: ['bookub-container', 'on-bookub-container'],
26
+ payub: ['payub-container', 'on-payub-container'],
27
+ founderub: ['founderub-container', 'on-founderub-container'],
28
+ };
29
+
30
+ /** Accent (text/icon) role per tone. */
31
+ export const TONE_TEXT: Record<ToneValue, RoleName> = {
32
+ neutral: 'on-surface-variant',
33
+ info: 'info',
34
+ success: 'success',
35
+ warning: 'warning',
36
+ danger: 'error',
37
+ leap: 'leap',
38
+ bookub: 'bookub',
39
+ payub: 'payub',
40
+ founderub: 'founderub',
41
+ };
42
+
43
+ /** Every touch target reaches 44pt (Apple; Android's 48dp is met by md+ controls). */
44
+ export const MIN_TARGET = 44;
45
+ export const hitSlopFor = (height: number) => {
46
+ const pad = Math.max(0, Math.ceil((MIN_TARGET - height) / 2));
47
+ return pad ? { top: pad, bottom: pad, left: pad, right: pad } : undefined;
48
+ };
49
+
50
+ export const controlHeight = (theme: NexubTheme, size: ControlSize) =>
51
+ theme.tokens.size.control[size];
52
+
53
+ /** Disabled content opacity (same as the web state token). */
54
+ export const disabledOpacity = (theme: NexubTheme) => theme.tokens.state['disabled-content'];
@@ -0,0 +1,27 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ // Same contract as @nexub/react's hook (no SSR on native, so plain useLayoutEffect).
4
+ export function useControllableState<T>({
5
+ value: controlled,
6
+ defaultValue,
7
+ onChange,
8
+ }: {
9
+ value?: T;
10
+ defaultValue: T;
11
+ onChange?: (value: T) => void;
12
+ }) {
13
+ const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
14
+ const isControlled = controlled !== undefined;
15
+ const onChangeRef = useRef(onChange);
16
+ useLayoutEffect(() => {
17
+ onChangeRef.current = onChange;
18
+ });
19
+ const setValue = useCallback(
20
+ (next: T) => {
21
+ if (!isControlled) setUncontrolled(next);
22
+ onChangeRef.current?.(next);
23
+ },
24
+ [isControlled],
25
+ );
26
+ return [isControlled ? controlled : uncontrolled, setValue] as const;
27
+ }