@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,39 +1,209 @@
1
- import { Meta } from "@storybook/react-vite";
2
- import { useState } from "react";
3
- import { Button } from "../../components";
4
- import { useClickOutside } from "../use-click-outside";
1
+ import type { Meta } from "@storybook/react-vite";
2
+ import { useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { Button } from "../../components/button";
5
+ import { useClickOutside } from "./use-click-outside";
5
6
 
7
+ /**
8
+ * Fires a callback when a `pointerdown` (or any configured event) happens
9
+ * outside the returned ref. Listener lives on `document`, so the wrapped
10
+ * element does not need to receive focus or capture events itself.
11
+ *
12
+ * ```tsx
13
+ * const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
14
+ * ```
15
+ *
16
+ * Good to know:
17
+ * - **Default event is `pointerdown`** (handles mouse + touch + pen in one
18
+ * listener). Override via `events` if you need legacy `mousedown` or want
19
+ * to combine `mousedown` + `touchstart`.
20
+ * - **Callback identity does not matter.** The hook reads the callback
21
+ * through an internal ref, so an inline arrow function will not re-attach
22
+ * the listener every render.
23
+ * - **`enabled` controls subscription.** When `false` no listener is
24
+ * attached at all — cheaper than checking inside the callback, and the
25
+ * typical way to "pause" the hook while a popover is closed.
26
+ * - **Portals:** elements rendered into another part of the tree (modals,
27
+ * popovers, tooltips) are NOT inside the ref's subtree. Use
28
+ * `additionalRefs` to whitelist them, or `ignore(event)` for finer
29
+ * control (e.g. ignore clicks on a specific class).
30
+ * - **SSR-safe:** the effect early-returns on the server.
31
+ *
32
+ * Breaking change from v1: the second argument is now an options object
33
+ * (`enabled`, `events`, `ignore`, `additionalRefs`) instead of the boolean
34
+ * `watch`. Default event changed from `mousedown` to `pointerdown`.
35
+ */
6
36
  const meta: Meta = {
7
37
  title: "hooks/useClickOutside",
38
+ parameters: { layout: "centered" },
39
+ tags: ["beta"],
8
40
  };
9
41
 
10
42
  export default meta;
11
43
 
44
+ /**
45
+ * Open the popover with the button, then click anywhere outside the
46
+ * yellow card to close it. Clicking inside the card (including the
47
+ * "Close from inside" button) does not trigger the callback.
48
+ */
12
49
  export const Default = {
13
50
  render: () => {
14
- const [isOpen, setIsOpen] = useState(true);
51
+ const [open, setOpen] = useState(true);
52
+ const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
15
53
 
16
- const handleClose = () => {
17
- setIsOpen(false);
18
- };
54
+ return (
55
+ <div className="flex flex-col items-start gap-3">
56
+ <Button onClick={() => setOpen(true)} disabled={open}>
57
+ Open
58
+ </Button>
59
+ {open ? (
60
+ <div
61
+ ref={ref}
62
+ className="rounded-md border border-warning bg-warning/10 p-4 text-sm shadow-md"
63
+ >
64
+ <p className="mb-2">Click outside this card to dismiss it.</p>
65
+ <Button size="sm" onClick={() => setOpen(false)}>
66
+ Close from inside
67
+ </Button>
68
+ </div>
69
+ ) : (
70
+ <p className="text-sm text-muted-foreground">
71
+ Popover closed. Re-open with the button.
72
+ </p>
73
+ )}
74
+ </div>
75
+ );
76
+ },
77
+ };
19
78
 
20
- const ref = useClickOutside<HTMLDivElement>(handleClose);
79
+ /**
80
+ * `enabled: false` removes the listener entirely. Useful when the consumer
81
+ * already has a separate "is open" flag — the hook does not need to be
82
+ * conditionally rendered, just toggled.
83
+ *
84
+ * ```tsx
85
+ * useClickOutside(close, { enabled: isOpen });
86
+ * ```
87
+ */
88
+ export const Enabled = {
89
+ render: () => {
90
+ const [enabled, setEnabled] = useState(true);
91
+ const [hits, setHits] = useState(0);
92
+ const ref = useClickOutside<HTMLDivElement>(() => setHits((n) => n + 1), {
93
+ enabled,
94
+ });
21
95
 
22
96
  return (
23
- <div className="flex gap-2">
24
- <Button onClick={() => setIsOpen(true)} disabled={isOpen}>
25
- Abrir
26
- </Button>
27
- <Button onClick={() => setIsOpen(false)} disabled={!isOpen}>
28
- Cerrar
97
+ <div className="flex flex-col items-start gap-3">
98
+ <label className="flex items-center gap-2 text-sm">
99
+ <input
100
+ type="checkbox"
101
+ checked={enabled}
102
+ onChange={(e) => setEnabled(e.target.checked)}
103
+ />
104
+ listener enabled
105
+ </label>
106
+ <div
107
+ ref={ref}
108
+ className="rounded-md border border-input bg-card p-4 text-sm"
109
+ >
110
+ Click outside this box.
111
+ </div>
112
+ <code className="text-xs">outside clicks counted: {hits}</code>
113
+ </div>
114
+ );
115
+ },
116
+ };
117
+
118
+ /**
119
+ * Use `additionalRefs` when the popover has a separate trigger element
120
+ * that should NOT count as "outside". Without this, clicking the trigger
121
+ * to toggle the popover would immediately close it again because the
122
+ * button lives outside the popover's DOM subtree.
123
+ *
124
+ * ```tsx
125
+ * const triggerRef = useRef<HTMLButtonElement>(null);
126
+ * const popoverRef = useClickOutside<HTMLDivElement>(close, {
127
+ * additionalRefs: [triggerRef],
128
+ * });
129
+ * ```
130
+ */
131
+ export const AdditionalRefs = {
132
+ render: () => {
133
+ const [open, setOpen] = useState(false);
134
+ const triggerRef = useRef<HTMLButtonElement>(null);
135
+ const popoverRef = useClickOutside<HTMLDivElement>(() => setOpen(false), {
136
+ additionalRefs: [triggerRef],
137
+ });
138
+
139
+ return (
140
+ <div className="flex flex-col items-start gap-3">
141
+ <Button ref={triggerRef} onClick={() => setOpen((v) => !v)}>
142
+ Toggle
29
143
  </Button>
144
+ {open ? (
145
+ <div
146
+ ref={popoverRef}
147
+ className="rounded-md border border-input bg-card p-4 text-sm shadow-md"
148
+ >
149
+ Toggling the trigger does NOT close me, but clicking elsewhere does.
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+ );
154
+ },
155
+ };
156
+
157
+ /**
158
+ * `ignore(event)` is the escape hatch for content rendered into a React
159
+ * portal — by definition that DOM is not under the ref's subtree, so a
160
+ * raw click on a tooltip / overlay / dropdown would close the parent.
161
+ * Returning `true` from `ignore` skips the callback for that event.
162
+ *
163
+ * Here, the green portal is anchored to `document.body` but tagged with
164
+ * `data-inside-portal`. The `ignore` predicate checks that attribute and
165
+ * treats clicks on the portal as "inside".
166
+ *
167
+ * ```tsx
168
+ * useClickOutside(close, {
169
+ * ignore: (event) =>
170
+ * (event.target as HTMLElement | null)?.closest("[data-tooltip]") != null,
171
+ * });
172
+ * ```
173
+ */
174
+ export const IgnorePortals = {
175
+ render: () => {
176
+ const [closed, setClosed] = useState(false);
177
+ const ref = useClickOutside<HTMLDivElement>(() => setClosed(true), {
178
+ ignore: (event) =>
179
+ (event.target as HTMLElement | null)?.closest("[data-inside-portal]") !=
180
+ null,
181
+ });
182
+
183
+ return (
184
+ <div className="flex flex-col items-start gap-3">
30
185
  <div
31
186
  ref={ref}
32
- className={`relative p-4 bg-white shadow-md ${isOpen ? "" : "hidden"}`}
187
+ className="rounded-md border border-input bg-card p-4 text-sm"
33
188
  >
34
- <p>Haz clic fuera de este cuadro para cerrarlo.</p>
35
- <Button onClick={() => setIsOpen(false)}>Cerrar desde dentro</Button>
189
+ {closed
190
+ ? "Closed click Reset."
191
+ : "Click the green portal: NOT counted as outside. Click anywhere else: counted."}
36
192
  </div>
193
+ {closed ? (
194
+ <Button size="sm" onClick={() => setClosed(false)}>
195
+ Reset
196
+ </Button>
197
+ ) : null}
198
+ {createPortal(
199
+ <div
200
+ data-inside-portal
201
+ className="fixed right-4 bottom-4 rounded-md border border-success bg-success/15 p-3 text-xs shadow-md"
202
+ >
203
+ Portal content (anchored to body, but ignored).
204
+ </div>,
205
+ document.body,
206
+ )}
37
207
  </div>
38
208
  );
39
209
  },
@@ -1,39 +1,118 @@
1
1
  import { act, render, screen } from "@testing-library/react";
2
+ import { useRef } from "react";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import { useClickOutside } from "../use-click-outside";
4
5
 
5
- function Component({ onOutside, watch }: { onOutside: () => void; watch?: boolean }) {
6
- const ref = useClickOutside<HTMLDivElement>(onOutside, watch);
6
+ type ComponentProps = {
7
+ onOutside: (event: Event) => void;
8
+ enabled?: boolean;
9
+ events?: Array<"mousedown" | "pointerdown" | "touchstart">;
10
+ ignore?: (event: Event) => boolean;
11
+ withExtra?: boolean;
12
+ };
13
+
14
+ function Component({
15
+ onOutside,
16
+ enabled,
17
+ events,
18
+ ignore,
19
+ withExtra,
20
+ }: ComponentProps) {
21
+ const extraRef = useRef<HTMLDivElement>(null);
22
+ const ref = useClickOutside<HTMLDivElement>(onOutside, {
23
+ enabled,
24
+ events,
25
+ ignore,
26
+ additionalRefs: withExtra ? [extraRef] : undefined,
27
+ });
7
28
  return (
8
29
  <div data-testid="wrapper">
9
30
  <div ref={ref} data-testid="target" />
31
+ {withExtra ? <div ref={extraRef} data-testid="extra" /> : null}
10
32
  </div>
11
33
  );
12
34
  }
13
35
 
14
- function fireMousedown(el: Element) {
15
- el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
36
+ function fireEvent(
37
+ el: Element,
38
+ type: "mousedown" | "pointerdown" | "touchstart",
39
+ ) {
40
+ el.dispatchEvent(new Event(type, { bubbles: true }));
16
41
  }
17
42
 
18
43
  describe("useClickOutside", () => {
19
- it("calls callback when clicking outside the ref element", () => {
44
+ it("calls callback on pointerdown outside the ref element (default event)", () => {
20
45
  const fn = vi.fn();
21
46
  render(<Component onOutside={fn} />);
22
- act(() => fireMousedown(screen.getByTestId("wrapper")));
47
+ act(() => fireEvent(screen.getByTestId("wrapper"), "pointerdown"));
23
48
  expect(fn).toHaveBeenCalledOnce();
24
49
  });
25
50
 
51
+ it("passes the event to the callback", () => {
52
+ const fn = vi.fn();
53
+ render(<Component onOutside={fn} />);
54
+ act(() => fireEvent(screen.getByTestId("wrapper"), "pointerdown"));
55
+ expect(fn.mock.calls[0][0]).toBeInstanceOf(Event);
56
+ });
57
+
26
58
  it("does not call callback when clicking inside the ref element", () => {
27
59
  const fn = vi.fn();
28
60
  render(<Component onOutside={fn} />);
29
- act(() => fireMousedown(screen.getByTestId("target")));
61
+ act(() => fireEvent(screen.getByTestId("target"), "pointerdown"));
62
+ expect(fn).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("does not attach listener when enabled=false", () => {
66
+ const fn = vi.fn();
67
+ render(<Component onOutside={fn} enabled={false} />);
68
+ act(() => fireEvent(screen.getByTestId("wrapper"), "pointerdown"));
30
69
  expect(fn).not.toHaveBeenCalled();
31
70
  });
32
71
 
33
- it("does not attach listener when watch=false", () => {
72
+ it("attaches listeners for each event in the events option", () => {
73
+ const fn = vi.fn();
74
+ render(<Component onOutside={fn} events={["mousedown", "touchstart"]} />);
75
+ act(() => fireEvent(screen.getByTestId("wrapper"), "mousedown"));
76
+ act(() => fireEvent(screen.getByTestId("wrapper"), "touchstart"));
77
+ expect(fn).toHaveBeenCalledTimes(2);
78
+ });
79
+
80
+ it("does not call callback for events not in the events option", () => {
81
+ const fn = vi.fn();
82
+ render(<Component onOutside={fn} events={["mousedown"]} />);
83
+ act(() => fireEvent(screen.getByTestId("wrapper"), "pointerdown"));
84
+ expect(fn).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("skips the callback when ignore returns true", () => {
88
+ const fn = vi.fn();
89
+ render(<Component onOutside={fn} ignore={() => true} />);
90
+ act(() => fireEvent(screen.getByTestId("wrapper"), "pointerdown"));
91
+ expect(fn).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("treats additional refs as inside", () => {
95
+ const fn = vi.fn();
96
+ render(<Component onOutside={fn} withExtra />);
97
+ act(() => fireEvent(screen.getByTestId("extra"), "pointerdown"));
98
+ expect(fn).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("uses the latest callback without re-attaching listeners", () => {
102
+ const first = vi.fn();
103
+ const second = vi.fn();
104
+ const { rerender } = render(<Component onOutside={first} />);
105
+ rerender(<Component onOutside={second} />);
106
+ act(() => fireEvent(screen.getByTestId("wrapper"), "pointerdown"));
107
+ expect(first).not.toHaveBeenCalled();
108
+ expect(second).toHaveBeenCalledOnce();
109
+ });
110
+
111
+ it("removes listeners on unmount", () => {
34
112
  const fn = vi.fn();
35
- render(<Component onOutside={fn} watch={false} />);
36
- act(() => fireMousedown(screen.getByTestId("wrapper")));
113
+ const { unmount } = render(<Component onOutside={fn} />);
114
+ unmount();
115
+ act(() => fireEvent(document.body, "pointerdown"));
37
116
  expect(fn).not.toHaveBeenCalled();
38
117
  });
39
118
  });
@@ -1,25 +1,71 @@
1
- import { useEffect, useRef } from "react";
1
+ import { type RefObject, useEffect, useRef } from "react";
2
+ import { isBrowser, useLatestRef } from "../internal";
3
+
4
+ type UseClickOutsideOptions = {
5
+ /** Disable the listener without unmounting the hook. Defaults to `true`. */
6
+ enabled?: boolean;
7
+ /**
8
+ * Which DOM events trigger the outside check. Any DOM event names work;
9
+ * pass `null` to fall back to the default. Defaults to `["pointerdown"]`.
10
+ */
11
+ events?: string[] | null;
12
+ /**
13
+ * Return `true` to skip the callback for a given event. Useful for portals
14
+ * or popovers whose DOM lives outside the wrapped ref but should still count
15
+ * as "inside".
16
+ */
17
+ ignore?: (event: Event) => boolean;
18
+ /**
19
+ * Extra refs that also count as "inside". An event whose target lives in
20
+ * any of these elements will not fire the callback.
21
+ */
22
+ additionalRefs?: RefObject<HTMLElement | null>[];
23
+ };
24
+
25
+ const DEFAULT_EVENTS = ["pointerdown"];
2
26
 
3
27
  export const useClickOutside = <T extends HTMLElement = HTMLElement>(
4
- callback: () => void,
5
- watch: boolean = true,
6
- ) => {
7
- const ref = useRef<T>(null);
8
-
9
- const handleClickOutside = (event: MouseEvent) => {
10
- if (ref.current && !ref.current.contains(event.target as Node)) {
11
- callback();
12
- }
13
- };
28
+ callback: (event: Event) => void,
29
+ options: UseClickOutsideOptions = {},
30
+ ): RefObject<T | null> => {
31
+ const { enabled = true, events, ignore, additionalRefs } = options;
32
+ const resolvedEvents = events ?? DEFAULT_EVENTS;
33
+
34
+ const ref = useRef<T | null>(null);
35
+ const callbackRef = useLatestRef(callback);
36
+ const ignoreRef = useLatestRef(ignore);
37
+ const additionalRefsRef = useLatestRef(additionalRefs);
14
38
 
15
39
  useEffect(() => {
16
- if (!watch) return;
17
- document.addEventListener("mousedown", handleClickOutside);
40
+ if (!enabled || !isBrowser) return;
41
+
42
+ const handler = (event: Event) => {
43
+ if (ignoreRef.current?.(event)) return;
44
+
45
+ const target = event.target as Node | null;
46
+ if (!target) return;
47
+
48
+ if (ref.current?.contains(target)) return;
49
+
50
+ const extras = additionalRefsRef.current;
51
+ if (extras) {
52
+ for (const extra of extras) {
53
+ if (extra.current?.contains(target)) return;
54
+ }
55
+ }
56
+
57
+ callbackRef.current(event);
58
+ };
59
+
60
+ for (const event of resolvedEvents) {
61
+ document.addEventListener(event, handler);
62
+ }
18
63
  return () => {
19
- document.removeEventListener("mousedown", handleClickOutside);
64
+ for (const event of resolvedEvents) {
65
+ document.removeEventListener(event, handler);
66
+ }
20
67
  };
21
- }, [watch]);
68
+ }, [enabled, resolvedEvents.join("|")]);
22
69
 
23
70
  return ref;
24
71
  };
25
-
@@ -1,15 +1,46 @@
1
1
  import { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Input } from "../../components";
3
- import { createToastManager, ToastProvider } from "../../components/toast";
3
+ import {
4
+ createToastManager,
5
+ ToastProvider,
6
+ toast,
7
+ } from "../../components/toast";
4
8
  import { useDebouncedCallback } from "./use-debounced-callback";
5
9
 
6
10
  const toastManager = createToastManager();
7
11
 
8
- const meta: Meta = {
12
+ type Args = { delay: number };
13
+
14
+ const meta: Meta<Args> = {
9
15
  title: "hooks/useDebouncedCallback",
16
+ argTypes: {
17
+ delay: {
18
+ control: { type: "range", min: 100, max: 3000, step: 100 },
19
+ description: "Debounce delay in milliseconds.",
20
+ },
21
+ },
22
+ args: { delay: 600 },
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component: [
27
+ "Returns a stable debounced wrapper around `callback` with controls.",
28
+ "",
29
+ "**Signature**: `[debounced, { cancel, flush, isPending }] = useDebouncedCallback(callback, delay)`",
30
+ "",
31
+ "- `cancel()` — discard the pending call without invoking.",
32
+ "- `flush()` — invoke the pending call immediately.",
33
+ "- `isPending` — reactive boolean, `true` while a call is scheduled.",
34
+ "",
35
+ "The wrapper has a **stable identity** — it is only recreated when `delay` changes,",
36
+ "never when `callback` changes. Inline arrow functions are safe to pass.",
37
+ ].join("\n"),
38
+ },
39
+ },
40
+ },
10
41
  decorators: [
11
42
  (Story) => (
12
- <ToastProvider toastManager={toastManager}>
43
+ <ToastProvider>
13
44
  <Story />
14
45
  </ToastProvider>
15
46
  ),
@@ -18,66 +49,135 @@ const meta: Meta = {
18
49
 
19
50
  export default meta;
20
51
 
21
- export const Basic = {
22
- render: () => {
23
- const handleSearch = useDebouncedCallback((value: string) => {
24
- console.log("Búsqueda con:", value);
25
- toastManager.add({
26
- variant: "success",
27
- description: `Búsqueda realizada con: ${value}`,
28
- });
29
- }, 600);
52
+ export const Basic: StoryObj<Args> = {
53
+ render: ({ delay }) => {
54
+ const [handleSearch, { isPending, cancel, flush }] = useDebouncedCallback(
55
+ (value: string) => {
56
+ toast({
57
+ description: `Fired with: "${value}"`,
58
+ });
59
+ },
60
+ delay,
61
+ );
30
62
 
31
63
  return (
32
- <div className="space-y-2">
33
- <pre className="rounded-md bg-slate-950 p-4 text-white">
34
- <code className="block">
35
- Escribe en el input para ver el callback debounced en acción.
36
- </code>
37
- <code className="block">Revisa la consola para ver los logs.</code>
38
- </pre>
64
+ <div className="flex max-w-sm flex-col gap-4">
39
65
  <Input
40
66
  onValueChange={(value) => handleSearch(value)}
41
- placeholder="Escribe para buscar..."
67
+ placeholder="Type to trigger the debounced callback..."
42
68
  />
69
+ <div className="overflow-hidden rounded-lg border border-slate-800 bg-slate-900 font-mono text-sm">
70
+ <div className="border-b border-slate-800 bg-slate-800/60 px-4 py-2 font-sans text-xs font-semibold uppercase tracking-widest text-slate-400">
71
+ State
72
+ </div>
73
+ <div className="px-4 py-2.5">
74
+ <span className="text-slate-500">status</span>
75
+ <span className="ml-4">
76
+ <span
77
+ className={isPending ? "text-yellow-300" : "text-slate-500"}
78
+ >
79
+ {isPending ? "pending" : "idle"}
80
+ </span>
81
+ </span>
82
+ </div>
83
+ </div>
84
+ <div className="flex gap-2">
85
+ <button
86
+ type="button"
87
+ onClick={cancel}
88
+ disabled={!isPending}
89
+ className="rounded border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
90
+ >
91
+ Cancel
92
+ </button>
93
+ <button
94
+ type="button"
95
+ onClick={flush}
96
+ disabled={!isPending}
97
+ className="rounded border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
98
+ >
99
+ Flush
100
+ </button>
101
+ </div>
43
102
  </div>
44
103
  );
45
104
  },
46
105
  };
47
106
 
48
- export const DifferentDelays: StoryObj = {
107
+ export const DifferentDelays: StoryObj<Args> = {
108
+ parameters: {
109
+ controls: { exclude: ["delay"] },
110
+ },
49
111
  render: () => {
50
- const handleFastDebounce = useDebouncedCallback((value: string) => {
112
+ const [
113
+ handleFast,
114
+ { isPending: fastPending, cancel: cancelFast, flush: flushFast },
115
+ ] = useDebouncedCallback((value: string) => {
51
116
  toastManager.add({
52
117
  variant: "success",
53
- description: `Debounce rápido (200ms): ${value}`,
118
+ description: `Fast (200ms): "${value}"`,
54
119
  });
55
120
  }, 200);
56
121
 
57
- const handleSlowDebounce = useDebouncedCallback((value: string) => {
122
+ const [
123
+ handleSlow,
124
+ { isPending: slowPending, cancel: cancelSlow, flush: flushSlow },
125
+ ] = useDebouncedCallback((value: string) => {
58
126
  toastManager.add({
59
127
  variant: "success",
60
- description: `Debounce lento (1000ms): ${value}`,
128
+ description: `Slow (1000ms): "${value}"`,
61
129
  });
62
130
  }, 1000);
63
131
 
64
132
  return (
65
- <div className="space-y-4">
66
- <div>
67
- <h3 className="text-sm font-semibold">Debounce Rápido (200ms)</h3>
68
- <Input
69
- onValueChange={(value) => handleFastDebounce(value)}
70
- placeholder="Debounce rápido..."
71
- />
72
- </div>
73
-
74
- <div>
75
- <h3 className="text-sm font-semibold">Debounce Lento (1000ms)</h3>
76
- <Input
77
- onValueChange={(value) => handleSlowDebounce(value)}
78
- placeholder="Debounce lento..."
79
- />
80
- </div>
133
+ <div className="flex max-w-sm flex-col gap-6">
134
+ {[
135
+ {
136
+ label: "Fast — 200ms",
137
+ handler: handleFast,
138
+ pending: fastPending,
139
+ cancel: cancelFast,
140
+ flush: flushFast,
141
+ },
142
+ {
143
+ label: "Slow 1000ms",
144
+ handler: handleSlow,
145
+ pending: slowPending,
146
+ cancel: cancelSlow,
147
+ flush: flushSlow,
148
+ },
149
+ ].map(({ label, handler, pending, cancel, flush }) => (
150
+ <div key={label} className="flex flex-col gap-2">
151
+ <p className="text-sm font-medium text-slate-700">{label}</p>
152
+ <Input
153
+ onValueChange={(value) => handler(value)}
154
+ placeholder="Type..."
155
+ />
156
+ <div className="flex items-center gap-3">
157
+ <span
158
+ className={`text-xs font-mono ${pending ? "text-yellow-500" : "text-slate-400"}`}
159
+ >
160
+ {pending ? "pending" : "idle"}
161
+ </span>
162
+ <button
163
+ type="button"
164
+ onClick={cancel}
165
+ disabled={!pending}
166
+ className="rounded border border-slate-300 px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-40"
167
+ >
168
+ Cancel
169
+ </button>
170
+ <button
171
+ type="button"
172
+ onClick={flush}
173
+ disabled={!pending}
174
+ className="rounded border border-slate-300 px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-40"
175
+ >
176
+ Flush
177
+ </button>
178
+ </div>
179
+ </div>
180
+ ))}
81
181
  </div>
82
182
  );
83
183
  },