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