@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,60 +1,207 @@
1
- import { Meta, StoryObj } from "@storybook/react-vite";
2
- import { ArrowDown } from "lucide-react";
3
- import { ReactNode } from "react";
4
- import { createToastManager, ToastProvider } from "../../components/toast";
5
- import { useIsVisible } from "../use-is-visible";
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useIsVisible } from "./use-is-visible";
6
3
 
7
- const toastManager = createToastManager();
4
+ // ─── Demo component ────────────────────────────────────────────────────────────
5
+
6
+ type IsVisibleDemoProps = {
7
+ onVisible?: () => void;
8
+ triggerOnce?: boolean;
9
+ freezeOnceVisible?: boolean;
10
+ threshold?: number;
11
+ rootMargin?: string;
12
+ };
13
+
14
+ const IsVisibleDemo = ({
15
+ onVisible,
16
+ triggerOnce = false,
17
+ freezeOnceVisible = false,
18
+ threshold = 0,
19
+ rootMargin = "0px",
20
+ }: IsVisibleDemoProps) => {
21
+ const { ref, isVisible, entry } = useIsVisible<HTMLDivElement>({
22
+ onVisible,
23
+ triggerOnce,
24
+ freezeOnceVisible,
25
+ threshold,
26
+ rootMargin,
27
+ });
28
+
29
+ return (
30
+ <div className="flex flex-col gap-3 max-w-xs">
31
+ <pre className="rounded-md bg-slate-950 p-3 text-xs text-white leading-relaxed">
32
+ {JSON.stringify(
33
+ {
34
+ isVisible,
35
+ "entry.isIntersecting": entry?.isIntersecting ?? null,
36
+ "entry.intersectionRatio": entry
37
+ ? +entry.intersectionRatio.toFixed(2)
38
+ : null,
39
+ },
40
+ null,
41
+ 2,
42
+ )}
43
+ </pre>
44
+
45
+ <div className="h-48 overflow-y-auto border rounded-md">
46
+ <div className="flex flex-col" style={{ height: 480 }}>
47
+ <div className="flex-1 flex items-start pt-4 justify-center text-xs text-muted-foreground">
48
+ ↓ scroll down
49
+ </div>
50
+ <div
51
+ ref={ref}
52
+ className={`mx-2 mb-2 p-4 rounded text-center text-sm font-medium text-white transition-colors duration-500 ${
53
+ isVisible ? "bg-primary" : "bg-muted-foreground/60"
54
+ }`}
55
+ >
56
+ {isVisible ? "Visible" : "Not visible"}
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ );
62
+ };
63
+
64
+ // ─── Meta ─────────────────────────────────────────────────────────────────────
8
65
 
9
66
  /**
10
- * Hook que permite detectar si un elemento es visible para el usuario
67
+ * `useIsVisible` observes a DOM element with `IntersectionObserver` and reports
68
+ * whether it is intersecting the viewport (or a custom root).
69
+ *
70
+ * **API summary**
11
71
  *
12
- * Extiende la API de `IntersectionObserver`
72
+ * ```ts
73
+ * const { ref, isVisible, entry } = useIsVisible<T>({
74
+ * onVisible?, // () => void — stable, safe to pass inline
75
+ * triggerOnce?, // boolean — disconnect after first intersection (default false)
76
+ * freezeOnceVisible?, // boolean — never reset isVisible to false (default false)
77
+ * // IntersectionObserverInit options:
78
+ * threshold?, // number | number[] (default 0)
79
+ * rootMargin?, // string (default "0px")
80
+ * root?, // Element | Document | null (default null = viewport)
81
+ * });
82
+ * ```
83
+ *
84
+ * **Return**
85
+ *
86
+ * | Field | Type | Description |
87
+ * |-------|------|-------------|
88
+ * | `ref` | `RefObject<T \| null>` | Attach to the element you want to observe |
89
+ * | `isVisible` | `boolean` | `true` when the element intersects the root |
90
+ * | `entry` | `IntersectionObserverEntry \| null` | Latest raw entry, `null` until first event |
91
+ *
92
+ * **Key behaviors**
93
+ * - `onVisible` is stabilized via `useLatestRef` — inline callbacks never re-attach the observer.
94
+ * - `triggerOnce: true` — observer disconnects after the first positive intersection; `isVisible` latches permanently.
95
+ * - `freezeOnceVisible: true` — `isVisible` never resets; observer stays connected so `entry` keeps updating.
96
+ * - SSR-safe: the effect short-circuits when `IntersectionObserver` is unavailable.
13
97
  */
14
- const meta: Meta = {
98
+ const meta: Meta<typeof IsVisibleDemo> = {
15
99
  title: "hooks/useIsVisible",
16
- decorators: [
17
- (Story) => (
18
- <ToastProvider toastManager={toastManager}>
19
- <Story />
20
- </ToastProvider>
21
- ),
22
- ],
100
+ component: IsVisibleDemo,
101
+ argTypes: {
102
+ onVisible: {
103
+ action: "onVisible",
104
+ description:
105
+ "Called once when the observed element first becomes visible. Stable — safe to pass an inline function without causing observer re-attachment.",
106
+ table: {
107
+ category: "Hook options",
108
+ type: { summary: "() => void" },
109
+ },
110
+ },
111
+ triggerOnce: {
112
+ control: "boolean",
113
+ description:
114
+ "When true, the observer disconnects after the first positive intersection. `isVisible` latches to `true` permanently and never flips back.",
115
+ table: {
116
+ category: "Hook options",
117
+ type: { summary: "boolean" },
118
+ defaultValue: { summary: "false" },
119
+ },
120
+ },
121
+ freezeOnceVisible: {
122
+ control: "boolean",
123
+ description:
124
+ "When true, `isVisible` never resets to `false` once set. Unlike `triggerOnce`, the observer stays connected and `entry` continues updating on subsequent intersections.",
125
+ table: {
126
+ category: "Hook options",
127
+ type: { summary: "boolean" },
128
+ defaultValue: { summary: "false" },
129
+ },
130
+ },
131
+ threshold: {
132
+ control: { type: "range", min: 0, max: 1, step: 0.05 },
133
+ description:
134
+ "Fraction of the element that must be visible before an intersection fires. `0` = any pixel, `1` = fully visible.",
135
+ table: {
136
+ category: "Hook options",
137
+ type: { summary: "number | number[]" },
138
+ defaultValue: { summary: "0" },
139
+ },
140
+ },
141
+ rootMargin: {
142
+ control: "text",
143
+ description:
144
+ 'Margin around the root (viewport by default). Expands or shrinks the detection area. CSS shorthand syntax, e.g. `"50px 0px"`.',
145
+ table: {
146
+ category: "Hook options",
147
+ type: { summary: "string" },
148
+ defaultValue: { summary: '"0px"' },
149
+ },
150
+ },
151
+ },
152
+ args: {
153
+ triggerOnce: false,
154
+ freezeOnceVisible: false,
155
+ threshold: 0,
156
+ rootMargin: "0px",
157
+ },
158
+ render: (args) => <IsVisibleDemo {...args} />,
23
159
  };
24
160
 
25
161
  export default meta;
162
+ type Story = StoryObj<typeof IsVisibleDemo>;
26
163
 
27
- const Wrapper = ({ children }: { children: ReactNode }) => {
28
- return (
29
- <div className="h-[200px] border rounded overflow-y-scroll">{children}</div>
30
- );
164
+ // ─── Stories ──────────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Scroll down inside the box to reveal the observed element. `isVisible`
168
+ * toggles as the element enters and leaves the viewport. `entry` updates on
169
+ * every intersection event. `onVisible` fires each time the element becomes
170
+ * visible (visible in the Actions tab).
171
+ */
172
+ export const Default: Story = {};
173
+
174
+ /**
175
+ * With `triggerOnce: true`, the observer disconnects after the first positive
176
+ * intersection. `isVisible` latches permanently to `true` — scrolling back up
177
+ * does not reset it and `onVisible` never fires a second time.
178
+ */
179
+ export const TriggerOnce: Story = {
180
+ args: {
181
+ triggerOnce: true,
182
+ },
31
183
  };
32
- export const Default: StoryObj = {
33
- render: () => {
34
- const { ref, isVisible } = useIsVisible<HTMLDivElement>({
35
- onVisible: () =>
36
- toastManager.add({
37
- variant: "success",
38
- description: "El elemento es visible",
39
- }),
40
- });
41
-
42
- return (
43
- <>
44
- <div className="flex items-center gap-x-2">
45
- <span>Scroll</span>
46
- <ArrowDown className="animate-bounce" />
47
- </div>
48
184
 
49
- <Wrapper>
50
- <div
51
- ref={ref}
52
- className={`transition-colors p-2 rounded text-primary-foreground duration-1000 mt-[400px] ${isVisible ? "bg-primary" : "bg-error"}`}
53
- >
54
- {isVisible ? "El elemento es visible" : "El elemento no es visible"}
55
- </div>
56
- </Wrapper>
57
- </>
58
- );
185
+ /**
186
+ * With `freezeOnceVisible: true`, `isVisible` never returns to `false` once
187
+ * set. The observer stays connected — scroll up and down after the first
188
+ * intersection to see `entry.intersectionRatio` still updating while
189
+ * `isVisible` remains `true`.
190
+ */
191
+ export const FreezeOnceVisible: Story = {
192
+ args: {
193
+ freezeOnceVisible: true,
194
+ },
195
+ };
196
+
197
+ /**
198
+ * `threshold: 0.5` requires 50% of the element to be visible before
199
+ * `isVisible` becomes `true`. Adjust the **threshold** slider in controls to
200
+ * shift the intersection boundary. Watch `entry.intersectionRatio` update in
201
+ * real time as you scroll.
202
+ */
203
+ export const WithThreshold: Story = {
204
+ args: {
205
+ threshold: 0.5,
59
206
  },
60
207
  };
@@ -7,14 +7,19 @@ type ObserverCallback = IntersectionObserverCallback;
7
7
 
8
8
  let observerCallback: ObserverCallback | null = null;
9
9
  let observedEl: Element | null = null;
10
+ let observerInit: IntersectionObserverInit | undefined = undefined;
11
+ let constructorCallCount = 0;
10
12
 
11
13
  class IntersectionObserverMock implements IntersectionObserver {
12
14
  readonly root = null;
13
15
  readonly rootMargin = "0px";
14
16
  readonly thresholds = [0];
15
17
 
16
- constructor(cb: ObserverCallback) {
18
+ constructor(cb: ObserverCallback, init?: IntersectionObserverInit) {
17
19
  observerCallback = cb;
20
+ observerInit = init;
21
+ constructorCallCount++;
22
+ observedEl = null;
18
23
  }
19
24
 
20
25
  observe(el: Element) {
@@ -37,10 +42,30 @@ function makeEntry(isIntersecting: boolean): IntersectionObserverEntry {
37
42
 
38
43
  function Fixture({
39
44
  onVisible,
45
+ triggerOnce,
46
+ freezeOnceVisible,
47
+ rootMargin,
48
+ threshold,
49
+ onRender,
40
50
  }: {
41
51
  onVisible?: () => void;
52
+ triggerOnce?: boolean;
53
+ freezeOnceVisible?: boolean;
54
+ rootMargin?: string;
55
+ threshold?: number;
56
+ onRender?: (result: {
57
+ isVisible: boolean;
58
+ entry: IntersectionObserverEntry | null;
59
+ }) => void;
42
60
  }) {
43
- const { ref, isVisible } = useIsVisible<HTMLDivElement>({ onVisible });
61
+ const { ref, isVisible, entry } = useIsVisible<HTMLDivElement>({
62
+ onVisible,
63
+ triggerOnce,
64
+ freezeOnceVisible,
65
+ rootMargin,
66
+ threshold,
67
+ });
68
+ onRender?.({ isVisible, entry });
44
69
  return (
45
70
  <div ref={ref} data-testid="target">
46
71
  {isVisible ? "visible" : "hidden"}
@@ -49,6 +74,13 @@ function Fixture({
49
74
  }
50
75
 
51
76
  describe("useIsVisible", () => {
77
+ beforeEach(() => {
78
+ constructorCallCount = 0;
79
+ observerInit = undefined;
80
+ observerCallback = null;
81
+ observedEl = null;
82
+ });
83
+
52
84
  it("starts as hidden", () => {
53
85
  render(<Fixture />);
54
86
  expect(screen.getByTestId("target")).toHaveTextContent("hidden");
@@ -109,10 +141,11 @@ describe("useIsVisible", () => {
109
141
  expect(observedEl).not.toBeNull();
110
142
  });
111
143
 
112
- it("calls unobserve when observer options change (cleanup while mounted)", () => {
113
- const unobserveSpy = vi.spyOn(
144
+ // T-02: updated to assert disconnect instead of unobserve
145
+ it("calls disconnect when observer options change (cleanup while mounted)", () => {
146
+ const disconnectSpy = vi.spyOn(
114
147
  IntersectionObserverMock.prototype,
115
- "unobserve",
148
+ "disconnect",
116
149
  );
117
150
 
118
151
  function Resettable({ margin }: { margin: string }) {
@@ -129,7 +162,202 @@ describe("useIsVisible", () => {
129
162
  const { rerender } = render(<Resettable margin="0px" />);
130
163
  rerender(<Resettable margin="10px" />);
131
164
 
132
- expect(unobserveSpy).toHaveBeenCalled();
133
- unobserveSpy.mockRestore();
165
+ expect(disconnectSpy).toHaveBeenCalled();
166
+ disconnectSpy.mockRestore();
167
+ });
168
+
169
+ // T-03: entry exposed and matches received entry
170
+ describe("entry", () => {
171
+ it("starts as null", () => {
172
+ let capturedEntry: IntersectionObserverEntry | null | undefined =
173
+ undefined;
174
+ render(
175
+ <Fixture
176
+ onRender={({ entry }) => {
177
+ capturedEntry = entry;
178
+ }}
179
+ />,
180
+ );
181
+ expect(capturedEntry).toBeNull();
182
+ });
183
+
184
+ it("reflects the received IntersectionObserverEntry after intersection fires", () => {
185
+ let capturedEntry: IntersectionObserverEntry | null | undefined =
186
+ undefined;
187
+ render(
188
+ <Fixture
189
+ onRender={({ entry }) => {
190
+ capturedEntry = entry;
191
+ }}
192
+ />,
193
+ );
194
+
195
+ const entry = makeEntry(true);
196
+ act(() => {
197
+ observerCallback?.([entry], {} as IntersectionObserver);
198
+ });
199
+
200
+ expect(capturedEntry).toBe(entry);
201
+ });
202
+
203
+ it("updates entry on negative intersection", () => {
204
+ let capturedEntry: IntersectionObserverEntry | null | undefined =
205
+ undefined;
206
+ render(
207
+ <Fixture
208
+ onRender={({ entry }) => {
209
+ capturedEntry = entry;
210
+ }}
211
+ />,
212
+ );
213
+
214
+ act(() => {
215
+ observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
216
+ });
217
+
218
+ const negEntry = makeEntry(false);
219
+ act(() => {
220
+ observerCallback?.([negEntry], {} as IntersectionObserver);
221
+ });
222
+
223
+ expect(capturedEntry).toBe(negEntry);
224
+ });
225
+ });
226
+
227
+ // T-04: triggerOnce behavior
228
+ describe("triggerOnce", () => {
229
+ it("isVisible stays true after element leaves viewport", () => {
230
+ render(<Fixture triggerOnce={true} />);
231
+
232
+ act(() => {
233
+ observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
234
+ });
235
+ expect(screen.getByTestId("target")).toHaveTextContent("visible");
236
+
237
+ act(() => {
238
+ observerCallback?.([makeEntry(false)], {} as IntersectionObserver);
239
+ });
240
+ expect(screen.getByTestId("target")).toHaveTextContent("visible");
241
+ });
242
+
243
+ it("observer is disconnected after first positive intersection", () => {
244
+ const disconnectSpy = vi.spyOn(
245
+ IntersectionObserverMock.prototype,
246
+ "disconnect",
247
+ );
248
+
249
+ render(<Fixture triggerOnce={true} />);
250
+
251
+ act(() => {
252
+ observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
253
+ });
254
+
255
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
256
+ disconnectSpy.mockRestore();
257
+ });
258
+ });
259
+
260
+ // T-05: freezeOnceVisible behavior
261
+ describe("freezeOnceVisible", () => {
262
+ it("isVisible stays true after element leaves viewport", () => {
263
+ render(<Fixture freezeOnceVisible={true} />);
264
+
265
+ act(() => {
266
+ observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
267
+ });
268
+ expect(screen.getByTestId("target")).toHaveTextContent("visible");
269
+
270
+ act(() => {
271
+ observerCallback?.([makeEntry(false)], {} as IntersectionObserver);
272
+ });
273
+ expect(screen.getByTestId("target")).toHaveTextContent("visible");
274
+ });
275
+ });
276
+
277
+ // T-06: stable onVisible — no re-attach on re-render
278
+ it("does not re-attach observer when onVisible changes reference", () => {
279
+ constructorCallCount = 0;
280
+
281
+ function StableFixture({ onVisible }: { onVisible: () => void }) {
282
+ const { ref } = useIsVisible<HTMLDivElement>({ onVisible });
283
+ return <div ref={ref} data-testid="stable-target" />;
284
+ }
285
+
286
+ const { rerender } = render(<StableFixture onVisible={() => {}} />);
287
+ rerender(<StableFixture onVisible={() => {}} />);
288
+ rerender(<StableFixture onVisible={() => {}} />);
289
+
290
+ expect(constructorCallCount).toBe(1);
291
+ });
292
+
293
+ // T-07: onVisible not called on negative intersection (stable-ref test)
294
+ it("does not call a newly-referenced onVisible on negative intersection", () => {
295
+ const firstMock = vi.fn();
296
+
297
+ function DynamicFixture({ onVisible }: { onVisible: () => void }) {
298
+ const { ref } = useIsVisible<HTMLDivElement>({ onVisible });
299
+ return <div ref={ref} data-testid="dynamic-target" />;
300
+ }
301
+
302
+ const { rerender } = render(<DynamicFixture onVisible={firstMock} />);
303
+
304
+ act(() => {
305
+ observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
306
+ });
307
+ firstMock.mockClear();
308
+
309
+ const secondMock = vi.fn();
310
+ rerender(<DynamicFixture onVisible={secondMock} />);
311
+
312
+ act(() => {
313
+ observerCallback?.([makeEntry(false)], {} as IntersectionObserver);
314
+ });
315
+
316
+ expect(secondMock).not.toHaveBeenCalled();
317
+ });
318
+
319
+ // T-08: SSR safety
320
+ describe("SSR safety", () => {
321
+ afterEach(() => {
322
+ vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
323
+ });
324
+
325
+ it("does not throw when IntersectionObserver is undefined (SSR)", () => {
326
+ vi.stubGlobal("IntersectionObserver", undefined);
327
+
328
+ function SSRFixture() {
329
+ const { ref, isVisible } = useIsVisible<HTMLDivElement>();
330
+ return (
331
+ <div ref={ref} data-testid="ssr-target">
332
+ {isVisible ? "visible" : "hidden"}
333
+ </div>
334
+ );
335
+ }
336
+
337
+ expect(() => render(<SSRFixture />)).not.toThrow();
338
+ expect(screen.getByTestId("ssr-target")).toHaveTextContent("hidden");
339
+ });
340
+ });
341
+
342
+ // T-09: IntersectionObserver options forwarded to constructor
343
+ it("forwards root, rootMargin, and threshold to IntersectionObserver constructor", () => {
344
+ render(<Fixture rootMargin="50px" threshold={0.5} />);
345
+
346
+ expect(observerInit?.rootMargin).toBe("50px");
347
+ expect(observerInit?.threshold).toBe(0.5);
348
+ });
349
+
350
+ // T-10: cleanup on unmount is distinct from options-change cleanup
351
+ it("calls disconnect on unmount (no prior options change)", () => {
352
+ const disconnectSpy = vi.spyOn(
353
+ IntersectionObserverMock.prototype,
354
+ "disconnect",
355
+ );
356
+
357
+ const { unmount } = render(<Fixture />);
358
+ unmount();
359
+
360
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
361
+ disconnectSpy.mockRestore();
134
362
  });
135
363
  });
@@ -0,0 +1,114 @@
1
+ import type { RefObject } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { isBrowser } from "../internal/is-browser";
4
+ import { useLatestRef } from "../internal/use-latest-ref";
5
+
6
+ export interface UseIsVisibleOptions extends IntersectionObserverInit {
7
+ /**
8
+ * Called once when the element first becomes visible.
9
+ * Stable — safe to pass an inline arrow function without causing observer re-attachment.
10
+ */
11
+ onVisible?: () => void;
12
+ /**
13
+ * When true, the IntersectionObserver disconnects after the first positive intersection.
14
+ * `isVisible` latches to `true` and never flips back to `false`.
15
+ * The observer lifecycle is torn down early; no further intersection callbacks fire.
16
+ */
17
+ triggerOnce?: boolean;
18
+ /**
19
+ * When true, once `isVisible` becomes `true` it is never reset to `false`.
20
+ * The observer stays connected (unlike `triggerOnce`), so `entry` may continue updating.
21
+ */
22
+ freezeOnceVisible?: boolean;
23
+ }
24
+
25
+ export interface UseIsVisibleReturn<T extends HTMLElement> {
26
+ /** Attach to the DOM element you want to observe. */
27
+ ref: RefObject<T | null>;
28
+ /**
29
+ * True when the observed element is intersecting the viewport (or root).
30
+ * Stays true permanently when `triggerOnce` or `freezeOnceVisible` has triggered.
31
+ */
32
+ isVisible: boolean;
33
+ /**
34
+ * The latest IntersectionObserverEntry received.
35
+ * `null` until the first intersection event fires.
36
+ */
37
+ entry: IntersectionObserverEntry | null;
38
+ }
39
+
40
+ export function useIsVisible<T extends HTMLElement>(
41
+ options: UseIsVisibleOptions = {},
42
+ ): UseIsVisibleReturn<T> {
43
+ const {
44
+ onVisible,
45
+ triggerOnce = false,
46
+ freezeOnceVisible = false,
47
+ root = null,
48
+ rootMargin = "0px",
49
+ threshold = 0,
50
+ } = options;
51
+
52
+ const ref = useRef<T | null>(null);
53
+ const [isVisible, setIsVisible] = useState(false);
54
+ const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
55
+
56
+ // AD-2: stable callback ref — inline onVisible never re-attaches the observer
57
+ const onVisibleRef = useLatestRef(onVisible);
58
+
59
+ // AD-3: guards triggerOnce against double-disconnect / late callbacks
60
+ const hasTriggeredRef = useRef(false);
61
+
62
+ useEffect(() => {
63
+ // AD-4: SSR guard at the top of the effect body
64
+ // Also guard against environments where IntersectionObserver may be undefined
65
+ // even when window is available (e.g. jsdom tests that stub it to undefined).
66
+ if (!isBrowser || typeof IntersectionObserver === "undefined") return;
67
+
68
+ const el = ref.current;
69
+ if (el == null) return;
70
+
71
+ // fresh observer per effect run → reset the trigger latch
72
+ hasTriggeredRef.current = false;
73
+
74
+ const observer = new IntersectionObserver(
75
+ ([nextEntry]) => {
76
+ // AD-3: ignore any callback that lands after triggerOnce teardown
77
+ if (hasTriggeredRef.current) return;
78
+ if (!nextEntry) return;
79
+
80
+ const intersecting = nextEntry.isIntersecting;
81
+
82
+ // entry always tracks the latest observed entry
83
+ setEntry(nextEntry);
84
+
85
+ // AD-3 freezeOnceVisible: once visible, never write false again
86
+ setIsVisible((prev) =>
87
+ freezeOnceVisible && prev ? true : intersecting,
88
+ );
89
+
90
+ if (intersecting) {
91
+ // AD-1/AD-2: fire the stable callback synchronously
92
+ onVisibleRef.current?.();
93
+
94
+ // AD-3 triggerOnce: tear down early, latch the guard
95
+ if (triggerOnce) {
96
+ hasTriggeredRef.current = true;
97
+ observer.disconnect();
98
+ }
99
+ }
100
+ },
101
+ { root, rootMargin, threshold },
102
+ );
103
+
104
+ observer.observe(el);
105
+
106
+ return () => {
107
+ // AD-3: skip redundant teardown if triggerOnce already disconnected
108
+ if (hasTriggeredRef.current) return;
109
+ observer.disconnect();
110
+ };
111
+ }, [root, rootMargin, threshold, triggerOnce, freezeOnceVisible]);
112
+
113
+ return { ref, isVisible, entry };
114
+ }