@jobber/components-native 0.31.0 → 0.32.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.
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { InputTextProps, InputTextRef } from "../InputText";
3
+ type NumberKeyboard = "decimal-pad" | "numbers-and-punctuation";
4
+ export interface InputNumberProps extends Omit<InputTextProps, "keyboard" | "onChangeText" | "value" | "defaultValue"> {
5
+ readonly value?: number;
6
+ readonly defaultValue?: number;
7
+ readonly onChange: (newValue?: number | string | undefined) => void;
8
+ readonly keyboard?: NumberKeyboard;
9
+ /**
10
+ * Used to locate this view in end-to-end tests
11
+ */
12
+ readonly testID?: string;
13
+ }
14
+ export declare const InputNumber: React.ForwardRefExoticComponent<InputNumberProps & React.RefAttributes<InputTextRef>>;
15
+ export declare function shouldShowUserValue(value: string): boolean;
16
+ export declare function useNumberTransform(controlledValue: number | undefined): {
17
+ inputTransform: (internalValue?: number) => string | undefined;
18
+ outputTransform: (value: string) => string | number;
19
+ };
20
+ export {};
@@ -0,0 +1,2 @@
1
+ export { InputNumber } from "./InputNumber";
2
+ export type { InputNumberProps } from "./InputNumber";
@@ -0,0 +1,7 @@
1
+ export declare const messages: {
2
+ notANumberError: {
3
+ id: string;
4
+ defaultMessage: string;
5
+ description: string;
6
+ };
7
+ };
@@ -17,6 +17,7 @@ export * from "./Heading";
17
17
  export * from "./Icon";
18
18
  export * from "./IconButton";
19
19
  export * from "./InputFieldWrapper";
20
+ export * from "./InputNumber";
20
21
  export * from "./InputPassword";
21
22
  export * from "./InputPressable";
22
23
  export * from "./InputSearch";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "license": "MIT",
5
5
  "description": "React Native implementation of Atlantis",
6
6
  "repository": {
@@ -37,6 +37,7 @@
37
37
  "dependencies": {
38
38
  "@jobber/design": "^0.41.3",
39
39
  "@react-native-picker/picker": "^2.4.10",
40
+ "lodash": "^4.17.21",
40
41
  "lodash.chunk": "^4.2.0",
41
42
  "lodash.debounce": "^4.0.8",
42
43
  "lodash.identity": "^3.0.0",
@@ -72,5 +73,5 @@
72
73
  "react": "^18",
73
74
  "react-native": ">=0.69.2"
74
75
  },
75
- "gitHead": "af99db94db46063487cedd6ddb3200674c9ce8e9"
76
+ "gitHead": "01fed020a3fe4466db91b66e0e7204b2f9f39e1d"
76
77
  }
@@ -0,0 +1,323 @@
1
+ import React from "react";
2
+ import { fireEvent, render, waitFor } from "@testing-library/react-native";
3
+ import { useIntl } from "react-intl";
4
+ import { InputNumber } from ".";
5
+ import { messages } from "./messages";
6
+
7
+ type OS = "ios" | "android";
8
+ let Platform: { OS: OS };
9
+ beforeEach(() => {
10
+ Platform = require("react-native").Platform;
11
+ });
12
+
13
+ const platforms: OS[] = ["ios", "android"];
14
+ it.each(platforms)("renders an InputNumber on %s", platform => {
15
+ Platform.OS = platform;
16
+ const label = "My Accessible label";
17
+ const { getByLabelText } = render(<InputNumber accessibilityLabel={label} />);
18
+ expect(getByLabelText(label)).toBeTruthy();
19
+ });
20
+
21
+ it.each(platforms)(
22
+ "renders an InputNumber with defaultValue on %s",
23
+ platform => {
24
+ Platform.OS = platform;
25
+ const value = 200;
26
+ const { getByDisplayValue } = render(<InputNumber value={value} />);
27
+ expect(getByDisplayValue(value.toString())).toBeTruthy();
28
+ },
29
+ );
30
+
31
+ it("Displays a validation message when the value is not a number", async () => {
32
+ const { formatMessage } = useIntl();
33
+ const a11yLabel = "InputNumberTest";
34
+ const onChange = jest.fn();
35
+ const { getByText, getByLabelText } = render(
36
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
37
+ );
38
+ const inputValue = "this";
39
+ fireEvent.changeText(getByLabelText(a11yLabel), inputValue);
40
+
41
+ await waitFor(() => {
42
+ fireEvent(getByLabelText(a11yLabel), "blur");
43
+ });
44
+ expect(
45
+ getByText(formatMessage(messages.notANumberError), {
46
+ includeHiddenElements: true,
47
+ }),
48
+ ).toBeDefined();
49
+ expect(onChange).toHaveBeenCalledWith(inputValue);
50
+ });
51
+
52
+ it("When onChange is called it returns a number", async () => {
53
+ const a11yLabel = "InputNumberTest";
54
+ const onChange = jest.fn();
55
+ const { getByLabelText } = render(
56
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
57
+ );
58
+
59
+ fireEvent.changeText(getByLabelText(a11yLabel), "100");
60
+ expect(onChange).toHaveBeenCalledWith(100);
61
+ });
62
+
63
+ it("doesn't change the value when the input is controlled without an onChange", async () => {
64
+ const a11yLabel = "InputNumberTest";
65
+ const initialValue = 12;
66
+ const changeValue = 100;
67
+ const { queryByText, getByLabelText, getByDisplayValue } = render(
68
+ <InputNumber accessibilityLabel={a11yLabel} value={initialValue} />,
69
+ );
70
+
71
+ fireEvent.changeText(getByLabelText(a11yLabel), changeValue.toString());
72
+ expect(queryByText(changeValue.toString())).toBeNull();
73
+ expect(getByDisplayValue(initialValue.toString())).toBeDefined();
74
+ });
75
+
76
+ it("passes validation when decimal value is entered", async () => {
77
+ const { formatMessage } = useIntl();
78
+ const a11yLabel = "InputNumberTest";
79
+ const onChange = jest.fn();
80
+ const { queryByText, getByLabelText } = render(
81
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
82
+ );
83
+ const numInput = "13.5";
84
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
85
+
86
+ await waitFor(() => {
87
+ fireEvent(getByLabelText(a11yLabel), "blur");
88
+ });
89
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
90
+ expect(onChange).toHaveBeenCalledWith(parseFloat(numInput));
91
+ });
92
+
93
+ it("passes validation when negative value is entered", async () => {
94
+ const { formatMessage } = useIntl();
95
+ const a11yLabel = "InputNumberTest";
96
+ const onChange = jest.fn();
97
+ const { queryByText, getByLabelText } = render(
98
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
99
+ );
100
+
101
+ const numInput = "-15";
102
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
103
+
104
+ await waitFor(() => {
105
+ fireEvent(getByLabelText(a11yLabel), "blur");
106
+ });
107
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
108
+ expect(onChange).toHaveBeenCalledWith(parseInt(numInput, 10));
109
+ });
110
+
111
+ it("passes validation when negative decimal value is entered", async () => {
112
+ const { formatMessage } = useIntl();
113
+ const a11yLabel = "InputNumberTest";
114
+ const onChange = jest.fn();
115
+ const { queryByText, getByLabelText } = render(
116
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
117
+ );
118
+ const numInput = "-15.123";
119
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
120
+
121
+ await waitFor(() => {
122
+ fireEvent(getByLabelText(a11yLabel), "blur");
123
+ });
124
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
125
+ expect(onChange).toHaveBeenCalledWith(parseFloat(numInput));
126
+ });
127
+
128
+ it("passes validation when explicit positive value is entered", async () => {
129
+ const { formatMessage } = useIntl();
130
+ const a11yLabel = "InputNumberTest";
131
+ const onChange = jest.fn();
132
+ const { queryByText, getByLabelText } = render(
133
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
134
+ );
135
+
136
+ const numInput = "+15";
137
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
138
+
139
+ await waitFor(() => {
140
+ fireEvent(getByLabelText(a11yLabel), "blur");
141
+ });
142
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
143
+ expect(onChange).toHaveBeenCalledWith(parseInt(numInput, 10));
144
+ });
145
+
146
+ it("passes validation when e notation value is entered", async () => {
147
+ const { formatMessage } = useIntl();
148
+ const a11yLabel = "InputNumberTest";
149
+ const onChange = jest.fn();
150
+ const { queryByText, getByLabelText } = render(
151
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
152
+ );
153
+
154
+ const numInput = "6e10";
155
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
156
+
157
+ await waitFor(() => {
158
+ fireEvent(getByLabelText(a11yLabel), "blur");
159
+ });
160
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
161
+ expect(onChange).toHaveBeenCalledWith(parseFloat(numInput));
162
+ });
163
+
164
+ it("passes validation when e notation decimal value is entered", async () => {
165
+ const { formatMessage } = useIntl();
166
+ const a11yLabel = "InputNumberTest";
167
+ const onChange = jest.fn();
168
+ const { queryByText, getByLabelText } = render(
169
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
170
+ );
171
+
172
+ const numInput = "6.456e10";
173
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
174
+
175
+ await waitFor(() => {
176
+ fireEvent(getByLabelText(a11yLabel), "blur");
177
+ });
178
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
179
+ expect(onChange).toHaveBeenCalledWith(parseFloat(numInput));
180
+ });
181
+
182
+ it("passes validation when e notation for representing decimal value is entered", async () => {
183
+ const { formatMessage } = useIntl();
184
+ const a11yLabel = "InputNumberTest";
185
+ const onChange = jest.fn();
186
+ const { queryByText, getByLabelText } = render(
187
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
188
+ );
189
+
190
+ const numInput = "6e-10";
191
+ fireEvent.changeText(getByLabelText(a11yLabel), numInput);
192
+
193
+ await waitFor(() => {
194
+ fireEvent(getByLabelText(a11yLabel), "blur");
195
+ });
196
+ expect(queryByText(formatMessage(messages.notANumberError))).toBeNull();
197
+ expect(onChange).toHaveBeenCalledWith(parseFloat(numInput));
198
+ });
199
+
200
+ describe("when the value ends with period", () => {
201
+ const values = [".", "0.", "12.", "+1.", "-0."];
202
+
203
+ it.each(values)("doesn't convert the value %s", value => {
204
+ const a11yLabel = "InputNumberTest";
205
+ const onChange = jest.fn();
206
+ const { getByLabelText, getByDisplayValue } = render(
207
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
208
+ );
209
+
210
+ fireEvent.changeText(getByLabelText(a11yLabel), value);
211
+ expect(getByDisplayValue(value)).toBeDefined();
212
+ });
213
+ });
214
+
215
+ describe("when the value ends with scientific notation", () => {
216
+ const values = ["1e", "+2e", "1.2e", "-3e"];
217
+
218
+ it.each(values)("doesn't convert the value %s", value => {
219
+ const a11yLabel = "InputNumberTest";
220
+ const onChange = jest.fn();
221
+ const { getByLabelText, getByDisplayValue } = render(
222
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
223
+ );
224
+
225
+ fireEvent.changeText(getByLabelText(a11yLabel), value);
226
+ expect(getByDisplayValue(value)).toBeDefined();
227
+ });
228
+ });
229
+
230
+ describe("when the value ends with + or -", () => {
231
+ const values = ["+", "-"];
232
+
233
+ it.each(values)("doesn't convert the value %s", value => {
234
+ const a11yLabel = "InputNumberTest";
235
+ const onChange = jest.fn();
236
+ const { getByLabelText, getByDisplayValue } = render(
237
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
238
+ );
239
+
240
+ fireEvent.changeText(getByLabelText(a11yLabel), value);
241
+ expect(getByDisplayValue(value)).toBeDefined();
242
+ });
243
+ });
244
+
245
+ describe("when the value ends with zero decimal", () => {
246
+ const values = ["0.0", "+0.00000", "-3.00000", "2.100", ".0", ".00000"];
247
+
248
+ it.each(values)("doesn't convert the value %s", value => {
249
+ const a11yLabel = "InputNumberTest";
250
+ const onChange = jest.fn();
251
+ const { getByLabelText, getByDisplayValue } = render(
252
+ <InputNumber accessibilityLabel={a11yLabel} onChange={onChange} />,
253
+ );
254
+
255
+ fireEvent.changeText(getByLabelText(a11yLabel), value);
256
+ expect(getByDisplayValue(value)).toBeDefined();
257
+ });
258
+ });
259
+
260
+ describe("when the OS is iOS", () => {
261
+ const a11yLabel = "InputNumberTest";
262
+ beforeEach(() => {
263
+ Platform.OS = "ios";
264
+ });
265
+ it("uses the decimal-pad keyboard when keyboard is 'decimal-pad'", () => {
266
+ const { getByLabelText } = render(
267
+ <InputNumber accessibilityLabel={a11yLabel} keyboard={"decimal-pad"} />,
268
+ );
269
+ expect(getByLabelText(a11yLabel).props.keyboardType).toEqual("decimal-pad");
270
+ });
271
+
272
+ it("uses the default numbers-and-punctuation keyboard when missing keyboard prop", () => {
273
+ const { getByLabelText } = render(
274
+ <InputNumber accessibilityLabel={a11yLabel} />,
275
+ );
276
+ expect(getByLabelText(a11yLabel).props.keyboardType).toEqual(
277
+ "numbers-and-punctuation",
278
+ );
279
+ });
280
+
281
+ it("uses the numbers-and-punctuation keyboard when keyboard is 'numbers-and-punctuation'", () => {
282
+ const { getByLabelText } = render(
283
+ <InputNumber
284
+ accessibilityLabel={a11yLabel}
285
+ keyboard={"numbers-and-punctuation"}
286
+ />,
287
+ );
288
+ expect(getByLabelText(a11yLabel).props.keyboardType).toEqual(
289
+ "numbers-and-punctuation",
290
+ );
291
+ });
292
+ });
293
+
294
+ describe("when the OS is android", () => {
295
+ const a11yLabel = "InputNumberTest";
296
+ beforeEach(() => {
297
+ Platform.OS = "android";
298
+ });
299
+
300
+ it("uses the numeric keyboard when missing keyboard prop", () => {
301
+ const { getByLabelText } = render(
302
+ <InputNumber accessibilityLabel={a11yLabel} />,
303
+ );
304
+ expect(getByLabelText(a11yLabel).props.keyboardType).toEqual("numeric");
305
+ });
306
+
307
+ it("uses the numeric keyboard when keyboard is 'decimal-pad'", () => {
308
+ const { getByLabelText } = render(
309
+ <InputNumber accessibilityLabel={a11yLabel} keyboard={"decimal-pad"} />,
310
+ );
311
+ expect(getByLabelText(a11yLabel).props.keyboardType).toEqual("numeric");
312
+ });
313
+
314
+ it("uses the numeric keyboard when keyboard is 'numbers-and-punctuation'", () => {
315
+ const { getByLabelText } = render(
316
+ <InputNumber
317
+ accessibilityLabel={a11yLabel}
318
+ keyboard={"numbers-and-punctuation"}
319
+ />,
320
+ );
321
+ expect(getByLabelText(a11yLabel).props.keyboardType).toEqual("numeric");
322
+ });
323
+ });
@@ -0,0 +1,126 @@
1
+ import React, { Ref, forwardRef, useState } from "react";
2
+ import { Platform } from "react-native";
3
+ import { useIntl } from "react-intl";
4
+ import flow from "lodash/flow";
5
+ import identity from "lodash/identity";
6
+ import { messages } from "./messages";
7
+ import { InputText, InputTextProps, InputTextRef } from "../InputText";
8
+
9
+ type NumberKeyboard = "decimal-pad" | "numbers-and-punctuation";
10
+ export interface InputNumberProps
11
+ extends Omit<
12
+ InputTextProps,
13
+ "keyboard" | "onChangeText" | "value" | "defaultValue"
14
+ > {
15
+ readonly value?: number;
16
+ readonly defaultValue?: number;
17
+ readonly onChange: (newValue?: number | string | undefined) => void;
18
+ readonly keyboard?: NumberKeyboard;
19
+ /**
20
+ * Used to locate this view in end-to-end tests
21
+ */
22
+ readonly testID?: string;
23
+ }
24
+
25
+ const NUMBER_VALIDATION_REGEX =
26
+ /^[-+]?(([0-9]*\.[0-9]+)|([0-9]+)|([0-9]+(\.?[0-9]+)?e[-+]?[0-9]+))$/;
27
+
28
+ export const InputNumber = forwardRef(InputNumberInternal);
29
+
30
+ function InputNumberInternal(props: InputNumberProps, ref: Ref<InputTextRef>) {
31
+ const getKeyboard = () => {
32
+ if (Platform.OS === "ios") {
33
+ //since we are checking for which keyboard to use here here, just implement default keyboard here instead of in params
34
+ return props.keyboard ?? "numbers-and-punctuation";
35
+ } else {
36
+ return "numeric";
37
+ }
38
+ };
39
+ const { formatMessage } = useIntl();
40
+ const handleChange = (newValue: number | string | undefined) => {
41
+ props.onChange?.(newValue);
42
+ };
43
+
44
+ const { inputTransform: convertToString, outputTransform: convertToNumber } =
45
+ useNumberTransform(props.value);
46
+ return (
47
+ <InputText
48
+ {...props}
49
+ keyboard={getKeyboard()}
50
+ transform={{
51
+ input: flow(convertToString, props.transform?.input || identity),
52
+ output: flow(convertToNumber, props.transform?.output || identity),
53
+ }}
54
+ ref={ref}
55
+ value={props.value?.toString()}
56
+ defaultValue={props.defaultValue?.toString()}
57
+ onChangeText={handleChange}
58
+ validations={{
59
+ pattern: {
60
+ value: NUMBER_VALIDATION_REGEX,
61
+ message: formatMessage(messages.notANumberError),
62
+ },
63
+ ...props.validations,
64
+ }}
65
+ />
66
+ );
67
+ }
68
+
69
+ function hasPeriodAtEnd(value: string) {
70
+ // matches patterns like ".", "0.", "12.", "+1.", and "-0."
71
+ return !!value?.match(/^[-+]?[0-9]*\.$/);
72
+ }
73
+ function hasScientificNotationAtEnd(value: string) {
74
+ // matches patterns like "1e", "+2e", "1.2e" and "-3e"
75
+ return !!value?.match(/^[-+]?[0-9]+(\.?[0-9]+)?e$/);
76
+ }
77
+ function hasPlusMinusAtEnd(value: string) {
78
+ // matches "+" and "-"
79
+ return !!value?.match(/^[-+]+$/);
80
+ }
81
+
82
+ function hasZeroDecimalAtEnd(value: string) {
83
+ // matches patterns like "0.0", "+0.00000", "-3.00000", "2.100", ".0", and ".00000"
84
+ return !!value?.match(/^[-+]?[0-9]*\.[0-9]*0+$/);
85
+ }
86
+
87
+ export function shouldShowUserValue(value: string): boolean {
88
+ const specialCasesFn = [
89
+ hasPeriodAtEnd,
90
+ hasScientificNotationAtEnd,
91
+ hasPlusMinusAtEnd,
92
+ hasZeroDecimalAtEnd,
93
+ ];
94
+ const isSpecial = (v: string) =>
95
+ specialCasesFn.reduce((acc, fn) => acc || fn(v), false);
96
+ return isSpecial(value);
97
+ }
98
+
99
+ export function useNumberTransform(controlledValue: number | undefined): {
100
+ inputTransform: (internalValue?: number) => string | undefined;
101
+ outputTransform: (value: string) => string | number;
102
+ } {
103
+ const [typedValue, setTypedValue] = useState<string>(
104
+ controlledValue?.toString() || "",
105
+ );
106
+
107
+ const convertToNumber = (newValue: string) => {
108
+ setTypedValue(newValue);
109
+ if (newValue?.match?.(NUMBER_VALIDATION_REGEX)) {
110
+ return parseFloat(newValue);
111
+ }
112
+ return newValue;
113
+ };
114
+
115
+ const convertToString = (internalValue: number | undefined) => {
116
+ if (shouldShowUserValue(typedValue)) {
117
+ return typedValue;
118
+ }
119
+ return internalValue?.toString() || undefined;
120
+ };
121
+
122
+ return {
123
+ inputTransform: convertToString,
124
+ outputTransform: convertToNumber,
125
+ };
126
+ }
@@ -0,0 +1,2 @@
1
+ export { InputNumber } from "./InputNumber";
2
+ export type { InputNumberProps } from "./InputNumber";
@@ -0,0 +1,10 @@
1
+ import { defineMessages } from "react-intl";
2
+
3
+ export const messages = defineMessages({
4
+ notANumberError: {
5
+ id: "notANumberError",
6
+ defaultMessage: "Enter a number",
7
+ description:
8
+ "Error message shown when a non-numeric value is typed in number input",
9
+ },
10
+ });
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./Heading";
17
17
  export * from "./Icon";
18
18
  export * from "./IconButton";
19
19
  export * from "./InputFieldWrapper";
20
+ export * from "./InputNumber";
20
21
  export * from "./InputPassword";
21
22
  export * from "./InputPressable";
22
23
  export * from "./InputSearch";