@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,899 @@
1
+ import * as React from 'react';
2
+ import {
3
+ PanResponder,
4
+ Switch as RNSwitch,
5
+ TextInput,
6
+ View,
7
+ type StyleProp,
8
+ type TextInputProps,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+ import { currencySymbol, formatForEditing, parseMoney, type ControlSize } from '@nexub/core';
12
+ import {
13
+ Check,
14
+ Eye,
15
+ EyeOff,
16
+ Minus,
17
+ Plus,
18
+ Search,
19
+ X,
20
+ type NativeIconProps,
21
+ } from '@nexub/icons/native';
22
+ import Animated, {
23
+ LayoutAnimationConfig,
24
+ useAnimatedStyle,
25
+ useSharedValue,
26
+ withSpring,
27
+ } from 'react-native-reanimated';
28
+ import { useControllableState } from '../hooks/useControllableState';
29
+ import { Pressable } from '../primitives/Pressable';
30
+ import { Text } from '../primitives/Text';
31
+ import { fontStyle } from '../theme/fonts';
32
+ import { useHaptics, useTheme } from '../theme/ThemeProvider';
33
+ import { nxEnter, nxExit, nxLayout, nxSpring } from '../theme/motion';
34
+ import { IconButton } from './actions';
35
+ import { controlHeight, disabledOpacity, hitSlopFor } from './shared';
36
+
37
+ // ─── TextField ───────────────────────────────────────────────────────────────
38
+
39
+ export interface TextFieldProps extends Omit<TextInputProps, 'style' | 'editable'> {
40
+ ref?: React.Ref<React.ComponentRef<typeof TextInput>>;
41
+ /** Visible label above the field (also its accessible name). */
42
+ label: string;
43
+ /** Hint under the field. Replaced by `error` when set. */
44
+ helperText?: string;
45
+ /** Error message: red outline, announced, replaces the helper text. */
46
+ error?: string;
47
+ disabled?: boolean;
48
+ /** @default 'lg' (48, the mobile field height) */
49
+ size?: Extract<ControlSize, 'md' | 'lg' | 'xl'>;
50
+ leading?: React.ReactNode;
51
+ trailing?: React.ReactNode;
52
+ style?: StyleProp<ViewStyle>;
53
+ }
54
+
55
+ /** Labelled text input (web Input + Label + HelperText + FormError in one). */
56
+ export function TextField({
57
+ ref,
58
+ label,
59
+ helperText,
60
+ error,
61
+ disabled,
62
+ size = 'lg',
63
+ leading,
64
+ trailing,
65
+ style,
66
+ multiline,
67
+ onFocus,
68
+ onBlur,
69
+ ...rest
70
+ }: TextFieldProps) {
71
+ const theme = useTheme();
72
+ const { roles, tokens, fonts } = theme;
73
+ const [focused, setFocused] = React.useState(false);
74
+ const body = tokens.type['body-lg'];
75
+ const border = error ? roles.error : focused ? roles.primary : roles.outline;
76
+ return (
77
+ <View style={[{ gap: 6, opacity: disabled ? disabledOpacity(theme) : 1 }, style]}>
78
+ <Text
79
+ variant="label-lg"
80
+ color={error ? 'error' : 'on-surface-variant'}
81
+ importantForAccessibility="no"
82
+ >
83
+ {label}
84
+ </Text>
85
+ <View
86
+ style={{
87
+ minHeight: controlHeight(theme, size),
88
+ flexDirection: 'row',
89
+ alignItems: multiline ? 'flex-start' : 'center',
90
+ gap: 8,
91
+ paddingHorizontal: 14,
92
+ borderRadius: tokens.shape.field,
93
+ borderWidth: focused || error ? 2 : 1,
94
+ borderColor: border,
95
+ backgroundColor: roles['surface-container-lowest'],
96
+ }}
97
+ >
98
+ {leading}
99
+ <TextInput
100
+ ref={ref}
101
+ accessibilityLabel={label}
102
+ accessibilityHint={error ?? helperText}
103
+ aria-invalid={!!error}
104
+ editable={!disabled}
105
+ multiline={multiline}
106
+ placeholderTextColor={roles['on-surface-muted']}
107
+ selectionColor={roles.primary}
108
+ onFocus={(e) => {
109
+ setFocused(true);
110
+ onFocus?.(e);
111
+ }}
112
+ onBlur={(e) => {
113
+ setFocused(false);
114
+ onBlur?.(e);
115
+ }}
116
+ style={{
117
+ flex: 1,
118
+ ...fontStyle('sans', 400, fonts),
119
+ fontSize: body.size,
120
+ color: roles['on-surface'],
121
+ paddingVertical: multiline ? 12 : 0,
122
+ minHeight: multiline ? 96 : undefined,
123
+ textAlignVertical: multiline ? 'top' : 'center',
124
+ }}
125
+ {...rest}
126
+ />
127
+ {trailing}
128
+ </View>
129
+ {(error || helperText) && (
130
+ <Text
131
+ variant="body-sm"
132
+ color={error ? 'error' : 'on-surface-variant'}
133
+ accessibilityLiveRegion={error ? 'polite' : 'none'}
134
+ >
135
+ {error ?? helperText}
136
+ </Text>
137
+ )}
138
+ </View>
139
+ );
140
+ }
141
+
142
+ export interface PasswordInputProps extends Omit<TextFieldProps, 'secureTextEntry' | 'trailing'> {}
143
+
144
+ /** Password field with a show/hide toggle and the platform autofill hints. */
145
+ export function PasswordInput(props: PasswordInputProps) {
146
+ const { messages } = useTheme();
147
+ const [visible, setVisible] = React.useState(false);
148
+ return (
149
+ <TextField
150
+ secureTextEntry={!visible}
151
+ autoCapitalize="none"
152
+ autoCorrect={false}
153
+ autoComplete="current-password"
154
+ textContentType="password"
155
+ trailing={
156
+ <IconButton
157
+ icon={visible ? EyeOff : Eye}
158
+ label={visible ? messages.hidePassword : messages.showPassword}
159
+ size="sm"
160
+ onPress={() => setVisible((v) => !v)}
161
+ />
162
+ }
163
+ {...props}
164
+ />
165
+ );
166
+ }
167
+
168
+ export interface SearchFieldProps extends Omit<TextInputProps, 'style'> {
169
+ /** Accessible name. @default messages.search */
170
+ label?: string;
171
+ style?: StyleProp<ViewStyle>;
172
+ }
173
+
174
+ /** Capsule search input with a clear button. */
175
+ export function SearchField({
176
+ label,
177
+ value,
178
+ defaultValue,
179
+ onChangeText,
180
+ style,
181
+ ...rest
182
+ }: SearchFieldProps) {
183
+ const theme = useTheme();
184
+ const { roles, tokens, fonts, messages } = theme;
185
+ const [text, setText] = useControllableState({
186
+ value,
187
+ defaultValue: defaultValue ?? '',
188
+ onChange: onChangeText,
189
+ });
190
+ return (
191
+ <View
192
+ style={[
193
+ {
194
+ height: controlHeight(theme, 'md'),
195
+ flexDirection: 'row',
196
+ alignItems: 'center',
197
+ gap: 8,
198
+ paddingStart: 14,
199
+ paddingEnd: 4,
200
+ borderRadius: tokens.shape.control,
201
+ backgroundColor: roles['surface-container-high'],
202
+ },
203
+ style,
204
+ ]}
205
+ >
206
+ <Search size="sm" color={roles['on-surface-variant']} />
207
+ <TextInput
208
+ accessibilityLabel={label ?? messages.search}
209
+ accessibilityRole="search"
210
+ placeholder={messages.searchPlaceholder}
211
+ placeholderTextColor={roles['on-surface-muted']}
212
+ selectionColor={roles.primary}
213
+ returnKeyType="search"
214
+ value={text}
215
+ onChangeText={setText}
216
+ style={{
217
+ flex: 1,
218
+ ...fontStyle('sans', 400, fonts),
219
+ fontSize: tokens.type['body-md'].size,
220
+ color: roles['on-surface'],
221
+ }}
222
+ {...rest}
223
+ />
224
+ {!!text && (
225
+ <IconButton icon={X} label={messages.clearSearch} size="xs" onPress={() => setText('')} />
226
+ )}
227
+ </View>
228
+ );
229
+ }
230
+
231
+ // ─── MoneyInput ──────────────────────────────────────────────────────────────
232
+
233
+ export interface MoneyInputProps extends Omit<
234
+ TextFieldProps,
235
+ 'value' | 'defaultValue' | 'onChange' | 'leading' | 'keyboardType'
236
+ > {
237
+ currency: string;
238
+ value?: number | null;
239
+ defaultValue?: number | null;
240
+ /** Parsed value (rounded to the currency's minor units), or null when empty. */
241
+ onValueChange?: (value: number | null) => void;
242
+ locale?: string;
243
+ }
244
+
245
+ /** Amount entry: decimal keypad, currency symbol, locale-aware parsing (de-DE "1.234,5" → 1234.5). */
246
+ export function MoneyInput({
247
+ currency,
248
+ value,
249
+ defaultValue = null,
250
+ onValueChange,
251
+ locale,
252
+ ...rest
253
+ }: MoneyInputProps) {
254
+ const theme = useTheme();
255
+ const loc = locale ?? theme.locale;
256
+ const [amount, setAmount] = useControllableState<number | null>({
257
+ value,
258
+ defaultValue,
259
+ onChange: onValueChange,
260
+ });
261
+ const [text, setText] = React.useState(
262
+ amount == null ? '' : formatForEditing(amount, currency, loc),
263
+ );
264
+ React.useEffect(() => {
265
+ // Follow outside changes without fighting the user's typing.
266
+ if (amount !== parseMoney(text, currency, loc))
267
+ setText(amount == null ? '' : formatForEditing(amount, currency, loc));
268
+ // eslint-disable-next-line react-hooks/exhaustive-deps
269
+ }, [amount, currency, loc]);
270
+ return (
271
+ <TextField
272
+ keyboardType="decimal-pad"
273
+ inputMode="decimal"
274
+ value={text}
275
+ onChangeText={(t) => {
276
+ setText(t);
277
+ setAmount(parseMoney(t, currency, loc));
278
+ }}
279
+ leading={
280
+ <Text variant="body-lg" color="on-surface-variant">
281
+ {currencySymbol(currency, loc)}
282
+ </Text>
283
+ }
284
+ {...rest}
285
+ />
286
+ );
287
+ }
288
+
289
+ // ─── Stepper ─────────────────────────────────────────────────────────────────
290
+
291
+ export interface StepperProps {
292
+ /** Accessible name of the value, e.g. "Guests". */
293
+ label: string;
294
+ value?: number;
295
+ defaultValue?: number;
296
+ onValueChange?: (value: number) => void;
297
+ min?: number;
298
+ max?: number;
299
+ step?: number;
300
+ disabled?: boolean;
301
+ style?: StyleProp<ViewStyle>;
302
+ }
303
+
304
+ /** − value + for small counts. Adjustable with VoiceOver/TalkBack swipe up/down. */
305
+ export function Stepper({
306
+ label,
307
+ value,
308
+ defaultValue = 0,
309
+ onValueChange,
310
+ min = -Infinity,
311
+ max = Infinity,
312
+ step = 1,
313
+ disabled,
314
+ style,
315
+ }: StepperProps) {
316
+ const { messages } = useTheme();
317
+ const play = useHaptics();
318
+ const [n, setN] = useControllableState({ value, defaultValue, onChange: onValueChange });
319
+ const set = (next: number) => {
320
+ const clamped = Math.min(max, Math.max(min, next));
321
+ if (clamped !== n) {
322
+ play('selection');
323
+ setN(clamped);
324
+ }
325
+ };
326
+ return (
327
+ <View
328
+ accessible
329
+ accessibilityRole="adjustable"
330
+ accessibilityLabel={label}
331
+ accessibilityValue={{
332
+ now: n,
333
+ min: Number.isFinite(min) ? min : undefined,
334
+ max: Number.isFinite(max) ? max : undefined,
335
+ }}
336
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
337
+ onAccessibilityAction={(e) =>
338
+ set(n + (e.nativeEvent.actionName === 'increment' ? step : -step))
339
+ }
340
+ style={[
341
+ { flexDirection: 'row', alignItems: 'center', gap: 12, alignSelf: 'flex-start' },
342
+ style,
343
+ ]}
344
+ >
345
+ <IconButton
346
+ icon={Minus}
347
+ label={messages.decrement}
348
+ variant="outlined"
349
+ size="sm"
350
+ disabled={disabled || n <= min}
351
+ onPress={() => set(n - step)}
352
+ />
353
+ <Text variant="title-md" tabular style={{ minWidth: 32, textAlign: 'center' }}>
354
+ {n}
355
+ </Text>
356
+ <IconButton
357
+ icon={Plus}
358
+ label={messages.increment}
359
+ variant="outlined"
360
+ size="sm"
361
+ disabled={disabled || n >= max}
362
+ onPress={() => set(n + step)}
363
+ />
364
+ </View>
365
+ );
366
+ }
367
+
368
+ // ─── Checkbox / Radio ────────────────────────────────────────────────────────
369
+
370
+ export interface CheckboxProps {
371
+ label: string;
372
+ checked?: boolean;
373
+ defaultChecked?: boolean;
374
+ onCheckedChange?: (checked: boolean) => void;
375
+ description?: string;
376
+ disabled?: boolean;
377
+ style?: StyleProp<ViewStyle>;
378
+ }
379
+
380
+ /** Checkbox with its label; the whole row is the target. */
381
+ export function Checkbox({
382
+ label,
383
+ checked,
384
+ defaultChecked = false,
385
+ onCheckedChange,
386
+ description,
387
+ disabled,
388
+ style,
389
+ }: CheckboxProps) {
390
+ const theme = useTheme();
391
+ const { roles, tokens } = theme;
392
+ const [on, setOn] = useControllableState({
393
+ value: checked,
394
+ defaultValue: defaultChecked,
395
+ onChange: onCheckedChange,
396
+ });
397
+ return (
398
+ <Pressable
399
+ accessibilityRole="checkbox"
400
+ accessibilityLabel={label}
401
+ accessibilityHint={description}
402
+ accessibilityState={{ checked: on, disabled: !!disabled }}
403
+ disabled={disabled}
404
+ haptic={on ? 'toggleOff' : 'toggleOn'}
405
+ onPress={() => setOn(!on)}
406
+ stateLayer={false}
407
+ pressScale={false}
408
+ style={[
409
+ {
410
+ flexDirection: 'row',
411
+ gap: 12,
412
+ alignItems: 'flex-start',
413
+ minHeight: 44,
414
+ paddingVertical: 10,
415
+ opacity: disabled ? disabledOpacity(theme) : 1,
416
+ },
417
+ style,
418
+ ]}
419
+ >
420
+ <View
421
+ style={{
422
+ width: 22,
423
+ height: 22,
424
+ marginTop: 1,
425
+ borderRadius: tokens.shape.min + 2,
426
+ borderWidth: on ? 0 : 2,
427
+ borderColor: roles.outline,
428
+ backgroundColor: on ? roles.primary : 'transparent',
429
+ alignItems: 'center',
430
+ justifyContent: 'center',
431
+ }}
432
+ >
433
+ {on && <Check size={16} strokeWidth={3} color={roles['on-primary']} />}
434
+ </View>
435
+ <View style={{ flex: 1, gap: 2 }}>
436
+ <Text variant="body-md">{label}</Text>
437
+ {description && (
438
+ <Text variant="body-sm" color="on-surface-variant">
439
+ {description}
440
+ </Text>
441
+ )}
442
+ </View>
443
+ </Pressable>
444
+ );
445
+ }
446
+
447
+ interface RadioContext {
448
+ value: string | undefined;
449
+ select: (v: string) => void;
450
+ disabled?: boolean;
451
+ }
452
+ const RadioCtx = React.createContext<RadioContext | null>(null);
453
+
454
+ export interface RadioGroupProps {
455
+ /** Accessible name of the group. */
456
+ label: string;
457
+ value?: string;
458
+ defaultValue?: string;
459
+ onValueChange?: (value: string) => void;
460
+ disabled?: boolean;
461
+ children: React.ReactNode;
462
+ style?: StyleProp<ViewStyle>;
463
+ }
464
+
465
+ export function RadioGroup({
466
+ label,
467
+ value,
468
+ defaultValue,
469
+ onValueChange,
470
+ disabled,
471
+ children,
472
+ style,
473
+ }: RadioGroupProps) {
474
+ const [v, setV] = useControllableState<string | undefined>({
475
+ value,
476
+ defaultValue,
477
+ onChange: onValueChange as (v: string | undefined) => void,
478
+ });
479
+ return (
480
+ <RadioCtx.Provider value={{ value: v, select: setV, disabled }}>
481
+ <View accessibilityRole="radiogroup" accessibilityLabel={label} style={style}>
482
+ {children}
483
+ </View>
484
+ </RadioCtx.Provider>
485
+ );
486
+ }
487
+
488
+ export interface RadioProps {
489
+ value: string;
490
+ label: string;
491
+ description?: string;
492
+ disabled?: boolean;
493
+ }
494
+
495
+ export function Radio({ value, label, description, disabled: own }: RadioProps) {
496
+ const theme = useTheme();
497
+ const ctx = React.useContext(RadioCtx);
498
+ if (!ctx) throw new Error('@nexub/react-native: <Radio> must be inside <RadioGroup>.');
499
+ const on = ctx.value === value;
500
+ const disabled = own || ctx.disabled;
501
+ return (
502
+ <Pressable
503
+ accessibilityRole="radio"
504
+ accessibilityLabel={label}
505
+ accessibilityHint={description}
506
+ accessibilityState={{ checked: on, disabled: !!disabled }}
507
+ disabled={disabled}
508
+ haptic={on ? undefined : 'selection'}
509
+ onPress={() => ctx.select(value)}
510
+ stateLayer={false}
511
+ pressScale={false}
512
+ style={{
513
+ flexDirection: 'row',
514
+ gap: 12,
515
+ alignItems: 'flex-start',
516
+ minHeight: 44,
517
+ paddingVertical: 10,
518
+ opacity: disabled ? disabledOpacity(theme) : 1,
519
+ }}
520
+ >
521
+ <View
522
+ style={{
523
+ width: 22,
524
+ height: 22,
525
+ marginTop: 1,
526
+ borderRadius: 11,
527
+ borderWidth: 2,
528
+ borderColor: on ? theme.roles.primary : theme.roles.outline,
529
+ alignItems: 'center',
530
+ justifyContent: 'center',
531
+ }}
532
+ >
533
+ {on && (
534
+ <View
535
+ style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: theme.roles.primary }}
536
+ />
537
+ )}
538
+ </View>
539
+ <View style={{ flex: 1, gap: 2 }}>
540
+ <Text variant="body-md">{label}</Text>
541
+ {description && (
542
+ <Text variant="body-sm" color="on-surface-variant">
543
+ {description}
544
+ </Text>
545
+ )}
546
+ </View>
547
+ </Pressable>
548
+ );
549
+ }
550
+
551
+ // ─── Switch ──────────────────────────────────────────────────────────────────
552
+
553
+ export interface SwitchProps {
554
+ /** Accessible name (visible label lives in the ListItem / row next to it). */
555
+ label: string;
556
+ checked?: boolean;
557
+ defaultChecked?: boolean;
558
+ onCheckedChange?: (checked: boolean) => void;
559
+ disabled?: boolean;
560
+ }
561
+
562
+ /** The platform switch (UISwitch / Material), coloured with Nexub roles. The OS plays its haptic. */
563
+ export function Switch({
564
+ label,
565
+ checked,
566
+ defaultChecked = false,
567
+ onCheckedChange,
568
+ disabled,
569
+ }: SwitchProps) {
570
+ const { roles } = useTheme();
571
+ const [on, setOn] = useControllableState({
572
+ value: checked,
573
+ defaultValue: defaultChecked,
574
+ onChange: onCheckedChange,
575
+ });
576
+ return (
577
+ <RNSwitch
578
+ accessibilityLabel={label}
579
+ value={on}
580
+ onValueChange={setOn}
581
+ disabled={disabled}
582
+ trackColor={{ false: roles['surface-container-highest'], true: roles.primary }}
583
+ ios_backgroundColor={roles['surface-container-highest']}
584
+ thumbColor={roles['surface-container-lowest']}
585
+ />
586
+ );
587
+ }
588
+
589
+ // ─── SegmentedControl ────────────────────────────────────────────────────────
590
+
591
+ export interface SegmentedControlProps<T extends string> {
592
+ label: string;
593
+ options: readonly { value: T; label: string }[];
594
+ value?: T;
595
+ defaultValue?: T;
596
+ onValueChange?: (value: T) => void;
597
+ style?: StyleProp<ViewStyle>;
598
+ }
599
+
600
+ /** 2–5 mutually exclusive views of the same content. */
601
+ export function SegmentedControl<T extends string>({
602
+ label,
603
+ options,
604
+ value,
605
+ defaultValue,
606
+ onValueChange,
607
+ style,
608
+ }: SegmentedControlProps<T>) {
609
+ const theme = useTheme();
610
+ const { roles, tokens } = theme;
611
+ const [v, setV] = useControllableState<T>({
612
+ value,
613
+ defaultValue: defaultValue ?? options[0]!.value,
614
+ onChange: onValueChange,
615
+ });
616
+ // The selected pill slides between segments on the spatial spring. Until the
617
+ // track is measured, the selected segment paints its own background.
618
+ const [trackWidth, setTrackWidth] = React.useState(0);
619
+ const index = Math.max(0, options.findIndex((o) => o.value === v));
620
+ const segment = trackWidth > 0 ? (trackWidth - 6) / options.length : 0;
621
+ const offset = useSharedValue(index * segment);
622
+ const measured = React.useRef(false);
623
+ React.useEffect(() => {
624
+ if (segment === 0) return;
625
+ const to = index * segment * (theme.dir === 'rtl' ? -1 : 1);
626
+ // First measure places the pill; later changes slide it.
627
+ offset.value = measured.current ? withSpring(to, nxSpring('spatial', 'default')) : to;
628
+ measured.current = true;
629
+ }, [index, segment, theme.dir, offset]);
630
+ const indicator = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] }));
631
+ return (
632
+ <View
633
+ accessibilityRole="tablist"
634
+ accessibilityLabel={label}
635
+ onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
636
+ style={[
637
+ {
638
+ flexDirection: 'row',
639
+ padding: 3,
640
+ borderRadius: tokens.shape.control,
641
+ backgroundColor: roles['surface-container-high'],
642
+ },
643
+ style,
644
+ ]}
645
+ >
646
+ {segment > 0 && (
647
+ <Animated.View
648
+ pointerEvents="none"
649
+ style={[
650
+ {
651
+ position: 'absolute',
652
+ top: 3,
653
+ bottom: 3,
654
+ start: 3,
655
+ width: segment,
656
+ borderRadius: tokens.shape.control,
657
+ backgroundColor: roles['surface-container-lowest'],
658
+ boxShadow: theme.elevation(1),
659
+ },
660
+ indicator,
661
+ ]}
662
+ />
663
+ )}
664
+ {options.map((o) => {
665
+ const on = o.value === v;
666
+ return (
667
+ <Pressable
668
+ key={o.value}
669
+ accessibilityRole="tab"
670
+ accessibilityLabel={o.label}
671
+ accessibilityState={{ selected: on }}
672
+ haptic={on ? undefined : 'selection'}
673
+ onPress={() => setV(o.value)}
674
+ stateLayer={on ? false : 'on-surface'}
675
+ style={[
676
+ {
677
+ flex: 1,
678
+ height: controlHeight(theme, 'sm') + 2,
679
+ borderRadius: tokens.shape.control,
680
+ alignItems: 'center',
681
+ justifyContent: 'center',
682
+ },
683
+ on &&
684
+ segment === 0 && {
685
+ backgroundColor: roles['surface-container-lowest'],
686
+ boxShadow: theme.elevation(1),
687
+ },
688
+ ]}
689
+ >
690
+ <Text
691
+ variant="label-lg"
692
+ color={on ? 'on-surface' : 'on-surface-variant'}
693
+ numberOfLines={1}
694
+ >
695
+ {o.label}
696
+ </Text>
697
+ </Pressable>
698
+ );
699
+ })}
700
+ </View>
701
+ );
702
+ }
703
+
704
+ // ─── Slider ──────────────────────────────────────────────────────────────────
705
+
706
+ export interface SliderProps {
707
+ label: string;
708
+ value?: number;
709
+ defaultValue?: number;
710
+ onValueChange?: (value: number) => void;
711
+ min?: number;
712
+ max?: number;
713
+ step?: number;
714
+ disabled?: boolean;
715
+ /** Announced value text, e.g. (v) => `${v}%`. */
716
+ formatValue?: (value: number) => string;
717
+ style?: StyleProp<ViewStyle>;
718
+ }
719
+
720
+ /** Continuous value. Drag anywhere on the track; VoiceOver/TalkBack adjust by `step`. */
721
+ export function Slider({
722
+ label,
723
+ value,
724
+ defaultValue = 0,
725
+ onValueChange,
726
+ min = 0,
727
+ max = 100,
728
+ step = 1,
729
+ disabled,
730
+ formatValue,
731
+ style,
732
+ }: SliderProps) {
733
+ const theme = useTheme();
734
+ const { roles } = theme;
735
+ const [v, setV] = useControllableState({ value, defaultValue, onChange: onValueChange });
736
+ const width = React.useRef(1);
737
+ const snap = (x: number) => {
738
+ const raw = min + Math.min(1, Math.max(0, x / width.current)) * (max - min);
739
+ return Math.min(max, Math.max(min, Math.round(raw / step) * step));
740
+ };
741
+ const current = React.useRef(v);
742
+ current.current = v;
743
+ const responder = React.useMemo(
744
+ () =>
745
+ PanResponder.create({
746
+ onStartShouldSetPanResponder: () => !disabled,
747
+ onMoveShouldSetPanResponder: () => !disabled,
748
+ onPanResponderTerminationRequest: () => false,
749
+ onPanResponderGrant: (e) => setV(snap(e.nativeEvent.locationX)),
750
+ onPanResponderMove: (e) => {
751
+ const next = snap(e.nativeEvent.locationX);
752
+ if (next !== current.current) setV(next);
753
+ },
754
+ }),
755
+ // eslint-disable-next-line react-hooks/exhaustive-deps
756
+ [disabled, min, max, step],
757
+ );
758
+ const pct = ((v - min) / (max - min)) * 100;
759
+ return (
760
+ <View
761
+ accessible
762
+ accessibilityRole="adjustable"
763
+ accessibilityLabel={label}
764
+ accessibilityValue={{ min, max, now: v, text: formatValue?.(v) }}
765
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
766
+ onAccessibilityAction={(e) =>
767
+ setV(
768
+ Math.min(
769
+ max,
770
+ Math.max(min, v + (e.nativeEvent.actionName === 'increment' ? step : -step)),
771
+ ),
772
+ )
773
+ }
774
+ onLayout={(e) => (width.current = e.nativeEvent.layout.width || 1)}
775
+ style={[
776
+ { height: 44, justifyContent: 'center', opacity: disabled ? disabledOpacity(theme) : 1 },
777
+ style,
778
+ ]}
779
+ {...responder.panHandlers}
780
+ >
781
+ <View
782
+ pointerEvents="none"
783
+ style={{ height: 6, borderRadius: 3, backgroundColor: roles['surface-container-highest'] }}
784
+ >
785
+ <View
786
+ style={{ width: `${pct}%`, height: 6, borderRadius: 3, backgroundColor: roles.primary }}
787
+ />
788
+ </View>
789
+ <View
790
+ pointerEvents="none"
791
+ style={{
792
+ position: 'absolute',
793
+ start: `${pct}%`,
794
+ marginStart: -12,
795
+ width: 24,
796
+ height: 24,
797
+ borderRadius: 12,
798
+ backgroundColor: roles['surface-container-lowest'],
799
+ boxShadow: theme.elevation(2),
800
+ }}
801
+ />
802
+ </View>
803
+ );
804
+ }
805
+
806
+ // ─── Chip ────────────────────────────────────────────────────────────────────
807
+
808
+ export interface ChipProps {
809
+ label: string;
810
+ /** Toggle state (filter chips). */
811
+ selected?: boolean;
812
+ onPress?: () => void;
813
+ /** Shows a remove button (its label is messages.remove). */
814
+ onRemove?: () => void;
815
+ leadingIcon?: React.ComponentType<NativeIconProps>;
816
+ disabled?: boolean;
817
+ style?: StyleProp<ViewStyle>;
818
+ }
819
+
820
+ /** Capsule control: a filter, a choice or an input token. */
821
+ export function Chip({
822
+ label,
823
+ selected,
824
+ onPress,
825
+ onRemove,
826
+ leadingIcon: Lead,
827
+ disabled,
828
+ style,
829
+ }: ChipProps) {
830
+ const theme = useTheme();
831
+ const { roles, tokens, messages } = theme;
832
+ const height = controlHeight(theme, 'sm');
833
+ const fg = selected ? 'on-secondary-container' : 'on-surface-variant';
834
+ return (
835
+ // Selecting grows the chip for its check: the width change glides.
836
+ <Animated.View
837
+ layout={nxLayout('fast')}
838
+ style={[
839
+ {
840
+ flexDirection: 'row',
841
+ alignItems: 'center',
842
+ alignSelf: 'flex-start',
843
+ opacity: disabled ? disabledOpacity(theme) : 1,
844
+ },
845
+ style,
846
+ ]}
847
+ >
848
+ <Pressable
849
+ accessibilityRole={selected === undefined ? 'button' : 'checkbox'}
850
+ accessibilityLabel={label}
851
+ accessibilityState={{ checked: selected, disabled: !!disabled }}
852
+ disabled={disabled || !onPress}
853
+ haptic={selected === undefined ? undefined : 'selection'}
854
+ onPress={onPress}
855
+ hitSlop={hitSlopFor(height)}
856
+ stateLayer={fg}
857
+ style={{
858
+ height,
859
+ paddingStart: 12,
860
+ paddingEnd: onRemove ? 32 : 12,
861
+ borderRadius: tokens.shape.control,
862
+ flexDirection: 'row',
863
+ alignItems: 'center',
864
+ gap: 6,
865
+ borderWidth: selected ? 0 : 1,
866
+ borderColor: roles['outline-variant'],
867
+ backgroundColor: selected ? roles['secondary-container'] : 'transparent',
868
+ }}
869
+ >
870
+ <LayoutAnimationConfig skipEntering>
871
+ {selected ? (
872
+ <Animated.View key="check" entering={nxEnter('scale', 'fast')} exiting={nxExit('scale')}>
873
+ <Check size="sm" color={roles[fg]} />
874
+ </Animated.View>
875
+ ) : (
876
+ Lead && <Lead size="sm" color={roles[fg]} />
877
+ )}
878
+ </LayoutAnimationConfig>
879
+ <Text variant="label-lg" color={fg}>
880
+ {label}
881
+ </Text>
882
+ </Pressable>
883
+ {onRemove && (
884
+ <IconButton
885
+ icon={X}
886
+ label={`${messages.remove} ${label}`}
887
+ size="xs"
888
+ onPress={onRemove}
889
+ style={{ position: 'absolute', end: 4, top: (height - controlHeight(theme, 'xs')) / 2 }}
890
+ />
891
+ )}
892
+ </Animated.View>
893
+ );
894
+ }
895
+
896
+ /** A toggleable Chip (selected → check icon). */
897
+ export function FilterChip(props: ChipProps & { selected: boolean }) {
898
+ return <Chip {...props} />;
899
+ }