@jobber/components-native 0.30.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.
Files changed (81) hide show
  1. package/README.md +3 -0
  2. package/dist/src/InputNumber/InputNumber.js +82 -0
  3. package/dist/src/InputNumber/index.js +1 -0
  4. package/dist/src/InputNumber/messages.js +8 -0
  5. package/dist/src/Select/Select.js +79 -0
  6. package/dist/src/Select/Select.style.js +45 -0
  7. package/dist/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.ios.js +30 -0
  8. package/dist/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.js +16 -0
  9. package/dist/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.style.js +29 -0
  10. package/dist/src/Select/components/SelectDefaultPicker/index.js +1 -0
  11. package/dist/src/Select/components/SelectDefaultPicker/messages.js +8 -0
  12. package/dist/src/Select/components/SelectIOSPicker/SelectIOSPicker.js +2 -0
  13. package/dist/src/Select/components/SelectIOSPicker/index.js +1 -0
  14. package/dist/src/Select/components/SelectInternalPicker/SelectInternalPicker.js +14 -0
  15. package/dist/src/Select/components/SelectInternalPicker/index.js +1 -0
  16. package/dist/src/Select/components/SelectInternalPicker/utils.js +13 -0
  17. package/dist/src/Select/components/SelectPressable/SelectPressable.js +15 -0
  18. package/dist/src/Select/components/SelectPressable/SelectPressable.style.js +7 -0
  19. package/dist/src/Select/components/SelectPressable/index.js +1 -0
  20. package/dist/src/Select/index.js +1 -0
  21. package/dist/src/Select/messages.js +13 -0
  22. package/dist/src/Select/types.js +1 -0
  23. package/dist/src/index.js +2 -0
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/dist/types/src/InputNumber/InputNumber.d.ts +20 -0
  26. package/dist/types/src/InputNumber/index.d.ts +2 -0
  27. package/dist/types/src/InputNumber/messages.d.ts +7 -0
  28. package/dist/types/src/Select/Select.d.ts +69 -0
  29. package/dist/types/src/Select/Select.style.d.ts +56 -0
  30. package/dist/types/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.d.ts +5 -0
  31. package/dist/types/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.ios.d.ts +5 -0
  32. package/dist/types/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.style.d.ts +30 -0
  33. package/dist/types/src/Select/components/SelectDefaultPicker/index.d.ts +1 -0
  34. package/dist/types/src/Select/components/SelectDefaultPicker/messages.d.ts +7 -0
  35. package/dist/types/src/Select/components/SelectIOSPicker/SelectIOSPicker.d.ts +10 -0
  36. package/dist/types/src/Select/components/SelectIOSPicker/index.d.ts +1 -0
  37. package/dist/types/src/Select/components/SelectInternalPicker/SelectInternalPicker.d.ts +3 -0
  38. package/dist/types/src/Select/components/SelectInternalPicker/index.d.ts +1 -0
  39. package/dist/types/src/Select/components/SelectInternalPicker/utils.d.ts +3 -0
  40. package/dist/types/src/Select/components/SelectPressable/SelectPressable.d.ts +11 -0
  41. package/dist/types/src/Select/components/SelectPressable/SelectPressable.style.d.ts +5 -0
  42. package/dist/types/src/Select/components/SelectPressable/index.d.ts +1 -0
  43. package/dist/types/src/Select/index.d.ts +1 -0
  44. package/dist/types/src/Select/messages.d.ts +12 -0
  45. package/dist/types/src/Select/types.d.ts +38 -0
  46. package/dist/types/src/index.d.ts +2 -0
  47. package/ios/ComponentsNative-Bridging-Header.h +2 -0
  48. package/ios/ComponentsNative.xcodeproj/project.pbxproj +303 -0
  49. package/ios/Picker/ATLFallBackPickerView.swift +13 -0
  50. package/ios/Picker/ATLPickerOption.swift +44 -0
  51. package/ios/Picker/ATLPickerView.swift +61 -0
  52. package/ios/Picker/RCTATLPickerManager.m +26 -0
  53. package/ios/Picker/RCTATLPickerManager.swift +25 -0
  54. package/jobber-components-native.podspec +35 -0
  55. package/package.json +19 -3
  56. package/src/InputNumber/InputNumber.test.tsx +323 -0
  57. package/src/InputNumber/InputNumber.tsx +126 -0
  58. package/src/InputNumber/index.ts +2 -0
  59. package/src/InputNumber/messages.ts +10 -0
  60. package/src/Select/Select.style.ts +51 -0
  61. package/src/Select/Select.test.tsx +323 -0
  62. package/src/Select/Select.tsx +240 -0
  63. package/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.ios.tsx +64 -0
  64. package/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.style.ts +30 -0
  65. package/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.test.tsx +76 -0
  66. package/src/Select/components/SelectDefaultPicker/SelectDefaultPicker.tsx +45 -0
  67. package/src/Select/components/SelectDefaultPicker/index.ts +1 -0
  68. package/src/Select/components/SelectDefaultPicker/messages.ts +9 -0
  69. package/src/Select/components/SelectIOSPicker/SelectIOSPicker.tsx +16 -0
  70. package/src/Select/components/SelectIOSPicker/index.ts +1 -0
  71. package/src/Select/components/SelectInternalPicker/SelectInternalPicker.test.tsx +100 -0
  72. package/src/Select/components/SelectInternalPicker/SelectInternalPicker.tsx +33 -0
  73. package/src/Select/components/SelectInternalPicker/index.ts +1 -0
  74. package/src/Select/components/SelectInternalPicker/utils.ts +20 -0
  75. package/src/Select/components/SelectPressable/SelectPressable.style.ts +8 -0
  76. package/src/Select/components/SelectPressable/SelectPressable.tsx +32 -0
  77. package/src/Select/components/SelectPressable/index.ts +1 -0
  78. package/src/Select/index.ts +1 -0
  79. package/src/Select/messages.ts +14 -0
  80. package/src/Select/types.ts +46 -0
  81. package/src/index.ts +2 -0
@@ -0,0 +1,44 @@
1
+ //
2
+ // PickerOption.swift
3
+ // @jobber/components-native
4
+ //
5
+ // Created by Darryl Tec on 2022-07-21.
6
+ // Copyright © 2022 Octopusapp Inc. All rights reserved.
7
+ //
8
+
9
+ import Foundation
10
+ import UIKit;
11
+
12
+ @available(iOS 13.0, *)
13
+ class ATLPickerOption {
14
+ var identifier: UIAction.Identifier?;
15
+ var title: String;
16
+ var isActive: Bool;
17
+ var state: UIAction.State = .off
18
+
19
+ init(details: NSDictionary){
20
+ if let identifier = details["value"] as? NSString {
21
+ self.identifier = UIAction.Identifier(rawValue: identifier as String);
22
+ }
23
+
24
+ if let title = details["label"] as? NSString {
25
+ self.title = title as String;
26
+ } else {
27
+ self.title = "";
28
+ }
29
+
30
+ if let isActive = details["isActive"] as? Bool {
31
+ self.isActive = isActive as Bool;
32
+ if isActive == true {
33
+ self.state = .on
34
+ }
35
+ } else {
36
+ self.isActive = false;
37
+ self.state = .off
38
+ }
39
+ }
40
+
41
+ func createUIAction(_ handler: @escaping UIActionHandler) -> UIAction {
42
+ return UIAction(title: title, identifier: identifier, state: state, handler: handler)
43
+ }
44
+ }
@@ -0,0 +1,61 @@
1
+ //
2
+ // PickerView.swift
3
+ // @jobber/components-native
4
+ //
5
+ // Created by Darryl Tec on 2022-07-21.
6
+ // Copyright © 2022 Octopusapp Inc. All rights reserved.
7
+ //
8
+
9
+ import Foundation
10
+
11
+ import UIKit
12
+ @available(iOS 14.0, *)
13
+ @objc(ATLPickerView)
14
+ class ATLPickerView: UIButton {
15
+ override init(frame: CGRect) {
16
+ super.init(frame: frame)
17
+ }
18
+ private var _hasSelectedValue: Bool = false;
19
+ private var _options: [UIAction] = [];
20
+
21
+ @objc var onOptionPress: RCTDirectEventBlock?
22
+ @objc func sendButtonAction(_ action: UIAction) {
23
+ if let onPress = onOptionPress {
24
+ onPress(["event": action.identifier.rawValue])
25
+ }
26
+ }
27
+
28
+ @objc var options: [NSDictionary]? {
29
+ didSet {
30
+ guard let options = self.options else {
31
+ return
32
+ }
33
+ _options.removeAll()
34
+ options.forEach { menuAction in
35
+ let opt = ATLPickerOption(details: menuAction)
36
+ _hasSelectedValue = opt.isActive
37
+ _options.append(opt.createUIAction({action in self.sendButtonAction(action)}))
38
+ }
39
+ self.setup()
40
+ }
41
+ }
42
+
43
+ func setup () {
44
+ let menu = UIMenu(title: "", identifier: nil, options: .displayInline, children: _options)
45
+
46
+ self.menu = menu
47
+ self.showsMenuAsPrimaryAction = true
48
+ if #available(iOS 15.0, *) {
49
+ self.setTitleColor(UIColor.clear, for: .normal)
50
+ self.changesSelectionAsPrimaryAction = true
51
+ }
52
+ }
53
+
54
+ override func reactSetFrame(_ frame: CGRect) {
55
+ super.reactSetFrame(frame);
56
+ };
57
+
58
+ required init?(coder aDecoder: NSCoder) {
59
+ fatalError("init(coder:) has not been implemented")
60
+ }
61
+ }
@@ -0,0 +1,26 @@
1
+ //
2
+ // RCTPickerManager.m
3
+ // Jobber
4
+ //
5
+ // Created by Darryl Tec on 2022-07-21.
6
+ // Copyright © 2022 Octopusapp Inc. All rights reserved.
7
+ //
8
+
9
+ #import <Foundation/Foundation.h>
10
+
11
+ #import <React/RCTBridgeModule.h>
12
+ #import <React/RCTViewManager.h>
13
+
14
+ @interface RCT_EXTERN_MODULE(RCTATLPicker, RCTViewManager)
15
+
16
+ /**
17
+ * Options the user can choose
18
+ */
19
+ RCT_EXPORT_VIEW_PROPERTY(options, NSArray)
20
+
21
+ /**
22
+ * Callback when one of the option is pressed
23
+ */
24
+ RCT_EXPORT_VIEW_PROPERTY(onOptionPress, RCTDirectEventBlock)
25
+
26
+ @end
@@ -0,0 +1,25 @@
1
+ //
2
+ // RCTPickerManager.swift
3
+ // @jobber/components-native
4
+ //
5
+ // Created by Darryl Tec on 2022-07-21.
6
+ // Copyright © 2022 Octopusapp Inc. All rights reserved.
7
+ //
8
+
9
+ import Foundation
10
+
11
+ @objc(RCTATLPicker)
12
+ class RCTATLPickerManager: RCTViewManager {
13
+
14
+ override static func requiresMainQueueSetup() -> Bool {
15
+ return true
16
+ }
17
+
18
+ override func view() -> UIView! {
19
+ if #available(iOS 14.0, *) {
20
+ return ATLPickerView();
21
+ } else {
22
+ return ATLFallBackPickerView()
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,35 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+ folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
5
+
6
+ Pod::Spec.new do |s|
7
+ s.name = "jobber-components-native"
8
+ s.version = package["version"]
9
+ s.summary = package["description"]
10
+ s.homepage = package["homepage"]
11
+ s.license = package["license"]
12
+ s.authors = package["author"]
13
+
14
+ s.platforms = { :ios => "11.0" }
15
+ s.source = { :git => "https://github.com/MichaelParadis/jobber-components-native.git", :tag => "#{s.version}" }
16
+
17
+ s.source_files = "ios/**/*.{h,m,mm,swift}"
18
+
19
+ s.dependency "React-Core"
20
+
21
+ # Don't install the dependencies when we run `pod install` in the old architecture.
22
+ if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
23
+ s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
24
+ s.pod_target_xcconfig = {
25
+ "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
26
+ "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
27
+ "CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
28
+ }
29
+ s.dependency "React-Codegen"
30
+ s.dependency "RCT-Folly"
31
+ s.dependency "RCTRequired"
32
+ s.dependency "RCTTypeSafety"
33
+ s.dependency "ReactCommon/turbomodule/core"
34
+ end
35
+ end
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "license": "MIT",
5
+ "description": "React Native implementation of Atlantis",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/GetJobber/atlantis.git"
9
+ },
10
+ "author": "The Frends",
11
+ "bugs": {
12
+ "url": "https://github.com/GetJobber/atlantis/issues"
13
+ },
14
+ "homepage": "https://github.com/GetJobber/atlantis#readme",
5
15
  "main": "dist/src/index.js",
6
16
  "module": "dist/src/index.js",
7
17
  "types": "dist/types/src/index.d.ts",
@@ -10,7 +20,11 @@
10
20
  "src",
11
21
  "!**/__tests__",
12
22
  "!**/__fixtures__",
13
- "!**/__mocks__"
23
+ "!**/__mocks__",
24
+ "ios",
25
+ "cpp",
26
+ "*.podspec",
27
+ "!ios/build"
14
28
  ],
15
29
  "scripts": {
16
30
  "clean": "rm -rf dist/* tsconfig.tsbuildinfo",
@@ -22,6 +36,8 @@
22
36
  },
23
37
  "dependencies": {
24
38
  "@jobber/design": "^0.41.3",
39
+ "@react-native-picker/picker": "^2.4.10",
40
+ "lodash": "^4.17.21",
25
41
  "lodash.chunk": "^4.2.0",
26
42
  "lodash.debounce": "^4.0.8",
27
43
  "lodash.identity": "^3.0.0",
@@ -57,5 +73,5 @@
57
73
  "react": "^18",
58
74
  "react-native": ">=0.69.2"
59
75
  },
60
- "gitHead": "362d0d3914aa440bcbb40f9eed388d447cd205e0"
76
+ "gitHead": "01fed020a3fe4466db91b66e0e7204b2f9f39e1d"
61
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";