@hero-design/rn-work-uikit 1.1.0 → 1.2.0-alpha.1

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 (49) hide show
  1. package/.cursorrules +57 -0
  2. package/CHANGELOG.md +16 -0
  3. package/DEVELOPMENT.md +118 -0
  4. package/assets/fonts/BeVietnamPro-Bold.ttf +0 -0
  5. package/assets/fonts/BeVietnamPro-Light.ttf +0 -0
  6. package/assets/fonts/BeVietnamPro-Regular.ttf +0 -0
  7. package/assets/fonts/BeVietnamPro-SemiBold.ttf +0 -0
  8. package/assets/fonts/Saiga-Light.otf +0 -0
  9. package/assets/fonts/Saiga-Medium.otf +0 -0
  10. package/assets/fonts/Saiga-Regular.otf +0 -0
  11. package/assets/fonts/hero-icons-mobile.ttf +0 -0
  12. package/eslint.config.js +20 -0
  13. package/lib/index.js +871 -5
  14. package/package.json +4 -1
  15. package/rollup.config.mjs +2 -2
  16. package/src/__tests__/__snapshots__/index.spec.tsx.snap +90 -115
  17. package/src/__tests__/theme-export-override.spec.ts +6 -0
  18. package/src/components/TextInput/ErrorOrHelpText.tsx +58 -0
  19. package/src/components/TextInput/FloatingLabel.tsx +120 -0
  20. package/src/components/TextInput/InputComponent.tsx +61 -0
  21. package/src/components/TextInput/InputRow.tsx +103 -0
  22. package/src/components/TextInput/MaxLengthMessage.tsx +66 -0
  23. package/src/components/TextInput/PrefixComponent.tsx +77 -0
  24. package/src/components/TextInput/StyledTextInput.tsx +134 -0
  25. package/src/components/TextInput/SuffixComponent.tsx +73 -0
  26. package/src/components/TextInput/__tests__/ErrorOrHelpText.spec.tsx +20 -0
  27. package/src/components/TextInput/__tests__/FloatingLabel.spec.tsx +203 -0
  28. package/src/components/TextInput/__tests__/InputComponent.spec.tsx +39 -0
  29. package/src/components/TextInput/__tests__/InputRow.spec.tsx +275 -0
  30. package/src/components/TextInput/__tests__/MaxLengthMessage.spec.tsx +17 -0
  31. package/src/components/TextInput/__tests__/PrefixComponent.spec.tsx +14 -0
  32. package/src/components/TextInput/__tests__/StyledTextInput.spec.tsx +114 -0
  33. package/src/components/TextInput/__tests__/SuffixComponent.spec.tsx +20 -0
  34. package/src/components/TextInput/__tests__/__snapshots__/StyledTextInput.spec.tsx.snap +571 -0
  35. package/src/components/TextInput/__tests__/__snapshots__/index.spec.tsx.snap +5671 -0
  36. package/src/components/TextInput/__tests__/getState.spec.tsx +89 -0
  37. package/src/components/TextInput/__tests__/index.spec.tsx +699 -0
  38. package/src/components/TextInput/constants.ts +1 -0
  39. package/src/components/TextInput/index.tsx +327 -0
  40. package/src/components/TextInput/types.ts +95 -0
  41. package/src/emotion.d.ts +15 -0
  42. package/src/index.ts +3 -0
  43. package/src/jest.d.ts +24 -0
  44. package/src/theme/__tests__/__snapshots__/index.spec.ts.snap +15 -8
  45. package/src/theme/components/textInput.ts +33 -0
  46. package/src/utils/__tests__/helpers.spec.ts +92 -0
  47. package/src/utils/helpers.ts +113 -0
  48. package/testUtils/renderWithTheme.tsx +6 -3
  49. package/stats/1.1.0/rn-work-uikit-stats.html +0 -4842
@@ -0,0 +1,699 @@
1
+ import React from 'react';
2
+ import { act, fireEvent, within } from '@testing-library/react-native';
3
+ import {
4
+ TextInput as RNTextInput,
5
+ ViewStyle,
6
+ StyleProp,
7
+ StyleSheet,
8
+ } from 'react-native';
9
+ import { Icon } from '@hero-design/rn';
10
+ import { theme } from '../../..';
11
+ import renderWithTheme from '../../../../testUtils/renderWithTheme';
12
+ import TextInput, { TextInputHandles } from '../index';
13
+
14
+ describe('TextInput', () => {
15
+ describe('when user sees an empty input field', () => {
16
+ it('should display label and icons but hide input content until user interacts', () => {
17
+ const { toJSON, getByTestId } = renderWithTheme(
18
+ <TextInput
19
+ label="Amount (AUD)"
20
+ prefix="dollar-sign"
21
+ suffix="arrow-down"
22
+ testID="idle-text-input"
23
+ style={{ borderColor: theme.colors.error }}
24
+ />
25
+ );
26
+
27
+ // User should see the main input container
28
+ const inputContainer = getByTestId('idle-text-input');
29
+ expect(inputContainer).toBeTruthy();
30
+
31
+ // User should see all visual elements
32
+ const textInput = within(inputContainer).getByTestId('text-input');
33
+ const inputPrefix = within(inputContainer).getByTestId('input-prefix');
34
+ const inputLabel = within(inputContainer).getByTestId('input-label');
35
+ const inputSuffix = within(inputContainer).getByTestId('input-suffix');
36
+ const inputRowWrapper = within(inputContainer).getByTestId(
37
+ 'input-row-input-wrapper'
38
+ );
39
+
40
+ expect(textInput).toBeTruthy();
41
+ expect(inputPrefix).toBeTruthy();
42
+ expect(inputLabel).toBeTruthy();
43
+ expect(inputSuffix).toBeTruthy();
44
+
45
+ // User should see the label text with optional indicator
46
+ expect(
47
+ within(inputContainer).getByText('Amount (AUD) (Optional)')
48
+ ).toBeTruthy();
49
+
50
+ // For accessibility: input content should be hidden from screen readers in idle state
51
+ expect(inputRowWrapper).toHaveProp('accessibilityElementsHidden', true);
52
+ expect(inputPrefix).toHaveProp('accessibilityElementsHidden', true);
53
+
54
+ // User should be able to identify the prefix icon through accessibility
55
+ const prefixIcon = within(inputContainer).getByA11yLabel(
56
+ 'Prefix icon: dollar-sign'
57
+ );
58
+ expect(prefixIcon).toBeTruthy();
59
+ expect(
60
+ within(inputContainer).getByTestId('input-prefix-icon')
61
+ ).toBeTruthy();
62
+
63
+ expect(toJSON()).toMatchSnapshot();
64
+ });
65
+
66
+ it('should not show label when no label text is provided', () => {
67
+ const { getByTestId } = renderWithTheme(
68
+ <TextInput
69
+ prefix="dollar-sign"
70
+ suffix="arrow-down"
71
+ testID="idle-text-input"
72
+ />
73
+ );
74
+
75
+ const inputContainer = getByTestId('idle-text-input');
76
+ expect(inputContainer).toBeTruthy();
77
+
78
+ // User should not see any label element when none is provided
79
+ expect(
80
+ within(inputContainer).queryAllByTestId('input-label')
81
+ ).toHaveLength(0);
82
+
83
+ // Input elements should still be present but hidden until interaction
84
+ expect(
85
+ within(inputContainer).queryAllByTestId('text-input')
86
+ ).toHaveLength(1);
87
+ expect(
88
+ within(inputContainer).queryAllByTestId('input-prefix')
89
+ ).toHaveLength(1);
90
+ });
91
+
92
+ it('should respond to user interactions with proper callbacks', () => {
93
+ const onChangeText = jest.fn();
94
+ const onBlur = jest.fn();
95
+ const onFocus = jest.fn();
96
+
97
+ const { getByTestId } = renderWithTheme(
98
+ <TextInput
99
+ label="Amount (AUD)"
100
+ prefix="dollar-sign"
101
+ suffix="arrow-down"
102
+ testID="idle-text-input"
103
+ onChangeText={onChangeText}
104
+ onBlur={onBlur}
105
+ onFocus={onFocus}
106
+ />
107
+ );
108
+
109
+ const inputContainer = getByTestId('idle-text-input');
110
+ const labelElement = within(inputContainer).getByTestId('input-label');
111
+ const textInput = within(inputContainer).getByTestId('text-input');
112
+
113
+ // User clicks on the label to focus the input
114
+ fireEvent.press(labelElement);
115
+
116
+ // User types text into the input
117
+ fireEvent.changeText(textInput, 'Thong Quach');
118
+ expect(onChangeText).toHaveBeenCalledWith('Thong Quach');
119
+
120
+ // User moves focus away from the input
121
+ fireEvent(textInput, 'blur');
122
+ expect(onBlur).toHaveBeenCalledTimes(1);
123
+
124
+ // User focuses the input again
125
+ fireEvent(textInput, 'focus');
126
+ expect(onFocus).toHaveBeenCalledTimes(1);
127
+ });
128
+ });
129
+
130
+ describe('when user sees input with custom prefix and suffix elements', () => {
131
+ it('should display custom React components instead of icon names', () => {
132
+ const { toJSON, queryAllByTestId, queryAllByText } = renderWithTheme(
133
+ <TextInput
134
+ label="Amount (AUD)"
135
+ prefix={<Icon icon="eye-circle" testID="prefix-element" />}
136
+ suffix={<Icon icon="eye-invisible" testID="suffix-element" />}
137
+ required
138
+ />
139
+ );
140
+
141
+ // User should see the label without asterisk (since required styling is handled differently)
142
+ expect(queryAllByText('Amount (AUD)')).toHaveLength(1);
143
+ expect(queryAllByText('*')).toHaveLength(0);
144
+
145
+ // User should see all visual elements including custom components
146
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
147
+ expect(queryAllByTestId('suffix-element')).toHaveLength(1);
148
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
149
+ expect(queryAllByTestId('prefix-element')).toHaveLength(1);
150
+
151
+ expect(toJSON()).toMatchSnapshot();
152
+ });
153
+ });
154
+
155
+ describe('when user sees a required field', () => {
156
+ it('should indicate the field is required through styling', () => {
157
+ const { toJSON, queryAllByTestId, queryAllByText } = renderWithTheme(
158
+ <TextInput
159
+ label="Amount (AUD)"
160
+ prefix="dollar-sign"
161
+ suffix="arrow-down"
162
+ required
163
+ />
164
+ );
165
+
166
+ // User should see the label text
167
+ expect(queryAllByText('Amount (AUD)')).toHaveLength(1);
168
+ // User should see all input elements
169
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
170
+ expect(queryAllByTestId('input-suffix')).toHaveLength(1);
171
+ expect(queryAllByTestId('input-prefix')).toHaveLength(1);
172
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
173
+
174
+ expect(toJSON()).toMatchSnapshot();
175
+ });
176
+ });
177
+
178
+ describe('when user has entered text', () => {
179
+ it('should show the input content and maintain all visual elements', () => {
180
+ const {
181
+ toJSON,
182
+ queryAllByTestId,
183
+ queryAllByText,
184
+ queryAllByDisplayValue,
185
+ } = renderWithTheme(
186
+ <TextInput
187
+ label="Amount (AUD)"
188
+ prefix="dollar-sign"
189
+ suffix="arrow-down"
190
+ value="100"
191
+ />
192
+ );
193
+
194
+ // User should see the label with optional indicator
195
+ expect(queryAllByText('Amount (AUD) (Optional)')).toHaveLength(1);
196
+
197
+ // User should see their entered value
198
+ expect(queryAllByDisplayValue('100')).toHaveLength(1);
199
+
200
+ // User should see all visual elements in active state
201
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
202
+ expect(queryAllByTestId('input-prefix')).toHaveLength(1);
203
+ expect(queryAllByTestId('input-suffix')).toHaveLength(1);
204
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
205
+
206
+ expect(toJSON()).toMatchSnapshot();
207
+ });
208
+ });
209
+
210
+ describe('when user encounters a read-only field', () => {
211
+ it('should display content but prevent user from editing', () => {
212
+ const onChangeText = jest.fn();
213
+ const onFocus = jest.fn();
214
+ const {
215
+ toJSON,
216
+ queryAllByTestId,
217
+ queryAllByText,
218
+ queryAllByDisplayValue,
219
+ getByTestId,
220
+ } = renderWithTheme(
221
+ <TextInput
222
+ label="Amount (AUD)"
223
+ prefix="dollar-sign"
224
+ suffix="arrow-down"
225
+ editable={false}
226
+ value="Read-only value"
227
+ onChangeText={onChangeText}
228
+ onFocus={onFocus}
229
+ testID="readonly-input"
230
+ />
231
+ );
232
+
233
+ // User should see the content and label
234
+ expect(queryAllByText('Amount (AUD) (Optional)')).toHaveLength(1);
235
+ expect(queryAllByDisplayValue('Read-only value')).toHaveLength(1);
236
+
237
+ // User should see all visual elements
238
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
239
+ expect(queryAllByTestId('input-prefix')).toHaveLength(1);
240
+ expect(queryAllByTestId('input-suffix')).toHaveLength(1);
241
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
242
+
243
+ // User should not be able to interact with the input due to Pressable being disabled
244
+ const inputContainer = getByTestId('readonly-input');
245
+ expect(inputContainer).toBeDisabled();
246
+
247
+ // User should see the input marked as read-only through accessibility
248
+ const textInput = getByTestId('text-input');
249
+ expect(textInput).toBeDisabled();
250
+
251
+ // The input should have editable=false prop
252
+ expect(textInput).toHaveProp('editable', false);
253
+
254
+ // Simulate user trying to type
255
+ fireEvent.changeText(textInput, 'New value');
256
+
257
+ // It should still show the old value
258
+ expect(textInput.props.value).toBe('Read-only value');
259
+
260
+ // User should still see the original value displayed
261
+ expect(queryAllByDisplayValue('Read-only value')).toHaveLength(1);
262
+ expect(queryAllByDisplayValue('New value')).toHaveLength(0);
263
+
264
+ expect(toJSON()).toMatchSnapshot();
265
+ });
266
+ });
267
+
268
+ describe('when user sees a loading state', () => {
269
+ it('should show loading indicator in place of suffix icon', () => {
270
+ const { toJSON, queryAllByTestId, queryAllByText, getByTestId } =
271
+ renderWithTheme(
272
+ <TextInput
273
+ label="Amount (AUD)"
274
+ prefix="dollar-sign"
275
+ suffix="arrow-down"
276
+ loading
277
+ value="100"
278
+ />
279
+ );
280
+
281
+ // User should see the label and content
282
+ expect(queryAllByText('Amount (AUD) (Optional)')).toHaveLength(1);
283
+
284
+ // User should see all input elements
285
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
286
+ expect(queryAllByTestId('input-prefix')).toHaveLength(1);
287
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
288
+
289
+ // User should see loading indicator instead of regular suffix
290
+ const suffixContainer = getByTestId('input-suffix');
291
+ expect(suffixContainer).toBeTruthy();
292
+
293
+ const loadingIcon = within(suffixContainer).getByA11yLabel(
294
+ 'Suffix icon: loading'
295
+ );
296
+ expect(loadingIcon).toBeTruthy();
297
+
298
+ expect(toJSON()).toMatchSnapshot();
299
+ });
300
+ });
301
+
302
+ describe('when user sees a textarea with character count', () => {
303
+ it('should display multiline input with character counter', () => {
304
+ const { toJSON, queryAllByTestId, queryAllByText, getByTestId } =
305
+ renderWithTheme(
306
+ <TextInput
307
+ label="Amount (AUD)"
308
+ prefix="dollar-sign"
309
+ suffix="arrow-down"
310
+ value="100"
311
+ maxLength={255}
312
+ variant="textarea"
313
+ />
314
+ );
315
+
316
+ // User should see the label and character count
317
+ expect(queryAllByText('Amount (AUD) (Optional)')).toHaveLength(1);
318
+ expect(queryAllByText('3/255')).toHaveLength(1);
319
+
320
+ // User should see all visual elements
321
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
322
+ expect(queryAllByTestId('input-prefix')).toHaveLength(1);
323
+ expect(queryAllByTestId('input-suffix')).toHaveLength(1);
324
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
325
+
326
+ // User should be able to enter multiple lines
327
+ expect(getByTestId('text-input')).toHaveProp('multiline', true);
328
+
329
+ expect(toJSON()).toMatchSnapshot();
330
+ });
331
+
332
+ it('should hide character count when user requests it', () => {
333
+ const { toJSON, queryAllByTestId, queryAllByText, getByTestId } =
334
+ renderWithTheme(
335
+ <TextInput
336
+ label="Amount (AUD)"
337
+ prefix="dollar-sign"
338
+ suffix="arrow-down"
339
+ value="100"
340
+ maxLength={255}
341
+ variant="textarea"
342
+ hideCharacterCount
343
+ />
344
+ );
345
+
346
+ // User should see the label but not the character count
347
+ expect(queryAllByText('Amount (AUD) (Optional)')).toHaveLength(1);
348
+ expect(queryAllByText('3/255')).toHaveLength(0);
349
+
350
+ // User should see all visual elements
351
+ expect(queryAllByTestId('input-label')).toHaveLength(1);
352
+ expect(queryAllByTestId('input-prefix')).toHaveLength(1);
353
+ expect(queryAllByTestId('input-suffix')).toHaveLength(1);
354
+ expect(queryAllByTestId('text-input')).toHaveLength(1);
355
+
356
+ // User should still be able to enter multiple lines
357
+ expect(getByTestId('text-input')).toHaveProp('multiline', true);
358
+
359
+ expect(toJSON()).toMatchSnapshot();
360
+ });
361
+ });
362
+
363
+ describe('when user encounters a disabled field', () => {
364
+ it('should display content but prevent any user interaction', () => {
365
+ const { toJSON, getByTestId } = renderWithTheme(
366
+ <TextInput
367
+ label="Amount (AUD)"
368
+ required
369
+ disabled
370
+ value="100"
371
+ testID="disabled-text-input"
372
+ />
373
+ );
374
+
375
+ const disabledInput = getByTestId('disabled-text-input');
376
+ const textInput = within(disabledInput).getByTestId('text-input');
377
+ const inputLabel = within(disabledInput).getByTestId('input-label');
378
+
379
+ // User should see the structure but understand it's disabled
380
+ expect(disabledInput).toBeTruthy();
381
+ expect(textInput).toBeTruthy();
382
+ expect(inputLabel).toBeTruthy();
383
+
384
+ // User should not be able to interact with the input
385
+ expect(textInput).toBeDisabled();
386
+
387
+ // Visual styling should indicate disabled state
388
+ const borderElement = getByTestId('text-input-border');
389
+ expect(borderElement).toBeTruthy();
390
+
391
+ // Helper function to extract border color from complex React Native styles
392
+ function getBorderColor(style: unknown): string | undefined {
393
+ if (Array.isArray(style)) {
394
+ return style.reduce<string | undefined>((result, styleItem) => {
395
+ if (result) return result;
396
+ return getBorderColor(styleItem);
397
+ }, undefined);
398
+ }
399
+ if (style && typeof style === 'object') {
400
+ const flattened = StyleSheet.flatten(style as StyleProp<ViewStyle>);
401
+ return flattened?.borderColor as string | undefined;
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ // User should see disabled styling (muted border color)
407
+ const borderColor = getBorderColor(borderElement.props.style);
408
+ expect(borderColor).toBe('#bfc1c5');
409
+
410
+ expect(toJSON()).toMatchSnapshot();
411
+ });
412
+ });
413
+
414
+ describe('when user sees an error state', () => {
415
+ it('should display error message to help user understand the issue', () => {
416
+ const { toJSON, queryAllByText } = renderWithTheme(
417
+ <TextInput
418
+ label="Amount (AUD)"
419
+ prefix="dollar-sign"
420
+ required
421
+ error="This field is required"
422
+ />
423
+ );
424
+
425
+ // User should see both the label and error message
426
+ expect(queryAllByText('Amount (AUD)')).toHaveLength(1);
427
+ expect(queryAllByText('This field is required')).toHaveLength(1);
428
+
429
+ expect(toJSON()).toMatchSnapshot();
430
+ });
431
+ });
432
+
433
+ describe('when user sees helper text', () => {
434
+ it('should display guidance text to assist user understanding', () => {
435
+ const { toJSON, queryAllByText } = renderWithTheme(
436
+ <TextInput
437
+ label="Amount (AUD)"
438
+ prefix="dollar-sign"
439
+ required
440
+ helpText="This is helper text"
441
+ />
442
+ );
443
+
444
+ // User should see both the label and helpful guidance
445
+ expect(queryAllByText('Amount (AUD)')).toHaveLength(1);
446
+ expect(queryAllByText('This is helper text')).toHaveLength(1);
447
+
448
+ expect(toJSON()).toMatchSnapshot();
449
+ });
450
+ });
451
+
452
+ describe('when user interacts with placeholder text', () => {
453
+ describe('starting from empty field', () => {
454
+ it('should show placeholder when user focuses, hide when user leaves empty', () => {
455
+ const wrapper = renderWithTheme(
456
+ <TextInput
457
+ label="Amount (AUD)"
458
+ prefix="dollar-sign"
459
+ required
460
+ helpText="This is helper text"
461
+ placeholder="Enter Amount"
462
+ />
463
+ );
464
+
465
+ // User should see the input field
466
+ expect(wrapper.queryByTestId('text-input')).toBeTruthy();
467
+
468
+ // User focuses the input field
469
+ fireEvent(wrapper.getByTestId('text-input'), 'focus');
470
+ expect(wrapper.queryByPlaceholderText('Enter Amount')).toBeTruthy();
471
+
472
+ // User leaves the field empty and moves focus away
473
+ fireEvent(wrapper.getByTestId('text-input'), 'blur');
474
+ expect(wrapper.getByTestId('text-input')).toHaveProp(
475
+ 'placeholder',
476
+ ' '
477
+ );
478
+
479
+ expect(wrapper.toJSON()).toMatchSnapshot();
480
+ });
481
+ });
482
+ });
483
+
484
+ describe('when user provides default values', () => {
485
+ describe('starting with pre-filled content', () => {
486
+ it('should display default value and character count', () => {
487
+ const wrapper = renderWithTheme(
488
+ <TextInput
489
+ label="Amount (AUD)"
490
+ prefix="dollar-sign"
491
+ required
492
+ helpText="This is helper text"
493
+ placeholder="Enter Amount"
494
+ defaultValue="1000"
495
+ maxLength={255}
496
+ />
497
+ );
498
+
499
+ // User should see the pre-filled value
500
+ expect(wrapper.queryByDisplayValue('1000')).toBeTruthy();
501
+
502
+ // User should see character count reflecting the default value
503
+ expect(wrapper.queryByText('4/255')).toBeTruthy();
504
+
505
+ expect(wrapper.toJSON()).toMatchSnapshot();
506
+ });
507
+ });
508
+
509
+ describe('when both default and controlled values are provided', () => {
510
+ it('should prioritize controlled value over default value', () => {
511
+ const wrapper = renderWithTheme(
512
+ <TextInput
513
+ label="Amount (AUD)"
514
+ prefix="dollar-sign"
515
+ required
516
+ helpText="This is helper text"
517
+ placeholder="Enter Amount"
518
+ defaultValue="1000"
519
+ value="2000"
520
+ maxLength={255}
521
+ />
522
+ );
523
+
524
+ // User should see the controlled value, not the default
525
+ expect(wrapper.queryByDisplayValue('2000')).toBeTruthy();
526
+ expect(wrapper.queryByDisplayValue('1000')).toBeFalsy();
527
+
528
+ // Character count should reflect the actual displayed value
529
+ expect(wrapper.queryByText('4/255')).toBeTruthy();
530
+
531
+ expect(wrapper.toJSON()).toMatchSnapshot();
532
+ });
533
+ });
534
+ });
535
+
536
+ describe('when user applies custom styling', () => {
537
+ it('should respect user-provided background color styling', () => {
538
+ const wrapper = renderWithTheme(
539
+ <TextInput
540
+ label="Amount (AUD)"
541
+ prefix="dollar-sign"
542
+ required
543
+ helpText="This is helper text"
544
+ placeholder="Enter Amount"
545
+ defaultValue="1000"
546
+ value="2000"
547
+ maxLength={255}
548
+ style={{ backgroundColor: 'customColor' }}
549
+ />
550
+ );
551
+
552
+ // Helper function to extract background color from complex React Native styles
553
+ function getBackgroundColor(
554
+ style: StyleProp<ViewStyle> | unknown[]
555
+ ): string | undefined {
556
+ function flatten(s: StyleProp<ViewStyle> | unknown[]): ViewStyle[] {
557
+ if (Array.isArray(s)) {
558
+ return s
559
+ .filter(Boolean)
560
+ .reduce<ViewStyle[]>(
561
+ (acc, item) =>
562
+ acc.concat(flatten(item as StyleProp<ViewStyle> | unknown[])),
563
+ []
564
+ );
565
+ }
566
+ if (s) {
567
+ return [StyleSheet.flatten(s as StyleProp<ViewStyle>) as ViewStyle];
568
+ }
569
+ return [];
570
+ }
571
+ const flat = flatten(style);
572
+ for (let i = flat.length - 1; i >= 0; i -= 1) {
573
+ if (flat[i].backgroundColor !== undefined) {
574
+ return flat[i].backgroundColor as string;
575
+ }
576
+ }
577
+ return undefined;
578
+ }
579
+
580
+ // User should see their custom background color applied
581
+ const textInputStyle = wrapper.getByTestId('text-input').props.style;
582
+ expect(getBackgroundColor(textInputStyle)).toBe('customColor');
583
+
584
+ const borderStyle = wrapper.getByTestId('text-input-border').props.style;
585
+ expect(getBackgroundColor(borderStyle)).toBe('customColor');
586
+
587
+ expect(wrapper.toJSON()).toMatchSnapshot();
588
+ });
589
+ });
590
+
591
+ describe('when user needs programmatic control', () => {
592
+ it('should provide ref methods for external control of the input', () => {
593
+ const ref = React.createRef<
594
+ TextInputHandles & {
595
+ getNativeTextInputRef(): RNTextInput;
596
+ }
597
+ >();
598
+
599
+ const wrapper = renderWithTheme(
600
+ <TextInput label="Amount (AUD)" value="2000" ref={ref} />
601
+ );
602
+
603
+ // User should see the initial value
604
+ expect(wrapper.queryByDisplayValue('2000')).toBeTruthy();
605
+ expect(wrapper.queryByDisplayValue('1000')).toBeFalsy();
606
+
607
+ // External code should be able to control the input programmatically
608
+ const nativeTextInputRef = ref.current?.getNativeTextInputRef();
609
+ if (nativeTextInputRef && ref.current) {
610
+ const focusSpy = jest.spyOn(nativeTextInputRef, 'focus');
611
+ const clearSpy = jest.spyOn(nativeTextInputRef, 'clear');
612
+ const blurSpy = jest.spyOn(nativeTextInputRef, 'blur');
613
+ const setNativePropsSpy = jest.spyOn(
614
+ nativeTextInputRef,
615
+ 'setNativeProps'
616
+ );
617
+
618
+ act(() => {
619
+ ref.current?.focus();
620
+ ref.current?.clear();
621
+ ref.current?.setNativeProps({ text: '1000' });
622
+ ref.current?.blur();
623
+ });
624
+
625
+ // Verify that programmatic methods work as expected
626
+ expect(focusSpy).toHaveBeenCalledTimes(1);
627
+ expect(clearSpy).toHaveBeenCalledTimes(1);
628
+ expect(setNativePropsSpy).toHaveBeenCalledTimes(1);
629
+ expect(blurSpy).toHaveBeenCalledTimes(1);
630
+ }
631
+
632
+ expect(wrapper.toJSON()).toMatchSnapshot();
633
+ });
634
+ });
635
+
636
+ describe('when user chooses textarea variant', () => {
637
+ it('should provide multiline text input experience', () => {
638
+ const { toJSON, queryByText, queryByDisplayValue } = renderWithTheme(
639
+ <TextInput label="Amount (AUD)" value="2000" variant="textarea" />
640
+ );
641
+
642
+ // User should see the label with optional indicator
643
+ expect(queryByText('Amount (AUD) (Optional)')).toBeTruthy();
644
+
645
+ // User should see their entered value
646
+ expect(queryByDisplayValue('2000')).toBeTruthy();
647
+
648
+ expect(toJSON()).toMatchSnapshot();
649
+ });
650
+ });
651
+
652
+ describe('when user encounters an error state that needs to be corrected', () => {
653
+ it('should allow focusing input even when error is present', () => {
654
+ const onFocus = jest.fn();
655
+ const onBlur = jest.fn();
656
+ const { getByTestId } = renderWithTheme(
657
+ <TextInput
658
+ label="Email"
659
+ error="Please enter a valid email address"
660
+ onFocus={onFocus}
661
+ onBlur={onBlur}
662
+ testID="error-input"
663
+ />
664
+ );
665
+
666
+ // User should be able to focus the input despite the error
667
+ const textInput = getByTestId('text-input');
668
+ fireEvent(textInput, 'focus');
669
+
670
+ // Should trigger focus callback
671
+ expect(onFocus).toHaveBeenCalledTimes(1);
672
+
673
+ // User should be able to blur the input
674
+ fireEvent(textInput, 'blur');
675
+ expect(onBlur).toHaveBeenCalledTimes(1);
676
+ });
677
+
678
+ it('should display focused styling when error input is focused', () => {
679
+ const { getByTestId } = renderWithTheme(
680
+ <TextInput
681
+ label="Email"
682
+ error="Please enter a valid email address"
683
+ testID="error-input"
684
+ />
685
+ );
686
+
687
+ // Get the border element
688
+ const border = getByTestId('text-input-border');
689
+
690
+ // Focus the input
691
+ const textInput = getByTestId('text-input');
692
+ fireEvent(textInput, 'focus');
693
+
694
+ // Border should have focused styling (themeFocused=true)
695
+ expect(border).toHaveProp('themeFocused', true);
696
+ expect(border).toHaveProp('themeState', 'error');
697
+ });
698
+ });
699
+ });
@@ -0,0 +1 @@
1
+ export const LABEL_ANIMATION_DURATION = 150;