@jobber/components-native 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/src/InputText/InputText.js +138 -0
  2. package/dist/src/InputText/InputText.style.js +20 -0
  3. package/dist/src/InputText/context/InputAccessoriesContext.js +17 -0
  4. package/dist/src/InputText/context/InputAccessoriesProvider.js +73 -0
  5. package/dist/src/InputText/context/InputAccessoriesProvider.style.js +21 -0
  6. package/dist/src/InputText/context/InputAccessory.style.js +16 -0
  7. package/dist/src/InputText/context/index.js +2 -0
  8. package/dist/src/InputText/context/types.js +1 -0
  9. package/dist/src/InputText/index.js +2 -0
  10. package/dist/src/hooks/index.js +1 -0
  11. package/dist/src/hooks/useFormController.js +38 -0
  12. package/dist/src/index.js +1 -0
  13. package/dist/tsconfig.tsbuildinfo +1 -1
  14. package/dist/types/src/InputText/InputText.d.ts +165 -0
  15. package/dist/types/src/InputText/InputText.style.d.ts +16 -0
  16. package/dist/types/src/InputText/context/InputAccessoriesContext.d.ts +4 -0
  17. package/dist/types/src/InputText/context/InputAccessoriesProvider.d.ts +4 -0
  18. package/dist/types/src/InputText/context/InputAccessoriesProvider.style.d.ts +17 -0
  19. package/dist/types/src/InputText/context/InputAccessory.style.d.ts +14 -0
  20. package/dist/types/src/InputText/context/index.d.ts +2 -0
  21. package/dist/types/src/InputText/context/types.d.ts +23 -0
  22. package/dist/types/src/InputText/index.d.ts +3 -0
  23. package/dist/types/src/hooks/index.d.ts +1 -0
  24. package/dist/types/src/hooks/useFormController.d.ts +12 -0
  25. package/dist/types/src/index.d.ts +1 -0
  26. package/package.json +4 -2
  27. package/src/InputText/InputText.style.ts +25 -0
  28. package/src/InputText/InputText.test.tsx +534 -0
  29. package/src/InputText/InputText.tsx +483 -0
  30. package/src/InputText/context/InputAccessoriesContext.ts +21 -0
  31. package/src/InputText/context/InputAccessoriesProvider.style.tsx +23 -0
  32. package/src/InputText/context/InputAccessoriesProvider.test.tsx +84 -0
  33. package/src/InputText/context/InputAccessoriesProvider.tsx +121 -0
  34. package/src/InputText/context/InputAccessory.style.ts +17 -0
  35. package/src/InputText/context/index.ts +2 -0
  36. package/src/InputText/context/types.ts +28 -0
  37. package/src/InputText/index.ts +3 -0
  38. package/src/hooks/index.ts +1 -0
  39. package/src/hooks/useFormController.ts +68 -0
  40. package/src/index.ts +1 -0
@@ -0,0 +1,483 @@
1
+ import React, {
2
+ Ref,
3
+ SyntheticEvent,
4
+ forwardRef,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import {
12
+ NativeSyntheticEvent,
13
+ Platform,
14
+ ReturnKeyTypeOptions,
15
+ StyleProp,
16
+ TextInput,
17
+ TextInputFocusEventData,
18
+ TextInputProps,
19
+ TextStyle,
20
+ } from "react-native";
21
+ import {
22
+ ControllerRenderProps,
23
+ FieldValues,
24
+ RegisterOptions,
25
+ } from "react-hook-form";
26
+ import { IconNames } from "@jobber/design";
27
+ import identity from "lodash.identity";
28
+ import { styles } from "./InputText.style";
29
+ import { useInputAccessoriesContext } from "./context";
30
+ import { useFormController } from "../hooks";
31
+ import { InputFieldStyleOverride } from "../InputFieldWrapper/InputFieldWrapper";
32
+ import {
33
+ Clearable,
34
+ InputFieldWrapper,
35
+ useShowClear,
36
+ } from "../InputFieldWrapper";
37
+ import { commonInputStyles } from "../InputFieldWrapper/CommonInputStyles.style";
38
+
39
+ export interface InputTextProps {
40
+ /**
41
+ * Highlights the field red and shows message below (if string) to indicate an error
42
+ */
43
+ readonly invalid?: boolean | string;
44
+
45
+ /**
46
+ * Disable the input
47
+ */
48
+ readonly disabled?: boolean;
49
+
50
+ /**
51
+ * Name of the input.
52
+ */
53
+ readonly name?: string;
54
+
55
+ /**
56
+ * Hint text that goes above the value once the field is filled out
57
+ */
58
+ readonly placeholder?: string;
59
+
60
+ /**
61
+ * Text that helps the user understand the input
62
+ */
63
+ readonly assistiveText?: string;
64
+
65
+ /**
66
+ * Determines what keyboard is shown
67
+ */
68
+ keyboard?:
69
+ | "default"
70
+ | "numeric"
71
+ | "phone-pad"
72
+ | "email-address"
73
+ | "numbers-and-punctuation"
74
+ | "decimal-pad";
75
+
76
+ /**
77
+ * Set the component to a given value
78
+ */
79
+ readonly value?: string;
80
+
81
+ /**
82
+ * Default value for when component is uncontrolled
83
+ */
84
+ readonly defaultValue?: string;
85
+
86
+ /**
87
+ * Automatically focus the input after it is rendered
88
+ */
89
+ readonly autoFocus?: boolean;
90
+
91
+ /**
92
+ * Shows an error message below the field and highlight the field red when value is invalid
93
+ */
94
+ readonly validations?: RegisterOptions;
95
+
96
+ /**
97
+ * Simplified callback that only provides the new value
98
+ * @param newValue
99
+ */
100
+ onChangeText?: (newValue: string) => void;
101
+
102
+ /**
103
+ * Callback that is called when the text input's submit button is pressed
104
+ * @param event
105
+ */
106
+ onSubmitEditing?: (event?: SyntheticEvent) => void;
107
+
108
+ /**
109
+ * Callback that is called when the text input is focused
110
+ * @param event
111
+ */
112
+ onFocus?: (event?: NativeSyntheticEvent<TextInputFocusEventData>) => void;
113
+
114
+ /**
115
+ * Callback that is called when the text input is blurred
116
+ */
117
+ onBlur?: () => void;
118
+
119
+ /**
120
+ * VoiceOver will read this string when a user selects the associated element
121
+ */
122
+ readonly accessibilityLabel?: string;
123
+
124
+ /**
125
+ * An accessibility hint helps users understand what will happen when they perform an action on the
126
+ * accessibility element when that result is not clear from the accessibility label
127
+ */
128
+ readonly accessibilityHint?: string;
129
+
130
+ /**
131
+ * Turn off autocorrect
132
+ */
133
+ readonly autoCorrect?: boolean;
134
+
135
+ /**
136
+ * Determines where to autocapitalize
137
+ */
138
+ readonly autoCapitalize?: "characters" | "words" | "sentences" | "none";
139
+
140
+ /**
141
+ * Determines which content to suggest on auto complete, e.g.`username`.
142
+ * Default is `off` which disables auto complete
143
+ *
144
+ * *Android Only*
145
+ *
146
+ */
147
+ readonly autoComplete?: TextInputProps["autoComplete"];
148
+
149
+ /**
150
+ * Determines which content to suggest on auto complete, e.g.`username`.
151
+ * Default is `none` which disables auto complete
152
+ *
153
+ * *iOS Only*
154
+ */
155
+ readonly textContentType?: TextInputProps["textContentType"];
156
+
157
+ /**
158
+ * Determines if inputText will span multiple lines.
159
+ * Default is `false`
160
+ *
161
+ * https://reactnative.dev/docs/textinput#multiline
162
+ */
163
+ readonly multiline?: TextInputProps["multiline"];
164
+
165
+ /**
166
+ * Symbol to display before the text input
167
+ */
168
+ readonly prefix?: {
169
+ icon?: IconNames;
170
+ label?: string;
171
+ };
172
+
173
+ /**
174
+ * Symbol to display after the text input
175
+ */
176
+ readonly suffix?: {
177
+ icon?: IconNames;
178
+ label?: string;
179
+ onPress?: () => void;
180
+ };
181
+
182
+ /**
183
+ * transform object is used to transform the internal TextInput value
184
+ * It's useful for components like InputNumber where we want to transform
185
+ * the internal value to a number.
186
+ * "input" is a function that transform the value to the string format that should be shown to the user
187
+ * "output" is a function that transform the string representation of the value to the value that is sent to onChange and the form
188
+ */
189
+ transform?: {
190
+ input?: (v: any) => string | undefined;
191
+ output?: (v: string | undefined) => any;
192
+ };
193
+
194
+ /**
195
+ * Add a clear action on the input that clears the value.
196
+ *
197
+ * You should always use `while-editing` if you want the input to be
198
+ * clearable. if the input value isn't editable (i.e. `InputDateTime`) you can
199
+ * set it to `always`.
200
+ */
201
+ readonly clearable?: Clearable;
202
+
203
+ /**
204
+ * Used to locate this view in end-to-end tests
205
+ */
206
+ testID?: string;
207
+
208
+ /**
209
+ * Use secure text entry
210
+ */
211
+ readonly secureTextEntry?: boolean;
212
+
213
+ /**
214
+ * Determines whether spell check is used. Turn it off to hide empty autoCorrect
215
+ * suggestions when autoCorrect is off.
216
+ *
217
+ * *iOS Only*
218
+ */
219
+ readonly spellCheck?: boolean;
220
+
221
+ /**
222
+ * Custom styling to override default style of the input text
223
+ */
224
+ readonly styleOverride?: InputTextStyleOverride;
225
+ }
226
+
227
+ interface InputTextStyleOverride extends InputFieldStyleOverride {
228
+ inputText?: StyleProp<TextStyle>;
229
+ }
230
+
231
+ export type InputTextRef = Pick<TextInput, "clear" | "focus" | "blur">;
232
+ export const InputText = forwardRef(InputTextInternal);
233
+
234
+ // eslint-disable-next-line max-statements
235
+ function InputTextInternal(
236
+ {
237
+ invalid,
238
+ disabled,
239
+ name,
240
+ placeholder,
241
+ assistiveText,
242
+ keyboard,
243
+ value: controlledValue,
244
+ defaultValue,
245
+ autoFocus,
246
+ autoComplete = "off",
247
+ spellCheck,
248
+ textContentType = "none",
249
+ validations,
250
+ onChangeText,
251
+ onSubmitEditing,
252
+ onFocus,
253
+ accessibilityLabel,
254
+ accessibilityHint,
255
+ autoCorrect,
256
+ autoCapitalize,
257
+ onBlur,
258
+ multiline = false,
259
+ prefix,
260
+ suffix,
261
+ transform = {},
262
+ clearable = multiline ? "never" : "while-editing",
263
+ testID,
264
+ secureTextEntry,
265
+ styleOverride,
266
+ }: InputTextProps,
267
+ ref: Ref<InputTextRef>,
268
+ ) {
269
+ const isAndroid = Platform.OS === "android";
270
+
271
+ const {
272
+ input: inputTransform = identity,
273
+ output: outputTransform = identity,
274
+ } = transform;
275
+ const { error, field } = useFormController({
276
+ name,
277
+ value: controlledValue ?? defaultValue,
278
+ validations,
279
+ });
280
+ const internalValue = controlledValue ?? field.value?.toString();
281
+
282
+ const hasValue = internalValue !== "" && internalValue !== undefined;
283
+ const [focused, setFocused] = useState(false);
284
+ const { hasMiniLabel, setHasMiniLabel } = useMiniLabel(internalValue);
285
+
286
+ const textInputRef = useTextInputRef({ ref, onClear: handleClear });
287
+
288
+ const showClear = useShowClear({
289
+ clearable,
290
+ multiline,
291
+ focused,
292
+ hasValue,
293
+ disabled,
294
+ });
295
+
296
+ // Android doesn't have an accessibility label like iOS does. By adding
297
+ // it as a placeholder it readds it like a label. However we don't want to
298
+ // add a placeholder on iOS.
299
+ const androidA11yProps = {
300
+ ...(isAndroid && {
301
+ placeholder: accessibilityLabel || placeholder,
302
+ placeholderTextColor: "transparent",
303
+ }),
304
+ };
305
+
306
+ const _name = name ?? field.name;
307
+ const {
308
+ inputAccessoryID,
309
+ register,
310
+ unregister,
311
+ setFocusedInput,
312
+ canFocusNext,
313
+ onFocusNext,
314
+ } = useInputAccessoriesContext();
315
+ useEffect(() => {
316
+ _name &&
317
+ register(_name, () => {
318
+ textInputRef.current?.focus();
319
+ });
320
+
321
+ return () => {
322
+ _name && unregister(_name);
323
+ };
324
+ }, [_name, register, textInputRef, unregister]);
325
+
326
+ const returnKeyType: ReturnKeyTypeOptions | undefined = useMemo(() => {
327
+ if (!multiline) {
328
+ if (inputAccessoryID && isAndroid) {
329
+ return "next";
330
+ } else {
331
+ return "done";
332
+ }
333
+ } else {
334
+ return undefined;
335
+ }
336
+ }, [multiline, inputAccessoryID, isAndroid]);
337
+
338
+ // If it's not inside an inputAcessoriesContext or cannot focus next,
339
+ // then hide the keyboard when the return key is pressed.
340
+ const shouldBlurOnSubmit =
341
+ !multiline && (!inputAccessoryID || !canFocusNext || !isAndroid);
342
+
343
+ function handleOnFocusNext(): void {
344
+ if (multiline) {
345
+ return;
346
+ }
347
+
348
+ onFocusNext();
349
+ }
350
+
351
+ return (
352
+ <InputFieldWrapper
353
+ prefix={prefix}
354
+ suffix={suffix}
355
+ hasValue={hasValue}
356
+ hasMiniLabel={hasMiniLabel}
357
+ assistiveText={assistiveText}
358
+ focused={focused}
359
+ error={error}
360
+ invalid={invalid}
361
+ placeholder={placeholder}
362
+ disabled={disabled}
363
+ onClear={handleClear}
364
+ showClearAction={showClear}
365
+ styleOverride={styleOverride}
366
+ >
367
+ <TextInput
368
+ inputAccessoryViewID={inputAccessoryID || undefined}
369
+ testID={testID}
370
+ autoCapitalize={autoCapitalize}
371
+ autoCorrect={autoCorrect}
372
+ spellCheck={spellCheck}
373
+ style={[
374
+ commonInputStyles.input,
375
+ styles.inputPaddingTop,
376
+ !hasMiniLabel && commonInputStyles.inputEmpty,
377
+ disabled && commonInputStyles.inputDisabled,
378
+ multiline && Platform.OS === "ios" && styles.multilineInputiOS,
379
+ multiline && styles.multiLineInput,
380
+ multiline && hasMiniLabel && styles.multiLineInputWithMini,
381
+ styleOverride?.inputText,
382
+ ]}
383
+ editable={!disabled}
384
+ keyboardType={keyboard}
385
+ value={inputTransform(internalValue)}
386
+ autoFocus={autoFocus}
387
+ autoComplete={autoComplete}
388
+ multiline={multiline}
389
+ scrollEnabled={false}
390
+ textContentType={textContentType}
391
+ onChangeText={handleChangeText}
392
+ onSubmitEditing={handleOnSubmitEditing}
393
+ returnKeyType={returnKeyType}
394
+ blurOnSubmit={shouldBlurOnSubmit}
395
+ accessibilityLabel={accessibilityLabel || placeholder}
396
+ accessibilityHint={accessibilityHint}
397
+ secureTextEntry={secureTextEntry}
398
+ {...androidA11yProps}
399
+ onFocus={event => {
400
+ _name && setFocusedInput(_name);
401
+ setFocused(true);
402
+ onFocus?.(event);
403
+ }}
404
+ onBlur={() => {
405
+ _name && setFocusedInput("");
406
+ setFocused(false);
407
+ onBlur?.();
408
+ field.onBlur();
409
+ trimWhitespace(field, onChangeText);
410
+ }}
411
+ ref={(instance: TextInput) => {
412
+ // RHF wants us to do it this way
413
+ // https://react-hook-form.com/faqs#Howtosharerefusage
414
+ textInputRef.current = instance;
415
+ field.ref(instance);
416
+ }}
417
+ />
418
+ </InputFieldWrapper>
419
+ );
420
+
421
+ function handleChangeText(value: string) {
422
+ const newValue = outputTransform(value);
423
+ setHasMiniLabel(Boolean(newValue));
424
+ onChangeText?.(newValue);
425
+ field.onChange(newValue);
426
+ }
427
+
428
+ function handleClear() {
429
+ handleChangeText("");
430
+ }
431
+
432
+ function handleOnSubmitEditing() {
433
+ onSubmitEditing?.();
434
+
435
+ if (isAndroid) {
436
+ handleOnFocusNext();
437
+ }
438
+ }
439
+ }
440
+
441
+ function trimWhitespace(
442
+ field: ControllerRenderProps<FieldValues, string>,
443
+ onChangeText?: (newValue: string) => void,
444
+ ) {
445
+ if (!field.value || !field.value.trim) {
446
+ return;
447
+ }
448
+ const trimmedInput = field.value.trim();
449
+ onChangeText?.(trimmedInput);
450
+ field.onChange(trimmedInput);
451
+ }
452
+
453
+ interface UseTextInputRefProps {
454
+ readonly ref: Ref<InputTextRef>;
455
+ readonly onClear: () => void;
456
+ }
457
+
458
+ function useTextInputRef({ ref, onClear }: UseTextInputRefProps) {
459
+ const textInputRef = useRef<InputTextRef | null>(null);
460
+
461
+ useImperativeHandle(
462
+ ref,
463
+ () => ({
464
+ focus: () => textInputRef.current?.focus(),
465
+ blur: () => textInputRef.current?.blur(),
466
+ clear: onClear,
467
+ }),
468
+ [onClear],
469
+ );
470
+
471
+ return textInputRef;
472
+ }
473
+
474
+ function useMiniLabel(internalValue: string): {
475
+ hasMiniLabel: boolean;
476
+ setHasMiniLabel: React.Dispatch<React.SetStateAction<boolean>>;
477
+ } {
478
+ const [hasMiniLabel, setHasMiniLabel] = useState(Boolean(internalValue));
479
+ useEffect(() => {
480
+ setHasMiniLabel(Boolean(internalValue));
481
+ }, [internalValue]);
482
+ return { hasMiniLabel, setHasMiniLabel };
483
+ }
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext } from "react";
2
+ import { InputAccessoriesContextProps } from "./types";
3
+
4
+ const defaultValues: InputAccessoriesContextProps = {
5
+ elements: {},
6
+ focusedInput: "",
7
+ canFocusNext: false,
8
+ canFocusPrevious: false,
9
+ inputAccessoryID: undefined,
10
+ register: () => undefined,
11
+ unregister: () => undefined,
12
+ onFocusNext: () => undefined,
13
+ onFocusPrevious: () => undefined,
14
+ setFocusedInput: () => undefined,
15
+ };
16
+
17
+ export const InputAccessoriesContext = createContext(defaultValues);
18
+
19
+ export function useInputAccessoriesContext(): InputAccessoriesContextProps {
20
+ return useContext(InputAccessoriesContext);
21
+ }
@@ -0,0 +1,23 @@
1
+ import { PlatformColor, StyleSheet } from "react-native";
2
+ import { tokens } from "../../utils/design";
3
+
4
+ const BAR_HEIGHT = 44;
5
+
6
+ export const styles = StyleSheet.create({
7
+ container: {
8
+ flexDirection: "row",
9
+ justifyContent: "flex-end",
10
+ alignItems: "center",
11
+ paddingHorizontal: tokens["space-small"],
12
+ borderTopWidth: tokens["space-minuscule"],
13
+ borderTopColor: tokens["color-border"],
14
+ height: BAR_HEIGHT,
15
+ },
16
+ lightTheme: {
17
+ backgroundColor: tokens["color-surface--background"],
18
+ },
19
+ darkTheme: {
20
+ // PlatformColor has to be conditional for Storybook to run without error
21
+ backgroundColor: PlatformColor?.("systemGray3"),
22
+ },
23
+ });
@@ -0,0 +1,84 @@
1
+ import { fireEvent, render } from "@testing-library/react-native";
2
+ import React, { useContext, useEffect } from "react";
3
+ import { Keyboard, Platform } from "react-native";
4
+ import { InputAccessoriesContext } from "./InputAccessoriesContext";
5
+ import { InputAccessoriesProvider } from "./InputAccessoriesProvider";
6
+ import { InputText } from "../InputText";
7
+
8
+ const mockUseFormController = jest.fn();
9
+ jest.mock("../../hooks", () => {
10
+ return {
11
+ useFormController: (
12
+ ...args: [{ name: string; value: string; validations: unknown }]
13
+ ) => mockUseFormController(...args),
14
+ };
15
+ });
16
+ const actualUseFormController =
17
+ jest.requireActual("../../hooks").useFormController;
18
+
19
+ describe("InputAccessories", () => {
20
+ beforeEach(() => {
21
+ Platform.OS = "ios";
22
+ });
23
+ afterEach(() => {
24
+ // restore the spy created with spyOn
25
+ jest.resetAllMocks();
26
+ });
27
+
28
+ const inputOneName = "testInput1";
29
+ const inputTwoName = "testInput2";
30
+
31
+ const mockInputOneFocus = jest.fn();
32
+ const mockInputTwoFocus = jest.fn();
33
+
34
+ function InputWrapper({ focusedInput }: { focusedInput: string }) {
35
+ const { register, setFocusedInput, unregister } = useContext(
36
+ InputAccessoriesContext,
37
+ );
38
+
39
+ useEffect(() => {
40
+ register(inputOneName, () => mockInputOneFocus());
41
+ register(inputTwoName, () => mockInputTwoFocus());
42
+ setFocusedInput(focusedInput);
43
+ return () => {
44
+ unregister(inputOneName);
45
+ unregister(inputTwoName);
46
+ setFocusedInput("");
47
+ };
48
+ }, [register, setFocusedInput, unregister, focusedInput]);
49
+ return (
50
+ <>
51
+ <InputText
52
+ testID={inputOneName}
53
+ name={inputOneName}
54
+ onFocus={mockInputOneFocus}
55
+ value=""
56
+ />
57
+ <InputText testID={inputTwoName} onFocus={mockInputTwoFocus} value="" />
58
+ </>
59
+ );
60
+ }
61
+ function SetupInputAccessoriesTest(focusedInput: string) {
62
+ mockUseFormController.mockImplementation(({ name, value, validations }) => {
63
+ const { field, error } = actualUseFormController({
64
+ name: name || inputTwoName,
65
+ value,
66
+ validations,
67
+ });
68
+ return { field, error };
69
+ });
70
+ return render(<InputWrapper focusedInput={focusedInput} />, {
71
+ wrapper: InputAccessoriesProvider,
72
+ });
73
+ }
74
+
75
+ it("pressing done dismisses the keyboard", async () => {
76
+ const keyboardDismissSpy = jest.spyOn(Keyboard, "dismiss");
77
+ const { getByTestId } = SetupInputAccessoriesTest(inputOneName);
78
+ const doneButton = getByTestId("ATL-InputAccessory-Done");
79
+
80
+ await fireEvent.press(doneButton);
81
+
82
+ expect(keyboardDismissSpy).toHaveBeenCalled();
83
+ });
84
+ });