@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.
- package/.cursorrules +57 -0
- package/CHANGELOG.md +16 -0
- package/DEVELOPMENT.md +118 -0
- package/assets/fonts/BeVietnamPro-Bold.ttf +0 -0
- package/assets/fonts/BeVietnamPro-Light.ttf +0 -0
- package/assets/fonts/BeVietnamPro-Regular.ttf +0 -0
- package/assets/fonts/BeVietnamPro-SemiBold.ttf +0 -0
- package/assets/fonts/Saiga-Light.otf +0 -0
- package/assets/fonts/Saiga-Medium.otf +0 -0
- package/assets/fonts/Saiga-Regular.otf +0 -0
- package/assets/fonts/hero-icons-mobile.ttf +0 -0
- package/eslint.config.js +20 -0
- package/lib/index.js +871 -5
- package/package.json +4 -1
- package/rollup.config.mjs +2 -2
- package/src/__tests__/__snapshots__/index.spec.tsx.snap +90 -115
- package/src/__tests__/theme-export-override.spec.ts +6 -0
- package/src/components/TextInput/ErrorOrHelpText.tsx +58 -0
- package/src/components/TextInput/FloatingLabel.tsx +120 -0
- package/src/components/TextInput/InputComponent.tsx +61 -0
- package/src/components/TextInput/InputRow.tsx +103 -0
- package/src/components/TextInput/MaxLengthMessage.tsx +66 -0
- package/src/components/TextInput/PrefixComponent.tsx +77 -0
- package/src/components/TextInput/StyledTextInput.tsx +134 -0
- package/src/components/TextInput/SuffixComponent.tsx +73 -0
- package/src/components/TextInput/__tests__/ErrorOrHelpText.spec.tsx +20 -0
- package/src/components/TextInput/__tests__/FloatingLabel.spec.tsx +203 -0
- package/src/components/TextInput/__tests__/InputComponent.spec.tsx +39 -0
- package/src/components/TextInput/__tests__/InputRow.spec.tsx +275 -0
- package/src/components/TextInput/__tests__/MaxLengthMessage.spec.tsx +17 -0
- package/src/components/TextInput/__tests__/PrefixComponent.spec.tsx +14 -0
- package/src/components/TextInput/__tests__/StyledTextInput.spec.tsx +114 -0
- package/src/components/TextInput/__tests__/SuffixComponent.spec.tsx +20 -0
- package/src/components/TextInput/__tests__/__snapshots__/StyledTextInput.spec.tsx.snap +571 -0
- package/src/components/TextInput/__tests__/__snapshots__/index.spec.tsx.snap +5671 -0
- package/src/components/TextInput/__tests__/getState.spec.tsx +89 -0
- package/src/components/TextInput/__tests__/index.spec.tsx +699 -0
- package/src/components/TextInput/constants.ts +1 -0
- package/src/components/TextInput/index.tsx +327 -0
- package/src/components/TextInput/types.ts +95 -0
- package/src/emotion.d.ts +15 -0
- package/src/index.ts +3 -0
- package/src/jest.d.ts +24 -0
- package/src/theme/__tests__/__snapshots__/index.spec.ts.snap +15 -8
- package/src/theme/components/textInput.ts +33 -0
- package/src/utils/__tests__/helpers.spec.ts +92 -0
- package/src/utils/helpers.ts +113 -0
- package/testUtils/renderWithTheme.tsx +6 -3
- 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;
|