jfs-components 0.0.73 → 0.0.74

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 (63) hide show
  1. package/CHANGELOG.md +23 -6
  2. package/lib/commonjs/components/AccountCard/AccountCard.js +247 -0
  3. package/lib/commonjs/components/AppBar/AppBar.js +17 -11
  4. package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +18 -2
  5. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +40 -25
  6. package/lib/commonjs/components/Dropdown/Dropdown.js +214 -0
  7. package/lib/commonjs/components/DropdownInput/DropdownInput.js +542 -0
  8. package/lib/commonjs/components/FormField/FormField.js +328 -178
  9. package/lib/commonjs/components/LottieIntroBlock/LottieIntroBlock.js +150 -0
  10. package/lib/commonjs/components/PageHero/PageHero.js +153 -0
  11. package/lib/commonjs/components/PoweredByLabel/PoweredByLabel.js +135 -0
  12. package/lib/commonjs/components/PoweredByLabel/finvu.png +0 -0
  13. package/lib/commonjs/components/Text/Text.js +9 -2
  14. package/lib/commonjs/components/Tooltip/Tooltip.js +34 -27
  15. package/lib/commonjs/components/index.js +60 -0
  16. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  17. package/lib/commonjs/icons/registry.js +1 -1
  18. package/lib/module/components/AccountCard/AccountCard.js +241 -0
  19. package/lib/module/components/AppBar/AppBar.js +17 -11
  20. package/lib/module/components/CardBankAccount/CardBankAccount.js +17 -2
  21. package/lib/module/components/CheckboxItem/CheckboxItem.js +41 -26
  22. package/lib/module/components/Dropdown/Dropdown.js +206 -0
  23. package/lib/module/components/DropdownInput/DropdownInput.js +536 -0
  24. package/lib/module/components/FormField/FormField.js +330 -180
  25. package/lib/module/components/LottieIntroBlock/LottieIntroBlock.js +144 -0
  26. package/lib/module/components/PageHero/PageHero.js +147 -0
  27. package/lib/module/components/PoweredByLabel/PoweredByLabel.js +130 -0
  28. package/lib/module/components/PoweredByLabel/finvu.png +0 -0
  29. package/lib/module/components/Text/Text.js +9 -2
  30. package/lib/module/components/Tooltip/Tooltip.js +34 -27
  31. package/lib/module/components/index.js +7 -1
  32. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  33. package/lib/module/icons/registry.js +1 -1
  34. package/lib/typescript/src/components/AccountCard/AccountCard.d.ts +81 -0
  35. package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +9 -2
  36. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +18 -2
  37. package/lib/typescript/src/components/Dropdown/Dropdown.d.ts +62 -0
  38. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +107 -0
  39. package/lib/typescript/src/components/FormField/FormField.d.ts +76 -19
  40. package/lib/typescript/src/components/LottieIntroBlock/LottieIntroBlock.d.ts +58 -0
  41. package/lib/typescript/src/components/PageHero/PageHero.d.ts +53 -0
  42. package/lib/typescript/src/components/PoweredByLabel/PoweredByLabel.d.ts +70 -0
  43. package/lib/typescript/src/components/Text/Text.d.ts +12 -2
  44. package/lib/typescript/src/components/Tooltip/Tooltip.d.ts +13 -2
  45. package/lib/typescript/src/components/index.d.ts +7 -1
  46. package/lib/typescript/src/icons/registry.d.ts +1 -1
  47. package/package.json +1 -3
  48. package/src/components/AccountCard/AccountCard.tsx +376 -0
  49. package/src/components/AppBar/AppBar.tsx +25 -14
  50. package/src/components/CardBankAccount/CardBankAccount.tsx +29 -3
  51. package/src/components/CheckboxItem/CheckboxItem.tsx +65 -30
  52. package/src/components/Dropdown/Dropdown.tsx +331 -0
  53. package/src/components/DropdownInput/DropdownInput.tsx +819 -0
  54. package/src/components/FormField/FormField.tsx +542 -215
  55. package/src/components/LottieIntroBlock/LottieIntroBlock.tsx +202 -0
  56. package/src/components/PageHero/PageHero.tsx +200 -0
  57. package/src/components/PoweredByLabel/PoweredByLabel.tsx +221 -0
  58. package/src/components/PoweredByLabel/finvu.png +0 -0
  59. package/src/components/Text/Text.tsx +24 -3
  60. package/src/components/Tooltip/Tooltip.tsx +50 -25
  61. package/src/components/index.ts +15 -1
  62. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  63. package/src/icons/registry.ts +1 -1
@@ -1,263 +1,590 @@
1
- import React, { useState, useMemo, useCallback } from 'react'
2
- import { View, Text, type StyleProp, type ViewStyle, type TextStyle } from 'react-native'
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput as RNTextInput,
6
+ type StyleProp,
7
+ type TextInputProps as RNTextInputProps,
8
+ type TextStyle,
9
+ type ViewStyle,
10
+ } from 'react-native'
3
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
- import { EMPTY_MODES } from '../../utils/react-utils'
5
12
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
6
- import TextInput from '../TextInput/TextInput'
13
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
7
14
  import SupportText from '../SupportText/SupportText'
8
15
  import type { SupportTextStatus } from '../SupportText/SupportTextIcon'
16
+ import Icon from '../../icons/Icon'
17
+ import { useFormContext } from '../Form/Form'
9
18
 
10
- export type FormFieldType = 'text' | 'password' | 'email' | 'search'
19
+ export type FormFieldType = 'text' | 'password' | 'email' | 'search' | 'number' | 'phone' | 'url'
11
20
 
12
21
  export type FormFieldProps = {
13
- label?: string | undefined;
14
- placeholder?: string | undefined;
15
- value?: string | undefined;
16
- onChangeText?: ((text: string) => void) | undefined;
17
- type?: FormFieldType | undefined;
18
-
19
- leading?: React.ReactNode;
20
- trailing?: React.ReactNode;
21
- leadingIconName?: string | undefined;
22
-
23
- isRequired?: boolean | undefined;
24
- isDisabled?: boolean | undefined;
25
- isInvalid?: boolean | undefined;
26
-
27
- supportText?: string | undefined;
28
- errorMessage?: string | undefined;
29
-
30
- modes?: Record<string, any> | undefined;
31
- style?: StyleProp<ViewStyle>;
32
-
33
- onFocus?: ((e: any) => void) | undefined;
34
- onBlur?: ((e: any) => void) | undefined;
35
- accessibilityLabel?: string | undefined;
36
- accessibilityHint?: string | undefined;
37
- };
38
-
39
- function useFormField(props: FormFieldProps) {
40
- const {
41
- type = 'text',
42
- isDisabled = false,
43
- isInvalid = false,
44
- supportText,
45
- errorMessage,
46
- modes: propModes = EMPTY_MODES,
47
- onFocus,
48
- onBlur,
49
- } = props
22
+ /** Label rendered above the input. */
23
+ label?: string
24
+ /** Placeholder text shown when the input is empty. */
25
+ placeholder?: string
26
+ /** Current value of the input (controlled). */
27
+ value?: string
28
+ /** Called when the input text changes. */
29
+ onChangeText?: (text: string) => void
30
+ /**
31
+ * Logical input type. Mapped to keyboard, secure entry and capitalization.
32
+ * @default 'text'
33
+ */
34
+ type?: FormFieldType
35
+ /**
36
+ * Field name. When used inside `<Form>`, this is the key used to look up
37
+ * server-side `validationErrors` and to notify the form when the value
38
+ * changes so the error can clear.
39
+ */
40
+ name?: string
41
+ /**
42
+ * Optional element rendered before the input text (Figma slot "start").
43
+ * No leading element is rendered by default — the Figma FormField design
44
+ * does not include one.
45
+ */
46
+ leading?: React.ReactNode
47
+ /**
48
+ * Optional element rendered after the input text (Figma slot "end").
49
+ * Typically used for an inline button (e.g. "Apply", "Show") or a status
50
+ * icon.
51
+ */
52
+ trailing?: React.ReactNode
53
+ /**
54
+ * Convenience prop to render a built-in icon as the leading element.
55
+ * Ignored when `leading` is provided. Defaults to `undefined` — meaning
56
+ * no leading icon, matching the Figma design.
57
+ */
58
+ leadingIconName?: string
59
+ /** Shows a required asterisk next to the label. */
60
+ isRequired?: boolean
61
+ /** Disables interaction and dims the field. Resolves to "Read Only" state. */
62
+ isDisabled?: boolean
63
+ /** Marks the field as invalid and shows `errorMessage`. */
64
+ isInvalid?: boolean
65
+ /**
66
+ * Renders the field in read-only mode (non-interactive but not dimmed).
67
+ * Resolves to the "Read Only" state.
68
+ */
69
+ isReadOnly?: boolean
70
+ /** Helper text displayed below the input. */
71
+ supportText?: string
72
+ /** Replaces `supportText` when `isInvalid` is true. */
73
+ errorMessage?: string
74
+ /** Maximum number of characters accepted. */
75
+ maxLength?: number
76
+ /** When true, focuses the input on mount. */
77
+ autoFocus?: boolean
78
+ /** Modes for design token resolution. */
79
+ modes?: Record<string, any>
80
+ /** Style overrides for the outermost wrapper. */
81
+ style?: StyleProp<ViewStyle>
82
+ /** Style overrides for the input row container. */
83
+ inputStyle?: StyleProp<ViewStyle>
84
+ /** Style overrides for the input text. */
85
+ inputTextStyle?: StyleProp<TextStyle>
86
+ /** Called when the input receives focus. */
87
+ onFocus?: RNTextInputProps['onFocus']
88
+ /** Called when the input loses focus. */
89
+ onBlur?: RNTextInputProps['onBlur']
90
+ /** Called when the user submits the input (e.g. presses return). */
91
+ onSubmitEditing?: RNTextInputProps['onSubmitEditing']
92
+ /** Accessibility label. Defaults to `label` or `placeholder`. */
93
+ accessibilityLabel?: string
94
+ /** Accessibility hint. */
95
+ accessibilityHint?: string
96
+ /** Test identifier. */
97
+ testID?: string
98
+ }
50
99
 
51
- const { modes: globalModes } = useTokens()
52
- const baseModes = useMemo(
53
- () => ({ ...globalModes, ...propModes }),
54
- [globalModes, propModes],
55
- )
100
+ // ---------------------------------------------------------------------------
101
+ // Token resolution
102
+ // ---------------------------------------------------------------------------
56
103
 
57
- const [isFocused, setIsFocused] = useState(false)
104
+ function toNumber(value: unknown, fallback: number): number {
105
+ if (typeof value === 'number' && Number.isFinite(value)) return value
106
+ if (typeof value === 'string') {
107
+ const parsed = parseFloat(value)
108
+ if (Number.isFinite(parsed)) return parsed
109
+ }
110
+ return fallback
111
+ }
112
+
113
+ function toFontWeight(value: unknown, fallback: TextStyle['fontWeight']): TextStyle['fontWeight'] {
114
+ if (typeof value === 'number') return value.toString() as TextStyle['fontWeight']
115
+ if (typeof value === 'string' && value.length > 0) return value as TextStyle['fontWeight']
116
+ return fallback
117
+ }
118
+
119
+ function useFormFieldTokens(modes: Record<string, any>) {
120
+ return useMemo(() => {
121
+ // Wrapper
122
+ const gap = toNumber(getVariableByName('formField/gap', modes), 8)
123
+
124
+ // Label (Figma: 14/17 medium, color #0c0d10)
125
+ const labelColor =
126
+ (getVariableByName('formField/label/color', modes) as string) || '#0c0d10'
127
+ const labelFontFamily =
128
+ (getVariableByName('formField/label/fontFamily', modes) as string) ||
129
+ 'JioType Var'
130
+ const labelFontSize = toNumber(
131
+ getVariableByName('formField/label/fontSize', modes),
132
+ 14,
133
+ )
134
+ const labelLineHeight = toNumber(
135
+ getVariableByName('formField/label/lineHeight', modes),
136
+ 17,
137
+ )
138
+ const labelFontWeight = toFontWeight(
139
+ getVariableByName('formField/label/fontWeight', modes),
140
+ '500',
141
+ )
142
+
143
+ // Input row (Figma: 12 px padding-h, 8 px gap, 8 px radius, 1 px border)
144
+ const inputPaddingH = toNumber(
145
+ getVariableByName('formField/input/padding/horizontal', modes),
146
+ 12,
147
+ )
148
+ const inputGap = toNumber(getVariableByName('formField/input/gap', modes), 8)
149
+ const inputRadius = toNumber(
150
+ getVariableByName('formField/input/radius', modes),
151
+ 8,
152
+ )
153
+ const inputBorderSize = toNumber(
154
+ getVariableByName('formField/input/border/size', modes),
155
+ 1,
156
+ )
157
+
158
+ // Input text (Figma: 16/45 regular)
159
+ const inputFontSize = toNumber(
160
+ getVariableByName('formField/input/label/fontSize', modes),
161
+ 16,
162
+ )
163
+ const inputLineHeight = toNumber(
164
+ getVariableByName('formField/input/label/lineHeight', modes),
165
+ 45,
166
+ )
167
+ const inputFontFamily =
168
+ (getVariableByName('formField/input/label/fontFamily', modes) as string) ||
169
+ 'JioType Var'
170
+ const inputFontWeight = toFontWeight(
171
+ getVariableByName('formField/input/label/fontWeight', modes),
172
+ '400',
173
+ )
174
+
175
+ const inputBackground =
176
+ (getVariableByName('formField/input/background', modes) as string) ||
177
+ '#ffffff'
178
+ const inputBorderColor =
179
+ (getVariableByName('formField/input/border/color', modes) as string) ||
180
+ '#b5b6b7'
58
181
 
59
- // Merge FormField States collection based on focus
60
- const modes = useMemo(() => ({
61
- ...baseModes,
62
- 'FormField States': isFocused ? 'Active' : 'Idle',
63
- }), [baseModes, isFocused])
64
-
65
- // -- Label tokens (from "FormField / Output" collection) --
66
- const labelColor = getVariableByName('formField/label/color', modes) || '#0c0d10'
67
- const labelFontFamily = getVariableByName('formField/label/fontFamily', modes) || 'JioType Var'
68
- const labelFontSize = parseInt(getVariableByName('formField/label/fontSize', modes), 10) || 14
69
- const labelLineHeight = parseInt(getVariableByName('formField/label/lineHeight', modes), 10) || 17
70
- const labelFontWeight = getVariableByName('formField/label/fontWeight', modes) || '500'
71
- const gap = parseInt(getVariableByName('formField/gap', modes), 10) || 8
72
-
73
- // -- Input tokens (from "FormField / Output" + "FormField States" collections) --
74
- const inputPaddingH = parseInt(getVariableByName('formField/input/padding/horizontal', modes), 10) || 12
75
- const inputGap = parseInt(getVariableByName('formField/input/gap', modes), 10) || 8
76
- const inputRadius = parseInt(getVariableByName('formField/input/radius', modes), 10) || 8
77
- const inputBackground = getVariableByName('formField/input/background', modes) || '#ffffff'
78
- const inputFontSize = parseInt(getVariableByName('formField/input/label/fontSize', modes), 10) || 16
79
- const inputLineHeight = parseInt(getVariableByName('formField/input/label/lineHeight', modes), 10) || 45
80
- const inputFontFamily = getVariableByName('formField/input/label/fontFamily', modes) || 'JioType Var'
81
- const inputFontWeight = getVariableByName('formField/input/label/fontWeight', modes) || '400'
82
- const inputTextColor = getVariableByName('states/formField/input/label/color', modes)
83
- || getVariableByName('formField/input/label/color', modes)
84
- || '#24262b'
85
- const inputBorderColor = getVariableByName('states/formField/input/border/color', modes)
86
- || getVariableByName('formField/input/border/color', modes)
87
- || '#b5b6b7'
88
- const inputBorderSize = parseInt(getVariableByName('formField/input/border/size', modes), 10) || 1
89
-
90
- // -- Styles --
91
- const labelStyle: TextStyle = useMemo(() => ({
92
- color: labelColor,
93
- fontFamily: labelFontFamily,
94
- fontSize: labelFontSize,
95
- lineHeight: labelLineHeight,
96
- fontWeight: labelFontWeight as TextStyle['fontWeight'],
97
- }), [labelColor, labelFontFamily, labelFontSize, labelLineHeight, labelFontWeight])
98
-
99
- const wrapperStyle: ViewStyle = useMemo(() => ({
100
- gap,
101
- opacity: isDisabled ? 0.5 : 1,
102
- }), [gap, isDisabled])
103
-
104
- const requiredIndicatorStyle: TextStyle = useMemo(() => ({
105
- color: '#d93d3d',
106
- fontFamily: labelFontFamily,
107
- fontSize: labelFontSize,
108
- lineHeight: labelLineHeight,
109
- fontWeight: labelFontWeight as TextStyle['fontWeight'],
110
- }), [labelFontFamily, labelFontSize, labelLineHeight, labelFontWeight])
111
-
112
- // Style overrides for the input row, sourced from formField/input/* tokens
113
- const inputContainerStyle: ViewStyle = useMemo(() => ({
114
- backgroundColor: inputBackground,
115
- borderColor: inputBorderColor,
116
- borderWidth: inputBorderSize,
117
- borderRadius: inputRadius,
118
- paddingHorizontal: inputPaddingH,
119
- paddingVertical: 0,
120
- gap: inputGap,
121
- }), [inputBackground, inputBorderColor, inputBorderSize, inputRadius, inputPaddingH, inputGap])
122
-
123
- const inputTextStyle: TextStyle = useMemo(() => ({
124
- color: inputTextColor,
125
- fontSize: inputFontSize,
126
- lineHeight: inputLineHeight,
127
- fontFamily: inputFontFamily,
128
- fontWeight: inputFontWeight as TextStyle['fontWeight'],
129
- }), [inputTextColor, inputFontSize, inputLineHeight, inputFontFamily, inputFontWeight])
130
-
131
- // -- Support text logic --
132
- const supportStatus: SupportTextStatus = isInvalid ? 'Error' : 'Neutral'
133
- const supportLabel = isInvalid && errorMessage ? errorMessage : supportText
134
-
135
- // -- Input type derived props --
136
- const secureTextEntry = type === 'password'
137
- const keyboardType = type === 'email' ? 'email-address' as const : 'default' as const
138
- const autoCapitalize = (type === 'email' || type === 'password') ? 'none' as const : 'sentences' as const
139
-
140
- // -- Event handlers --
141
- const handleFocus = useCallback((e: any) => {
142
- setIsFocused(true)
143
- onFocus?.(e)
144
- }, [onFocus])
145
-
146
- const handleBlur = useCallback((e: any) => {
147
- setIsFocused(false)
148
- onBlur?.(e)
149
- }, [onBlur])
150
-
151
- return {
152
- modes,
153
- labelStyle,
154
- wrapperStyle,
155
- requiredIndicatorStyle,
156
- inputContainerStyle,
157
- inputTextStyle,
158
- supportStatus,
159
- supportLabel,
160
- secureTextEntry,
161
- keyboardType,
162
- autoCapitalize,
163
- handleFocus,
164
- handleBlur,
182
+ if (__DEV__) {
183
+ console.warn('[FormField] border color (modes changed)', {
184
+ 'FormField States': modes['FormField States'],
185
+ inputBorderColor,
186
+ 'formField/input/border/color': getVariableByName(
187
+ 'formField/input/border/color',
188
+ modes,
189
+ ),
190
+ })
191
+ }
192
+
193
+ const inputTextColor =
194
+ (getVariableByName('formField/input/label/color', modes) as string) ||
195
+ '#24262b'
196
+
197
+ return {
198
+ gap,
199
+ labelColor,
200
+ labelFontFamily,
201
+ labelFontSize,
202
+ labelLineHeight,
203
+ labelFontWeight,
204
+ inputPaddingH,
205
+ inputGap,
206
+ inputRadius,
207
+ inputBorderSize,
208
+ inputFontSize,
209
+ inputLineHeight,
210
+ inputFontFamily,
211
+ inputFontWeight,
212
+ inputBackground,
213
+ inputBorderColor,
214
+ inputTextColor,
215
+ }
216
+ }, [modes])
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Helpers
221
+ // ---------------------------------------------------------------------------
222
+
223
+ type DerivedTypeProps = {
224
+ secureTextEntry: boolean
225
+ keyboardType: RNTextInputProps['keyboardType']
226
+ autoCapitalize: RNTextInputProps['autoCapitalize']
227
+ autoComplete: RNTextInputProps['autoComplete']
228
+ textContentType: RNTextInputProps['textContentType']
229
+ }
230
+
231
+ function deriveTypeProps(type: FormFieldType): DerivedTypeProps {
232
+ switch (type) {
233
+ case 'password':
234
+ return {
235
+ secureTextEntry: true,
236
+ keyboardType: 'default',
237
+ autoCapitalize: 'none',
238
+ autoComplete: 'password',
239
+ textContentType: 'password',
240
+ }
241
+ case 'email':
242
+ return {
243
+ secureTextEntry: false,
244
+ keyboardType: 'email-address',
245
+ autoCapitalize: 'none',
246
+ autoComplete: 'email',
247
+ textContentType: 'emailAddress',
248
+ }
249
+ case 'number':
250
+ return {
251
+ secureTextEntry: false,
252
+ keyboardType: 'numeric',
253
+ autoCapitalize: 'none',
254
+ autoComplete: 'off',
255
+ textContentType: 'none',
256
+ }
257
+ case 'phone':
258
+ return {
259
+ secureTextEntry: false,
260
+ keyboardType: 'phone-pad',
261
+ autoCapitalize: 'none',
262
+ autoComplete: 'tel',
263
+ textContentType: 'telephoneNumber',
264
+ }
265
+ case 'url':
266
+ return {
267
+ secureTextEntry: false,
268
+ keyboardType: 'url',
269
+ autoCapitalize: 'none',
270
+ autoComplete: 'url',
271
+ textContentType: 'URL',
272
+ }
273
+ case 'search':
274
+ return {
275
+ secureTextEntry: false,
276
+ keyboardType: 'default',
277
+ autoCapitalize: 'none',
278
+ autoComplete: 'off',
279
+ textContentType: 'none',
280
+ }
281
+ case 'text':
282
+ default:
283
+ return {
284
+ secureTextEntry: false,
285
+ keyboardType: 'default',
286
+ autoCapitalize: 'sentences',
287
+ autoComplete: 'off',
288
+ textContentType: 'none',
289
+ }
165
290
  }
166
291
  }
167
292
 
293
+ function firstError(error: string | string[] | undefined): string | undefined {
294
+ if (!error) return undefined
295
+ if (Array.isArray(error)) return error[0]
296
+ return error
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Component
301
+ // ---------------------------------------------------------------------------
302
+
168
303
  function FormField({
169
304
  label,
170
305
  placeholder,
171
- value = '',
306
+ value,
172
307
  onChangeText,
173
308
  type = 'text',
309
+ name,
174
310
  leading,
175
311
  trailing,
176
312
  leadingIconName,
177
313
  isRequired = false,
178
314
  isDisabled = false,
179
315
  isInvalid = false,
316
+ isReadOnly = false,
180
317
  supportText,
181
318
  errorMessage,
182
- modes = EMPTY_MODES,
319
+ maxLength,
320
+ autoFocus = false,
321
+ modes: propModes = EMPTY_MODES,
183
322
  style,
323
+ inputStyle,
324
+ inputTextStyle,
184
325
  onFocus,
185
326
  onBlur,
327
+ onSubmitEditing,
186
328
  accessibilityLabel,
187
329
  accessibilityHint,
330
+ testID,
188
331
  }: FormFieldProps) {
189
- const {
190
- modes: resolvedModes,
191
- labelStyle,
192
- wrapperStyle,
193
- requiredIndicatorStyle,
194
- inputContainerStyle,
195
- inputTextStyle,
196
- supportStatus,
197
- supportLabel,
198
- secureTextEntry,
199
- keyboardType,
200
- autoCapitalize,
201
- handleFocus,
202
- handleBlur,
203
- } = useFormField({
204
- type,
205
- isDisabled,
206
- isInvalid,
207
- supportText,
208
- errorMessage,
209
- modes,
210
- onFocus,
211
- onBlur,
212
- })
213
-
214
- const resolvedA11yLabel = accessibilityLabel || label || placeholder || 'Form field'
332
+ // -- Form context integration -------------------------------------------
333
+ const formCtx = useFormContext()
334
+ const formError =
335
+ name && formCtx ? firstError(formCtx.validationErrors[name]) : undefined
336
+ const resolvedIsInvalid = isInvalid || Boolean(formError)
337
+ const resolvedErrorMessage = errorMessage ?? formError
338
+
339
+ // -- Mode resolution ----------------------------------------------------
340
+ const { modes: globalModes } = useTokens()
341
+ const baseModes = useMemo(
342
+ () => ({ ...globalModes, ...propModes }),
343
+ [globalModes, propModes],
344
+ )
345
+
346
+ const [isFocused, setIsFocused] = useState(false)
347
+ const interactive = !isDisabled && !isReadOnly
348
+
349
+ // FormField States cascade — error > read only/disabled > active (focused) > idle.
350
+ // Disabled maps to "Read Only" since there is no dedicated disabled mode and
351
+ // the visual treatment is closest. This is only the DEFAULT — an explicit
352
+ // `modes['FormField States']` passed in via props or the global theme
353
+ // always wins so consumers can force a state (e.g. for documentation).
354
+ const derivedStateMode: 'Idle' | 'Active' | 'Read Only' | 'Error' = useMemo(() => {
355
+ if (resolvedIsInvalid) return 'Error'
356
+ if (isReadOnly || isDisabled) return 'Read Only'
357
+ if (isFocused) return 'Active'
358
+ return 'Idle'
359
+ }, [resolvedIsInvalid, isReadOnly, isDisabled, isFocused])
360
+
361
+ const modes = useMemo(() => {
362
+ const explicitStateMode = baseModes['FormField States'] as
363
+ | 'Idle'
364
+ | 'Active'
365
+ | 'Read Only'
366
+ | 'Error'
367
+ | undefined
368
+ const stateMode = explicitStateMode ?? derivedStateMode
369
+
370
+ const explicitStatus = baseModes.Status as string | undefined
371
+ // Default SupportText token mode is Auto (Figma resolves foreground from
372
+ // context). Pass modes={{ Status: 'Error' }} etc. to override.
373
+ const status = explicitStatus ?? 'Auto'
374
+
375
+ return {
376
+ ...baseModes,
377
+ 'FormField States': stateMode,
378
+ Status: status,
379
+ }
380
+ }, [baseModes, derivedStateMode])
381
+
382
+ const tokens = useFormFieldTokens(modes)
383
+
384
+ // -- Type-derived input props ------------------------------------------
385
+ const typeProps = useMemo(() => deriveTypeProps(type), [type])
386
+
387
+ // -- Event handlers ----------------------------------------------------
388
+ const handleFocus = useCallback<NonNullable<RNTextInputProps['onFocus']>>(
389
+ (e) => {
390
+ setIsFocused(true)
391
+ onFocus?.(e)
392
+ },
393
+ [onFocus],
394
+ )
395
+
396
+ const handleBlur = useCallback<NonNullable<RNTextInputProps['onBlur']>>(
397
+ (e) => {
398
+ setIsFocused(false)
399
+ onBlur?.(e)
400
+ },
401
+ [onBlur],
402
+ )
403
+
404
+ const handleChangeText = useCallback(
405
+ (next: string) => {
406
+ onChangeText?.(next)
407
+ if (name && formCtx) formCtx.onFieldChange(name)
408
+ },
409
+ [onChangeText, name, formCtx],
410
+ )
411
+
412
+ // -- Styles ------------------------------------------------------------
413
+ const wrapperStyle: ViewStyle = useMemo(
414
+ () => ({
415
+ gap: tokens.gap,
416
+ opacity: isDisabled ? 0.5 : 1,
417
+ }),
418
+ [tokens.gap, isDisabled],
419
+ )
420
+
421
+ const labelRowStyle: ViewStyle = useMemo(
422
+ () => ({ flexDirection: 'row', alignItems: 'baseline' }),
423
+ [],
424
+ )
425
+
426
+ const labelTextStyle: TextStyle = useMemo(
427
+ () => ({
428
+ color: tokens.labelColor,
429
+ fontFamily: tokens.labelFontFamily,
430
+ fontSize: tokens.labelFontSize,
431
+ lineHeight: tokens.labelLineHeight,
432
+ fontWeight: tokens.labelFontWeight,
433
+ }),
434
+ [
435
+ tokens.labelColor,
436
+ tokens.labelFontFamily,
437
+ tokens.labelFontSize,
438
+ tokens.labelLineHeight,
439
+ tokens.labelFontWeight,
440
+ ],
441
+ )
442
+
443
+ const requiredIndicatorStyle: TextStyle = useMemo(
444
+ () => ({ ...labelTextStyle, color: '#d93d3d' }),
445
+ [labelTextStyle],
446
+ )
447
+
448
+ const inputRowStyle: ViewStyle = useMemo(
449
+ () => ({
450
+ flexDirection: 'row',
451
+ alignItems: 'center',
452
+ backgroundColor: tokens.inputBackground,
453
+ borderColor: tokens.inputBorderColor,
454
+ borderWidth: tokens.inputBorderSize,
455
+ borderStyle: 'solid',
456
+ borderRadius: tokens.inputRadius,
457
+ paddingHorizontal: tokens.inputPaddingH,
458
+ paddingVertical: 0,
459
+ gap: tokens.inputGap,
460
+ minHeight: tokens.inputLineHeight,
461
+ width: '100%',
462
+ }),
463
+ [
464
+ tokens.inputBackground,
465
+ tokens.inputBorderColor,
466
+ tokens.inputBorderSize,
467
+ tokens.inputRadius,
468
+ tokens.inputPaddingH,
469
+ tokens.inputGap,
470
+ tokens.inputLineHeight,
471
+ ],
472
+ )
473
+
474
+ const inputTextStyles: TextStyle = useMemo(
475
+ () => ({
476
+ flex: 1,
477
+ color: tokens.inputTextColor,
478
+ fontFamily: tokens.inputFontFamily,
479
+ fontSize: tokens.inputFontSize,
480
+ lineHeight: tokens.inputLineHeight,
481
+ fontWeight: tokens.inputFontWeight,
482
+ padding: 0,
483
+ margin: 0,
484
+ // Remove the default web focus ring; the input row's border acts as the
485
+ // focus indicator via the FormField States cascade.
486
+ outlineStyle: 'none' as TextStyle['outlineStyle'],
487
+ outlineWidth: 0,
488
+ outlineColor: 'transparent',
489
+ }),
490
+ [
491
+ tokens.inputTextColor,
492
+ tokens.inputFontFamily,
493
+ tokens.inputFontSize,
494
+ tokens.inputLineHeight,
495
+ tokens.inputFontWeight,
496
+ ],
497
+ )
498
+
499
+ const placeholderColor = useMemo(() => {
500
+ // Slightly muted version of the resolved text color, mirroring the
501
+ // sibling TextInput behavior.
502
+ const c = tokens.inputTextColor
503
+ if (typeof c !== 'string') return undefined
504
+ if (c.startsWith('rgb(')) {
505
+ return c.replace('rgb(', 'rgba(').replace(')', ', 0.55)')
506
+ }
507
+ return '#888a8d'
508
+ }, [tokens.inputTextColor])
509
+
510
+ // -- Slots --------------------------------------------------------------
511
+ const leadingElement = leading ?? (leadingIconName
512
+ ? <Icon name={leadingIconName} size={20} color={tokens.inputTextColor} />
513
+ : null)
514
+ const processedLeading = leadingElement
515
+ ? cloneChildrenWithModes(leadingElement, modes)
516
+ : null
517
+ const processedTrailing = trailing
518
+ ? cloneChildrenWithModes(trailing, modes)
519
+ : null
520
+
521
+ // -- Support text -------------------------------------------------------
522
+ const supportStatus: SupportTextStatus = resolvedIsInvalid ? 'Error' : 'Neutral'
523
+ const supportLabel =
524
+ resolvedIsInvalid && resolvedErrorMessage ? resolvedErrorMessage : supportText
525
+
526
+ // -- Accessibility ------------------------------------------------------
527
+ const resolvedA11yLabel =
528
+ accessibilityLabel || label || placeholder || 'Form field'
215
529
 
216
530
  return (
217
531
  <View
218
532
  style={[wrapperStyle, style]}
219
533
  pointerEvents={isDisabled ? 'none' : 'auto'}
220
- accessible={true}
221
- accessibilityRole="none"
222
- accessibilityLabel={resolvedA11yLabel}
223
- accessibilityState={{
224
- disabled: isDisabled,
225
- }}
534
+ testID={testID}
535
+ accessible={false}
226
536
  >
227
537
  {label != null && (
228
- <View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
229
- <Text style={labelStyle}>{label}</Text>
230
- {isRequired && (
231
- <Text style={requiredIndicatorStyle}> *</Text>
232
- )}
538
+ <View style={labelRowStyle}>
539
+ <Text style={labelTextStyle}>{label}</Text>
540
+ {isRequired && <Text style={requiredIndicatorStyle}> *</Text>}
233
541
  </View>
234
542
  )}
235
543
 
236
- <TextInput
237
- placeholder={placeholder || ''}
238
- value={value}
239
- {...(onChangeText ? { onChangeText } : {})}
240
- leading={leading}
241
- trailing={trailing}
242
- leadingIconName={leadingIconName || 'ic_search'}
243
- modes={resolvedModes}
244
- style={inputContainerStyle}
245
- inputStyle={inputTextStyle}
246
- onFocus={handleFocus}
247
- onBlur={handleBlur}
248
- secureTextEntry={secureTextEntry}
249
- keyboardType={keyboardType}
250
- autoCapitalize={autoCapitalize}
251
- editable={!isDisabled}
252
- accessibilityLabel={resolvedA11yLabel}
253
- accessibilityHint={accessibilityHint || ''}
254
- />
255
-
256
- {supportLabel != null && (
544
+ <View style={[inputRowStyle, inputStyle]}>
545
+ {processedLeading != null && (
546
+ <View
547
+ accessibilityElementsHidden
548
+ importantForAccessibility="no"
549
+ >
550
+ {processedLeading}
551
+ </View>
552
+ )}
553
+ <RNTextInput
554
+ style={[inputTextStyles, inputTextStyle]}
555
+ value={value ?? ''}
556
+ onChangeText={handleChangeText}
557
+ onFocus={handleFocus}
558
+ onBlur={handleBlur}
559
+ onSubmitEditing={onSubmitEditing}
560
+ placeholder={placeholder ?? ''}
561
+ placeholderTextColor={placeholderColor}
562
+ editable={interactive}
563
+ maxLength={maxLength}
564
+ autoFocus={autoFocus}
565
+ secureTextEntry={typeProps.secureTextEntry}
566
+ keyboardType={typeProps.keyboardType}
567
+ autoCapitalize={typeProps.autoCapitalize}
568
+ autoComplete={typeProps.autoComplete}
569
+ textContentType={typeProps.textContentType}
570
+ accessibilityLabel={resolvedA11yLabel}
571
+ accessibilityHint={accessibilityHint}
572
+ />
573
+ {processedTrailing != null && (
574
+ <View
575
+ accessibilityElementsHidden
576
+ importantForAccessibility="no"
577
+ >
578
+ {processedTrailing}
579
+ </View>
580
+ )}
581
+ </View>
582
+
583
+ {supportLabel != null && supportLabel !== '' && (
257
584
  <SupportText
258
585
  label={supportLabel}
259
586
  status={supportStatus}
260
- modes={resolvedModes}
587
+ modes={modes}
261
588
  />
262
589
  )}
263
590
  </View>