@nexub/react-native 0.0.0-stage → 2.1.0

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