@terreno/ui 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/ActionSheet.d.ts +5 -5
  2. package/dist/ActionSheet.js +2 -2
  3. package/dist/ActionSheet.js.map +1 -1
  4. package/dist/Avatar.js +1 -1
  5. package/dist/Avatar.js.map +1 -1
  6. package/dist/Banner.js.map +1 -1
  7. package/dist/Box.js +2 -0
  8. package/dist/Box.js.map +1 -1
  9. package/dist/Button.d.ts +2 -2
  10. package/dist/Button.js +35 -23
  11. package/dist/Button.js.map +1 -1
  12. package/dist/Common.d.ts +16 -4
  13. package/dist/Common.js +4 -4
  14. package/dist/Common.js.map +1 -1
  15. package/dist/ConsentFormScreen.js +3 -3
  16. package/dist/ConsentFormScreen.js.map +1 -1
  17. package/dist/ConsentNavigator.d.ts +1 -1
  18. package/dist/ConsentNavigator.js +2 -1
  19. package/dist/ConsentNavigator.js.map +1 -1
  20. package/dist/CustomSelectField.js +3 -1
  21. package/dist/CustomSelectField.js.map +1 -1
  22. package/dist/DataTable.js +1 -1
  23. package/dist/DataTable.js.map +1 -1
  24. package/dist/DateTimeActionSheet.js +2 -1
  25. package/dist/DateTimeActionSheet.js.map +1 -1
  26. package/dist/DateTimeField.js +3 -2
  27. package/dist/DateTimeField.js.map +1 -1
  28. package/dist/DateUtilities.d.ts +25 -25
  29. package/dist/DateUtilities.js +31 -32
  30. package/dist/DateUtilities.js.map +1 -1
  31. package/dist/HeightField.js.map +1 -1
  32. package/dist/Hyperlink.js +19 -9
  33. package/dist/Hyperlink.js.map +1 -1
  34. package/dist/IconButton.js.map +1 -1
  35. package/dist/ImageBackground.d.ts +2 -5
  36. package/dist/ImageBackground.js +1 -1
  37. package/dist/ImageBackground.js.map +1 -1
  38. package/dist/MediaQuery.d.ts +4 -4
  39. package/dist/MediaQuery.js +8 -8
  40. package/dist/MediaQuery.js.map +1 -1
  41. package/dist/ModalSheet.d.ts +3 -2
  42. package/dist/ModalSheet.js +1 -1
  43. package/dist/ModalSheet.js.map +1 -1
  44. package/dist/OfflineBanner.d.ts +21 -0
  45. package/dist/OfflineBanner.js +25 -0
  46. package/dist/OfflineBanner.js.map +1 -0
  47. package/dist/OpenAPIContext.js +1 -1
  48. package/dist/OpenAPIContext.js.map +1 -1
  49. package/dist/Page.d.ts +1 -0
  50. package/dist/Page.js +7 -2
  51. package/dist/Page.js.map +1 -1
  52. package/dist/Pagination.js.map +1 -1
  53. package/dist/Permissions.js +3 -0
  54. package/dist/Permissions.js.map +1 -1
  55. package/dist/PickerSelect.d.ts +1 -1
  56. package/dist/PickerSelect.js +9 -6
  57. package/dist/PickerSelect.js.map +1 -1
  58. package/dist/SelectField.js +1 -1
  59. package/dist/SelectField.js.map +1 -1
  60. package/dist/SplitPage.js +7 -2
  61. package/dist/SplitPage.js.map +1 -1
  62. package/dist/SplitPage.native.js +4 -1
  63. package/dist/SplitPage.native.js.map +1 -1
  64. package/dist/TapToEdit.d.ts +1 -1
  65. package/dist/TapToEdit.js +12 -14
  66. package/dist/TapToEdit.js.map +1 -1
  67. package/dist/Toast.js.map +1 -1
  68. package/dist/ToastNotifications.js +2 -2
  69. package/dist/ToastNotifications.js.map +1 -1
  70. package/dist/Tooltip.d.ts +24 -1
  71. package/dist/Tooltip.js +2 -2
  72. package/dist/Tooltip.js.map +1 -1
  73. package/dist/Unifier.d.ts +3 -3
  74. package/dist/Unifier.js +15 -12
  75. package/dist/Unifier.js.map +1 -1
  76. package/dist/Utilities.d.ts +12 -8
  77. package/dist/Utilities.js +13 -15
  78. package/dist/Utilities.js.map +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.js +2 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/signUp/PasswordRequirements.js +3 -3
  83. package/dist/signUp/PasswordRequirements.js.map +1 -1
  84. package/dist/table/TableHeaderCell.js +1 -9
  85. package/dist/table/TableHeaderCell.js.map +1 -1
  86. package/dist/table/tableContext.d.ts +1 -1
  87. package/dist/table/tableContext.js +2 -2
  88. package/dist/table/tableContext.js.map +1 -1
  89. package/package.json +2 -1
  90. package/src/ActionSheet.test.tsx +1 -0
  91. package/src/ActionSheet.tsx +8 -6
  92. package/src/Avatar.tsx +9 -2
  93. package/src/Badge.test.tsx +1 -0
  94. package/src/Banner.test.tsx +71 -0
  95. package/src/Banner.tsx +1 -1
  96. package/src/Box.test.tsx +1 -0
  97. package/src/Box.tsx +10 -6
  98. package/src/Button.test.tsx +35 -0
  99. package/src/Button.tsx +65 -34
  100. package/src/Common.ts +42 -19
  101. package/src/ConsentFormScreen.test.tsx +124 -0
  102. package/src/ConsentFormScreen.tsx +18 -6
  103. package/src/ConsentNavigator.test.tsx +1 -0
  104. package/src/ConsentNavigator.tsx +5 -3
  105. package/src/CustomSelectField.tsx +3 -1
  106. package/src/DataTable.test.tsx +218 -0
  107. package/src/DataTable.tsx +1 -1
  108. package/src/DateTimeActionSheet.tsx +7 -3
  109. package/src/DateTimeField.test.tsx +1 -0
  110. package/src/DateTimeField.tsx +3 -2
  111. package/src/DateUtilities.test.ts +111 -0
  112. package/src/DateUtilities.tsx +43 -44
  113. package/src/DecimalRangeActionSheet.test.tsx +28 -0
  114. package/src/ErrorBoundary.test.tsx +1 -0
  115. package/src/HeightActionSheet.test.tsx +16 -0
  116. package/src/HeightField.test.tsx +106 -1
  117. package/src/HeightField.tsx +2 -1
  118. package/src/Hyperlink.tsx +83 -52
  119. package/src/IconButton.tsx +1 -1
  120. package/src/ImageBackground.tsx +5 -6
  121. package/src/MediaQuery.ts +8 -8
  122. package/src/MobileAddressAutoComplete.test.tsx +20 -1
  123. package/src/ModalSheet.test.tsx +1 -5
  124. package/src/ModalSheet.tsx +15 -6
  125. package/src/NumberField.test.tsx +14 -0
  126. package/src/OfflineBanner.test.tsx +70 -0
  127. package/src/OfflineBanner.tsx +54 -0
  128. package/src/OpenAPIContext.tsx +3 -2
  129. package/src/Page.test.tsx +28 -0
  130. package/src/Page.tsx +18 -2
  131. package/src/Pagination.tsx +1 -1
  132. package/src/Permissions.ts +3 -0
  133. package/src/PickerSelect.tsx +20 -17
  134. package/src/SelectBadge.test.tsx +1 -0
  135. package/src/SelectField.tsx +1 -1
  136. package/src/Signature.test.tsx +1 -0
  137. package/src/SplitPage.native.tsx +2 -0
  138. package/src/SplitPage.tsx +6 -1
  139. package/src/TapToEdit.test.tsx +48 -0
  140. package/src/TapToEdit.tsx +13 -14
  141. package/src/Toast.tsx +1 -1
  142. package/src/ToastNotifications.test.tsx +738 -0
  143. package/src/ToastNotifications.tsx +3 -6
  144. package/src/Tooltip.test.tsx +586 -8
  145. package/src/Tooltip.tsx +2 -2
  146. package/src/Unifier.ts +20 -16
  147. package/src/Utilities.tsx +20 -19
  148. package/src/WebAddressAutocomplete.test.tsx +138 -0
  149. package/src/WebDropdownMenu.test.tsx +23 -0
  150. package/src/__snapshots__/AddressField.test.tsx.snap +3 -1
  151. package/src/__snapshots__/Button.test.tsx.snap +92 -50
  152. package/src/__snapshots__/CustomSelectField.test.tsx.snap +21 -7
  153. package/src/__snapshots__/DecimalRangeActionSheet.test.tsx.snap +14 -8
  154. package/src/__snapshots__/ErrorPage.test.tsx.snap +7 -4
  155. package/src/__snapshots__/Field.test.tsx.snap +18 -6
  156. package/src/__snapshots__/HeightActionSheet.test.tsx.snap +14 -8
  157. package/src/__snapshots__/HeightField.test.tsx.snap +35 -20
  158. package/src/__snapshots__/InfoModalIcon.test.tsx.snap +28 -16
  159. package/src/__snapshots__/Modal.test.tsx.snap +19 -10
  160. package/src/__snapshots__/ModalSheet.test.tsx.snap +0 -1
  161. package/src/__snapshots__/NumberPickerActionSheet.test.tsx.snap +14 -8
  162. package/src/__snapshots__/Page.test.tsx.snap +7 -4
  163. package/src/__snapshots__/SelectField.test.tsx.snap +18 -6
  164. package/src/__snapshots__/TerrenoProvider.test.tsx.snap +0 -2
  165. package/src/__snapshots__/TimezonePicker.test.tsx.snap +18 -6
  166. package/src/bunSetup.ts +25 -2
  167. package/src/index.tsx +2 -1
  168. package/src/login/LoginScreen.test.tsx +23 -1
  169. package/src/login/__snapshots__/LoginScreen.test.tsx.snap +15 -6
  170. package/src/signUp/PasswordRequirements.tsx +9 -6
  171. package/src/signUp/__snapshots__/PasswordRequirements.test.tsx.snap +50 -2
  172. package/src/signUp/__snapshots__/SignUpScreen.test.tsx.snap +35 -5
  173. package/src/table/TableHeaderCell.tsx +8 -11
  174. package/src/table/TableRow.test.tsx +31 -1
  175. package/src/table/__snapshots__/TableBadge.test.tsx.snap +3 -1
  176. package/src/table/__snapshots__/TableHeaderCell.test.tsx.snap +2 -0
  177. package/src/table/tableContext.tsx +2 -2
  178. package/src/types/react-native-swiper-flatlist.d.ts +1 -0
  179. package/src/useStoredState.test.tsx +47 -0
@@ -56,7 +56,7 @@ export const styles = StyleSheet.create({
56
56
  },
57
57
  });
58
58
 
59
- export function getDeviceHeight(statusBarTranslucent: boolean | undefined): number {
59
+ export const getDeviceHeight = (statusBarTranslucent: boolean | undefined): number => {
60
60
  const height = Dimensions.get("window").height;
61
61
 
62
62
  if (Platform.OS === "android" && !statusBarTranslucent) {
@@ -64,7 +64,7 @@ export function getDeviceHeight(statusBarTranslucent: boolean | undefined): numb
64
64
  }
65
65
 
66
66
  return height;
67
- }
67
+ };
68
68
 
69
69
  export const getElevation = (elevation?: number) => {
70
70
  if (!elevation) {
@@ -133,7 +133,7 @@ const defaultProps = {
133
133
 
134
134
  type Props = Partial<typeof defaultProps> & ActionSheetProps;
135
135
 
136
- export class ActionSheet extends Component<Props, State, any> {
136
+ export class ActionSheet extends Component<Props, State, unknown> {
137
137
  static defaultProps = defaultProps;
138
138
 
139
139
  actionSheetHeight = 0;
@@ -144,7 +144,7 @@ export class ActionSheet extends Component<Props, State, any> {
144
144
 
145
145
  prevScroll = 0;
146
146
 
147
- timeout: any | null = null;
147
+ timeout: ReturnType<typeof setTimeout> | null = null;
148
148
 
149
149
  offsetY = 0;
150
150
 
@@ -164,8 +164,10 @@ export class ActionSheet extends Component<Props, State, any> {
164
164
 
165
165
  deviceLayoutCalled = false;
166
166
 
167
+ // biome-ignore lint/suspicious/noExplicitAny: FlatList ref is accessed via internal _listRef._scrollRef which is not part of the public type
167
168
  scrollViewRef: React.RefObject<any>;
168
169
 
170
+ // biome-ignore lint/suspicious/noExplicitAny: SafeAreaView ref is passed to findNodeHandle and accessed via untyped React Native internals
169
171
  safeAreaViewRef: React.RefObject<any>;
170
172
 
171
173
  transformValue: Animated.Value;
@@ -669,7 +671,7 @@ export class ActionSheet extends Component<Props, State, any> {
669
671
  this.keyboardDidHideListener?.remove();
670
672
  }
671
673
 
672
- _onDeviceLayout = async (_event: any) => {
674
+ _onDeviceLayout = async (_event: LayoutChangeEvent) => {
673
675
  const event = {..._event};
674
676
 
675
677
  if (this.timeout) {
@@ -721,7 +723,7 @@ export class ActionSheet extends Component<Props, State, any> {
721
723
  return scrollPosition;
722
724
  }
723
725
 
724
- _keyExtractor = (item: any) => item;
726
+ _keyExtractor = (item: string) => item;
725
727
 
726
728
  render() {
727
729
  const {scrollable, modalVisible, keyboard} = this.state;
package/src/Avatar.tsx CHANGED
@@ -3,7 +3,14 @@ import {launchImageLibraryAsync} from "expo-image-picker";
3
3
  import {LinearGradient} from "expo-linear-gradient";
4
4
  import type React from "react";
5
5
  import {type FC, useState} from "react";
6
- import {Image, Pressable, Text, View} from "react-native";
6
+ import {
7
+ Image,
8
+ type ImageErrorEventData,
9
+ type NativeSyntheticEvent,
10
+ Pressable,
11
+ Text,
12
+ View,
13
+ } from "react-native";
7
14
 
8
15
  import type {AvatarProps, CustomSvgProps} from "./Common";
9
16
  import {Icon} from "./Icon";
@@ -88,7 +95,7 @@ export const Avatar: FC<AvatarProps> = ({
88
95
  console.warn("Avatars with the status of 'imagePicker' should also have an onChange property.");
89
96
  }
90
97
 
91
- const handleImageError = (event: any) => {
98
+ const handleImageError = (event: NativeSyntheticEvent<ImageErrorEventData>) => {
92
99
  setIsImageLoaded(false);
93
100
  console.warn("Image load error: ", event);
94
101
  };
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {describe, expect, it} from "bun:test";
2
3
 
3
4
  import {Badge} from "./Badge";
@@ -168,4 +168,75 @@ describe("Banner", () => {
168
168
  expect(setItemMock).not.toHaveBeenCalled();
169
169
  });
170
170
  });
171
+
172
+ it("hides banner when storage already has the dismissed flag", async () => {
173
+ const getItemMock = Unifier.storage.getItem as ReturnType<typeof mock>;
174
+ getItemMock.mockReturnValueOnce(Promise.resolve("true"));
175
+
176
+ const {queryByText} = renderWithTheme(
177
+ <Banner dismissible id="stored-banner" text="Previously dismissed" />
178
+ );
179
+
180
+ await waitFor(() => {
181
+ expect(queryByText("Previously dismissed")).toBeNull();
182
+ });
183
+ });
184
+
185
+ it("exercises the async .then path in useEffect", async () => {
186
+ const getItemMock = Unifier.storage.getItem as ReturnType<typeof mock>;
187
+ getItemMock.mockReturnValueOnce(Promise.resolve(null));
188
+
189
+ const {queryByText} = renderWithTheme(
190
+ <Banner dismissible id="flush-banner" text="Flush banner" />
191
+ );
192
+
193
+ await act(async () => {
194
+ await new Promise((resolve) => setTimeout(resolve, 50));
195
+ });
196
+
197
+ expect(queryByText("Flush banner")).toBeTruthy();
198
+ });
199
+
200
+ it("renders button without icon name (text-only button path)", async () => {
201
+ const handleClick = mock(() => Promise.resolve());
202
+ const {getByText} = renderWithTheme(
203
+ <Banner
204
+ buttonOnClick={handleClick}
205
+ buttonText="TextOnly"
206
+ id="textonly-banner"
207
+ text="Banner"
208
+ />
209
+ );
210
+ expect(getByText("TextOnly")).toBeTruthy();
211
+
212
+ await act(async () => {
213
+ fireEvent.press(getByText("TextOnly"));
214
+ await new Promise((resolve) => setTimeout(resolve, 100));
215
+ });
216
+
217
+ await waitFor(() => {
218
+ expect(handleClick).toHaveBeenCalled();
219
+ });
220
+ });
221
+
222
+ it("covers catch block when buttonOnClick rejects", async () => {
223
+ const handleClick = mock(() => Promise.reject(new Error("boom")));
224
+ const {UNSAFE_root} = renderWithTheme(
225
+ <Banner buttonOnClick={handleClick} buttonText="Fail" id="catch-banner" text="Banner" />
226
+ );
227
+
228
+ const pressable = UNSAFE_root.findAll(
229
+ (node) => node.props?.["aria-label"] === "Fail" && typeof node.props?.onPress === "function"
230
+ )[0];
231
+
232
+ try {
233
+ await act(async () => {
234
+ await pressable.props.onPress();
235
+ });
236
+ } catch (_e) {
237
+ // Expected: catch block in BannerButton re-throws
238
+ }
239
+
240
+ expect(handleClick).toHaveBeenCalled();
241
+ });
171
242
  });
package/src/Banner.tsx CHANGED
@@ -54,7 +54,7 @@ const BannerButton = ({
54
54
  alignItems: "center",
55
55
  alignSelf: "stretch",
56
56
  backgroundColor: theme.surface.base,
57
- borderRadius: theme.radius.rounded as any,
57
+ borderRadius: theme.radius.rounded,
58
58
  flexDirection: "column",
59
59
  justifyContent: "center",
60
60
  paddingHorizontal: 12,
package/src/Box.test.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {describe, expect, it, mock, spyOn} from "bun:test";
2
3
  import {act, fireEvent} from "@testing-library/react-native";
3
4
  import React from "react";
package/src/Box.tsx CHANGED
@@ -86,7 +86,9 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
86
86
 
87
87
  const BOX_STYLE_MAP: {
88
88
  [prop: string]: (
89
+ // biome-ignore lint/suspicious/noExplicitAny: Box's style mapper accepts heterogeneous prop values (spacing, colors, dimensions, theme tokens, booleans) that cannot be enumerated in a single union; the mapper functions narrow at call site
89
90
  value: any,
91
+ // biome-ignore lint/suspicious/noExplicitAny: see above - heterogeneous prop values
90
92
  all: {[prop: string]: any}
91
93
  ) => {[style: string]: string | number} | {};
92
94
  } = {
@@ -125,8 +127,8 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
125
127
  },
126
128
  bottom: (bottom) => ({bottom: bottom ? 0 : undefined}),
127
129
  color: (value: keyof SurfaceTheme) => ({backgroundColor: theme.surface[value]}),
128
- direction: (value: any) => ({display: "flex", flexDirection: value}),
129
- display: (value: any) => {
130
+ direction: (value: "row" | "column") => ({display: "flex", flexDirection: value}),
131
+ display: (value: "none" | "flex" | "block" | "inlineBlock" | "visuallyHidden") => {
130
132
  if (value === "none") {
131
133
  return {display: "none"};
132
134
  }
@@ -157,7 +159,7 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
157
159
  },
158
160
  justifyContent: (value: JustifyContent) => ({justifyContent: ALIGN_CONTENT[value]}),
159
161
  left: (left) => ({left: left ? 0 : undefined}),
160
- lgDirection: (value: any) =>
162
+ lgDirection: (value: "row" | "column") =>
161
163
  mediaQueryLargerThan("lg") ? {display: "flex", flexDirection: value} : {},
162
164
  margin: (value) => ({margin: getSpacing(value)}),
163
165
  marginBottom: (value) => ({marginBottom: getSpacing(value)}),
@@ -182,7 +184,7 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
182
184
  }
183
185
  return {maxWidth: value};
184
186
  },
185
- mdDirection: (value: any) =>
187
+ mdDirection: (value: "row" | "column") =>
186
188
  mediaQueryLargerThan("md") ? {display: "flex", flexDirection: value} : {},
187
189
  minHeight: (value) => {
188
190
  if (!isValidWidthHeight(value)) {
@@ -240,7 +242,7 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
240
242
  return {elevation: 4};
241
243
  }
242
244
  },
243
- smDirection: (value: any) =>
245
+ smDirection: (value: "row" | "column") =>
244
246
  mediaQueryLargerThan("sm") ? {display: "flex", flexDirection: value} : {},
245
247
  top: (top) => ({top: top ? 0 : undefined}),
246
248
  width: (value) => {
@@ -262,7 +264,9 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
262
264
 
263
265
  const scrollRef = props.scrollRef ?? React.createRef();
264
266
 
267
+ // biome-ignore lint/suspicious/noExplicitAny: the style object is assembled from heterogeneous mapper outputs and consumed by RN ViewStyle which has narrow union types per property
265
268
  const propsToStyle = (): any => {
269
+ // biome-ignore lint/suspicious/noExplicitAny: same as above - heterogeneous style assembly
266
270
  let style: any = {};
267
271
  for (const prop of Object.keys(props) as Array<keyof typeof props>) {
268
272
  const value = props[prop];
@@ -294,7 +298,7 @@ export const Box = React.forwardRef((props: BoxProps, ref) => {
294
298
  await props.onHoverEnd?.();
295
299
  };
296
300
 
297
- let box;
301
+ let box: React.ReactElement;
298
302
 
299
303
  // Adding the accessibilityRole of button throws a warning in React Native since we nest buttons
300
304
  // within Box and RN does not support nested buttons
@@ -56,6 +56,28 @@ describe("Button", () => {
56
56
  expect(toJSON()).toMatchSnapshot();
57
57
  });
58
58
 
59
+ it("defaults to scale press animation", () => {
60
+ const tree = renderWithTheme(<Button onClick={() => {}} text="Default animation" />).toJSON();
61
+ expect(Array.isArray(tree)).toBe(false);
62
+ expect(tree?.type).toBe("PressableScale");
63
+ });
64
+
65
+ it("renders opacity press animation", () => {
66
+ const tree = renderWithTheme(
67
+ <Button onClick={() => {}} pressAnimation="opacity" text="Opacity" />
68
+ ).toJSON();
69
+ expect(Array.isArray(tree)).toBe(false);
70
+ expect(tree?.type).toBe("PressableOpacity");
71
+ });
72
+
73
+ it("renders no press animation", () => {
74
+ const tree = renderWithTheme(
75
+ <Button onClick={() => {}} pressAnimation="none" text="No animation" />
76
+ ).toJSON();
77
+ expect(Array.isArray(tree)).toBe(false);
78
+ expect(tree?.type).toBe("PressableWithoutFeedback");
79
+ });
80
+
59
81
  // Disabled state
60
82
  it("renders disabled state", () => {
61
83
  const {toJSON} = renderWithTheme(<Button disabled onClick={() => {}} text="Disabled" />);
@@ -111,6 +133,19 @@ describe("Button", () => {
111
133
  });
112
134
  });
113
135
 
136
+ it("does not call onClick again on the trailing debounce edge after rapid presses", async () => {
137
+ const handleClick = mock(() => Promise.resolve());
138
+ const {getByText} = renderWithTheme(<Button onClick={handleClick} text="Click" />);
139
+
140
+ await act(async () => {
141
+ fireEvent.press(getByText("Click"));
142
+ fireEvent.press(getByText("Click"));
143
+ await new Promise((resolve) => setTimeout(resolve, 700));
144
+ });
145
+
146
+ expect(handleClick).toHaveBeenCalledTimes(1);
147
+ });
148
+
114
149
  // Confirmation modal tests
115
150
  it("renders with confirmation modal props", () => {
116
151
  const {toJSON} = renderWithTheme(
package/src/Button.tsx CHANGED
@@ -1,10 +1,17 @@
1
1
  import FontAwesome6 from "@expo/vector-icons/FontAwesome6";
2
2
  import debounce from "lodash/debounce";
3
- import {type FC, lazy, Suspense, useMemo, useState} from "react";
4
- import {ActivityIndicator, Pressable, Text, View} from "react-native";
3
+ import {
4
+ type CustomPressableProps,
5
+ PressableOpacity,
6
+ PressableScale,
7
+ PressableWithoutFeedback,
8
+ } from "pressto";
9
+ import type React from "react";
10
+ import {lazy, Suspense, useCallback, useMemo, useState} from "react";
11
+ import {ActivityIndicator, Pressable, type PressableProps, Text, View} from "react-native";
5
12
 
6
13
  import {Box} from "./Box";
7
- import type {ButtonProps} from "./Common";
14
+ import type {ButtonPressAnimation, ButtonProps} from "./Common";
8
15
  import {isMobileDevice} from "./MediaQuery";
9
16
  import {useTheme} from "./Theme";
10
17
  import {Tooltip} from "./Tooltip";
@@ -14,7 +21,20 @@ import {isNative} from "./Utilities";
14
21
  // Lazy load Modal to break the circular dependency: Modal -> Button -> Modal
15
22
  const LazyModal = lazy(() => import("./Modal").then((module) => ({default: module.Modal})));
16
23
 
17
- const ButtonComponent: FC<ButtonProps> = ({
24
+ const DEFAULT_BUTTON_PRESS_ANIMATION: ButtonPressAnimation = "scale";
25
+
26
+ const PRESSABLE_BY_ANIMATION: Record<
27
+ ButtonPressAnimation,
28
+ React.ComponentType<CustomPressableProps>
29
+ > = {
30
+ none: PressableWithoutFeedback,
31
+ opacity: PressableOpacity,
32
+ scale: PressableScale,
33
+ };
34
+
35
+ type ButtonPressableProps = CustomPressableProps & PressableProps;
36
+
37
+ const ButtonComponent: React.FC<ButtonProps> = ({
18
38
  confirmationText = "Are you sure you want to continue?",
19
39
  disabled = false,
20
40
  fullWidth = false,
@@ -23,6 +43,7 @@ const ButtonComponent: FC<ButtonProps> = ({
23
43
  loading: propsLoading,
24
44
  modalTitle = "Confirm",
25
45
  modalSubTitle,
46
+ pressAnimation = DEFAULT_BUTTON_PRESS_ANIMATION,
26
47
  testID,
27
48
  text,
28
49
  variant = "primary",
@@ -66,41 +87,51 @@ const ButtonComponent: FC<ButtonProps> = ({
66
87
  };
67
88
  }, [disabled, variant, theme]);
68
89
 
90
+ const handlePress = useCallback(async (): Promise<void> => {
91
+ await Unifier.utils.haptic();
92
+ setLoading(true);
93
+
94
+ try {
95
+ // If a confirmation is required, and the confirmation modal is not currently open,
96
+ // open it.
97
+ if (withConfirmation && !showConfirmation) {
98
+ setShowConfirmation(true);
99
+ } else if (!withConfirmation && onClick) {
100
+ // If a confirmation is not required, perform the action.
101
+ await onClick();
102
+ }
103
+ } catch (error) {
104
+ setLoading(false);
105
+ throw error;
106
+ }
107
+ setLoading(false);
108
+ }, [onClick, showConfirmation, withConfirmation]);
109
+
110
+ const debouncedHandlePress = useMemo(
111
+ () => debounce(handlePress, 500, {leading: true, trailing: false}),
112
+ [handlePress]
113
+ );
114
+
69
115
  if (!theme) {
70
116
  return null;
71
117
  }
72
118
 
119
+ const isPressDisabled = disabled || Boolean(loading);
120
+ const PressableComponent = (
121
+ isPressDisabled ? Pressable : PRESSABLE_BY_ANIMATION[pressAnimation]
122
+ ) as React.ComponentType<ButtonPressableProps>;
123
+ const pressableInteractionProps = isPressDisabled ? {disabled: true} : {enabled: true};
124
+
73
125
  return (
74
- <Pressable
126
+ <PressableComponent
75
127
  accessibilityHint={
76
128
  withConfirmation ? "Opens a confirmation dialog" : "Press to perform action"
77
129
  }
78
- aria-label={text}
79
- aria-role="button"
80
- disabled={disabled || loading}
81
- onPress={debounce(
82
- async () => {
83
- await Unifier.utils.haptic();
84
- setLoading(true);
85
-
86
- try {
87
- // If a confirmation is required, and the confirmation modal is not currently open,
88
- // open it
89
- if (withConfirmation && !showConfirmation) {
90
- setShowConfirmation(true);
91
- } else if (!withConfirmation && onClick) {
92
- // If a confirmation is not required, perform the action.
93
- await onClick();
94
- }
95
- } catch (error) {
96
- setLoading(false);
97
- throw error;
98
- }
99
- setLoading(false);
100
- },
101
- 500,
102
- {leading: true}
103
- )}
130
+ accessibilityLabel={text}
131
+ accessibilityRole="button"
132
+ accessibilityState={{disabled: isPressDisabled}}
133
+ {...pressableInteractionProps}
134
+ onPress={debouncedHandlePress}
104
135
  style={{
105
136
  alignItems: "center",
106
137
  alignSelf: fullWidth ? "stretch" : "flex-start",
@@ -141,7 +172,7 @@ const ButtonComponent: FC<ButtonProps> = ({
141
172
  <Suspense fallback={null}>
142
173
  <LazyModal
143
174
  onDismiss={() => setShowConfirmation(false)}
144
- primaryButtonOnClick={async () => {
175
+ primaryButtonOnClick={async (): Promise<void> => {
145
176
  await onClick();
146
177
  setShowConfirmation(false);
147
178
  }}
@@ -155,11 +186,11 @@ const ButtonComponent: FC<ButtonProps> = ({
155
186
  />
156
187
  </Suspense>
157
188
  )}
158
- </Pressable>
189
+ </PressableComponent>
159
190
  );
160
191
  };
161
192
 
162
- export const Button: FC<ButtonProps> = (props) => {
193
+ export const Button: React.FC<ButtonProps> = (props) => {
163
194
  const {tooltipText, tooltipIdealPosition, tooltipIncludeArrow = false} = props;
164
195
  const isMobileOrNative = isMobileDevice() || isNative();
165
196