jfs-components 0.0.77 → 0.0.79
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.
- package/CHANGELOG.md +28 -0
- package/lib/commonjs/components/Accordion/Accordion.js +55 -55
- package/lib/commonjs/components/ActionFooter/ActionFooter.js +48 -2
- package/lib/commonjs/components/Attached/Attached.js +144 -0
- package/lib/commonjs/components/Card/Card.js +25 -2
- package/lib/commonjs/components/Checkbox/Checkbox.js +21 -9
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -16
- package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +167 -0
- package/lib/commonjs/components/FormField/FormField.js +14 -1
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +353 -0
- package/lib/commonjs/components/ListItem/ListItem.js +46 -24
- package/lib/commonjs/components/MessageField/MessageField.js +318 -0
- package/lib/commonjs/components/NavArrow/NavArrow.js +58 -17
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
- package/lib/commonjs/components/Slot/Slot.js +73 -0
- package/lib/commonjs/components/Stepper/Step.js +47 -60
- package/lib/commonjs/components/Stepper/StepLabel.js +40 -10
- package/lib/commonjs/components/Stepper/Stepper.js +15 -17
- package/lib/commonjs/components/SuggestiveSearch/SuggestiveSearch.js +487 -0
- package/lib/commonjs/components/TextInput/TextInput.js +16 -1
- package/lib/commonjs/components/Title/Title.js +10 -2
- package/lib/commonjs/components/index.js +49 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/Accordion/Accordion.js +56 -56
- package/lib/module/components/ActionFooter/ActionFooter.js +50 -4
- package/lib/module/components/Attached/Attached.js +139 -0
- package/lib/module/components/Card/Card.js +25 -2
- package/lib/module/components/Checkbox/Checkbox.js +22 -10
- package/lib/module/components/DropdownInput/DropdownInput.js +30 -16
- package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +161 -0
- package/lib/module/components/FormField/FormField.js +16 -3
- package/lib/module/components/FullscreenModal/FullscreenModal.js +348 -0
- package/lib/module/components/ListItem/ListItem.js +46 -24
- package/lib/module/components/MessageField/MessageField.js +313 -0
- package/lib/module/components/NavArrow/NavArrow.js +59 -18
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
- package/lib/module/components/Slot/Slot.js +68 -0
- package/lib/module/components/Stepper/Step.js +48 -61
- package/lib/module/components/Stepper/StepLabel.js +40 -10
- package/lib/module/components/Stepper/Stepper.js +15 -17
- package/lib/module/components/SuggestiveSearch/SuggestiveSearch.js +481 -0
- package/lib/module/components/TextInput/TextInput.js +17 -2
- package/lib/module/components/Title/Title.js +10 -2
- package/lib/module/components/index.js +7 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -20
- package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
- package/lib/typescript/src/components/Card/Card.d.ts +9 -2
- package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +63 -0
- package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +99 -0
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
- package/lib/typescript/src/components/MessageField/MessageField.d.ts +81 -0
- package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +10 -5
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
- package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
- package/lib/typescript/src/components/Stepper/Step.d.ts +4 -1
- package/lib/typescript/src/components/Stepper/StepLabel.d.ts +4 -1
- package/lib/typescript/src/components/Stepper/Stepper.d.ts +3 -1
- package/lib/typescript/src/components/SuggestiveSearch/SuggestiveSearch.d.ts +123 -0
- package/lib/typescript/src/components/index.d.ts +10 -3
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.tsx +113 -73
- package/src/components/ActionFooter/ActionFooter.tsx +56 -4
- package/src/components/Attached/Attached.tsx +181 -0
- package/src/components/Card/Card.tsx +28 -1
- package/src/components/Checkbox/Checkbox.tsx +22 -9
- package/src/components/DropdownInput/DropdownInput.tsx +67 -39
- package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +237 -0
- package/src/components/FormField/FormField.tsx +19 -3
- package/src/components/FullscreenModal/FullscreenModal.tsx +414 -0
- package/src/components/ListItem/ListItem.tsx +55 -25
- package/src/components/MessageField/MessageField.tsx +543 -0
- package/src/components/NavArrow/NavArrow.tsx +81 -17
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
- package/src/components/Slot/Slot.tsx +91 -0
- package/src/components/Stepper/Step.tsx +52 -51
- package/src/components/Stepper/StepLabel.tsx +46 -9
- package/src/components/Stepper/Stepper.tsx +20 -15
- package/src/components/SuggestiveSearch/SuggestiveSearch.tsx +756 -0
- package/src/components/TextInput/TextInput.tsx +14 -1
- package/src/components/Title/Title.tsx +13 -2
- package/src/components/index.ts +10 -3
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/registry.ts +1 -1
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
TextInput as RNTextInput,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type TextInputProps as RNTextInputProps,
|
|
9
|
+
type TextStyle,
|
|
10
|
+
type ViewStyle,
|
|
11
|
+
} from 'react-native'
|
|
12
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
13
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
14
|
+
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
15
|
+
import { useFormContext } from '../Form/Form'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Visual state of the textarea. Mirrors the `FormField States` collection so
|
|
23
|
+
* MessageField slots into the same theming pipeline as FormField. The state
|
|
24
|
+
* is always derived from props (`isInvalid`, `isDisabled`, `isReadOnly` and
|
|
25
|
+
* focus) and is locked in `modes['FormField States']` — passing that key in
|
|
26
|
+
* `modes` is intentionally ignored to keep interactive behaviour and visual
|
|
27
|
+
* state in sync.
|
|
28
|
+
*/
|
|
29
|
+
export type MessageFieldState =
|
|
30
|
+
| 'Idle'
|
|
31
|
+
| 'Active'
|
|
32
|
+
| 'Read Only'
|
|
33
|
+
| 'Error'
|
|
34
|
+
| 'Disabled'
|
|
35
|
+
|
|
36
|
+
export type MessageFieldProps = {
|
|
37
|
+
/** Label rendered above the textarea. */
|
|
38
|
+
label?: string
|
|
39
|
+
/** Placeholder text shown when the textarea is empty. */
|
|
40
|
+
placeholder?: string
|
|
41
|
+
/**
|
|
42
|
+
* Current value of the textarea (controlled). When provided, the consumer
|
|
43
|
+
* is responsible for updating it via `onChangeText`.
|
|
44
|
+
*/
|
|
45
|
+
value?: string
|
|
46
|
+
/** Initial value when used uncontrolled. Ignored when `value` is provided. */
|
|
47
|
+
defaultValue?: string
|
|
48
|
+
/** Called whenever the text changes. Fires for both controlled and uncontrolled use. */
|
|
49
|
+
onChangeText?: (text: string) => void
|
|
50
|
+
/**
|
|
51
|
+
* Form field name. When the field is rendered inside a `<Form>`, this is
|
|
52
|
+
* the key used to look up server-side `validationErrors` and to clear
|
|
53
|
+
* the error when the value changes.
|
|
54
|
+
*/
|
|
55
|
+
name?: string
|
|
56
|
+
/**
|
|
57
|
+
* Maximum number of characters accepted. Drives the counter when
|
|
58
|
+
* `showCounter` is not explicitly false.
|
|
59
|
+
*/
|
|
60
|
+
maxLength?: number
|
|
61
|
+
/**
|
|
62
|
+
* Controls visibility of the character counter.
|
|
63
|
+
* - Default: counter is shown when `maxLength` is provided.
|
|
64
|
+
* - `true`: always show counter (shows `<count>/<maxLength>` when
|
|
65
|
+
* `maxLength` is set, or just `<count>` otherwise).
|
|
66
|
+
* - `false`: never show counter.
|
|
67
|
+
*/
|
|
68
|
+
showCounter?: boolean
|
|
69
|
+
/**
|
|
70
|
+
* Number of visible text rows. When provided, overrides the default
|
|
71
|
+
* `messageField/textarea/height` token to derive the textarea height as
|
|
72
|
+
* `rows * lineHeight + 2 * padding`.
|
|
73
|
+
*/
|
|
74
|
+
rows?: number
|
|
75
|
+
/** Renders a required indicator (asterisk) next to the label. */
|
|
76
|
+
isRequired?: boolean
|
|
77
|
+
/** Disables interaction and dims the field. */
|
|
78
|
+
isDisabled?: boolean
|
|
79
|
+
/** Marks the field as invalid and resolves to the `Error` state token mode. */
|
|
80
|
+
isInvalid?: boolean
|
|
81
|
+
/** Read-only, non-interactive but not dimmed. */
|
|
82
|
+
isReadOnly?: boolean
|
|
83
|
+
/** Auto-focus the textarea on mount. */
|
|
84
|
+
autoFocus?: boolean
|
|
85
|
+
/** Modes for design token resolution (e.g. `{ 'Color Mode': 'Light' }`). */
|
|
86
|
+
modes?: Record<string, any>
|
|
87
|
+
/** Style overrides for the outermost wrapper. */
|
|
88
|
+
style?: StyleProp<ViewStyle>
|
|
89
|
+
/** Style overrides for the textarea container (border/padding/etc). */
|
|
90
|
+
textareaStyle?: StyleProp<ViewStyle>
|
|
91
|
+
/** Style overrides for the input text. */
|
|
92
|
+
inputStyle?: StyleProp<TextStyle>
|
|
93
|
+
/** Accessibility label. Defaults to `label` or `placeholder`. */
|
|
94
|
+
accessibilityLabel?: string
|
|
95
|
+
/** Accessibility hint. */
|
|
96
|
+
accessibilityHint?: string
|
|
97
|
+
/** Test identifier. */
|
|
98
|
+
testID?: string
|
|
99
|
+
/** Called when the textarea receives focus. */
|
|
100
|
+
onFocus?: RNTextInputProps['onFocus']
|
|
101
|
+
/** Called when the textarea loses focus. */
|
|
102
|
+
onBlur?: RNTextInputProps['onBlur']
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Token helpers
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function toNumber(value: unknown, fallback: number): number {
|
|
110
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
111
|
+
if (typeof value === 'string') {
|
|
112
|
+
const parsed = parseFloat(value)
|
|
113
|
+
if (Number.isFinite(parsed)) return parsed
|
|
114
|
+
}
|
|
115
|
+
return fallback
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toFontWeight(
|
|
119
|
+
value: unknown,
|
|
120
|
+
fallback: TextStyle['fontWeight'],
|
|
121
|
+
): TextStyle['fontWeight'] {
|
|
122
|
+
if (typeof value === 'number') return value.toString() as TextStyle['fontWeight']
|
|
123
|
+
if (typeof value === 'string' && value.length > 0) return value as TextStyle['fontWeight']
|
|
124
|
+
return fallback
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function firstError(error: string | string[] | undefined): string | undefined {
|
|
128
|
+
if (!error) return undefined
|
|
129
|
+
if (Array.isArray(error)) return error[0]
|
|
130
|
+
return error
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function useMessageFieldTokens(modes: Record<string, any>) {
|
|
134
|
+
return useMemo(() => {
|
|
135
|
+
const wrapperGap = toNumber(getVariableByName('messageField/gap', modes), 8)
|
|
136
|
+
|
|
137
|
+
const labelColor =
|
|
138
|
+
(getVariableByName('messageField/label/foreground', modes) as string) ||
|
|
139
|
+
'#000000'
|
|
140
|
+
const labelFontFamily =
|
|
141
|
+
(getVariableByName('messageField/label/fontFamily', modes) as string) ||
|
|
142
|
+
'JioType Var'
|
|
143
|
+
const labelFontSize = toNumber(
|
|
144
|
+
getVariableByName('messageField/label/fontSize', modes),
|
|
145
|
+
14,
|
|
146
|
+
)
|
|
147
|
+
const labelLineHeight = toNumber(
|
|
148
|
+
getVariableByName('messageField/label/lineHeight', modes),
|
|
149
|
+
17,
|
|
150
|
+
)
|
|
151
|
+
const labelFontWeight = toFontWeight(
|
|
152
|
+
getVariableByName('messageField/label/fontWeight', modes),
|
|
153
|
+
'500',
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const textareaBackground =
|
|
157
|
+
(getVariableByName('messageField/textarea/background', modes) as string) ||
|
|
158
|
+
'#ffffff'
|
|
159
|
+
const textareaBorderColor =
|
|
160
|
+
(getVariableByName('messageField/textarea/border/color', modes) as string) ||
|
|
161
|
+
'#b5b6b7'
|
|
162
|
+
const textareaBorderSize = toNumber(
|
|
163
|
+
getVariableByName('messageField/textarea/border/size', modes),
|
|
164
|
+
1.5,
|
|
165
|
+
)
|
|
166
|
+
const textareaRadius = toNumber(
|
|
167
|
+
getVariableByName('messageField/textarea/radius', modes),
|
|
168
|
+
8,
|
|
169
|
+
)
|
|
170
|
+
const textareaPadding = toNumber(
|
|
171
|
+
getVariableByName('messageField/textarea/padding', modes),
|
|
172
|
+
12,
|
|
173
|
+
)
|
|
174
|
+
const textareaHeight = toNumber(
|
|
175
|
+
getVariableByName('messageField/textarea/height', modes),
|
|
176
|
+
108,
|
|
177
|
+
)
|
|
178
|
+
const textareaGap = toNumber(
|
|
179
|
+
getVariableByName('messageField/textarea/gap', modes),
|
|
180
|
+
0,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// `messageField/text/foreground` is the input text color. It also
|
|
184
|
+
// serves as the placeholder color — in mode-aware token sets it
|
|
185
|
+
// resolves to a muted/idle color when empty and shifts darker via
|
|
186
|
+
// the `FormField States` cascade once typed-state tokens land. We
|
|
187
|
+
// never re-route this through another token (e.g. the counter
|
|
188
|
+
// color) because that conflates two semantically distinct tokens.
|
|
189
|
+
const inputTextColor =
|
|
190
|
+
(getVariableByName('messageField/text/foreground', modes) as string) ||
|
|
191
|
+
'#707275'
|
|
192
|
+
const inputFontFamily =
|
|
193
|
+
(getVariableByName('messageField/text/fontFamily', modes) as string) ||
|
|
194
|
+
'JioType Var'
|
|
195
|
+
const inputFontSize = toNumber(
|
|
196
|
+
getVariableByName('messageField/text/fontSize', modes),
|
|
197
|
+
16,
|
|
198
|
+
)
|
|
199
|
+
const inputLineHeight = toNumber(
|
|
200
|
+
getVariableByName('messageField/text/lineHeight', modes),
|
|
201
|
+
21,
|
|
202
|
+
)
|
|
203
|
+
const inputFontWeight = toFontWeight(
|
|
204
|
+
getVariableByName('messageField/text/fontWeight', modes),
|
|
205
|
+
'400',
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const counterColor =
|
|
209
|
+
(getVariableByName('messageField/maxLength/foreground', modes) as string) ||
|
|
210
|
+
'#24262b'
|
|
211
|
+
const counterFontFamily =
|
|
212
|
+
(getVariableByName('messageField/maxLength/fontFamily', modes) as string) ||
|
|
213
|
+
'JioType Var'
|
|
214
|
+
const counterFontSize = toNumber(
|
|
215
|
+
getVariableByName('messageField/maxLength/fontSize', modes),
|
|
216
|
+
14,
|
|
217
|
+
)
|
|
218
|
+
const counterLineHeight = toNumber(
|
|
219
|
+
getVariableByName('messageField/maxLength/lineHeight', modes),
|
|
220
|
+
18,
|
|
221
|
+
)
|
|
222
|
+
const counterFontWeight = toFontWeight(
|
|
223
|
+
getVariableByName('messageField/maxLength/fontWeight', modes),
|
|
224
|
+
'400',
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
wrapperGap,
|
|
229
|
+
labelColor,
|
|
230
|
+
labelFontFamily,
|
|
231
|
+
labelFontSize,
|
|
232
|
+
labelLineHeight,
|
|
233
|
+
labelFontWeight,
|
|
234
|
+
textareaBackground,
|
|
235
|
+
textareaBorderColor,
|
|
236
|
+
textareaBorderSize,
|
|
237
|
+
textareaRadius,
|
|
238
|
+
textareaPadding,
|
|
239
|
+
textareaHeight,
|
|
240
|
+
textareaGap,
|
|
241
|
+
inputTextColor,
|
|
242
|
+
inputFontFamily,
|
|
243
|
+
inputFontSize,
|
|
244
|
+
inputLineHeight,
|
|
245
|
+
inputFontWeight,
|
|
246
|
+
counterColor,
|
|
247
|
+
counterFontFamily,
|
|
248
|
+
counterFontSize,
|
|
249
|
+
counterLineHeight,
|
|
250
|
+
counterFontWeight,
|
|
251
|
+
}
|
|
252
|
+
}, [modes])
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Component
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
const REQUIRED_INDICATOR_COLOR = '#d93d3d'
|
|
260
|
+
|
|
261
|
+
function MessageField({
|
|
262
|
+
label,
|
|
263
|
+
placeholder,
|
|
264
|
+
value,
|
|
265
|
+
defaultValue,
|
|
266
|
+
onChangeText,
|
|
267
|
+
name,
|
|
268
|
+
maxLength,
|
|
269
|
+
showCounter,
|
|
270
|
+
rows,
|
|
271
|
+
isRequired = false,
|
|
272
|
+
isDisabled = false,
|
|
273
|
+
isInvalid = false,
|
|
274
|
+
isReadOnly = false,
|
|
275
|
+
autoFocus = false,
|
|
276
|
+
modes: propModes = EMPTY_MODES,
|
|
277
|
+
style,
|
|
278
|
+
textareaStyle,
|
|
279
|
+
inputStyle,
|
|
280
|
+
accessibilityLabel,
|
|
281
|
+
accessibilityHint,
|
|
282
|
+
testID,
|
|
283
|
+
onFocus,
|
|
284
|
+
onBlur,
|
|
285
|
+
}: MessageFieldProps) {
|
|
286
|
+
const formCtx = useFormContext()
|
|
287
|
+
const formError =
|
|
288
|
+
name && formCtx ? firstError(formCtx.validationErrors[name]) : undefined
|
|
289
|
+
const resolvedIsInvalid = isInvalid || Boolean(formError)
|
|
290
|
+
|
|
291
|
+
const isControlled = value !== undefined
|
|
292
|
+
const [uncontrolledValue, setUncontrolledValue] = useState<string>(
|
|
293
|
+
defaultValue ?? '',
|
|
294
|
+
)
|
|
295
|
+
const currentValue = isControlled ? (value as string) : uncontrolledValue
|
|
296
|
+
|
|
297
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
298
|
+
const interactive = !isDisabled && !isReadOnly
|
|
299
|
+
|
|
300
|
+
// Ref to the native textarea so tapping anywhere in the (padded) textarea
|
|
301
|
+
// container focuses it on the FIRST tap, fixing the Android "two taps to
|
|
302
|
+
// open the keyboard" issue.
|
|
303
|
+
const inputRef = useRef<RNTextInput>(null)
|
|
304
|
+
const focusInput = useCallback(() => {
|
|
305
|
+
if (!interactive) return
|
|
306
|
+
inputRef.current?.focus()
|
|
307
|
+
}, [interactive])
|
|
308
|
+
|
|
309
|
+
const { modes: globalModes } = useTokens()
|
|
310
|
+
const baseModes = useMemo(
|
|
311
|
+
() => ({ ...globalModes, ...propModes }),
|
|
312
|
+
[globalModes, propModes],
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
// FormField States cascade — error > disabled > read only > active (focus)
|
|
316
|
+
// > idle. Always derived from props and locked into the modes object so
|
|
317
|
+
// consumers cannot pass `modes={{ 'FormField States': ... }}` and get out
|
|
318
|
+
// of sync with the component's actual interactive behaviour.
|
|
319
|
+
const stateMode: MessageFieldState = useMemo(() => {
|
|
320
|
+
if (resolvedIsInvalid) return 'Error'
|
|
321
|
+
if (isDisabled) return 'Disabled'
|
|
322
|
+
if (isReadOnly) return 'Read Only'
|
|
323
|
+
if (isFocused) return 'Active'
|
|
324
|
+
return 'Idle'
|
|
325
|
+
}, [resolvedIsInvalid, isDisabled, isReadOnly, isFocused])
|
|
326
|
+
|
|
327
|
+
const modes = useMemo(
|
|
328
|
+
() => ({
|
|
329
|
+
...baseModes,
|
|
330
|
+
'FormField States': stateMode,
|
|
331
|
+
}),
|
|
332
|
+
[baseModes, stateMode],
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
const tokens = useMessageFieldTokens(modes)
|
|
336
|
+
|
|
337
|
+
// ---------- Event handlers ---------------------------------------------
|
|
338
|
+
const handleFocus = useCallback<NonNullable<RNTextInputProps['onFocus']>>(
|
|
339
|
+
(e) => {
|
|
340
|
+
setIsFocused(true)
|
|
341
|
+
onFocus?.(e)
|
|
342
|
+
},
|
|
343
|
+
[onFocus],
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
const handleBlur = useCallback<NonNullable<RNTextInputProps['onBlur']>>(
|
|
347
|
+
(e) => {
|
|
348
|
+
setIsFocused(false)
|
|
349
|
+
onBlur?.(e)
|
|
350
|
+
},
|
|
351
|
+
[onBlur],
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const handleChangeText = useCallback(
|
|
355
|
+
(next: string) => {
|
|
356
|
+
if (!isControlled) {
|
|
357
|
+
setUncontrolledValue(next)
|
|
358
|
+
}
|
|
359
|
+
onChangeText?.(next)
|
|
360
|
+
if (name && formCtx) formCtx.onFieldChange(name)
|
|
361
|
+
},
|
|
362
|
+
[isControlled, onChangeText, name, formCtx],
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
// ---------- Derived layout values --------------------------------------
|
|
366
|
+
const computedHeight = useMemo(() => {
|
|
367
|
+
if (rows && rows > 0) {
|
|
368
|
+
return Math.round(
|
|
369
|
+
rows * tokens.inputLineHeight + 2 * tokens.textareaPadding,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
return tokens.textareaHeight
|
|
373
|
+
}, [rows, tokens.inputLineHeight, tokens.textareaPadding, tokens.textareaHeight])
|
|
374
|
+
|
|
375
|
+
const shouldShowCounter = useMemo(() => {
|
|
376
|
+
if (showCounter === false) return false
|
|
377
|
+
if (showCounter === true) return true
|
|
378
|
+
return typeof maxLength === 'number'
|
|
379
|
+
}, [showCounter, maxLength])
|
|
380
|
+
|
|
381
|
+
const counterText = useMemo(() => {
|
|
382
|
+
const count = currentValue.length
|
|
383
|
+
if (typeof maxLength === 'number') return `${count}/${maxLength}`
|
|
384
|
+
return `${count}`
|
|
385
|
+
}, [currentValue.length, maxLength])
|
|
386
|
+
|
|
387
|
+
// ---------- Styles -----------------------------------------------------
|
|
388
|
+
const wrapperStyle: ViewStyle = useMemo(
|
|
389
|
+
() => ({
|
|
390
|
+
gap: tokens.wrapperGap,
|
|
391
|
+
width: '100%',
|
|
392
|
+
}),
|
|
393
|
+
[tokens.wrapperGap],
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const labelRowStyle: ViewStyle = useMemo(
|
|
397
|
+
() => ({ flexDirection: 'row', alignItems: 'baseline' }),
|
|
398
|
+
[],
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
const labelTextStyle: TextStyle = useMemo(
|
|
402
|
+
() => ({
|
|
403
|
+
color: tokens.labelColor,
|
|
404
|
+
fontFamily: tokens.labelFontFamily,
|
|
405
|
+
fontSize: tokens.labelFontSize,
|
|
406
|
+
lineHeight: tokens.labelLineHeight,
|
|
407
|
+
fontWeight: tokens.labelFontWeight,
|
|
408
|
+
}),
|
|
409
|
+
[
|
|
410
|
+
tokens.labelColor,
|
|
411
|
+
tokens.labelFontFamily,
|
|
412
|
+
tokens.labelFontSize,
|
|
413
|
+
tokens.labelLineHeight,
|
|
414
|
+
tokens.labelFontWeight,
|
|
415
|
+
],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
const requiredIndicatorStyle: TextStyle = useMemo(
|
|
419
|
+
() => ({ ...labelTextStyle, color: REQUIRED_INDICATOR_COLOR }),
|
|
420
|
+
[labelTextStyle],
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
const textareaContainerStyle: ViewStyle = useMemo(
|
|
424
|
+
() => ({
|
|
425
|
+
backgroundColor: tokens.textareaBackground,
|
|
426
|
+
borderColor: tokens.textareaBorderColor,
|
|
427
|
+
borderWidth: tokens.textareaBorderSize,
|
|
428
|
+
borderStyle: 'solid',
|
|
429
|
+
borderRadius: tokens.textareaRadius,
|
|
430
|
+
padding: tokens.textareaPadding,
|
|
431
|
+
height: computedHeight,
|
|
432
|
+
width: '100%',
|
|
433
|
+
overflow: 'hidden',
|
|
434
|
+
// The gap token is for content within the textarea (icons, etc.);
|
|
435
|
+
// we keep it so downstream layouts that pass children align.
|
|
436
|
+
gap: tokens.textareaGap,
|
|
437
|
+
}),
|
|
438
|
+
[
|
|
439
|
+
tokens.textareaBackground,
|
|
440
|
+
tokens.textareaBorderColor,
|
|
441
|
+
tokens.textareaBorderSize,
|
|
442
|
+
tokens.textareaRadius,
|
|
443
|
+
tokens.textareaPadding,
|
|
444
|
+
computedHeight,
|
|
445
|
+
tokens.textareaGap,
|
|
446
|
+
],
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const inputTextStyle: TextStyle = useMemo(
|
|
450
|
+
() => ({
|
|
451
|
+
flex: 1,
|
|
452
|
+
color: tokens.inputTextColor,
|
|
453
|
+
fontFamily: tokens.inputFontFamily,
|
|
454
|
+
fontSize: tokens.inputFontSize,
|
|
455
|
+
lineHeight: tokens.inputLineHeight,
|
|
456
|
+
fontWeight: tokens.inputFontWeight,
|
|
457
|
+
padding: 0,
|
|
458
|
+
margin: 0,
|
|
459
|
+
textAlignVertical: 'top',
|
|
460
|
+
// Disable the default web focus ring; the textarea border
|
|
461
|
+
// already encodes focus state.
|
|
462
|
+
outlineStyle: 'none' as TextStyle['outlineStyle'],
|
|
463
|
+
outlineWidth: 0,
|
|
464
|
+
outlineColor: 'transparent',
|
|
465
|
+
}),
|
|
466
|
+
[
|
|
467
|
+
tokens.inputTextColor,
|
|
468
|
+
tokens.inputFontFamily,
|
|
469
|
+
tokens.inputFontSize,
|
|
470
|
+
tokens.inputLineHeight,
|
|
471
|
+
tokens.inputFontWeight,
|
|
472
|
+
],
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
const counterTextStyle: TextStyle = useMemo(
|
|
476
|
+
() => ({
|
|
477
|
+
color: tokens.counterColor,
|
|
478
|
+
fontFamily: tokens.counterFontFamily,
|
|
479
|
+
fontSize: tokens.counterFontSize,
|
|
480
|
+
lineHeight: tokens.counterLineHeight,
|
|
481
|
+
fontWeight: tokens.counterFontWeight,
|
|
482
|
+
textAlign: 'right',
|
|
483
|
+
width: '100%',
|
|
484
|
+
}),
|
|
485
|
+
[
|
|
486
|
+
tokens.counterColor,
|
|
487
|
+
tokens.counterFontFamily,
|
|
488
|
+
tokens.counterFontSize,
|
|
489
|
+
tokens.counterLineHeight,
|
|
490
|
+
tokens.counterFontWeight,
|
|
491
|
+
],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
const resolvedA11yLabel =
|
|
495
|
+
accessibilityLabel || label || placeholder || 'Message field'
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
<View
|
|
499
|
+
style={[wrapperStyle, style]}
|
|
500
|
+
pointerEvents={isDisabled ? 'none' : 'auto'}
|
|
501
|
+
testID={testID}
|
|
502
|
+
accessible={false}
|
|
503
|
+
>
|
|
504
|
+
{label != null && label !== '' && (
|
|
505
|
+
<View style={labelRowStyle}>
|
|
506
|
+
<Text style={labelTextStyle}>{label}</Text>
|
|
507
|
+
{isRequired && <Text style={requiredIndicatorStyle}> *</Text>}
|
|
508
|
+
</View>
|
|
509
|
+
)}
|
|
510
|
+
|
|
511
|
+
<Pressable
|
|
512
|
+
style={[textareaContainerStyle, textareaStyle]}
|
|
513
|
+
onPress={focusInput}
|
|
514
|
+
accessible={false}
|
|
515
|
+
>
|
|
516
|
+
<RNTextInput
|
|
517
|
+
ref={inputRef}
|
|
518
|
+
multiline
|
|
519
|
+
value={currentValue}
|
|
520
|
+
onChangeText={handleChangeText}
|
|
521
|
+
onFocus={handleFocus}
|
|
522
|
+
onBlur={handleBlur}
|
|
523
|
+
placeholder={placeholder ?? ''}
|
|
524
|
+
placeholderTextColor={tokens.inputTextColor}
|
|
525
|
+
editable={interactive}
|
|
526
|
+
maxLength={maxLength}
|
|
527
|
+
autoFocus={autoFocus}
|
|
528
|
+
accessibilityLabel={resolvedA11yLabel}
|
|
529
|
+
accessibilityHint={accessibilityHint}
|
|
530
|
+
style={[inputTextStyle, inputStyle]}
|
|
531
|
+
/>
|
|
532
|
+
</Pressable>
|
|
533
|
+
|
|
534
|
+
{shouldShowCounter && (
|
|
535
|
+
<Text style={counterTextStyle} accessibilityElementsHidden>
|
|
536
|
+
{counterText}
|
|
537
|
+
</Text>
|
|
538
|
+
)}
|
|
539
|
+
</View>
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export default MessageField
|
|
@@ -1,21 +1,48 @@
|
|
|
1
1
|
import React, { useMemo } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable,
|
|
5
|
+
View,
|
|
6
|
+
type PressableStateCallbackType,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native'
|
|
3
10
|
import Svg, { Polyline } from 'react-native-svg'
|
|
4
11
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
12
|
+
import {
|
|
13
|
+
usePressableWebSupport,
|
|
14
|
+
type SafePressableProps,
|
|
15
|
+
} from '../../utils/web-platform-utils'
|
|
5
16
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
6
17
|
|
|
7
18
|
type NavArrowDirection = 'Back' | 'Forward' | 'Down'
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
/** Minimum touch target per iOS HIG / Material accessibility guidance. */
|
|
21
|
+
const MIN_TOUCH_TARGET = 44
|
|
22
|
+
const IS_IOS = Platform.OS === 'ios'
|
|
23
|
+
const PRESS_DELAY = IS_IOS ? 130 : 0
|
|
24
|
+
|
|
25
|
+
const touchTargetStyle: ViewStyle = {
|
|
26
|
+
minWidth: MIN_TOUCH_TARGET,
|
|
27
|
+
minHeight: MIN_TOUCH_TARGET,
|
|
28
|
+
alignItems: 'center',
|
|
29
|
+
justifyContent: 'center',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type NavArrowProps = SafePressableProps & {
|
|
10
33
|
/** Direction of the arrow: 'Back' (left chevron), 'Forward' (right chevron), or 'Down' */
|
|
11
34
|
direction?: NavArrowDirection
|
|
12
35
|
/** Modes used to resolve design tokens */
|
|
13
36
|
modes?: Record<string, any>
|
|
14
37
|
/** Optional additional container style */
|
|
15
|
-
style?: ViewStyle
|
|
38
|
+
style?: StyleProp<ViewStyle>
|
|
16
39
|
/** Accessibility label for the arrow */
|
|
17
40
|
accessibilityLabel?: string
|
|
18
|
-
|
|
41
|
+
/** Called when the arrow is pressed. Expands the hit area to at least 44×44. */
|
|
42
|
+
onPress?: () => void
|
|
43
|
+
/** Disables press interaction when `onPress` is provided */
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
}
|
|
19
46
|
|
|
20
47
|
interface NavArrowTokens {
|
|
21
48
|
iconColor: string
|
|
@@ -75,6 +102,8 @@ function NavArrow({
|
|
|
75
102
|
modes = EMPTY_MODES,
|
|
76
103
|
style,
|
|
77
104
|
accessibilityLabel,
|
|
105
|
+
onPress,
|
|
106
|
+
disabled = false,
|
|
78
107
|
...rest
|
|
79
108
|
}: NavArrowProps) {
|
|
80
109
|
const tokens = useMemo(() => resolveNavArrowTokens(modes), [modes])
|
|
@@ -91,7 +120,6 @@ function NavArrow({
|
|
|
91
120
|
backgroundColor: tokens.backgroundColor,
|
|
92
121
|
alignItems: 'center',
|
|
93
122
|
justifyContent: 'center',
|
|
94
|
-
...(style || {}),
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
const chevronW = isDown ? tokens.iconHeight : tokens.iconWidth
|
|
@@ -116,7 +144,7 @@ function NavArrow({
|
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
return { containerStyle, svgWidth, svgHeight, points }
|
|
119
|
-
}, [tokens, direction
|
|
147
|
+
}, [tokens, direction])
|
|
120
148
|
|
|
121
149
|
const defaultAccessibilityLabel =
|
|
122
150
|
accessibilityLabel ||
|
|
@@ -126,23 +154,59 @@ function NavArrow({
|
|
|
126
154
|
? 'Go forward'
|
|
127
155
|
: 'Go down')
|
|
128
156
|
|
|
157
|
+
const webProps = usePressableWebSupport({
|
|
158
|
+
restProps: rest,
|
|
159
|
+
onPress,
|
|
160
|
+
disabled,
|
|
161
|
+
accessibilityLabel: defaultAccessibilityLabel,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const chevron = (
|
|
165
|
+
<Svg
|
|
166
|
+
width={computed.svgWidth}
|
|
167
|
+
height={computed.svgHeight}
|
|
168
|
+
viewBox={`0 0 ${computed.svgWidth} ${computed.svgHeight}`}
|
|
169
|
+
>
|
|
170
|
+
<Polyline
|
|
171
|
+
points={computed.points}
|
|
172
|
+
stroke={tokens.iconColor}
|
|
173
|
+
strokeWidth={tokens.strokeWeight}
|
|
174
|
+
strokeLinecap="round"
|
|
175
|
+
strokeLinejoin="round"
|
|
176
|
+
fill="none"
|
|
177
|
+
/>
|
|
178
|
+
</Svg>
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (onPress) {
|
|
182
|
+
return (
|
|
183
|
+
<Pressable
|
|
184
|
+
onPress={onPress}
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
accessibilityRole="button"
|
|
187
|
+
accessibilityLabel={defaultAccessibilityLabel}
|
|
188
|
+
accessibilityState={{ disabled }}
|
|
189
|
+
unstable_pressDelay={PRESS_DELAY}
|
|
190
|
+
style={({ pressed }: PressableStateCallbackType) => [
|
|
191
|
+
touchTargetStyle,
|
|
192
|
+
style,
|
|
193
|
+
pressed && !disabled ? { opacity: 0.7 } : null,
|
|
194
|
+
]}
|
|
195
|
+
{...webProps}
|
|
196
|
+
>
|
|
197
|
+
<View style={computed.containerStyle}>{chevron}</View>
|
|
198
|
+
</Pressable>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
129
202
|
return (
|
|
130
203
|
<View
|
|
131
|
-
style={computed.containerStyle}
|
|
204
|
+
style={[computed.containerStyle, style]}
|
|
132
205
|
accessibilityRole="image"
|
|
133
206
|
accessibilityLabel={defaultAccessibilityLabel}
|
|
134
207
|
{...rest}
|
|
135
208
|
>
|
|
136
|
-
|
|
137
|
-
<Polyline
|
|
138
|
-
points={computed.points}
|
|
139
|
-
stroke={tokens.iconColor}
|
|
140
|
-
strokeWidth={tokens.strokeWeight}
|
|
141
|
-
strokeLinecap="round"
|
|
142
|
-
strokeLinejoin="round"
|
|
143
|
-
fill="none"
|
|
144
|
-
/>
|
|
145
|
-
</Svg>
|
|
209
|
+
{chevron}
|
|
146
210
|
</View>
|
|
147
211
|
)
|
|
148
212
|
}
|