@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
@@ -61,7 +61,7 @@ import {
61
61
  // useDimensions hook
62
62
  // ============================================================================
63
63
 
64
- function useDimensions() {
64
+ const useDimensions = () => {
65
65
  const [dimensions, setDimensions] = useState(Dimensions.get("window"));
66
66
 
67
67
  const onChange = useCallback(({window}: {window: ScaledSize}) => {
@@ -77,7 +77,7 @@ function useDimensions() {
77
77
  }, [onChange]);
78
78
 
79
79
  return dimensions;
80
- }
80
+ };
81
81
 
82
82
  // ============================================================================
83
83
  // Toast Options and Props
@@ -177,10 +177,7 @@ export interface ToastOptions {
177
177
  /**
178
178
  * Payload data for custom toasts. You can pass whatever you want
179
179
  */
180
- // noExplicitAny: This is the public API of a vendored 3rd-party library
181
- // (react-native-toast-notifications); the `data` field is an opaque
182
- // user-provided payload. Tightening to `unknown` breaks downstream consumers
183
- // that spread `data` into `Toast` (e.g. TerrenoProvider) without refactor.
180
+ // biome-ignore lint/suspicious/noExplicitAny: This is the public API of a vendored 3rd-party library (react-native-toast-notifications); the data field is an opaque user-provided payload. Tightening to unknown breaks downstream consumers that spread data into Toast (e.g. TerrenoProvider) without refactor.
184
181
  data?: any;
185
182
 
186
183
  swipeEnabled?: boolean;
@@ -1,9 +1,8 @@
1
- import {beforeAll, describe, expect, it, mock} from "bun:test";
1
+ import {afterEach, beforeAll, beforeEach, describe, expect, it, mock} from "bun:test";
2
2
  import {act} from "@testing-library/react-native";
3
- import {View} from "react-native";
4
3
 
5
4
  import {Text} from "./Text";
6
- import {Tooltip} from "./Tooltip";
5
+ import {Arrow, getTooltipPosition, Tooltip} from "./Tooltip";
7
6
  import {renderWithTheme} from "./test-utils";
8
7
 
9
8
  // Minimal shape of the tree returned by toJSON() that we rely on here.
@@ -20,11 +19,14 @@ interface TestNode {
20
19
  children: null | Array<TestNode | string>;
21
20
  }
22
21
 
23
- // Mock react-native-portalize so Portal renders inline in tests
24
- mock.module("react-native-portalize", () => ({
25
- Host: ({children}: {children: React.ReactNode}) => <View testID="portal-host">{children}</View>,
26
- Portal: ({children}: {children: React.ReactNode}) => <View testID="portal">{children}</View>,
27
- }));
22
+ type MeasureCallback = (
23
+ x: number,
24
+ y: number,
25
+ width: number,
26
+ height: number,
27
+ pageX: number,
28
+ pageY: number
29
+ ) => void;
28
30
 
29
31
  beforeAll(() => {
30
32
  globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => {
@@ -274,6 +276,198 @@ describe("Tooltip", () => {
274
276
  }
275
277
  });
276
278
 
279
+ it("getTooltipPosition returns empty when not measured", () => {
280
+ const result = getTooltipPosition({
281
+ children: {},
282
+ measured: false,
283
+ tooltip: {},
284
+ });
285
+ expect(result).toEqual({});
286
+ });
287
+
288
+ it("getTooltipPosition places tooltip at top (default) when space allows", () => {
289
+ const result = getTooltipPosition({
290
+ children: {height: 40, pageX: 200, pageY: 300, width: 100},
291
+ idealPosition: "top",
292
+ measured: true,
293
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
294
+ });
295
+ expect(result).toHaveProperty("finalPosition", "top");
296
+ expect(result).toHaveProperty("top");
297
+ expect(result).toHaveProperty("left");
298
+ });
299
+
300
+ it("getTooltipPosition places tooltip at bottom when specified", () => {
301
+ const result = getTooltipPosition({
302
+ children: {height: 40, pageX: 200, pageY: 100, width: 100},
303
+ idealPosition: "bottom",
304
+ measured: true,
305
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
306
+ });
307
+ expect(result).toHaveProperty("finalPosition", "bottom");
308
+ });
309
+
310
+ it("getTooltipPosition places tooltip at left when space allows", () => {
311
+ const result = getTooltipPosition({
312
+ children: {height: 40, pageX: 400, pageY: 200, width: 100},
313
+ idealPosition: "left",
314
+ measured: true,
315
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
316
+ });
317
+ expect(result).toHaveProperty("finalPosition", "left");
318
+ });
319
+
320
+ it("getTooltipPosition places tooltip at right when specified", () => {
321
+ const result = getTooltipPosition({
322
+ children: {height: 40, pageX: 50, pageY: 200, width: 100},
323
+ idealPosition: "right",
324
+ measured: true,
325
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
326
+ });
327
+ expect(result).toHaveProperty("finalPosition", "right");
328
+ });
329
+
330
+ it("getTooltipPosition falls back to bottom when top overflows", () => {
331
+ const result = getTooltipPosition({
332
+ children: {height: 40, pageX: 200, pageY: 5, width: 100},
333
+ idealPosition: "top",
334
+ measured: true,
335
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
336
+ });
337
+ expect(result).toHaveProperty("finalPosition", "bottom");
338
+ });
339
+
340
+ it("getTooltipPosition falls back to top when bottom overflows", () => {
341
+ const {Dimensions} = require("react-native");
342
+ const origGet = Dimensions.get;
343
+ Dimensions.get = () => ({fontScale: 1, height: 200, scale: 1, width: 800});
344
+ try {
345
+ const result = getTooltipPosition({
346
+ children: {height: 40, pageX: 200, pageY: 160, width: 100},
347
+ idealPosition: "bottom",
348
+ measured: true,
349
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
350
+ });
351
+ expect(result).toHaveProperty("finalPosition", "top");
352
+ } finally {
353
+ Dimensions.get = origGet;
354
+ }
355
+ });
356
+
357
+ it("getTooltipPosition falls back to bottom when right overflows", () => {
358
+ const {Dimensions} = require("react-native");
359
+ const origGet = Dimensions.get;
360
+ Dimensions.get = () => ({fontScale: 1, height: 800, scale: 1, width: 300});
361
+ try {
362
+ const result = getTooltipPosition({
363
+ children: {height: 40, pageX: 200, pageY: 200, width: 100},
364
+ idealPosition: "right",
365
+ measured: true,
366
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
367
+ });
368
+ // Fallback order is: bottom -> top -> left -> right
369
+ expect(result).toHaveProperty("finalPosition", "bottom");
370
+ } finally {
371
+ Dimensions.get = origGet;
372
+ }
373
+ });
374
+
375
+ it("getTooltipPosition falls back to left when right, bottom, and top overflow", () => {
376
+ const {Dimensions} = require("react-native");
377
+ const origGet = Dimensions.get;
378
+ Dimensions.get = () => ({fontScale: 1, height: 80, scale: 1, width: 300});
379
+ try {
380
+ const result = getTooltipPosition({
381
+ children: {height: 40, pageX: 200, pageY: 20, width: 40},
382
+ idealPosition: "right",
383
+ measured: true,
384
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
385
+ });
386
+ expect(result).toHaveProperty("finalPosition", "left");
387
+ } finally {
388
+ Dimensions.get = origGet;
389
+ }
390
+ });
391
+
392
+ it("getTooltipPosition falls back to right when all other directions overflow", () => {
393
+ const {Dimensions} = require("react-native");
394
+ const origGet = Dimensions.get;
395
+ Dimensions.get = () => ({fontScale: 1, height: 50, scale: 1, width: 50});
396
+ try {
397
+ const result = getTooltipPosition({
398
+ children: {height: 40, pageX: 5, pageY: 5, width: 40},
399
+ idealPosition: "top",
400
+ measured: true,
401
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
402
+ });
403
+ expect(result).toHaveProperty("finalPosition", "right");
404
+ } finally {
405
+ Dimensions.get = origGet;
406
+ }
407
+ });
408
+
409
+ it("getTooltipPosition with no idealPosition defaults to top", () => {
410
+ const result = getTooltipPosition({
411
+ children: {height: 40, pageX: 200, pageY: 300, width: 100},
412
+ measured: true,
413
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
414
+ });
415
+ expect(result).toHaveProperty("finalPosition", "top");
416
+ });
417
+
418
+ it("getTooltipPosition falls back when left placement overflows", () => {
419
+ const result = getTooltipPosition({
420
+ children: {height: 40, pageX: 10, pageY: 200, width: 100},
421
+ idealPosition: "left",
422
+ measured: true,
423
+ tooltip: {height: 30, width: 150, x: 0, y: 0},
424
+ });
425
+ // Left overflows, should fall back to bottom (first available)
426
+ expect(result).toHaveProperty("finalPosition");
427
+ const pos = (result as {finalPosition: string}).finalPosition;
428
+ expect(["top", "bottom", "right"]).toContain(pos);
429
+ });
430
+
431
+ it("Arrow renders for each position", () => {
432
+ const positions = ["top", "bottom", "left", "right"] as const;
433
+ for (const position of positions) {
434
+ const {toJSON} = renderWithTheme(<Arrow color="#333" position={position} />);
435
+ expect(toJSON()).toBeTruthy();
436
+ }
437
+ });
438
+
439
+ it("exercises handleClick to hide visible tooltip on web press", async () => {
440
+ const {queryByTestId, toJSON} = renderWithTheme(
441
+ <Tooltip text="Click hides">
442
+ <Text>Click me</Text>
443
+ </Tooltip>
444
+ );
445
+
446
+ const tree = toJSON() as TestNode;
447
+ const root = tree.children?.[0] as TestNode;
448
+
449
+ // Show the tooltip
450
+ await act(async () => {
451
+ root.props.onPointerEnter?.();
452
+ });
453
+ await act(async () => {
454
+ await new Promise((resolve) => setTimeout(resolve, 900));
455
+ });
456
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
457
+
458
+ // Find the wrapper and trigger onPress (web click)
459
+ const treeAfter = toJSON() as TestNode;
460
+ const wrapper = (treeAfter.children as Array<TestNode | string>)[
461
+ (treeAfter.children as Array<TestNode | string>).length - 1
462
+ ] as TestNode;
463
+
464
+ if (wrapper.props && "onPress" in wrapper.props) {
465
+ await act(async () => {
466
+ (wrapper.props as {onPress?: () => void}).onPress?.();
467
+ });
468
+ }
469
+ });
470
+
277
471
  it("renders tooltip with arrow at all idealPositions", async () => {
278
472
  const positions: Array<"top" | "bottom" | "left" | "right"> = [
279
473
  "top",
@@ -308,4 +502,388 @@ describe("Tooltip", () => {
308
502
  // No assertions needed - just ensuring no crashes on unmount.
309
503
  expect(true).toBe(true);
310
504
  });
505
+
506
+ it("hides tooltip when pressing on the tooltip container", async () => {
507
+ const {queryByTestId, toJSON, getByTestId} = renderWithTheme(
508
+ <Tooltip text="Click to hide">
509
+ <Text>Hover me</Text>
510
+ </Tooltip>
511
+ );
512
+
513
+ const tree = toJSON() as TestNode;
514
+ const root = tree.children?.[0] as TestNode;
515
+
516
+ await act(async () => {
517
+ root.props.onPointerEnter?.();
518
+ });
519
+ await act(async () => {
520
+ await new Promise((resolve) => setTimeout(resolve, 900));
521
+ });
522
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
523
+
524
+ const {fireEvent} = await import("@testing-library/react-native");
525
+ await act(async () => {
526
+ fireEvent.press(getByTestId("tooltip-container"));
527
+ });
528
+ expect(queryByTestId("tooltip-container")).toBeNull();
529
+ });
530
+
531
+ it("handleClick does nothing when tooltip is not visible", async () => {
532
+ const {queryByTestId, toJSON} = renderWithTheme(
533
+ <Tooltip text="Click test">
534
+ <Text>Click me</Text>
535
+ </Tooltip>
536
+ );
537
+
538
+ const tree = toJSON() as TestNode;
539
+ const root = tree.children?.[0] as TestNode;
540
+
541
+ // Call onPress when tooltip is not visible (no-op)
542
+ await act(async () => {
543
+ (root.props as {onPress?: () => void}).onPress?.();
544
+ });
545
+ expect(queryByTestId("tooltip-container")).toBeNull();
546
+ });
547
+
548
+ it("handles onLayout with measure callback and sets position", async () => {
549
+ const {queryByTestId, toJSON, UNSAFE_getAllByType} = renderWithTheme(
550
+ <Tooltip idealPosition="top" includeArrow text="Layout position test">
551
+ <Text>Trigger</Text>
552
+ </Tooltip>
553
+ );
554
+
555
+ const tree = toJSON() as TestNode;
556
+ const root = tree.children?.[0] as TestNode;
557
+
558
+ // Show the tooltip first
559
+ await act(async () => {
560
+ root.props.onTouchStart?.({nativeEvent: {}});
561
+ });
562
+ await act(async () => {
563
+ await new Promise((resolve) => setTimeout(resolve, 150));
564
+ });
565
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
566
+
567
+ const {View: ViewComp} = await import("react-native");
568
+ const allViews = UNSAFE_getAllByType(ViewComp);
569
+ for (const v of allViews) {
570
+ const props = v.props as TestNode["props"];
571
+ if (props.onLayout) {
572
+ await act(async () => {
573
+ props.onLayout?.({
574
+ nativeEvent: {
575
+ layout: {height: 40, width: 200, x: 0, y: 0},
576
+ },
577
+ });
578
+ });
579
+ }
580
+ }
581
+ });
582
+
583
+ it("exercises getTooltipPosition with all ideal positions", async () => {
584
+ const positions: Array<"top" | "bottom" | "left" | "right"> = [
585
+ "top",
586
+ "bottom",
587
+ "left",
588
+ "right",
589
+ ];
590
+
591
+ for (const pos of positions) {
592
+ const {queryByTestId, toJSON, UNSAFE_getAllByType, unmount} = renderWithTheme(
593
+ <Tooltip idealPosition={pos} includeArrow text={`${pos} test`}>
594
+ <Text>Position {pos}</Text>
595
+ </Tooltip>
596
+ );
597
+
598
+ const tree = toJSON() as TestNode;
599
+ const root = tree.children?.[0] as TestNode;
600
+
601
+ await act(async () => {
602
+ root.props.onTouchStart?.({nativeEvent: {}});
603
+ });
604
+ await act(async () => {
605
+ await new Promise((resolve) => setTimeout(resolve, 150));
606
+ });
607
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
608
+
609
+ const {View: ViewComp} = await import("react-native");
610
+ const allViews = UNSAFE_getAllByType(ViewComp);
611
+ for (const v of allViews) {
612
+ const props = v.props as TestNode["props"];
613
+ if (props.onLayout) {
614
+ await act(async () => {
615
+ props.onLayout?.({
616
+ nativeEvent: {
617
+ layout: {height: 30, width: 100, x: 50, y: 50},
618
+ },
619
+ });
620
+ });
621
+ }
622
+ }
623
+ unmount();
624
+ }
625
+ });
626
+
627
+ it("renders arrow styles for all positions when tooltip is shown with arrow", async () => {
628
+ const positions: Array<"top" | "bottom" | "left" | "right"> = [
629
+ "top",
630
+ "bottom",
631
+ "left",
632
+ "right",
633
+ ];
634
+
635
+ for (const pos of positions) {
636
+ const {queryByTestId, toJSON, unmount} = renderWithTheme(
637
+ <Tooltip idealPosition={pos} includeArrow text={`Arrow ${pos}`}>
638
+ <Text>Arrow</Text>
639
+ </Tooltip>
640
+ );
641
+
642
+ const tree = toJSON() as TestNode;
643
+ const root = tree.children?.[0] as TestNode;
644
+
645
+ await act(async () => {
646
+ root.props.onPointerEnter?.();
647
+ });
648
+ await act(async () => {
649
+ await new Promise((resolve) => setTimeout(resolve, 900));
650
+ });
651
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
652
+ unmount();
653
+ }
654
+ });
655
+
656
+ it("mobilePressProps fires children onClick when not touched", async () => {
657
+ const onClick = mock(() => {});
658
+ const TestChild: React.FC<{onClick?: () => void}> = () => <Text>Pressable child</Text>;
659
+
660
+ const {toJSON} = renderWithTheme(
661
+ <Tooltip text="Mobile test">
662
+ <TestChild onClick={onClick} />
663
+ </Tooltip>
664
+ );
665
+
666
+ const tree = toJSON() as TestNode;
667
+ const root = tree.children?.[0] as TestNode;
668
+
669
+ // Fire onPress (mobilePressProps) without having touched first
670
+ await act(async () => {
671
+ (root.props as {onPress?: () => void}).onPress?.();
672
+ });
673
+ expect(onClick).toHaveBeenCalled();
674
+ });
675
+
676
+ it("getArrowContainerStyle returns empty when includeArrow is false", async () => {
677
+ const {queryByTestId, toJSON} = renderWithTheme(
678
+ <Tooltip text="No arrow test">
679
+ <Text>No arrow</Text>
680
+ </Tooltip>
681
+ );
682
+
683
+ const tree = toJSON() as TestNode;
684
+ const root = tree.children?.[0] as TestNode;
685
+
686
+ await act(async () => {
687
+ root.props.onPointerEnter?.();
688
+ });
689
+ await act(async () => {
690
+ await new Promise((resolve) => setTimeout(resolve, 900));
691
+ });
692
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
693
+ });
694
+
695
+ describe("web platform behavior", () => {
696
+ let Platform: {OS: string};
697
+
698
+ beforeEach(async () => {
699
+ const rn = await import("react-native");
700
+ Platform = rn.Platform;
701
+ Platform.OS = "web";
702
+ });
703
+
704
+ afterEach(() => {
705
+ Platform.OS = "ios";
706
+ });
707
+
708
+ it("renders Arrow component when isWeb and includeArrow", async () => {
709
+ const {queryByTestId, toJSON} = renderWithTheme(
710
+ <Tooltip idealPosition="top" includeArrow text="Web arrow">
711
+ <Text>Arrow child</Text>
712
+ </Tooltip>
713
+ );
714
+
715
+ const tree = toJSON() as TestNode;
716
+ const root = tree.children?.[0] as TestNode;
717
+
718
+ await act(async () => {
719
+ root.props.onPointerEnter?.();
720
+ });
721
+ await act(async () => {
722
+ await new Promise((resolve) => setTimeout(resolve, 900));
723
+ });
724
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
725
+ });
726
+
727
+ it("renders Arrow for all positions on web", async () => {
728
+ const positions: Array<"top" | "bottom" | "left" | "right"> = [
729
+ "top",
730
+ "bottom",
731
+ "left",
732
+ "right",
733
+ ];
734
+ for (const pos of positions) {
735
+ const {queryByTestId, toJSON, unmount} = renderWithTheme(
736
+ <Tooltip idealPosition={pos} includeArrow text={`Web ${pos}`}>
737
+ <Text>{pos}</Text>
738
+ </Tooltip>
739
+ );
740
+
741
+ const tree = toJSON() as TestNode;
742
+ const root = tree.children?.[0] as TestNode;
743
+
744
+ await act(async () => {
745
+ root.props.onTouchStart?.({nativeEvent: {}});
746
+ });
747
+ await act(async () => {
748
+ await new Promise((resolve) => setTimeout(resolve, 150));
749
+ });
750
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
751
+ unmount();
752
+ }
753
+ });
754
+
755
+ it("handleClick hides tooltip on web when visible", async () => {
756
+ const {queryByTestId, toJSON} = renderWithTheme(
757
+ <Tooltip text="Web click hide">
758
+ <Text>Click me</Text>
759
+ </Tooltip>
760
+ );
761
+
762
+ const tree = toJSON() as TestNode;
763
+ const root = tree.children?.[0] as TestNode;
764
+
765
+ await act(async () => {
766
+ root.props.onPointerEnter?.();
767
+ });
768
+ await act(async () => {
769
+ await new Promise((resolve) => setTimeout(resolve, 900));
770
+ });
771
+ expect(queryByTestId("tooltip-container")).toBeTruthy();
772
+
773
+ // On web, onPress is handleClick which hides tooltip when visible
774
+ const treeAfter = toJSON() as TestNode;
775
+ const wrapper = (treeAfter.children as TestNode[]).find(
776
+ (c: TestNode) => c.props && (c.props as {hitSlop?: object}).hitSlop !== undefined
777
+ ) as TestNode | undefined;
778
+ await act(async () => {
779
+ (wrapper?.props as {onPress?: () => void}).onPress?.();
780
+ });
781
+ expect(queryByTestId("tooltip-container")).toBeNull();
782
+ });
783
+
784
+ it("exercises measure callback and getTooltipPosition for all positions", async () => {
785
+ const positions: Array<"top" | "bottom" | "left" | "right"> = [
786
+ "top",
787
+ "bottom",
788
+ "left",
789
+ "right",
790
+ ];
791
+
792
+ for (const pos of positions) {
793
+ const {toJSON, UNSAFE_getAllByType, unmount} = renderWithTheme(
794
+ <Tooltip idealPosition={pos} includeArrow text={`Measure ${pos}`}>
795
+ <Text>{pos}</Text>
796
+ </Tooltip>
797
+ );
798
+
799
+ const tree = toJSON() as TestNode;
800
+ const root = tree.children?.[0] as TestNode;
801
+
802
+ await act(async () => {
803
+ root.props.onTouchStart?.({nativeEvent: {}});
804
+ });
805
+ await act(async () => {
806
+ await new Promise((resolve) => setTimeout(resolve, 150));
807
+ });
808
+
809
+ // Find the ref-holding View (has hitSlop) and inject measure via fiber ref
810
+ const {View: ViewComp} = await import("react-native");
811
+ const allViews = UNSAFE_getAllByType(ViewComp);
812
+ for (const v of allViews) {
813
+ if (!(v.props as {hitSlop?: object}).hitSlop) {
814
+ continue;
815
+ }
816
+ const fiber = (v as unknown as {_fiber?: {ref?: {current: unknown}}})._fiber;
817
+ if (fiber?.ref && typeof fiber.ref === "object") {
818
+ const pageX = pos === "left" ? 400 : pos === "right" ? 50 : 200;
819
+ const pageY = pos === "top" ? 400 : 100;
820
+ fiber.ref.current = {
821
+ measure: (cb: MeasureCallback) => {
822
+ cb(0, 0, 100, 40, pageX, pageY);
823
+ },
824
+ };
825
+ }
826
+ }
827
+
828
+ // Trigger onLayout to invoke measure and getTooltipPosition
829
+ for (const v of allViews) {
830
+ const props = v.props as TestNode["props"];
831
+ if (props.onLayout) {
832
+ await act(async () => {
833
+ props.onLayout?.({
834
+ nativeEvent: {layout: {height: 30, width: 80, x: 0, y: 0}},
835
+ });
836
+ });
837
+ }
838
+ }
839
+ unmount();
840
+ }
841
+ });
842
+
843
+ it("getTooltipPosition fallback when all positions overflow", async () => {
844
+ const {toJSON, UNSAFE_getAllByType, unmount} = renderWithTheme(
845
+ <Tooltip idealPosition="top" text="Overflow">
846
+ <Text>Overflow</Text>
847
+ </Tooltip>
848
+ );
849
+
850
+ const tree = toJSON() as TestNode;
851
+ const root = tree.children?.[0] as TestNode;
852
+
853
+ await act(async () => {
854
+ root.props.onTouchStart?.({nativeEvent: {}});
855
+ });
856
+ await act(async () => {
857
+ await new Promise((resolve) => setTimeout(resolve, 150));
858
+ });
859
+
860
+ const {View: ViewComp} = await import("react-native");
861
+ const allViews = UNSAFE_getAllByType(ViewComp);
862
+ for (const v of allViews) {
863
+ if (!(v.props as {hitSlop?: object}).hitSlop) {
864
+ continue;
865
+ }
866
+ const fiber = (v as unknown as {_fiber?: {ref?: {current: unknown}}})._fiber;
867
+ if (fiber?.ref && typeof fiber.ref === "object") {
868
+ fiber.ref.current = {
869
+ measure: (cb: MeasureCallback) => {
870
+ cb(0, 0, 900, 900, 0, 0);
871
+ },
872
+ };
873
+ }
874
+ }
875
+
876
+ for (const v of allViews) {
877
+ const props = v.props as TestNode["props"];
878
+ if (props.onLayout) {
879
+ await act(async () => {
880
+ props.onLayout?.({
881
+ nativeEvent: {layout: {height: 900, width: 900, x: 0, y: 0}},
882
+ });
883
+ });
884
+ }
885
+ }
886
+ unmount();
887
+ });
888
+ });
311
889
  });
package/src/Tooltip.tsx CHANGED
@@ -39,7 +39,7 @@ interface ChildrenProps {
39
39
  onHoverOut?: () => void;
40
40
  }
41
41
 
42
- const getTooltipPosition = ({
42
+ export const getTooltipPosition = ({
43
43
  children,
44
44
  tooltip,
45
45
  measured,
@@ -117,7 +117,7 @@ const getTooltipPosition = ({
117
117
  }
118
118
  };
119
119
 
120
- const Arrow: FC<{position: TooltipPosition; color: string}> = ({position, color}) => {
120
+ export const Arrow: FC<{position: TooltipPosition; color: string}> = ({position, color}) => {
121
121
  const getArrowStyle = (): ViewStyle => {
122
122
  const arrowStyles = {
123
123
  bottom: {