@terreno/ui 0.9.3 → 0.11.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 (94) hide show
  1. package/dist/Banner.js +2 -2
  2. package/dist/Banner.js.map +1 -1
  3. package/dist/Common.d.ts +6 -10
  4. package/dist/Common.js.map +1 -1
  5. package/dist/ConsentHistory.d.ts +2 -1
  6. package/dist/DataTable.js +4 -2
  7. package/dist/DataTable.js.map +1 -1
  8. package/dist/DateUtilities.js +16 -7
  9. package/dist/DateUtilities.js.map +1 -1
  10. package/dist/DraggableList.d.ts +5 -4
  11. package/dist/DraggableList.js.map +1 -1
  12. package/dist/Icon.js +0 -3
  13. package/dist/Icon.js.map +1 -1
  14. package/dist/PasswordField.d.ts +1 -1
  15. package/dist/PasswordField.js.map +1 -1
  16. package/dist/TextFieldNumberActionSheet.d.ts +1 -1
  17. package/dist/Toast.d.ts +1 -1
  18. package/dist/Toast.js +2 -2
  19. package/dist/Toast.js.map +1 -1
  20. package/dist/index.d.ts +2 -2
  21. package/package.json +2 -1
  22. package/src/ActionSheet.test.tsx +262 -3
  23. package/src/AddressField.test.tsx +50 -0
  24. package/src/Banner.test.tsx +22 -0
  25. package/src/Banner.tsx +2 -2
  26. package/src/Box.test.tsx +218 -0
  27. package/src/Button.test.tsx +71 -0
  28. package/src/Common.ts +11 -9
  29. package/src/ConsentFormScreen.test.tsx +167 -0
  30. package/src/ConsentHistory.tsx +1 -1
  31. package/src/ConsentNavigator.test.tsx +210 -4
  32. package/src/DataTable.tsx +15 -15
  33. package/src/DateUtilities.test.ts +34 -16
  34. package/src/DateUtilities.tsx +24 -13
  35. package/src/DecimalRangeActionSheet.test.tsx +53 -2
  36. package/src/DraggableList.tsx +5 -5
  37. package/src/EmailField.test.tsx +81 -0
  38. package/src/EmojiSelector.test.tsx +262 -1
  39. package/src/ErrorBoundary.test.tsx +52 -1
  40. package/src/HeightActionSheet.test.tsx +57 -2
  41. package/src/Icon.tsx +0 -3
  42. package/src/InfoModalIcon.test.tsx +16 -0
  43. package/src/InfoTooltipButton.test.tsx +53 -1
  44. package/src/MobileAddressAutoComplete.test.tsx +137 -7
  45. package/src/Modal.test.tsx +188 -0
  46. package/src/NumberPickerActionSheet.test.tsx +59 -2
  47. package/src/OpenAPIContext.test.tsx +184 -3
  48. package/src/Page.test.tsx +162 -1
  49. package/src/Pagination.test.tsx +16 -0
  50. package/src/PasswordField.tsx +1 -1
  51. package/src/PhoneNumberField.test.tsx +46 -9
  52. package/src/PickerSelect.test.tsx +230 -0
  53. package/src/SegmentedControl.test.tsx +38 -0
  54. package/src/SelectBadge.test.tsx +52 -1
  55. package/src/SideDrawer.test.tsx +69 -0
  56. package/src/Signature.test.tsx +42 -5
  57. package/src/SignatureField.test.tsx +35 -0
  58. package/src/Slider.test.tsx +59 -0
  59. package/src/Spinner.test.tsx +6 -0
  60. package/src/SplitPage.test.tsx +228 -2
  61. package/src/TapToEdit.test.tsx +171 -1
  62. package/src/TerrenoProvider.test.tsx +42 -2
  63. package/src/TextFieldNumberActionSheet.tsx +1 -1
  64. package/src/Theme.test.tsx +118 -28
  65. package/src/Toast.test.tsx +95 -2
  66. package/src/Toast.tsx +3 -3
  67. package/src/Tooltip.test.tsx +204 -1
  68. package/src/UnifiedAddressAutoComplete.test.tsx +38 -19
  69. package/src/UserInactivity.test.tsx +73 -1
  70. package/src/Utilities.test.tsx +190 -2
  71. package/src/WebAddressAutocomplete.test.tsx +148 -1
  72. package/src/__snapshots__/ActionSheet.test.tsx.snap +1736 -0
  73. package/src/__snapshots__/Button.test.tsx.snap +68 -0
  74. package/src/__snapshots__/EmojiSelector.test.tsx.snap +1363 -0
  75. package/src/__snapshots__/InfoTooltipButton.test.tsx.snap +72 -3
  76. package/src/__snapshots__/MobileAddressAutoComplete.test.tsx.snap +60 -9
  77. package/src/__snapshots__/Modal.test.tsx.snap +181 -0
  78. package/src/__snapshots__/Page.test.tsx.snap +48 -2
  79. package/src/__snapshots__/PhoneNumberField.test.tsx.snap +0 -93
  80. package/src/__snapshots__/PickerSelect.test.tsx.snap +706 -0
  81. package/src/__snapshots__/SideDrawer.test.tsx.snap +533 -1399
  82. package/src/__snapshots__/Signature.test.tsx.snap +0 -3
  83. package/src/__snapshots__/SplitPage.test.tsx.snap +970 -0
  84. package/src/__snapshots__/UnifiedAddressAutoComplete.test.tsx.snap +220 -4
  85. package/src/__snapshots__/WebAddressAutocomplete.test.tsx.snap +93 -0
  86. package/src/bunSetup.ts +204 -121
  87. package/src/index.tsx +2 -2
  88. package/src/table/TableHeaderCell.test.tsx +142 -0
  89. package/src/table/TableRow.test.tsx +33 -0
  90. package/src/table/__snapshots__/TableRow.test.tsx.snap +403 -0
  91. package/src/table/tableContext.test.tsx +96 -0
  92. package/src/test-utils.tsx +1 -1
  93. package/src/useConsentForms.test.ts +130 -0
  94. package/src/useSubmitConsent.test.ts +64 -0
@@ -1,8 +1,20 @@
1
- import {describe, expect, it, mock} from "bun:test";
1
+ import {beforeAll, describe, expect, it, mock} from "bun:test";
2
+ import {render} from "@testing-library/react-native";
3
+ import {Text as RNText} from "react-native";
2
4
 
3
- import {Toast} from "./Toast";
5
+ import {Toast, useToast} from "./Toast";
6
+ import {ToastProvider} from "./ToastNotifications";
4
7
  import {renderWithTheme} from "./test-utils";
5
8
 
9
+ beforeAll(() => {
10
+ (global as any).requestAnimationFrame = (callback: FrameRequestCallback) => {
11
+ return setTimeout(() => callback(Date.now()), 0) as unknown as number;
12
+ };
13
+ (global as any).cancelAnimationFrame = (id: number) => {
14
+ clearTimeout(id);
15
+ };
16
+ });
17
+
6
18
  describe("Toast", () => {
7
19
  it("renders correctly with default props", () => {
8
20
  const {toJSON} = renderWithTheme(<Toast title="Test message" />);
@@ -80,3 +92,84 @@ describe("Toast", () => {
80
92
  });
81
93
  });
82
94
  });
95
+
96
+ describe("useToast", () => {
97
+ const renderHookWithProvider = (callback: (toast: ReturnType<typeof useToast>) => void) => {
98
+ let hookResult: ReturnType<typeof useToast> | null = null;
99
+ const Harness = () => {
100
+ const toast = useToast();
101
+ hookResult = toast;
102
+ return <RNText>harness</RNText>;
103
+ };
104
+ render(
105
+ <ToastProvider>
106
+ <Harness />
107
+ </ToastProvider>
108
+ );
109
+ if (hookResult) {
110
+ callback(hookResult);
111
+ }
112
+ return hookResult!;
113
+ };
114
+
115
+ it("returns an object with expected methods", () => {
116
+ const hook = renderHookWithProvider(() => {});
117
+ expect(typeof hook.show).toBe("function");
118
+ expect(typeof hook.success).toBe("function");
119
+ expect(typeof hook.info).toBe("function");
120
+ expect(typeof hook.warn).toBe("function");
121
+ expect(typeof hook.error).toBe("function");
122
+ expect(typeof hook.catch).toBe("function");
123
+ expect(typeof hook.hide).toBe("function");
124
+ });
125
+
126
+ it("returns empty string from show when provider is not ready", () => {
127
+ const originalWarn = console.warn;
128
+ console.warn = mock(() => {});
129
+ const Harness = () => {
130
+ const toast = useToast();
131
+ const result = toast.show("No provider");
132
+ return <RNText>{result}</RNText>;
133
+ };
134
+ // Render without ToastProvider, so useToastNotifications returns no ref
135
+ const {getByText} = renderWithTheme(<Harness />);
136
+ expect(getByText("")).toBeTruthy();
137
+ console.warn = originalWarn;
138
+ });
139
+
140
+ it("calls success, info, warn, error, show via hook without throwing", () => {
141
+ const hook = renderHookWithProvider(() => {});
142
+ expect(() => hook.success("success!")).not.toThrow();
143
+ expect(() => hook.info("info!")).not.toThrow();
144
+ expect(() => hook.warn("warn!")).not.toThrow();
145
+ expect(() => hook.error("error!")).not.toThrow();
146
+ expect(() => hook.show("plain", {variant: "info"})).not.toThrow();
147
+ expect(() => hook.show("persistent", {persistent: true})).not.toThrow();
148
+ });
149
+
150
+ it("catch handles plain errors by printing the message", () => {
151
+ const originalError = console.error;
152
+ console.error = mock(() => {});
153
+ const hook = renderHookWithProvider(() => {});
154
+ expect(() => hook.catch(new Error("boom"), "Failed")).not.toThrow();
155
+ expect(() => hook.catch("string error", "Failed")).not.toThrow();
156
+ expect(() => hook.catch({error: "something"}, "Failed")).not.toThrow();
157
+ console.error = originalError;
158
+ });
159
+
160
+ it("catch handles APIError by calling printAPIError", () => {
161
+ const originalError = console.error;
162
+ console.error = mock(() => {});
163
+ const hook = renderHookWithProvider(() => {});
164
+ const apiError = {
165
+ errors: [{detail: "Something bad", status: "500", title: "API Error"}],
166
+ };
167
+ expect(() => hook.catch(apiError, "Request failed")).not.toThrow();
168
+ console.error = originalError;
169
+ });
170
+
171
+ it("hide does not throw when id is valid", () => {
172
+ const hook = renderHookWithProvider(() => {});
173
+ expect(() => hook.hide("some-id")).not.toThrow();
174
+ });
175
+ });
package/src/Toast.tsx CHANGED
@@ -21,7 +21,7 @@ type UseToastVariantOptions = {
21
21
 
22
22
  type UseToastOptions = {variant?: ToastProps["variant"]} & UseToastVariantOptions;
23
23
 
24
- export function useToast(): {
24
+ export const useToast = (): {
25
25
  hide: (id: string) => void;
26
26
  success: (title: string, options?: UseToastVariantOptions) => string;
27
27
  info: (title: string, options?: UseToastVariantOptions) => string;
@@ -29,7 +29,7 @@ export function useToast(): {
29
29
  error: (title: string, options?: UseToastVariantOptions) => string;
30
30
  show: (title: string, options?: UseToastOptions) => string;
31
31
  catch: (error: any, message?: string, options?: UseToastVariantOptions) => void;
32
- } {
32
+ } => {
33
33
  const toast = useToastNotifications();
34
34
  const show = (title: string, options?: UseToastOptions): string => {
35
35
  if (!toast?.show) {
@@ -78,7 +78,7 @@ export function useToast(): {
78
78
  return show(title, {...options, variant: "warning"});
79
79
  },
80
80
  };
81
- }
81
+ };
82
82
 
83
83
  // TODO: Support secondary version of Toast.
84
84
  // TODO: Support dismissible version of Toast. Currently only persistent are dismissible.
@@ -1,9 +1,26 @@
1
- import {describe, expect, it} from "bun:test";
1
+ import {beforeAll, describe, expect, it, mock} from "bun:test";
2
+ import {act} from "@testing-library/react-native";
3
+ import {View} from "react-native";
2
4
 
3
5
  import {Text} from "./Text";
4
6
  import {Tooltip} from "./Tooltip";
5
7
  import {renderWithTheme} from "./test-utils";
6
8
 
9
+ // Mock react-native-portalize so Portal renders inline in tests
10
+ mock.module("react-native-portalize", () => ({
11
+ Host: ({children}: {children: React.ReactNode}) => <View testID="portal-host">{children}</View>,
12
+ Portal: ({children}: {children: React.ReactNode}) => <View testID="portal">{children}</View>,
13
+ }));
14
+
15
+ beforeAll(() => {
16
+ (global as any).requestAnimationFrame = (callback: FrameRequestCallback) => {
17
+ return setTimeout(() => callback(Date.now()), 0) as unknown as number;
18
+ };
19
+ (global as any).cancelAnimationFrame = (id: number) => {
20
+ clearTimeout(id);
21
+ };
22
+ });
23
+
7
24
  describe("Tooltip", () => {
8
25
  it("renders children correctly", () => {
9
26
  const {getByText} = renderWithTheme(
@@ -86,4 +103,190 @@ describe("Tooltip", () => {
86
103
  );
87
104
  expect(toJSON()).toMatchSnapshot();
88
105
  });
106
+
107
+ it("shows tooltip after hover in delay", async () => {
108
+ const {queryByTestId, toJSON} = renderWithTheme(
109
+ <Tooltip text="Hover reveals">
110
+ <Text>Hover me</Text>
111
+ </Tooltip>
112
+ );
113
+
114
+ const wrapper = toJSON() as any;
115
+ expect(wrapper).toBeTruthy();
116
+ expect(queryByTestId("tooltip-container")).toBeNull();
117
+
118
+ const tree = toJSON();
119
+ await act(async () => {
120
+ // Trigger pointer enter on the wrapper
121
+ const root = (tree as any).children?.[0];
122
+ if (root?.props?.onPointerEnter) {
123
+ root.props.onPointerEnter();
124
+ }
125
+ });
126
+
127
+ await act(async () => {
128
+ await new Promise((resolve) => setTimeout(resolve, 900));
129
+ });
130
+
131
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
132
+ });
133
+
134
+ it("shows tooltip on touch and hides on second touch", async () => {
135
+ const {queryByTestId, toJSON} = renderWithTheme(
136
+ <Tooltip text="Touch reveals">
137
+ <Text>Touch me</Text>
138
+ </Tooltip>
139
+ );
140
+
141
+ const tree = toJSON() as any;
142
+ const root = tree.children?.[0];
143
+
144
+ await act(async () => {
145
+ root.props.onTouchStart?.({nativeEvent: {}});
146
+ });
147
+ await act(async () => {
148
+ await new Promise((resolve) => setTimeout(resolve, 150));
149
+ });
150
+
151
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
152
+
153
+ // Second touch should hide
154
+ const treeAfterShow = toJSON() as any;
155
+ const updatedRoot = treeAfterShow.children?.[treeAfterShow.children.length - 1];
156
+ await act(async () => {
157
+ updatedRoot.props.onTouchStart?.({nativeEvent: {}});
158
+ });
159
+
160
+ expect(queryByTestId("tooltip-container")).toBeNull();
161
+ });
162
+
163
+ it("hides tooltip when onPointerLeave is triggered", async () => {
164
+ const {queryByTestId, toJSON} = renderWithTheme(
165
+ <Tooltip text="Hover reveals">
166
+ <Text>Hover me</Text>
167
+ </Tooltip>
168
+ );
169
+
170
+ const tree = toJSON() as any;
171
+ const root = tree.children?.[0];
172
+
173
+ await act(async () => {
174
+ root.props.onPointerEnter?.();
175
+ });
176
+ await act(async () => {
177
+ await new Promise((resolve) => setTimeout(resolve, 900));
178
+ });
179
+
180
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
181
+
182
+ const treeAfter = toJSON() as any;
183
+ const wrapper = treeAfter.children?.[treeAfter.children.length - 1];
184
+ await act(async () => {
185
+ wrapper.props.onPointerLeave?.();
186
+ });
187
+
188
+ await act(async () => {
189
+ await new Promise((resolve) => setTimeout(resolve, 50));
190
+ });
191
+
192
+ expect(queryByTestId("tooltip-container")).toBeNull();
193
+ });
194
+
195
+ it("calls onHoverIn/onHoverOut handlers from children props", async () => {
196
+ const onHoverIn = mock(() => {});
197
+ const onHoverOut = mock(() => {});
198
+ const TestChild: React.FC<{onHoverIn?: () => void; onHoverOut?: () => void}> = ({
199
+ onHoverIn: _in,
200
+ onHoverOut: _out,
201
+ }) => <Text>Child</Text>;
202
+
203
+ const {toJSON} = renderWithTheme(
204
+ <Tooltip text="Hover handlers">
205
+ <TestChild onHoverIn={onHoverIn} onHoverOut={onHoverOut} />
206
+ </Tooltip>
207
+ );
208
+
209
+ const tree = toJSON() as any;
210
+ const root = tree.children?.[0];
211
+
212
+ await act(async () => {
213
+ root.props.onPointerEnter?.();
214
+ });
215
+ expect(onHoverIn).toHaveBeenCalled();
216
+
217
+ await act(async () => {
218
+ root.props.onPointerLeave?.();
219
+ });
220
+ expect(onHoverOut).toHaveBeenCalled();
221
+ });
222
+
223
+ it("triggers onLayout and exercises getTooltipPosition with overflow cases", async () => {
224
+ const {queryByTestId, UNSAFE_getAllByType, toJSON} = renderWithTheme(
225
+ <Tooltip idealPosition="bottom" includeArrow text="Layout test">
226
+ <Text>Trigger</Text>
227
+ </Tooltip>
228
+ );
229
+
230
+ const tree = toJSON() as any;
231
+ const root = tree.children?.[0];
232
+
233
+ // Show the tooltip
234
+ await act(async () => {
235
+ root.props.onTouchStart?.({nativeEvent: {}});
236
+ });
237
+ await act(async () => {
238
+ await new Promise((resolve) => setTimeout(resolve, 150));
239
+ });
240
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
241
+
242
+ // Find any views with onLayout to simulate layout event
243
+ const {View: ViewComp} = await import("react-native");
244
+ const allViews = UNSAFE_getAllByType(ViewComp as any);
245
+ for (const v of allViews) {
246
+ if ((v.props as any).onLayout) {
247
+ await act(async () => {
248
+ (v.props as any).onLayout({
249
+ nativeEvent: {
250
+ layout: {height: 100, width: 200, x: 0, y: 0},
251
+ },
252
+ });
253
+ });
254
+ }
255
+ }
256
+ });
257
+
258
+ it("renders tooltip with arrow at all idealPositions", async () => {
259
+ const positions: Array<"top" | "bottom" | "left" | "right"> = [
260
+ "top",
261
+ "bottom",
262
+ "left",
263
+ "right",
264
+ ];
265
+
266
+ for (const position of positions) {
267
+ const {toJSON} = renderWithTheme(
268
+ <Tooltip idealPosition={position} includeArrow text={`${position} tooltip`}>
269
+ <Text>{position}</Text>
270
+ </Tooltip>
271
+ );
272
+ expect(toJSON()).toBeTruthy();
273
+ }
274
+ });
275
+
276
+ it("unmount hides tooltip and clears timers", async () => {
277
+ const {unmount, toJSON} = renderWithTheme(
278
+ <Tooltip text="Will unmount">
279
+ <Text>Unmount child</Text>
280
+ </Tooltip>
281
+ );
282
+
283
+ const tree = toJSON() as any;
284
+ const root = tree.children?.[0];
285
+ await act(async () => {
286
+ root.props.onPointerEnter?.();
287
+ });
288
+ unmount();
289
+ // No assertions needed - just ensuring no crashes on unmount.
290
+ expect(true).toBe(true);
291
+ });
89
292
  });
@@ -1,18 +1,9 @@
1
1
  import {describe, expect, it, mock} from "bun:test";
2
- import {forwardRef} from "react";
3
- import {Text, View} from "react-native";
2
+ import {fireEvent} from "@testing-library/react-native";
3
+
4
4
  import {renderWithTheme} from "./test-utils";
5
5
  import {UnifiedAddressAutoCompleteField} from "./UnifiedAddressAutoComplete";
6
6
 
7
- // Mock react-native-google-places-autocomplete (used by MobileAddressAutocomplete)
8
- mock.module("react-native-google-places-autocomplete", () => ({
9
- GooglePlacesAutocomplete: forwardRef(({placeholder}: any, ref) => (
10
- <View ref={ref as any} testID="google-places-autocomplete">
11
- <Text>{placeholder}</Text>
12
- </View>
13
- )),
14
- }));
15
-
16
7
  describe("UnifiedAddressAutoCompleteField", () => {
17
8
  const defaultProps = {
18
9
  handleAddressChange: () => {},
@@ -20,19 +11,24 @@ describe("UnifiedAddressAutoCompleteField", () => {
20
11
  inputValue: "",
21
12
  };
22
13
 
23
- it("renders correctly without Google API key (fallback to TextField)", () => {
14
+ it("renders plain TextField when no API key provided", () => {
24
15
  const {toJSON} = renderWithTheme(<UnifiedAddressAutoCompleteField {...defaultProps} />);
25
16
  expect(toJSON()).toMatchSnapshot();
26
17
  });
27
18
 
28
- it("component is defined", () => {
29
- expect(UnifiedAddressAutoCompleteField).toBeDefined();
30
- expect(typeof UnifiedAddressAutoCompleteField).toBe("function");
19
+ it("renders plain TextField when API key is invalid", () => {
20
+ const {toJSON} = renderWithTheme(
21
+ <UnifiedAddressAutoCompleteField {...defaultProps} googleMapsApiKey="invalid!key" />
22
+ );
23
+ expect(toJSON()).toMatchSnapshot();
31
24
  });
32
25
 
33
- it("renders with input value", () => {
26
+ it("renders WebAddressAutocomplete when valid API key provided on web", () => {
34
27
  const {toJSON} = renderWithTheme(
35
- <UnifiedAddressAutoCompleteField {...defaultProps} inputValue="123 Main St" />
28
+ <UnifiedAddressAutoCompleteField
29
+ {...defaultProps}
30
+ googleMapsApiKey="test-dummy-key-not-real-0123456789"
31
+ />
36
32
  );
37
33
  expect(toJSON()).toMatchSnapshot();
38
34
  });
@@ -44,10 +40,33 @@ describe("UnifiedAddressAutoCompleteField", () => {
44
40
  expect(toJSON()).toMatchSnapshot();
45
41
  });
46
42
 
47
- it("renders with invalid Google API key (falls back to TextField)", () => {
43
+ it("renders with input value", () => {
48
44
  const {toJSON} = renderWithTheme(
49
- <UnifiedAddressAutoCompleteField {...defaultProps} googleMapsApiKey="invalid" />
45
+ <UnifiedAddressAutoCompleteField {...defaultProps} inputValue="123 Main St" />
50
46
  );
51
47
  expect(toJSON()).toMatchSnapshot();
52
48
  });
49
+
50
+ it("renders with testID", () => {
51
+ const {toJSON} = renderWithTheme(
52
+ <UnifiedAddressAutoCompleteField {...defaultProps} testID="address-field" />
53
+ );
54
+ expect(toJSON()).toMatchSnapshot();
55
+ });
56
+
57
+ it("forwards typing to handleAddressChange via the fallback TextField", () => {
58
+ const handleAddressChange = mock(() => {});
59
+ const {UNSAFE_getAllByType} = renderWithTheme(
60
+ <UnifiedAddressAutoCompleteField
61
+ handleAddressChange={handleAddressChange}
62
+ handleAutoCompleteChange={() => {}}
63
+ inputValue=""
64
+ />
65
+ );
66
+ const {TextInput} = require("react-native");
67
+ const inputs = UNSAFE_getAllByType(TextInput);
68
+ expect(inputs.length).toBeGreaterThan(0);
69
+ fireEvent.changeText(inputs[0], "1600 Amphitheatre Pkwy");
70
+ expect(handleAddressChange).toHaveBeenCalledWith("1600 Amphitheatre Pkwy");
71
+ });
53
72
  });
@@ -1,5 +1,6 @@
1
- import {describe, expect, it, mock} from "bun:test";
1
+ import {describe, expect, it, mock, spyOn} from "bun:test";
2
2
  import {act} from "@testing-library/react-native";
3
+ import {Keyboard} from "react-native";
3
4
 
4
5
  import {Text} from "./Text";
5
6
  import {renderWithTheme} from "./test-utils";
@@ -93,4 +94,75 @@ describe("UserInactivity", () => {
93
94
  );
94
95
  expect(toJSON()).toBeTruthy();
95
96
  });
97
+
98
+ it("clears any pending timer when unmounted before the timeout fires", () => {
99
+ const onAction = mock((_active: boolean) => {});
100
+ const {unmount} = renderWithTheme(
101
+ <UserInactivity onAction={onAction} timeForInactivity={10_000}>
102
+ <Text>Test Content</Text>
103
+ </UserInactivity>
104
+ );
105
+
106
+ act(() => {
107
+ unmount();
108
+ });
109
+
110
+ expect(onAction).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it("removes keyboard listeners on unmount", () => {
114
+ const onAction = mock((_active: boolean) => {});
115
+ const removeHide = mock(() => {});
116
+ const removeShow = mock(() => {});
117
+ const addListenerSpy = spyOn(Keyboard, "addListener")
118
+ .mockReturnValueOnce({remove: removeHide} as any)
119
+ .mockReturnValueOnce({remove: removeShow} as any);
120
+
121
+ const {unmount} = renderWithTheme(
122
+ <UserInactivity onAction={onAction}>
123
+ <Text>Test Content</Text>
124
+ </UserInactivity>
125
+ );
126
+
127
+ act(() => {
128
+ unmount();
129
+ });
130
+
131
+ expect(addListenerSpy).toHaveBeenCalled();
132
+ expect(removeHide).toHaveBeenCalled();
133
+ expect(removeShow).toHaveBeenCalled();
134
+ addListenerSpy.mockRestore();
135
+ });
136
+
137
+ it("calls onAction with true when activity occurs after inactivity", async () => {
138
+ const onAction = mock((_active: boolean) => {});
139
+ let capturedHideCallback: (() => void) | undefined;
140
+ const addListenerSpy = spyOn(Keyboard, "addListener").mockImplementation(
141
+ (event: string, callback: () => void) => {
142
+ if (event === "keyboardDidHide") {
143
+ capturedHideCallback = callback;
144
+ }
145
+ return {remove: mock(() => {})} as any;
146
+ }
147
+ );
148
+
149
+ renderWithTheme(
150
+ <UserInactivity onAction={onAction} timeForInactivity={30}>
151
+ <Text>Test Content</Text>
152
+ </UserInactivity>
153
+ );
154
+
155
+ await act(async () => {
156
+ await new Promise((resolve) => setTimeout(resolve, 80));
157
+ });
158
+
159
+ expect(onAction).toHaveBeenCalledWith(false);
160
+
161
+ await act(async () => {
162
+ capturedHideCallback?.();
163
+ });
164
+
165
+ expect(onAction).toHaveBeenCalledWith(true);
166
+ addListenerSpy.mockRestore();
167
+ });
96
168
  });