@boxcustodia/library 2.0.0-alpha.19 → 2.0.0-alpha.20

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 (265) hide show
  1. package/dist/components/button/button.cjs.js +1 -1
  2. package/dist/components/button/button.es.js +19 -18
  3. package/dist/components/button/components/base-button.cjs.js +1 -1
  4. package/dist/components/button/components/base-button.es.js +20 -20
  5. package/dist/components/calendar/calendar.cjs.js +1 -1
  6. package/dist/components/calendar/calendar.es.js +1 -0
  7. package/dist/components/date-picker/date-input.cjs.js +1 -1
  8. package/dist/components/date-picker/date-input.es.js +92 -75
  9. package/dist/components/date-picker/date-picker.cjs.js +1 -1
  10. package/dist/components/date-picker/date-picker.es.js +104 -95
  11. package/dist/components/date-picker/date-picker.utils.cjs.js +1 -1
  12. package/dist/components/date-picker/date-picker.utils.es.js +51 -43
  13. package/dist/components/date-picker/use-hidden-field-value.cjs.js +1 -0
  14. package/dist/components/date-picker/use-hidden-field-value.es.js +11 -0
  15. package/dist/components/menu/menu.es.js +1 -9
  16. package/dist/components/otp/otp.cjs.js +2 -0
  17. package/dist/components/otp/otp.es.js +93 -0
  18. package/dist/components/password/password.cjs.js +1 -1
  19. package/dist/components/password/password.es.js +2 -2
  20. package/dist/components/select/select.cjs.js +1 -1
  21. package/dist/components/select/select.es.js +68 -60
  22. package/dist/hooks/internal/is-apple-device.cjs.js +1 -0
  23. package/dist/hooks/internal/is-apple-device.es.js +9 -0
  24. package/dist/hooks/internal/use-latest-ref.cjs.js +1 -0
  25. package/dist/hooks/internal/use-latest-ref.es.js +11 -0
  26. package/dist/hooks/use-array/use-array.cjs.js +1 -1
  27. package/dist/hooks/use-array/use-array.es.js +54 -42
  28. package/dist/hooks/use-async/use-async.cjs.js +1 -1
  29. package/dist/hooks/use-async/use-async.es.js +53 -20
  30. package/dist/hooks/use-boolean/use-boolean.cjs.js +1 -0
  31. package/dist/hooks/use-boolean/use-boolean.es.js +25 -0
  32. package/dist/hooks/use-click-outside/use-click-outside.cjs.js +1 -1
  33. package/dist/hooks/use-click-outside/use-click-outside.es.js +26 -12
  34. package/dist/hooks/use-debounce-callback/use-debounced-callback.cjs.js +1 -1
  35. package/dist/hooks/use-debounce-callback/use-debounced-callback.es.js +27 -10
  36. package/dist/hooks/use-debounce-value/use-debounced-value.cjs.js +1 -1
  37. package/dist/hooks/use-debounce-value/use-debounced-value.es.js +7 -9
  38. package/dist/hooks/use-disclosure/use-disclosure.cjs.js +1 -1
  39. package/dist/hooks/use-disclosure/use-disclosure.es.js +21 -11
  40. package/dist/hooks/use-document-title/use-document-title.cjs.js +1 -1
  41. package/dist/hooks/use-document-title/use-document-title.es.js +14 -12
  42. package/dist/hooks/use-event-listener/use-event-listener.cjs.js +1 -1
  43. package/dist/hooks/use-event-listener/use-event-listener.es.js +17 -9
  44. package/dist/hooks/use-hotkey/use-hotkey.cjs.js +1 -1
  45. package/dist/hooks/use-hotkey/use-hotkey.es.js +30 -14
  46. package/dist/hooks/use-hotkey/utils/is-input-field.cjs.js +1 -1
  47. package/dist/hooks/use-hotkey/utils/is-input-field.es.js +4 -2
  48. package/dist/hooks/use-hotkey/utils/match-and-run.cjs.js +1 -0
  49. package/dist/hooks/use-hotkey/utils/match-and-run.es.js +12 -0
  50. package/dist/hooks/use-hotkey/utils/match-key-modifiers.cjs.js +1 -1
  51. package/dist/hooks/use-hotkey/utils/match-key-modifiers.es.js +13 -12
  52. package/dist/hooks/use-hover/use-hover.cjs.js +1 -1
  53. package/dist/hooks/use-hover/use-hover.es.js +32 -17
  54. package/dist/hooks/use-is-visible/use-is-visible.cjs.js +1 -1
  55. package/dist/hooks/use-is-visible/use-is-visible.es.js +31 -27
  56. package/dist/hooks/use-local-storage/use-local-storage.cjs.js +1 -1
  57. package/dist/hooks/use-local-storage/use-local-storage.es.js +52 -20
  58. package/dist/hooks/use-media-query/use-media-query.cjs.js +1 -1
  59. package/dist/hooks/use-media-query/use-media-query.es.js +21 -11
  60. package/dist/hooks/use-mutation/use-mutation.cjs.js +1 -1
  61. package/dist/hooks/use-mutation/use-mutation.es.js +36 -22
  62. package/dist/hooks/use-object/use-object.cjs.js +1 -1
  63. package/dist/hooks/use-object/use-object.es.js +26 -22
  64. package/dist/hooks/use-prevent-page-close/use-prevent-page-close.cjs.js +1 -0
  65. package/dist/hooks/use-prevent-page-close/use-prevent-page-close.es.js +14 -0
  66. package/dist/hooks/use-step/use-step.cjs.js +1 -1
  67. package/dist/hooks/use-step/use-step.es.js +25 -24
  68. package/dist/index.cjs.js +1 -1
  69. package/dist/index.es.js +308 -300
  70. package/dist/src/components/date-picker/date-picker.utils.d.ts +17 -0
  71. package/dist/src/components/date-picker/use-hidden-field-value.d.ts +12 -0
  72. package/dist/src/components/index.d.ts +1 -0
  73. package/dist/src/hooks/index.d.ts +2 -2
  74. package/dist/src/hooks/internal/index.d.ts +2 -0
  75. package/dist/src/hooks/internal/is-apple-device.d.ts +12 -0
  76. package/dist/src/hooks/internal/use-latest-ref.d.ts +12 -0
  77. package/dist/src/hooks/use-array/use-array.d.ts +24 -11
  78. package/dist/src/hooks/use-async/use-async.d.ts +16 -13
  79. package/dist/src/hooks/use-boolean/index.d.ts +1 -0
  80. package/dist/src/hooks/use-boolean/use-boolean.d.ts +15 -0
  81. package/dist/src/hooks/use-boolean/use-boolean.test.d.ts +1 -0
  82. package/dist/src/hooks/use-click-outside/use-click-outside.d.ts +23 -1
  83. package/dist/src/hooks/use-debounce-callback/use-debounced-callback.d.ts +19 -1
  84. package/dist/src/hooks/use-debounce-value/use-debounced-value.d.ts +10 -1
  85. package/dist/src/hooks/use-disclosure/use-disclosure.d.ts +17 -8
  86. package/dist/src/hooks/use-document-title/use-document-title.d.ts +11 -0
  87. package/dist/src/hooks/use-event-listener/use-event-listener.d.ts +18 -1
  88. package/dist/src/hooks/use-hotkey/index.d.ts +2 -1
  89. package/dist/src/hooks/use-hotkey/use-hotkey.d.ts +62 -5
  90. package/dist/src/hooks/use-hotkey/utils/index.d.ts +4 -3
  91. package/dist/src/hooks/use-hotkey/utils/is-input-field.d.ts +12 -2
  92. package/dist/src/hooks/use-hotkey/utils/is-input-field.test.d.ts +1 -0
  93. package/dist/src/hooks/use-hotkey/utils/match-and-run.d.ts +36 -0
  94. package/dist/src/hooks/use-hotkey/utils/match-and-run.test.d.ts +1 -0
  95. package/dist/src/hooks/use-hotkey/utils/match-key-modifiers.d.ts +20 -6
  96. package/dist/src/hooks/use-hotkey/utils/match-key-modifiers.test.d.ts +1 -0
  97. package/dist/src/hooks/use-hover/use-hover.d.ts +8 -4
  98. package/dist/src/hooks/use-is-visible/use-is-visible.d.ts +28 -4
  99. package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +13 -2
  100. package/dist/src/hooks/use-media-query/use-media-query.d.ts +10 -1
  101. package/dist/src/hooks/use-media-query/use-media-query.test.d.ts +1 -0
  102. package/dist/src/hooks/use-mutation/use-mutation.d.ts +18 -11
  103. package/dist/src/hooks/use-object/use-object.d.ts +15 -6
  104. package/dist/src/hooks/use-prevent-page-close/index.d.ts +1 -0
  105. package/dist/src/hooks/use-prevent-page-close/use-prevent-page-close.d.ts +10 -0
  106. package/dist/src/hooks/use-prevent-page-close/use-prevent-page-close.test.d.ts +1 -0
  107. package/dist/src/hooks/use-step/use-step.d.ts +18 -11
  108. package/dist/src/utils/form.d.ts +10 -0
  109. package/package.json +1 -1
  110. package/src/components/alert-dialog/alert-dialog.test.tsx +13 -9
  111. package/src/components/auto-complete/auto-complete.test.tsx +4 -14
  112. package/src/components/avatar/avatar.test.tsx +7 -12
  113. package/src/components/button/button.test.tsx +10 -15
  114. package/src/components/button/button.tsx +14 -9
  115. package/src/components/button/components/base-button.tsx +2 -4
  116. package/src/components/calendar/calendar.test.tsx +12 -19
  117. package/src/components/calendar/calendar.tsx +4 -0
  118. package/src/components/card/card.test.tsx +4 -6
  119. package/src/components/checkbox/checkbox.test.tsx +12 -8
  120. package/src/components/checkbox-group/checkbox-group.test.tsx +7 -8
  121. package/src/components/combobox/combobox.test.tsx +24 -21
  122. package/src/components/date-picker/date-input-form.test.tsx +77 -0
  123. package/src/components/date-picker/date-input.stories.tsx +30 -18
  124. package/src/components/date-picker/date-input.tsx +77 -44
  125. package/src/components/date-picker/date-picker.stories.tsx +31 -1
  126. package/src/components/date-picker/date-picker.test.tsx +3 -13
  127. package/src/components/date-picker/date-picker.tsx +35 -16
  128. package/src/components/date-picker/date-picker.utils.test.ts +32 -14
  129. package/src/components/date-picker/date-picker.utils.ts +33 -0
  130. package/src/components/date-picker/use-date-input-popover.test.ts +3 -1
  131. package/src/components/date-picker/use-hidden-field-value.ts +23 -0
  132. package/src/components/dialog/dialog.test.tsx +10 -8
  133. package/src/components/dropzone/dropzone.test.tsx +11 -13
  134. package/src/components/empty/empty.test.tsx +4 -3
  135. package/src/components/field/field.test.tsx +12 -13
  136. package/src/components/form/form.stories.tsx +16 -1
  137. package/src/components/index.ts +1 -0
  138. package/src/components/label/label.test.tsx +3 -3
  139. package/src/components/menu/menu.tsx +1 -5
  140. package/src/components/number-input/number-input.test.tsx +6 -2
  141. package/src/components/password/password.test.tsx +20 -6
  142. package/src/components/password/password.tsx +2 -2
  143. package/src/components/popover/popover.test.tsx +4 -4
  144. package/src/components/progress/progress.test.tsx +7 -8
  145. package/src/components/radio-group/radio-group.test.tsx +17 -11
  146. package/src/components/select/select.test.tsx +10 -10
  147. package/src/components/select/select.tsx +9 -1
  148. package/src/components/stepper/stepper.stories.tsx +11 -15
  149. package/src/components/stepper/stepper.test.tsx +6 -4
  150. package/src/components/switch/switch.test.tsx +3 -3
  151. package/src/components/table/table.test.tsx +9 -3
  152. package/src/components/tabs/tabs.test.tsx +6 -2
  153. package/src/components/tag/tag.test.tsx +1 -3
  154. package/src/components/textarea/textarea.test.tsx +4 -1
  155. package/src/components/timeline/timeline.test.tsx +10 -5
  156. package/src/components/toast/toast.test.tsx +11 -14
  157. package/src/components/tooltip/tooltip.test.tsx +1 -5
  158. package/src/components/tree/tree.test.tsx +3 -1
  159. package/src/hooks/index.ts +2 -2
  160. package/src/hooks/internal/index.ts +2 -0
  161. package/src/hooks/internal/is-apple-device.test.ts +41 -0
  162. package/src/hooks/internal/is-apple-device.ts +33 -0
  163. package/src/hooks/internal/use-isomorphic-layout-effect.ts +3 -1
  164. package/src/hooks/internal/use-latest-ref.ts +21 -0
  165. package/src/hooks/use-array/use-array.stories.tsx +435 -64
  166. package/src/hooks/use-array/use-array.test.tsx +398 -15
  167. package/src/hooks/use-array/use-array.ts +105 -66
  168. package/src/hooks/use-async/use-async.stories.tsx +255 -131
  169. package/src/hooks/use-async/use-async.test.ts +397 -0
  170. package/src/hooks/use-async/use-async.ts +117 -39
  171. package/src/hooks/use-boolean/index.ts +1 -0
  172. package/src/hooks/use-boolean/use-boolean.stories.tsx +377 -0
  173. package/src/hooks/use-boolean/use-boolean.test.tsx +177 -0
  174. package/src/hooks/use-boolean/use-boolean.ts +50 -0
  175. package/src/hooks/use-click-outside/use-click-outside.stories.tsx +188 -18
  176. package/src/hooks/use-click-outside/use-click-outside.test.tsx +89 -10
  177. package/src/hooks/use-click-outside/use-click-outside.ts +62 -16
  178. package/src/hooks/use-debounce-callback/use-debounced-callback.stories.tsx +141 -41
  179. package/src/hooks/use-debounce-callback/use-debounced-callback.test.ts +217 -9
  180. package/src/hooks/use-debounce-callback/use-debounced-callback.ts +71 -11
  181. package/src/hooks/use-debounce-value/use-debounced-value.stories.tsx +247 -47
  182. package/src/hooks/use-debounce-value/use-debounced-value.test.ts +105 -10
  183. package/src/hooks/use-debounce-value/use-debounced-value.ts +19 -10
  184. package/src/hooks/use-disclosure/use-disclosure.stories.tsx +305 -14
  185. package/src/hooks/use-disclosure/use-disclosure.test.ts +198 -50
  186. package/src/hooks/use-disclosure/use-disclosure.ts +49 -29
  187. package/src/hooks/use-document-title/use-document-title.stories.tsx +54 -0
  188. package/src/hooks/use-document-title/use-document-title.test.tsx +26 -0
  189. package/src/hooks/use-document-title/{use-document-title.tsx → use-document-title.ts} +17 -3
  190. package/src/hooks/use-event-listener/use-event-listener.stories.tsx +105 -9
  191. package/src/hooks/use-event-listener/use-event-listener.test.tsx +77 -10
  192. package/src/hooks/use-event-listener/use-event-listener.ts +71 -11
  193. package/src/hooks/use-focus-trap/use-focus-trap.test.ts +31 -6
  194. package/src/hooks/use-focus-trap/use-focus-trap.ts +3 -2
  195. package/src/hooks/use-hotkey/index.ts +9 -1
  196. package/src/hooks/use-hotkey/use-hotkey.stories.tsx +279 -74
  197. package/src/hooks/use-hotkey/use-hotkey.test.tsx +286 -34
  198. package/src/hooks/use-hotkey/use-hotkey.ts +141 -17
  199. package/src/hooks/use-hotkey/utils/index.ts +8 -3
  200. package/src/hooks/use-hotkey/utils/is-input-field.test.ts +78 -0
  201. package/src/hooks/use-hotkey/utils/is-input-field.ts +31 -10
  202. package/src/hooks/use-hotkey/utils/match-and-run.test.ts +203 -0
  203. package/src/hooks/use-hotkey/utils/match-and-run.ts +62 -0
  204. package/src/hooks/use-hotkey/utils/match-key-modifiers.test.ts +65 -0
  205. package/src/hooks/use-hotkey/utils/match-key-modifiers.ts +39 -12
  206. package/src/hooks/use-hover/use-hover.stories.tsx +258 -80
  207. package/src/hooks/use-hover/use-hover.test.tsx +266 -26
  208. package/src/hooks/use-hover/use-hover.tsx +93 -28
  209. package/src/hooks/use-is-visible/use-is-visible.stories.tsx +193 -46
  210. package/src/hooks/use-is-visible/use-is-visible.test.tsx +235 -7
  211. package/src/hooks/use-is-visible/use-is-visible.ts +114 -0
  212. package/src/hooks/use-local-storage/use-local-storage.stories.tsx +129 -29
  213. package/src/hooks/use-local-storage/use-local-storage.test.ts +106 -41
  214. package/src/hooks/use-local-storage/use-local-storage.ts +100 -31
  215. package/src/hooks/use-media-query/use-media-query.stories.tsx +86 -26
  216. package/src/hooks/use-media-query/use-media-query.test.ts +132 -0
  217. package/src/hooks/use-media-query/use-media-query.ts +39 -14
  218. package/src/hooks/use-memoized-fn/use-memoized-fn.ts +0 -1
  219. package/src/hooks/use-mutation/use-mutation.stories.tsx +260 -94
  220. package/src/hooks/use-mutation/use-mutation.test.ts +359 -0
  221. package/src/hooks/use-mutation/use-mutation.ts +97 -0
  222. package/src/hooks/use-object/use-object.stories.tsx +310 -79
  223. package/src/hooks/use-object/use-object.test.tsx +235 -56
  224. package/src/hooks/use-object/use-object.ts +59 -0
  225. package/src/hooks/use-pagination/use-pagination.tsx +0 -1
  226. package/src/hooks/use-prevent-page-close/index.ts +1 -0
  227. package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +39 -0
  228. package/src/hooks/use-prevent-page-close/use-prevent-page-close.test.ts +89 -0
  229. package/src/hooks/use-prevent-page-close/use-prevent-page-close.ts +27 -0
  230. package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +1 -1
  231. package/src/hooks/use-range-pagination/use-range-pagination.tsx +1 -1
  232. package/src/hooks/use-selection/use-selection.ts +0 -1
  233. package/src/hooks/use-step/use-step.stories.tsx +178 -65
  234. package/src/hooks/use-step/use-step.test.ts +178 -53
  235. package/src/hooks/use-step/use-step.ts +57 -49
  236. package/src/utils/form.test.tsx +13 -8
  237. package/src/utils/form.tsx +10 -0
  238. package/src/utils/functions/getFormData.test.ts +1 -1
  239. package/dist/hooks/use-hotkey/utils/create-hotkey-listener.cjs.js +0 -1
  240. package/dist/hooks/use-hotkey/utils/create-hotkey-listener.es.js +0 -10
  241. package/dist/hooks/use-prevent-close-window/use-prevent-close-window.cjs.js +0 -1
  242. package/dist/hooks/use-prevent-close-window/use-prevent-close-window.es.js +0 -15
  243. package/dist/hooks/use-toggle/use-toggle.cjs.js +0 -1
  244. package/dist/hooks/use-toggle/use-toggle.es.js +0 -10
  245. package/dist/src/hooks/use-hotkey/utils/create-hotkey-listener.d.ts +0 -1
  246. package/dist/src/hooks/use-prevent-close-window/index.d.ts +0 -1
  247. package/dist/src/hooks/use-prevent-close-window/use-prevent-close-window.d.ts +0 -13
  248. package/dist/src/hooks/use-toggle/index.d.ts +0 -1
  249. package/dist/src/hooks/use-toggle/use-toggle.d.ts +0 -3
  250. package/src/hooks/use-async/use-async.test.tsx +0 -68
  251. package/src/hooks/use-hotkey/utils/create-hotkey-listener.ts +0 -25
  252. package/src/hooks/use-is-visible/use-is-visible.tsx +0 -49
  253. package/src/hooks/use-mutation/use-mutation.test.tsx +0 -83
  254. package/src/hooks/use-mutation/use-mutation.tsx +0 -59
  255. package/src/hooks/use-object/use-object.tsx +0 -46
  256. package/src/hooks/use-prevent-close-window/index.ts +0 -1
  257. package/src/hooks/use-prevent-close-window/use-prevent-close-window.stories.tsx +0 -32
  258. package/src/hooks/use-prevent-close-window/use-prevent-close-window.test.ts +0 -79
  259. package/src/hooks/use-prevent-close-window/use-prevent-close-window.ts +0 -33
  260. package/src/hooks/use-toggle/index.ts +0 -1
  261. package/src/hooks/use-toggle/use-toggle.stories.tsx +0 -25
  262. package/src/hooks/use-toggle/use-toggle.test.tsx +0 -64
  263. package/src/hooks/use-toggle/use-toggle.ts +0 -14
  264. /package/dist/src/{hooks/use-prevent-close-window/use-prevent-close-window.test.d.ts → components/date-picker/date-input-form.test.d.ts} +0 -0
  265. /package/dist/src/hooks/{use-toggle/use-toggle.test.d.ts → internal/is-apple-device.test.d.ts} +0 -0
@@ -1,17 +1,33 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
1
+ import {
2
+ act,
3
+ fireEvent,
4
+ render,
5
+ renderHook,
6
+ screen,
7
+ } from "@testing-library/react";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
9
  import { useHover } from "./use-hover";
4
10
 
11
+ // ---------------------------------------------------------------------------
12
+ // Test component helpers
13
+ // ---------------------------------------------------------------------------
14
+
5
15
  function HoverTarget({
6
16
  onHoverStart,
7
17
  onHoverEnd,
18
+ openDelay,
19
+ closeDelay,
8
20
  }: {
9
- onHoverStart?: (event: MouseEvent) => void;
10
- onHoverEnd?: (event: MouseEvent) => void;
21
+ onHoverStart?: (event: PointerEvent) => void;
22
+ onHoverEnd?: (event: PointerEvent) => void;
23
+ openDelay?: number;
24
+ closeDelay?: number;
11
25
  }) {
12
26
  const { ref, hovered } = useHover<HTMLDivElement>({
13
27
  onHoverStart,
14
28
  onHoverEnd,
29
+ openDelay,
30
+ closeDelay,
15
31
  });
16
32
  return (
17
33
  <div ref={ref} data-testid="target">
@@ -20,73 +36,297 @@ function HoverTarget({
20
36
  );
21
37
  }
22
38
 
23
- describe("useHover", () => {
24
- it("starts in the idle state", () => {
39
+ // ---------------------------------------------------------------------------
40
+ // Pointer Event Subscription (R-PointerEvent)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe("useHover — pointer event subscription", () => {
44
+ it("1: initial state — hovered is false and ref is a callable RefCallback", () => {
45
+ const { result } = renderHook(() => useHover());
46
+ expect(result.current.hovered).toBe(false);
47
+ expect(typeof result.current.ref).toBe("function");
48
+ });
49
+
50
+ it("2: pointerenter sets hovered to true", () => {
51
+ render(<HoverTarget />);
52
+ const target = screen.getByTestId("target");
53
+
54
+ fireEvent.pointerEnter(target);
55
+
56
+ expect(target).toHaveTextContent("hovered");
57
+ });
58
+
59
+ it("3: pointerleave sets hovered to false", () => {
25
60
  render(<HoverTarget />);
26
- expect(screen.getByTestId("target")).toHaveTextContent("idle");
61
+ const target = screen.getByTestId("target");
62
+
63
+ fireEvent.pointerEnter(target);
64
+ expect(target).toHaveTextContent("hovered");
65
+
66
+ fireEvent.pointerLeave(target);
67
+ expect(target).toHaveTextContent("idle");
27
68
  });
28
69
 
29
- it("flips hovered to true on mouseenter and back on mouseleave", () => {
70
+ it("4: mouseenter does NOT change state (regression guard)", () => {
30
71
  render(<HoverTarget />);
31
72
  const target = screen.getByTestId("target");
32
73
 
33
74
  fireEvent.mouseEnter(target);
75
+
76
+ expect(target).toHaveTextContent("idle");
77
+ });
78
+
79
+ it("5: mouseleave does NOT change state (regression guard)", () => {
80
+ render(<HoverTarget />);
81
+ const target = screen.getByTestId("target");
82
+
83
+ // Manually set hovered via pointer first
84
+ fireEvent.pointerEnter(target);
34
85
  expect(target).toHaveTextContent("hovered");
35
86
 
36
87
  fireEvent.mouseLeave(target);
88
+
89
+ expect(target).toHaveTextContent("hovered");
90
+ });
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Open Delay (R-OpenDelay)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ describe("useHover — openDelay", () => {
98
+ beforeEach(() => {
99
+ vi.useFakeTimers();
100
+ });
101
+
102
+ afterEach(() => {
103
+ vi.useRealTimers();
104
+ });
105
+
106
+ it("6: openDelay=0 — hovered flips immediately on pointerenter (no timer)", () => {
107
+ render(<HoverTarget openDelay={0} />);
108
+ const target = screen.getByTestId("target");
109
+
110
+ fireEvent.pointerEnter(target);
111
+
112
+ expect(target).toHaveTextContent("hovered");
113
+ });
114
+
115
+ it("7: openDelay=300 — hovered stays false until timer fires", () => {
116
+ render(<HoverTarget openDelay={300} />);
117
+ const target = screen.getByTestId("target");
118
+
119
+ fireEvent.pointerEnter(target);
37
120
  expect(target).toHaveTextContent("idle");
121
+
122
+ act(() => {
123
+ vi.advanceTimersByTime(300);
124
+ });
125
+
126
+ expect(target).toHaveTextContent("hovered");
38
127
  });
39
128
 
40
- it("ignores mouseover/mouseout (which bubble from children)", () => {
41
- render(<HoverTarget />);
129
+ it("8: openDelay=300 leave before timer fires cancels open, hovered never becomes true", () => {
130
+ render(<HoverTarget openDelay={300} />);
131
+ const target = screen.getByTestId("target");
132
+
133
+ fireEvent.pointerEnter(target);
134
+ expect(target).toHaveTextContent("idle");
135
+
136
+ fireEvent.pointerLeave(target);
137
+
138
+ act(() => {
139
+ vi.advanceTimersByTime(300);
140
+ });
141
+
142
+ expect(target).toHaveTextContent("idle");
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Close Delay (R-CloseDelay)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe("useHover — closeDelay", () => {
151
+ beforeEach(() => {
152
+ vi.useFakeTimers();
153
+ });
154
+
155
+ afterEach(() => {
156
+ vi.useRealTimers();
157
+ });
158
+
159
+ it("9: closeDelay=0 — hovered flips immediately on pointerleave (no timer)", () => {
160
+ render(<HoverTarget closeDelay={0} />);
161
+ const target = screen.getByTestId("target");
162
+
163
+ fireEvent.pointerEnter(target);
164
+ expect(target).toHaveTextContent("hovered");
165
+
166
+ fireEvent.pointerLeave(target);
167
+
168
+ expect(target).toHaveTextContent("idle");
169
+ });
170
+
171
+ it("10: closeDelay=200 — hovered stays true until timer fires", () => {
172
+ render(<HoverTarget closeDelay={200} />);
42
173
  const target = screen.getByTestId("target");
43
174
 
44
- fireEvent.mouseOver(target);
175
+ fireEvent.pointerEnter(target);
176
+ expect(target).toHaveTextContent("hovered");
177
+
178
+ fireEvent.pointerLeave(target);
179
+ expect(target).toHaveTextContent("hovered");
180
+
181
+ act(() => {
182
+ vi.advanceTimersByTime(200);
183
+ });
184
+
45
185
  expect(target).toHaveTextContent("idle");
46
186
  });
47
187
 
48
- it("invokes onHoverStart with the MouseEvent on enter", () => {
188
+ it("11: closeDelay=200 re-enter before close timer fires cancels close, hovered remains true", () => {
189
+ render(<HoverTarget closeDelay={200} />);
190
+ const target = screen.getByTestId("target");
191
+
192
+ fireEvent.pointerEnter(target);
193
+ fireEvent.pointerLeave(target);
194
+ expect(target).toHaveTextContent("hovered");
195
+
196
+ // Re-enter before close timer fires
197
+ fireEvent.pointerEnter(target);
198
+
199
+ act(() => {
200
+ vi.advanceTimersByTime(200);
201
+ });
202
+
203
+ expect(target).toHaveTextContent("hovered");
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Callback Invocation (R-Callbacks)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe("useHover — callbacks", () => {
212
+ beforeEach(() => {
213
+ vi.useFakeTimers();
214
+ });
215
+
216
+ afterEach(() => {
217
+ vi.useRealTimers();
218
+ });
219
+
220
+ it("12: onHoverStart is called on pointer enter (after open delay)", () => {
49
221
  const onHoverStart = vi.fn();
50
- render(<HoverTarget onHoverStart={onHoverStart} />);
222
+ render(<HoverTarget onHoverStart={onHoverStart} openDelay={100} />);
51
223
  const target = screen.getByTestId("target");
52
224
 
53
- fireEvent.mouseEnter(target);
225
+ fireEvent.pointerEnter(target);
226
+ expect(onHoverStart).not.toHaveBeenCalled();
227
+
228
+ act(() => {
229
+ vi.advanceTimersByTime(100);
230
+ });
54
231
 
55
232
  expect(onHoverStart).toHaveBeenCalledTimes(1);
56
- expect(onHoverStart.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
57
233
  });
58
234
 
59
- it("invokes onHoverEnd with the MouseEvent on leave", () => {
235
+ it("13: onHoverEnd is called on pointer leave (after close delay)", () => {
60
236
  const onHoverEnd = vi.fn();
61
- render(<HoverTarget onHoverEnd={onHoverEnd} />);
237
+ render(<HoverTarget onHoverEnd={onHoverEnd} closeDelay={100} />);
62
238
  const target = screen.getByTestId("target");
63
239
 
64
- fireEvent.mouseEnter(target);
65
- fireEvent.mouseLeave(target);
240
+ fireEvent.pointerEnter(target);
241
+ fireEvent.pointerLeave(target);
242
+ expect(onHoverEnd).not.toHaveBeenCalled();
243
+
244
+ act(() => {
245
+ vi.advanceTimersByTime(100);
246
+ });
66
247
 
67
248
  expect(onHoverEnd).toHaveBeenCalledTimes(1);
68
- expect(onHoverEnd.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
69
249
  });
70
250
 
71
- it("uses the latest callback identity without re-attaching listeners", () => {
251
+ it("14: callbacks receive the originating PointerEvent (behavioral assertion + guarded instanceof)", () => {
252
+ const onHoverStart = vi.fn();
253
+ const onHoverEnd = vi.fn();
254
+ render(<HoverTarget onHoverStart={onHoverStart} onHoverEnd={onHoverEnd} />);
255
+ const target = screen.getByTestId("target");
256
+
257
+ fireEvent.pointerEnter(target);
258
+ fireEvent.pointerLeave(target);
259
+
260
+ expect(onHoverStart).toHaveBeenCalledTimes(1);
261
+ expect(onHoverStart.mock.calls[0][0]).toMatchObject({
262
+ type: "pointerenter",
263
+ });
264
+
265
+ expect(onHoverEnd).toHaveBeenCalledTimes(1);
266
+ expect(onHoverEnd.mock.calls[0][0]).toMatchObject({ type: "pointerleave" });
267
+
268
+ // Guard: jsdom may not expose PointerEvent as a constructor
269
+ if (typeof window.PointerEvent === "function") {
270
+ expect(onHoverStart.mock.calls[0][0]).toBeInstanceOf(PointerEvent);
271
+ expect(onHoverEnd.mock.calls[0][0]).toBeInstanceOf(PointerEvent);
272
+ }
273
+ });
274
+ });
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Latest-Ref Callback Pattern (R-LatestRef)
278
+ // ---------------------------------------------------------------------------
279
+
280
+ describe("useHover — latest-ref callback stability", () => {
281
+ it("15: callback identity stable — re-render with new function identity calls latest fn without re-attaching", () => {
72
282
  const first = vi.fn();
73
283
  const second = vi.fn();
74
284
  const { rerender } = render(<HoverTarget onHoverStart={first} />);
75
285
  const target = screen.getByTestId("target");
76
286
 
77
287
  rerender(<HoverTarget onHoverStart={second} />);
78
- fireEvent.mouseEnter(target);
288
+ fireEvent.pointerEnter(target);
79
289
 
80
290
  expect(first).not.toHaveBeenCalled();
81
291
  expect(second).toHaveBeenCalledTimes(1);
82
292
  });
293
+ });
83
294
 
84
- it("removes listeners on unmount", () => {
85
- const { unmount } = render(<HoverTarget />);
295
+ // ---------------------------------------------------------------------------
296
+ // Timer Cleanup (R-TimerCleanup)
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("useHover — timer cleanup on unmount", () => {
300
+ beforeEach(() => {
301
+ vi.useFakeTimers();
302
+ });
303
+
304
+ afterEach(() => {
305
+ vi.useRealTimers();
306
+ });
307
+
308
+ it("16: unmount cancels pending open timer — no state update after unmount", () => {
309
+ const onHoverStart = vi.fn();
310
+ const { unmount } = render(
311
+ <HoverTarget openDelay={300} onHoverStart={onHoverStart} />,
312
+ );
86
313
  const target = screen.getByTestId("target");
87
314
 
88
- fireEvent.mouseEnter(target);
89
- expect(target).toHaveTextContent("hovered");
315
+ fireEvent.pointerEnter(target);
316
+ expect(target).toHaveTextContent("idle");
317
+
318
+ unmount();
319
+
320
+ act(() => {
321
+ vi.advanceTimersByTime(300);
322
+ });
323
+
324
+ // onHoverStart must not have been called after unmount
325
+ expect(onHoverStart).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("17: unmount with no pending timers — no throw", () => {
329
+ const { unmount } = render(<HoverTarget />);
90
330
 
91
331
  expect(() => unmount()).not.toThrow();
92
332
  });
@@ -1,10 +1,21 @@
1
- import { type RefCallback, useCallback, useRef, useState } from "react";
1
+ import {
2
+ type RefCallback,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import { useLatestRef } from "../internal";
2
9
 
3
10
  export interface UseHoverInput {
4
- /** Fired when the pointer enters the target element. Receives the native MouseEvent. */
5
- onHoverStart?: (event: MouseEvent) => void;
6
- /** Fired when the pointer leaves the target element. Receives the native MouseEvent. */
7
- onHoverEnd?: (event: MouseEvent) => void;
11
+ /** Delay in ms before `hovered` becomes true after pointer enters. Default 0 (synchronous). */
12
+ openDelay?: number;
13
+ /** Delay in ms before `hovered` becomes false after pointer leaves. Default 0 (synchronous). */
14
+ closeDelay?: number;
15
+ /** Fired when the pointer enters the target element. Receives the native PointerEvent. */
16
+ onHoverStart?: (event: PointerEvent) => void;
17
+ /** Fired when the pointer leaves the target element. Receives the native PointerEvent. */
18
+ onHoverEnd?: (event: PointerEvent) => void;
8
19
  }
9
20
 
10
21
  export interface UseHoverReturnValue<T extends HTMLElement = HTMLElement> {
@@ -15,31 +26,85 @@ export interface UseHoverReturnValue<T extends HTMLElement = HTMLElement> {
15
26
  export function useHover<T extends HTMLElement = HTMLElement>(
16
27
  options: UseHoverInput = {},
17
28
  ): UseHoverReturnValue<T> {
29
+ const { openDelay = 0, closeDelay = 0, onHoverStart, onHoverEnd } = options;
30
+
18
31
  const [hovered, setHovered] = useState(false);
19
- const callbacksRef = useRef(options);
20
- callbacksRef.current = options;
21
-
22
- const ref: RefCallback<T> = useCallback((node) => {
23
- if (!node) return;
24
-
25
- const handleEnter = (event: MouseEvent) => {
26
- setHovered(true);
27
- callbacksRef.current.onHoverStart?.(event);
28
- };
29
- const handleLeave = (event: MouseEvent) => {
30
- setHovered(false);
31
- callbacksRef.current.onHoverEnd?.(event);
32
- };
33
-
34
- node.addEventListener("mouseenter", handleEnter);
35
- node.addEventListener("mouseleave", handleLeave);
36
-
37
- return () => {
38
- node.removeEventListener("mouseenter", handleEnter);
39
- node.removeEventListener("mouseleave", handleLeave);
40
- setHovered(false);
41
- };
32
+
33
+ const onStartRef = useLatestRef(onHoverStart);
34
+ const onEndRef = useLatestRef(onHoverEnd);
35
+
36
+ const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38
+
39
+ const clearOpenTimer = useCallback(() => {
40
+ if (openTimerRef.current !== null) {
41
+ clearTimeout(openTimerRef.current);
42
+ openTimerRef.current = null;
43
+ }
44
+ }, []);
45
+
46
+ const clearCloseTimer = useCallback(() => {
47
+ if (closeTimerRef.current !== null) {
48
+ clearTimeout(closeTimerRef.current);
49
+ closeTimerRef.current = null;
50
+ }
42
51
  }, []);
43
52
 
53
+ const ref: RefCallback<T> = useCallback(
54
+ (node) => {
55
+ if (!node) return;
56
+
57
+ const handleEnter = (event: PointerEvent) => {
58
+ clearCloseTimer();
59
+ if (openDelay > 0) {
60
+ openTimerRef.current = setTimeout(() => {
61
+ openTimerRef.current = null;
62
+ setHovered(true);
63
+ onStartRef.current?.(event);
64
+ }, openDelay);
65
+ } else {
66
+ setHovered(true);
67
+ onStartRef.current?.(event);
68
+ }
69
+ };
70
+
71
+ const handleLeave = (event: PointerEvent) => {
72
+ clearOpenTimer();
73
+ if (closeDelay > 0) {
74
+ closeTimerRef.current = setTimeout(() => {
75
+ closeTimerRef.current = null;
76
+ setHovered(false);
77
+ onEndRef.current?.(event);
78
+ }, closeDelay);
79
+ } else {
80
+ setHovered(false);
81
+ onEndRef.current?.(event);
82
+ }
83
+ };
84
+
85
+ node.addEventListener("pointerenter", handleEnter);
86
+ node.addEventListener("pointerleave", handleLeave);
87
+
88
+ return () => {
89
+ node.removeEventListener("pointerenter", handleEnter);
90
+ node.removeEventListener("pointerleave", handleLeave);
91
+ clearOpenTimer();
92
+ clearCloseTimer();
93
+ setHovered(false);
94
+ };
95
+ },
96
+ [openDelay, closeDelay, clearOpenTimer, clearCloseTimer],
97
+ );
98
+
99
+ // Unmount safety net: covers Suspense/concurrent remount races where a
100
+ // delayed timer could fire after the hook lifecycle ends.
101
+ useEffect(
102
+ () => () => {
103
+ clearOpenTimer();
104
+ clearCloseTimer();
105
+ },
106
+ [clearOpenTimer, clearCloseTimer],
107
+ );
108
+
44
109
  return { hovered, ref };
45
110
  }