@jobber/components-native 0.38.0 → 0.40.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 (184) hide show
  1. package/dist/src/AtlantisContext/AtlantisContext.js +2 -0
  2. package/dist/src/Form/Form.js +187 -0
  3. package/dist/src/Form/Form.style.js +33 -0
  4. package/dist/src/Form/components/FormActionBar/FormActionBar.js +21 -0
  5. package/dist/src/Form/components/FormActionBar/FormActionBar.style.js +5 -0
  6. package/dist/src/Form/components/FormActionBar/index.js +1 -0
  7. package/dist/src/Form/components/FormBody/FormBody.js +20 -0
  8. package/dist/src/Form/components/FormBody/FormBody.style.js +26 -0
  9. package/dist/src/Form/components/FormBody/index.js +1 -0
  10. package/dist/src/Form/components/FormCache/FormCache.js +34 -0
  11. package/dist/src/Form/components/FormErrorBanner/FormErrorBanner.js +21 -0
  12. package/dist/src/Form/components/FormErrorBanner/index.js +1 -0
  13. package/dist/src/Form/components/FormErrorBanner/messages.js +13 -0
  14. package/dist/src/Form/components/FormMask/FormMask.js +11 -0
  15. package/dist/src/Form/components/FormMask/FormMask.style.js +15 -0
  16. package/dist/src/Form/components/FormMask/index.js +1 -0
  17. package/dist/src/Form/components/FormMessage/FormMessage.js +48 -0
  18. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.js +28 -0
  19. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.js +17 -0
  20. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/index.js +1 -0
  21. package/dist/src/Form/components/FormMessage/components/InternalFormMessage/messages.js +8 -0
  22. package/dist/src/Form/components/FormMessage/index.js +1 -0
  23. package/dist/src/Form/components/FormMessageBanner/FormMessageBanner.js +15 -0
  24. package/dist/src/Form/components/FormMessageBanner/index.js +1 -0
  25. package/dist/src/Form/components/FormSaveButton/FormSaveButton.js +69 -0
  26. package/dist/src/Form/components/FormSaveButton/index.js +1 -0
  27. package/dist/src/Form/components/FormSaveButton/messages.js +8 -0
  28. package/dist/src/Form/constants.js +2 -0
  29. package/dist/src/Form/context/AtlantisFormContext.js +16 -0
  30. package/dist/src/Form/context/index.js +1 -0
  31. package/dist/src/Form/context/types.js +1 -0
  32. package/dist/src/Form/hooks/useFormViewRefs.js +14 -0
  33. package/dist/src/Form/hooks/useInternalForm.js +37 -0
  34. package/dist/src/Form/hooks/useOfflineHandler.js +24 -0
  35. package/dist/src/Form/hooks/useSaveButtonPosition.js +25 -0
  36. package/dist/src/Form/hooks/useScreenInformation.js +15 -0
  37. package/dist/src/Form/hooks/useScrollToError/index.js +1 -0
  38. package/dist/src/Form/hooks/useScrollToError/useScrollToError.js +63 -0
  39. package/dist/src/Form/index.js +4 -0
  40. package/dist/src/Form/messages.js +28 -0
  41. package/dist/src/Form/types.js +10 -0
  42. package/dist/src/InputDate/InputDate.js +76 -0
  43. package/dist/src/InputDate/index.js +1 -0
  44. package/dist/src/InputDate/messages.js +8 -0
  45. package/dist/src/Menu/Menu.js +67 -0
  46. package/dist/src/Menu/Menu.style.js +6 -0
  47. package/dist/src/Menu/components/MenuOption/MenuOption.js +25 -0
  48. package/dist/src/Menu/components/MenuOption/MenuOption.style.js +10 -0
  49. package/dist/src/Menu/components/MenuOption/index.js +1 -0
  50. package/dist/src/Menu/components/Overlay/Overlay.js +9 -0
  51. package/dist/src/Menu/components/Overlay/Overlay.style.js +6 -0
  52. package/dist/src/Menu/components/Overlay/index.js +1 -0
  53. package/dist/src/Menu/index.js +1 -0
  54. package/dist/src/Menu/messages.js +8 -0
  55. package/dist/src/Menu/types.js +1 -0
  56. package/dist/src/Menu/utils.js +84 -0
  57. package/dist/src/index.js +3 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/dist/types/src/AtlantisContext/AtlantisContext.d.ts +7 -1
  60. package/dist/types/src/Form/Form.d.ts +4 -0
  61. package/dist/types/src/Form/Form.style.d.ts +31 -0
  62. package/dist/types/src/Form/components/FormActionBar/FormActionBar.d.ts +13 -0
  63. package/dist/types/src/Form/components/FormActionBar/FormActionBar.style.d.ts +15 -0
  64. package/dist/types/src/Form/components/FormActionBar/index.d.ts +2 -0
  65. package/dist/types/src/Form/components/FormBody/FormBody.d.ts +10 -0
  66. package/dist/types/src/Form/components/FormBody/FormBody.style.d.ts +24 -0
  67. package/dist/types/src/Form/components/FormBody/index.d.ts +1 -0
  68. package/dist/types/src/Form/components/FormCache/FormCache.d.ts +10 -0
  69. package/dist/types/src/Form/components/FormErrorBanner/FormErrorBanner.d.ts +3 -0
  70. package/dist/types/src/Form/components/FormErrorBanner/index.d.ts +1 -0
  71. package/dist/types/src/Form/components/FormErrorBanner/messages.d.ts +12 -0
  72. package/dist/types/src/Form/components/FormMask/FormMask.d.ts +2 -0
  73. package/dist/types/src/Form/components/FormMask/FormMask.style.d.ts +13 -0
  74. package/dist/types/src/Form/components/FormMask/index.d.ts +1 -0
  75. package/dist/types/src/Form/components/FormMessage/FormMessage.d.ts +19 -0
  76. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.d.ts +8 -0
  77. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.d.ts +20 -0
  78. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/index.d.ts +1 -0
  79. package/dist/types/src/Form/components/FormMessage/components/InternalFormMessage/messages.d.ts +7 -0
  80. package/dist/types/src/Form/components/FormMessage/index.d.ts +1 -0
  81. package/dist/types/src/Form/components/FormMessageBanner/FormMessageBanner.d.ts +7 -0
  82. package/dist/types/src/Form/components/FormMessageBanner/index.d.ts +1 -0
  83. package/dist/types/src/Form/components/FormSaveButton/FormSaveButton.d.ts +3 -0
  84. package/dist/types/src/Form/components/FormSaveButton/index.d.ts +1 -0
  85. package/dist/types/src/Form/components/FormSaveButton/messages.d.ts +7 -0
  86. package/dist/types/src/Form/constants.d.ts +2 -0
  87. package/dist/types/src/Form/context/AtlantisFormContext.d.ts +12 -0
  88. package/dist/types/src/Form/context/index.d.ts +2 -0
  89. package/dist/types/src/Form/context/types.d.ts +26 -0
  90. package/dist/types/src/Form/hooks/useFormViewRefs.d.ts +10 -0
  91. package/dist/types/src/Form/hooks/useInternalForm.d.ts +19 -0
  92. package/dist/types/src/Form/hooks/useOfflineHandler.d.ts +1 -0
  93. package/dist/types/src/Form/hooks/useSaveButtonPosition.d.ts +12 -0
  94. package/dist/types/src/Form/hooks/useScreenInformation.d.ts +8 -0
  95. package/dist/types/src/Form/hooks/useScrollToError/index.d.ts +1 -0
  96. package/dist/types/src/Form/hooks/useScrollToError/useScrollToError.d.ts +10 -0
  97. package/dist/types/src/Form/index.d.ts +5 -0
  98. package/dist/types/src/Form/messages.d.ts +27 -0
  99. package/dist/types/src/Form/types.d.ts +199 -0
  100. package/dist/types/src/InputDate/InputDate.d.ts +74 -0
  101. package/dist/types/src/InputDate/index.d.ts +1 -0
  102. package/dist/types/src/InputDate/messages.d.ts +7 -0
  103. package/dist/types/src/InputNumber/InputNumber.d.ts +1 -1
  104. package/dist/types/src/Menu/Menu.d.ts +3 -0
  105. package/dist/types/src/Menu/Menu.style.d.ts +18 -0
  106. package/dist/types/src/Menu/components/MenuOption/MenuOption.d.ts +3 -0
  107. package/dist/types/src/Menu/components/MenuOption/MenuOption.style.d.ts +8 -0
  108. package/dist/types/src/Menu/components/MenuOption/index.d.ts +1 -0
  109. package/dist/types/src/Menu/components/Overlay/Overlay.d.ts +3 -0
  110. package/dist/types/src/Menu/components/Overlay/Overlay.style.d.ts +12 -0
  111. package/dist/types/src/Menu/components/Overlay/index.d.ts +1 -0
  112. package/dist/types/src/Menu/index.d.ts +2 -0
  113. package/dist/types/src/Menu/messages.d.ts +7 -0
  114. package/dist/types/src/Menu/types.d.ts +22 -0
  115. package/dist/types/src/Menu/utils.d.ts +10 -0
  116. package/dist/types/src/index.d.ts +3 -0
  117. package/package.json +3 -2
  118. package/src/AtlantisContext/AtlantisContext.tsx +10 -1
  119. package/src/Form/Form.style.ts +34 -0
  120. package/src/Form/Form.test.tsx +588 -0
  121. package/src/Form/Form.tsx +296 -0
  122. package/src/Form/components/FormActionBar/FormActionBar.style.ts +11 -0
  123. package/src/Form/components/FormActionBar/FormActionBar.tsx +63 -0
  124. package/src/Form/components/FormActionBar/index.ts +2 -0
  125. package/src/Form/components/FormBody/FormBody.style.ts +27 -0
  126. package/src/Form/components/FormBody/FormBody.tsx +62 -0
  127. package/src/Form/components/FormBody/index.ts +1 -0
  128. package/src/Form/components/FormCache/FormCache.tsx +50 -0
  129. package/src/Form/components/FormErrorBanner/FormErrorBanner.test.tsx +124 -0
  130. package/src/Form/components/FormErrorBanner/FormErrorBanner.tsx +34 -0
  131. package/src/Form/components/FormErrorBanner/index.ts +1 -0
  132. package/src/Form/components/FormErrorBanner/messages.ts +14 -0
  133. package/src/Form/components/FormMask/FormMask.style.tsx +16 -0
  134. package/src/Form/components/FormMask/FormMask.tsx +19 -0
  135. package/src/Form/components/FormMask/index.ts +1 -0
  136. package/src/Form/components/FormMessage/FormMessage.test.tsx +72 -0
  137. package/src/Form/components/FormMessage/FormMessage.tsx +63 -0
  138. package/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.style.ts +18 -0
  139. package/src/Form/components/FormMessage/components/InternalFormMessage/InternalFormMessage.tsx +55 -0
  140. package/src/Form/components/FormMessage/components/InternalFormMessage/index.ts +1 -0
  141. package/src/Form/components/FormMessage/components/InternalFormMessage/messages.ts +10 -0
  142. package/src/Form/components/FormMessage/index.ts +1 -0
  143. package/src/Form/components/FormMessageBanner/FormMessageBanner.test.tsx +27 -0
  144. package/src/Form/components/FormMessageBanner/FormMessageBanner.tsx +33 -0
  145. package/src/Form/components/FormMessageBanner/index.ts +1 -0
  146. package/src/Form/components/FormSaveButton/FormSaveButton.test.tsx +159 -0
  147. package/src/Form/components/FormSaveButton/FormSaveButton.tsx +103 -0
  148. package/src/Form/components/FormSaveButton/index.ts +1 -0
  149. package/src/Form/components/FormSaveButton/messages.ts +9 -0
  150. package/src/Form/constants.ts +2 -0
  151. package/src/Form/context/AtlantisFormContext.test.tsx +45 -0
  152. package/src/Form/context/AtlantisFormContext.tsx +21 -0
  153. package/src/Form/context/index.ts +5 -0
  154. package/src/Form/context/types.ts +34 -0
  155. package/src/Form/hooks/useFormViewRefs.ts +23 -0
  156. package/src/Form/hooks/useInternalForm.ts +99 -0
  157. package/src/Form/hooks/useOfflineHandler.ts +36 -0
  158. package/src/Form/hooks/useSaveButtonPosition.ts +52 -0
  159. package/src/Form/hooks/useScreenInformation.ts +25 -0
  160. package/src/Form/hooks/useScrollToError/index.ts +1 -0
  161. package/src/Form/hooks/useScrollToError/useScrollToError.test.tsx +103 -0
  162. package/src/Form/hooks/useScrollToError/useScrollToError.ts +102 -0
  163. package/src/Form/index.ts +13 -0
  164. package/src/Form/messages.ts +33 -0
  165. package/src/Form/types.ts +255 -0
  166. package/src/InputDate/InputDate.test.tsx +295 -0
  167. package/src/InputDate/InputDate.tsx +231 -0
  168. package/src/InputDate/index.ts +1 -0
  169. package/src/InputDate/messages.ts +9 -0
  170. package/src/InputNumber/InputNumber.tsx +1 -1
  171. package/src/Menu/Menu.style.ts +16 -0
  172. package/src/Menu/Menu.test.tsx +201 -0
  173. package/src/Menu/Menu.tsx +116 -0
  174. package/src/Menu/components/MenuOption/MenuOption.style.tsx +11 -0
  175. package/src/Menu/components/MenuOption/MenuOption.tsx +63 -0
  176. package/src/Menu/components/MenuOption/index.ts +1 -0
  177. package/src/Menu/components/Overlay/Overlay.style.ts +13 -0
  178. package/src/Menu/components/Overlay/Overlay.tsx +16 -0
  179. package/src/Menu/components/Overlay/index.ts +1 -0
  180. package/src/Menu/index.ts +6 -0
  181. package/src/Menu/messages.ts +9 -0
  182. package/src/Menu/types.ts +25 -0
  183. package/src/Menu/utils.ts +151 -0
  184. package/src/index.ts +3 -0
@@ -0,0 +1,231 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import DateTimePicker from "react-native-modal-datetime-picker";
3
+ import { Platform } from "react-native";
4
+ import { FieldError, UseControllerProps } from "react-hook-form";
5
+ import { XOR } from "ts-xor";
6
+ import { useIntl } from "react-intl";
7
+ import { utcToZonedTime } from "date-fns-tz";
8
+ import { format as formatDate } from "date-fns";
9
+ import { messages } from "./messages";
10
+ import { Clearable, InputFieldWrapperProps } from "../InputFieldWrapper";
11
+ import { FormField } from "../FormField";
12
+ import { InputPressable } from "../InputPressable";
13
+ import { useAtlantisContext } from "../AtlantisContext";
14
+
15
+ interface BaseInputDateProps
16
+ extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder"> {
17
+ /**
18
+ * Defaulted to "always" so user can clear the dates whenever there's a value.
19
+ */
20
+ readonly clearable?: Extract<Clearable, "always" | "never">;
21
+
22
+ /**
23
+ * This label is shown to the user when there's no selected date.
24
+ */
25
+ readonly emptyValueLabel?: string;
26
+
27
+ /**
28
+ * Maximum date the user can set.
29
+ */
30
+ readonly maxDate?: Date;
31
+
32
+ /**
33
+ * Minimum date the user can set
34
+ */
35
+ readonly minDate?: Date;
36
+
37
+ /**
38
+ * VoiceOver will read this string when a user selects the element
39
+ */
40
+ readonly accessibilityLabel?: string;
41
+
42
+ /**
43
+ * Helps users understand what will happen when they perform an action
44
+ */
45
+ readonly accessibilityHint?: string;
46
+ }
47
+
48
+ interface FormControlledInputDate extends BaseInputDateProps {
49
+ /**
50
+ * Adding a `name` would make this component "Form controlled" and must be
51
+ * nested within a `<Form />` component.
52
+ *
53
+ * Cannot be declared if `value` prop is used.
54
+ */
55
+ readonly name: string;
56
+
57
+ /**
58
+ * Shows an error message below the field and highlights it red when the
59
+ * value is invalid. Only applies when nested within a `<Form />` component.
60
+ *
61
+ * You can see **most** of the rules you can pass in
62
+ * [React-hook-form Documentation](https://react-hook-form.com/api/useform/register#options).
63
+ */
64
+ readonly validations?: UseControllerProps["rules"];
65
+
66
+ /**
67
+ * The initial value for the input.
68
+ */
69
+ readonly defaultValue?: Date;
70
+
71
+ /**
72
+ * The callback that fires whenever a date gets selected.
73
+ */
74
+ readonly onChange?: (value?: Date | null) => void;
75
+ }
76
+
77
+ interface DevControlledInputDate extends BaseInputDateProps {
78
+ /**
79
+ * The value shown on the field. This gets automatically formatted to the
80
+ * account's date format.
81
+ *
82
+ * Cannot be declared if `name` prop is used.
83
+ */
84
+ readonly value: Date | string | undefined;
85
+
86
+ /**
87
+ * The callback that fires whenever a date gets selected.
88
+ */
89
+ readonly onChange: (value?: Date) => void;
90
+ }
91
+
92
+ export type InputDateProps = XOR<
93
+ FormControlledInputDate,
94
+ DevControlledInputDate
95
+ >;
96
+
97
+ function formatInvalidState(
98
+ error: FieldError | undefined,
99
+ invalid: InputFieldWrapperProps["invalid"],
100
+ ): boolean | string {
101
+ if (invalid) return invalid;
102
+
103
+ if (error && error.message) {
104
+ return error.message;
105
+ }
106
+
107
+ return Boolean(error);
108
+ }
109
+
110
+ const display = Platform.OS === "ios" ? "inline" : "default";
111
+
112
+ /**
113
+ * Allow users to select a date using the device date picker.
114
+ */
115
+ export function InputDate(props: InputDateProps): JSX.Element {
116
+ if (props.name) {
117
+ return (
118
+ <FormField<Date>
119
+ name={props.name}
120
+ defaultValue={props.defaultValue}
121
+ validations={props.validations}
122
+ >
123
+ {({ value, onChange, onBlur }, error) => (
124
+ <InternalInputDate
125
+ {...props}
126
+ value={value}
127
+ onChange={(newValue?: Date | null) => {
128
+ onChange(newValue);
129
+ onBlur();
130
+ props.onChange?.(newValue);
131
+ }}
132
+ invalid={formatInvalidState(error, props.invalid)}
133
+ />
134
+ )}
135
+ </FormField>
136
+ );
137
+ }
138
+
139
+ return <InternalInputDate {...props} />;
140
+ }
141
+
142
+ function InternalInputDate({
143
+ clearable = "always",
144
+ disabled,
145
+ emptyValueLabel,
146
+ invalid,
147
+ maxDate,
148
+ minDate,
149
+ placeholder,
150
+ value,
151
+ name,
152
+ onChange,
153
+ accessibilityLabel,
154
+ accessibilityHint,
155
+ }: InputDateProps): JSX.Element {
156
+ const [showPicker, setShowPicker] = useState(false);
157
+ const { formatMessage } = useIntl();
158
+ const { timeZone, dateFormat } = useAtlantisContext();
159
+
160
+ const date = useMemo(() => {
161
+ if (typeof value === "string") return new Date(value);
162
+ return value;
163
+ }, [value]);
164
+
165
+ const formattedDate = useMemo(() => {
166
+ if (date) {
167
+ const zonedTime = utcToZonedTime(date, timeZone);
168
+ return formatDate(zonedTime, dateFormat);
169
+ }
170
+
171
+ return emptyValueLabel;
172
+ }, [date, emptyValueLabel, timeZone, dateFormat]);
173
+
174
+ const canClearDate = formattedDate === emptyValueLabel ? "never" : clearable;
175
+
176
+ const placeholderLabel =
177
+ placeholder ?? formatMessage(messages.datePlaceholder);
178
+
179
+ return (
180
+ <>
181
+ <InputPressable
182
+ clearable={canClearDate}
183
+ disabled={disabled}
184
+ invalid={invalid}
185
+ placeholder={placeholderLabel}
186
+ prefix={{ icon: "calendar" }}
187
+ value={formattedDate}
188
+ onClear={handleClear}
189
+ onPress={showDatePicker}
190
+ accessibilityLabel={accessibilityLabel}
191
+ accessibilityHint={accessibilityHint}
192
+ />
193
+ <DateTimePicker
194
+ testID="inputDate-datePicker"
195
+ date={date || undefined}
196
+ display={display}
197
+ isVisible={showPicker}
198
+ maximumDate={maxDate}
199
+ minimumDate={minDate}
200
+ mode="date"
201
+ onCancel={handleCancel}
202
+ onConfirm={handleConfirm}
203
+ />
204
+ </>
205
+ );
206
+
207
+ function showDatePicker() {
208
+ setShowPicker(true);
209
+ }
210
+
211
+ function handleConfirm(newVal: Date) {
212
+ setShowPicker(false);
213
+ onChange?.(newVal);
214
+ }
215
+
216
+ function handleCancel() {
217
+ setShowPicker(false);
218
+
219
+ // Ensure a change happens so we trigger the validation of one exists
220
+ onChange?.(date);
221
+ }
222
+
223
+ function handleClear() {
224
+ // Returns null only for Form controlled scenarios due to a limitation of react-hook-form that doesn't allow passing undefined to form values.
225
+ if (name) {
226
+ onChange?.(null);
227
+ } else {
228
+ onChange?.(undefined);
229
+ }
230
+ }
231
+ }
@@ -0,0 +1 @@
1
+ export { InputDate, InputDateProps } from "./InputDate";
@@ -0,0 +1,9 @@
1
+ import { defineMessages } from "react-intl";
2
+
3
+ export const messages = defineMessages({
4
+ datePlaceholder: {
5
+ id: "datePlaceholder",
6
+ defaultMessage: "Date",
7
+ description: "Default input label for the InputDate component",
8
+ },
9
+ });
@@ -14,7 +14,7 @@ export interface InputNumberProps
14
14
  > {
15
15
  readonly value?: number;
16
16
  readonly defaultValue?: number;
17
- readonly onChange: (newValue?: number | string | undefined) => void;
17
+ readonly onChange?: (newValue?: number | string | undefined) => void;
18
18
  readonly keyboard?: NumberKeyboard;
19
19
  /**
20
20
  * Used to locate this view in end-to-end tests
@@ -0,0 +1,16 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { tokens } from "../utils/design";
3
+
4
+ const menuWidth = 208;
5
+
6
+ export const styles = StyleSheet.create({
7
+ menu: {
8
+ position: "absolute",
9
+ backgroundColor: tokens["color-surface"],
10
+ paddingHorizontal: tokens["space-small"],
11
+ paddingVertical: tokens["space-small"] + tokens["space-smallest"],
12
+ borderRadius: tokens["radius-larger"],
13
+ width: menuWidth,
14
+ ...tokens["shadow-high"],
15
+ },
16
+ });
@@ -0,0 +1,201 @@
1
+ import React from "react";
2
+ import { fireEvent, render } from "@testing-library/react-native";
3
+ import { Host } from "react-native-portalize";
4
+ import { View } from "react-native";
5
+ import { tokens } from "@jobber/design/foundation";
6
+ import { Menu, MenuOptionProps, MenuProps } from ".";
7
+ import { messages } from "./messages";
8
+ import { Icon } from "../Icon";
9
+ import { Button } from "../Button";
10
+
11
+ const mockOnPress = jest.fn();
12
+ jest
13
+ .spyOn(View.prototype, "measureInWindow")
14
+ .mockImplementation(cb => cb(50, 50, 100, 100));
15
+
16
+ const setup = (props?: MenuProps) => {
17
+ return render(
18
+ <Host>
19
+ <Menu
20
+ menuOptions={props?.menuOptions}
21
+ customActivator={props?.customActivator}
22
+ />
23
+ </Host>,
24
+ );
25
+ };
26
+
27
+ describe("Menu", () => {
28
+ beforeEach(() => {
29
+ mockOnPress.mockClear();
30
+ });
31
+
32
+ it("renders the default Activator", () => {
33
+ const { getByTestId, getByLabelText } = setup({
34
+ menuOptions: [{ label: "hi", icon: "add", onPress: mockOnPress }],
35
+ });
36
+
37
+ expect(getByTestId("more")).toBeDefined();
38
+ expect(getByLabelText(messages.more.defaultMessage)).toBeDefined();
39
+ });
40
+
41
+ it("renders every menu option when menu is opened", () => {
42
+ const menuOptions: MenuOptionProps[] = [
43
+ { label: "option1", icon: "add", onPress: mockOnPress },
44
+ { label: "option2", icon: "arrowDown", onPress: mockOnPress },
45
+ { label: "option3", onPress: mockOnPress },
46
+ ];
47
+ const { getByLabelText } = setup({
48
+ menuOptions,
49
+ });
50
+
51
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
52
+ expect(getByLabelText(menuOptions[0].label)).toBeDefined();
53
+ expect(getByLabelText(menuOptions[1].label)).toBeDefined();
54
+ expect(getByLabelText(menuOptions[2].label)).toBeDefined();
55
+ });
56
+
57
+ describe("Custom Activator", () => {
58
+ it("renders a custom Activator", () => {
59
+ const { getByTestId } = setup({
60
+ menuOptions: [{ label: "hi", onPress: mockOnPress }],
61
+ customActivator: <Icon name="addNote" />,
62
+ });
63
+
64
+ expect(getByTestId("addNote")).toBeDefined();
65
+ });
66
+
67
+ it("renders menu when the custom activator is clicked", () => {
68
+ const { getByLabelText, getByTestId, getAllByTestId } = setup({
69
+ menuOptions: [
70
+ { label: "hi", icon: "add", onPress: mockOnPress },
71
+ { label: "option2", onPress: mockOnPress },
72
+ ],
73
+ customActivator: <Icon name="addNote" />,
74
+ });
75
+
76
+ fireEvent.press(getByTestId("addNote"));
77
+
78
+ expect(getAllByTestId("ATL-MENU-OPTIONS")).toHaveLength(2);
79
+ expect(getByTestId("add")).toBeDefined();
80
+ expect(getByLabelText("hi")).toBeDefined();
81
+ });
82
+
83
+ describe("Pressable used as Custom Activator", () => {
84
+ it("menu is open and custom activator's onPress is called", () => {
85
+ const mockActivatorPress = jest.fn();
86
+ const buttonLabel = "Test me!";
87
+ const menuOptions: MenuOptionProps[] = [
88
+ { label: "menuOption", icon: "add", onPress: mockOnPress },
89
+ ];
90
+
91
+ const { getByLabelText } = setup({
92
+ menuOptions,
93
+ customActivator: (
94
+ <Button label={buttonLabel} onPress={mockActivatorPress} />
95
+ ),
96
+ });
97
+
98
+ fireEvent.press(getByLabelText(buttonLabel));
99
+
100
+ expect(mockActivatorPress).toHaveBeenCalledTimes(1);
101
+ expect(getByLabelText(menuOptions[0].label)).toBeDefined();
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("Menu Options", () => {
107
+ it("fires the onPress of the menu option", () => {
108
+ const { getByLabelText } = setup({
109
+ menuOptions: [
110
+ {
111
+ label: "hi",
112
+ icon: "add",
113
+ onPress: mockOnPress,
114
+ destructive: true,
115
+ },
116
+ ],
117
+ });
118
+
119
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
120
+ fireEvent.press(getByLabelText("hi"));
121
+ expect(mockOnPress).toHaveBeenCalled();
122
+ });
123
+
124
+ it("renders a menuOption with an icon with destructive styling", () => {
125
+ const { getByLabelText, getByTestId } = setup({
126
+ menuOptions: [
127
+ { label: "hi", icon: "add", onPress: mockOnPress, destructive: true },
128
+ ],
129
+ });
130
+
131
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
132
+ expect(getByTestId("add").props.style).toContainEqual({
133
+ display: "flex",
134
+ fill: tokens["color-critical"],
135
+ height: 24,
136
+ verticalAlign: "middle",
137
+ width: 24,
138
+ });
139
+ });
140
+
141
+ it("closes the menu after clicking on a menu option", () => {
142
+ const { getByLabelText, queryByLabelText } = setup({
143
+ menuOptions: [
144
+ {
145
+ label: "hi",
146
+ icon: "add",
147
+ onPress: mockOnPress,
148
+ destructive: true,
149
+ },
150
+ ],
151
+ });
152
+
153
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
154
+ fireEvent.press(getByLabelText("hi"));
155
+ expect(mockOnPress).toHaveBeenCalled();
156
+ expect(queryByLabelText("hi")).toBeNull();
157
+ });
158
+
159
+ it("transforms the text", () => {
160
+ const { getByLabelText, getByText } = setup({
161
+ menuOptions: [
162
+ {
163
+ label: "hi",
164
+ icon: "add",
165
+ onPress: mockOnPress,
166
+ textTransform: "capitalize",
167
+ },
168
+ ],
169
+ });
170
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
171
+ expect(getByText("Hi")).toBeDefined();
172
+ });
173
+
174
+ it("does not transform the text when textTransform set to none", () => {
175
+ const { getByLabelText, getByText } = setup({
176
+ menuOptions: [
177
+ {
178
+ label: "hi",
179
+ icon: "add",
180
+ onPress: mockOnPress,
181
+ textTransform: "none",
182
+ },
183
+ ],
184
+ });
185
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
186
+ expect(getByText("hi")).toBeDefined();
187
+ });
188
+ });
189
+
190
+ it("renders a menu when the default activator is clicked", () => {
191
+ const { getByLabelText, getByTestId } = setup({
192
+ menuOptions: [{ label: "hi", icon: "add", onPress: mockOnPress }],
193
+ });
194
+
195
+ fireEvent.press(getByLabelText(messages.more.defaultMessage));
196
+
197
+ expect(getByTestId("ATL-MENU-OPTIONS")).toBeDefined();
198
+ expect(getByTestId("add")).toBeDefined();
199
+ expect(getByLabelText("hi")).toBeDefined();
200
+ });
201
+ });
@@ -0,0 +1,116 @@
1
+ import React, { useCallback, useRef, useState } from "react";
2
+ import {
3
+ LayoutRectangle,
4
+ Pressable,
5
+ View,
6
+ useWindowDimensions,
7
+ } from "react-native";
8
+ import { Portal } from "react-native-portalize";
9
+ import { useIntl } from "react-intl";
10
+ import { useSafeAreaFrame } from "react-native-safe-area-context";
11
+ import { styles } from "./Menu.style";
12
+ import { messages } from "./messages";
13
+ import { findViewpoint } from "./utils";
14
+ import { MenuProps } from "./types";
15
+ import { MenuOption } from "./components/MenuOption";
16
+ import { Overlay } from "./components/Overlay";
17
+ import { tokens } from "../utils/design";
18
+ import { Button } from "../Button";
19
+ import { Content } from "../Content";
20
+ import { useAtlantisContext } from "../AtlantisContext";
21
+
22
+ export function Menu({ menuOptions, customActivator }: MenuProps): JSX.Element {
23
+ const [open, setOpen] = useState<boolean>(false);
24
+ const [menuPosition, setMenuPosition] = useState<object>();
25
+ const activatorLayout = useRef<LayoutRectangle>();
26
+ const menuButtonRef = useRef<View | null>();
27
+ const screenInfo = useScreenInformation();
28
+
29
+ const { formatMessage } = useIntl();
30
+
31
+ const findMenuLayout = useCallback(() => {
32
+ if (activatorLayout.current) {
33
+ setMenuPosition(findViewpoint(screenInfo, activatorLayout.current));
34
+ }
35
+ }, [screenInfo, activatorLayout]);
36
+
37
+ const activatorOnPress = (onPress?: () => void) => {
38
+ menuButtonRef.current?.measureInWindow(
39
+ (x: number, y: number, width: number, height: number) => {
40
+ activatorLayout.current = {
41
+ x,
42
+ y,
43
+ width,
44
+ height,
45
+ };
46
+ findMenuLayout();
47
+ setOpen(true);
48
+ onPress && onPress();
49
+ },
50
+ );
51
+ };
52
+
53
+ return (
54
+ <>
55
+ <View
56
+ ref={ref => {
57
+ menuButtonRef.current = ref;
58
+ }}
59
+ collapsable={false}
60
+ >
61
+ {customActivator && (
62
+ <Pressable
63
+ style={({ pressed }) => [
64
+ {
65
+ opacity: pressed ? tokens["opacity-pressed"] : 1,
66
+ },
67
+ ]}
68
+ pointerEvents="box-only"
69
+ onPress={() => {
70
+ activatorOnPress(customActivator.props.onPress);
71
+ }}
72
+ onLongPress={customActivator.props.onLongPress}
73
+ >
74
+ {customActivator}
75
+ </Pressable>
76
+ )}
77
+
78
+ {!customActivator && (
79
+ <Button
80
+ icon="more"
81
+ accessibilityLabel={formatMessage(messages.more)}
82
+ variation="cancel"
83
+ type="tertiary"
84
+ onPress={() => {
85
+ activatorOnPress();
86
+ }}
87
+ />
88
+ )}
89
+ </View>
90
+ <Portal>
91
+ {open && (
92
+ <>
93
+ <Overlay setOpen={setOpen} />
94
+ <View style={[open && menuPosition, styles.menu]}>
95
+ <Content spacing="none" childSpacing="small">
96
+ {menuOptions?.map((menuOption, index) => {
97
+ return (
98
+ <MenuOption {...menuOption} key={index} setOpen={setOpen} />
99
+ );
100
+ })}
101
+ </Content>
102
+ </View>
103
+ </>
104
+ )}
105
+ </Portal>
106
+ </>
107
+ );
108
+ }
109
+
110
+ function useScreenInformation() {
111
+ const { headerHeight } = useAtlantisContext();
112
+ const windowWidth = useWindowDimensions().width;
113
+ const { height: windowHeight } = useSafeAreaFrame();
114
+
115
+ return { headerHeight, windowWidth, windowHeight };
116
+ }
@@ -0,0 +1,11 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { tokens } from "../../../utils/design";
3
+
4
+ export const styles = StyleSheet.create({
5
+ menuOption: {
6
+ display: "flex",
7
+ paddingHorizontal: tokens["space-base"],
8
+ paddingVertical: tokens["space-small"],
9
+ borderRadius: tokens["radius-large"],
10
+ },
11
+ });
@@ -0,0 +1,63 @@
1
+ import React from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import capitalize from "lodash/capitalize";
4
+ import { styles } from "./MenuOption.style";
5
+ import { MenuOptionInternalProps } from "../../types";
6
+ import { tokens } from "../../../utils/design";
7
+ import { Flex } from "../../../Flex";
8
+ import { Typography } from "../../../Typography";
9
+ import { Icon } from "../../../Icon";
10
+
11
+ export function MenuOption({
12
+ label,
13
+ icon,
14
+ iconColor = "heading",
15
+ textAlign,
16
+ destructive,
17
+ textTransform = "capitalize",
18
+ onPress,
19
+ setOpen,
20
+ }: MenuOptionInternalProps): JSX.Element {
21
+ const destructiveColor = "critical";
22
+ const textVariation = destructive ? destructiveColor : "heading";
23
+ return (
24
+ <View testID="ATL-MENU-OPTIONS">
25
+ <Pressable
26
+ style={({ pressed }) => [
27
+ styles.menuOption,
28
+ { opacity: pressed ? tokens["opacity-pressed"] : 1 },
29
+ ]}
30
+ onPress={() => {
31
+ onPress();
32
+ setOpen(false);
33
+ }}
34
+ accessibilityLabel={label}
35
+ >
36
+ <Flex
37
+ template={["grow", "shrink"]}
38
+ align={"flex-start"}
39
+ gap={"smaller"}
40
+ >
41
+ <Typography
42
+ selectable={false}
43
+ color={textVariation}
44
+ fontWeight={"semiBold"}
45
+ lineHeight={"large"}
46
+ align={textAlign}
47
+ >
48
+ {textTransform === "capitalize"
49
+ ? capitalize(label.toLocaleLowerCase())
50
+ : label}
51
+ </Typography>
52
+
53
+ {icon && (
54
+ <Icon
55
+ name={icon}
56
+ color={destructive ? destructiveColor : iconColor}
57
+ />
58
+ )}
59
+ </Flex>
60
+ </Pressable>
61
+ </View>
62
+ );
63
+ }
@@ -0,0 +1 @@
1
+ export { MenuOption } from "./MenuOption";
@@ -0,0 +1,13 @@
1
+ import { Dimensions, StyleSheet } from "react-native";
2
+ import { tokens } from "../../../utils/design";
3
+
4
+ const { height } = Dimensions.get("window");
5
+
6
+ export const styles = StyleSheet.create({
7
+ overlay: {
8
+ ...StyleSheet.absoluteFillObject,
9
+ backgroundColor: tokens["color-overlay"],
10
+ opacity: 0,
11
+ height,
12
+ },
13
+ });
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { styles } from "./Overlay.style";
4
+ import { OverlayProp } from "../../types";
5
+
6
+ export function Overlay({ setOpen }: OverlayProp): JSX.Element {
7
+ return (
8
+ <Pressable
9
+ onPressIn={() => {
10
+ setOpen(false);
11
+ }}
12
+ >
13
+ <View style={styles.overlay} />
14
+ </Pressable>
15
+ );
16
+ }
@@ -0,0 +1 @@
1
+ export { Overlay } from "./Overlay";