@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
@@ -3,70 +3,218 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import { useDisclosure } from "../use-disclosure";
4
4
 
5
5
  describe("useDisclosure", () => {
6
- it("starts closed by default", () => {
7
- const { result } = renderHook(() => useDisclosure());
8
- expect(result.current[0]).toBe(false);
6
+ // ── Return shape ──────────────────────────────────────────────────────────
7
+ describe("return shape", () => {
8
+ it("returns a two-element tuple [boolean, actions]", () => {
9
+ const { result } = renderHook(() => useDisclosure());
10
+ expect(Array.isArray(result.current)).toBe(true);
11
+ expect(result.current).toHaveLength(2);
12
+ expect(typeof result.current[0]).toBe("boolean");
13
+ const actions = result.current[1];
14
+ expect(typeof actions.open).toBe("function");
15
+ expect(typeof actions.close).toBe("function");
16
+ expect(typeof actions.toggle).toBe("function");
17
+ expect(typeof actions.setOpen).toBe("function");
18
+ });
9
19
  });
10
20
 
11
- it("starts open when initialState=true", () => {
12
- const { result } = renderHook(() => useDisclosure(true));
13
- expect(result.current[0]).toBe(true);
14
- });
21
+ // ── Uncontrolled mode ─────────────────────────────────────────────────────
22
+ describe("uncontrolled mode", () => {
23
+ it("defaults to closed (opened === false) when no args passed", () => {
24
+ const { result } = renderHook(() => useDisclosure());
25
+ expect(result.current[0]).toBe(false);
26
+ });
15
27
 
16
- it("open() opens disclosure", () => {
17
- const { result } = renderHook(() => useDisclosure());
18
- act(() => result.current[1].open());
19
- expect(result.current[0]).toBe(true);
20
- });
28
+ it("starts open when defaultOpen: true", () => {
29
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: true }));
30
+ expect(result.current[0]).toBe(true);
31
+ });
21
32
 
22
- it("open() is idempotent when already open", () => {
23
- const { result } = renderHook(() => useDisclosure(true));
24
- act(() => result.current[1].open());
25
- expect(result.current[0]).toBe(true);
26
- });
33
+ it("open() sets opened to true", async () => {
34
+ const { result } = renderHook(() => useDisclosure());
35
+ await act(async () => {
36
+ result.current[1].open();
37
+ });
38
+ expect(result.current[0]).toBe(true);
39
+ });
27
40
 
28
- it("close() closes disclosure", () => {
29
- const { result } = renderHook(() => useDisclosure(true));
30
- act(() => result.current[1].close());
31
- expect(result.current[0]).toBe(false);
32
- });
41
+ it("close() sets opened to false", async () => {
42
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: true }));
43
+ await act(async () => {
44
+ result.current[1].close();
45
+ });
46
+ expect(result.current[0]).toBe(false);
47
+ });
33
48
 
34
- it("close() is a no-op when already closed", () => {
35
- const { result } = renderHook(() => useDisclosure(false));
36
- act(() => result.current[1].close());
37
- expect(result.current[0]).toBe(false);
38
- });
49
+ it("toggle() flips false true", async () => {
50
+ const { result } = renderHook(() => useDisclosure());
51
+ await act(async () => {
52
+ result.current[1].toggle();
53
+ });
54
+ expect(result.current[0]).toBe(true);
55
+ });
39
56
 
40
- it("toggle() opens when closed", () => {
41
- const { result } = renderHook(() => useDisclosure());
42
- act(() => result.current[1].toggle());
43
- expect(result.current[0]).toBe(true);
57
+ it("toggle() flips true → false", async () => {
58
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: true }));
59
+ await act(async () => {
60
+ result.current[1].toggle();
61
+ });
62
+ expect(result.current[0]).toBe(false);
63
+ });
64
+
65
+ it("setOpen(true) sets opened to true", async () => {
66
+ const { result } = renderHook(() => useDisclosure());
67
+ await act(async () => {
68
+ result.current[1].setOpen(true);
69
+ });
70
+ expect(result.current[0]).toBe(true);
71
+ });
72
+
73
+ it("setOpen(false) sets opened to false", async () => {
74
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: true }));
75
+ await act(async () => {
76
+ result.current[1].setOpen(false);
77
+ });
78
+ expect(result.current[0]).toBe(false);
79
+ });
44
80
  });
45
81
 
46
- it("toggle() closes when open", () => {
47
- const { result } = renderHook(() => useDisclosure(true));
48
- act(() => result.current[1].toggle());
49
- expect(result.current[0]).toBe(false);
82
+ // ── Idempotency ───────────────────────────────────────────────────────────
83
+ describe("idempotency", () => {
84
+ it("open() when already open does NOT call onOpenChange", async () => {
85
+ const onOpenChange = vi.fn();
86
+ const { result } = renderHook(() =>
87
+ useDisclosure({ defaultOpen: true, onOpenChange }),
88
+ );
89
+ await act(async () => {});
90
+ onOpenChange.mockClear();
91
+
92
+ await act(async () => {
93
+ result.current[1].open();
94
+ });
95
+ expect(onOpenChange).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("close() when already closed does NOT call onOpenChange", async () => {
99
+ const onOpenChange = vi.fn();
100
+ const { result } = renderHook(() => useDisclosure({ onOpenChange }));
101
+ await act(async () => {});
102
+ onOpenChange.mockClear();
103
+
104
+ await act(async () => {
105
+ result.current[1].close();
106
+ });
107
+ expect(onOpenChange).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("setOpen(same value) does NOT call onOpenChange", async () => {
111
+ const onOpenChange = vi.fn();
112
+ const { result } = renderHook(() =>
113
+ useDisclosure({ defaultOpen: true, onOpenChange }),
114
+ );
115
+ await act(async () => {});
116
+ onOpenChange.mockClear();
117
+
118
+ await act(async () => {
119
+ result.current[1].setOpen(true);
120
+ });
121
+ expect(onOpenChange).not.toHaveBeenCalled();
122
+ });
50
123
  });
51
124
 
52
- it("calls onOpen callback when opening", () => {
53
- const onOpen = vi.fn();
54
- const { result } = renderHook(() => useDisclosure(false, { onOpen }));
55
- act(() => result.current[1].open());
56
- expect(onOpen).toHaveBeenCalledOnce();
125
+ // ── onOpenChange ──────────────────────────────────────────────────────────
126
+ describe("onOpenChange", () => {
127
+ it("fires once with true on real transition via open()", async () => {
128
+ const onOpenChange = vi.fn();
129
+ const { result } = renderHook(() => useDisclosure({ onOpenChange }));
130
+ await act(async () => {
131
+ result.current[1].open();
132
+ });
133
+ expect(onOpenChange).toHaveBeenCalledOnce();
134
+ expect(onOpenChange).toHaveBeenCalledWith(true);
135
+ });
136
+
137
+ it("fires once with false on real transition via close()", async () => {
138
+ const onOpenChange = vi.fn();
139
+ const { result } = renderHook(() =>
140
+ useDisclosure({ defaultOpen: true, onOpenChange }),
141
+ );
142
+ await act(async () => {});
143
+ onOpenChange.mockClear();
144
+
145
+ await act(async () => {
146
+ result.current[1].close();
147
+ });
148
+ expect(onOpenChange).toHaveBeenCalledOnce();
149
+ expect(onOpenChange).toHaveBeenCalledWith(false);
150
+ });
151
+
152
+ it("fires once with new value on real transition via setOpen()", async () => {
153
+ const onOpenChange = vi.fn();
154
+ const { result } = renderHook(() => useDisclosure({ onOpenChange }));
155
+ await act(async () => {
156
+ result.current[1].setOpen(true);
157
+ });
158
+ expect(onOpenChange).toHaveBeenCalledOnce();
159
+ expect(onOpenChange).toHaveBeenCalledWith(true);
160
+ });
57
161
  });
58
162
 
59
- it("calls onClose callback when closing", () => {
60
- const onClose = vi.fn();
61
- const { result } = renderHook(() => useDisclosure(true, { onClose }));
62
- act(() => result.current[1].close());
63
- expect(onClose).toHaveBeenCalledOnce();
163
+ // ── Action reference stability ────────────────────────────────────────────
164
+ describe("action reference stability", () => {
165
+ it("open ref is stable after state change (uncontrolled)", async () => {
166
+ const { result } = renderHook(() => useDisclosure());
167
+ const before = result.current[1].open;
168
+ await act(async () => {
169
+ result.current[1].open();
170
+ });
171
+ expect(result.current[1].open).toBe(before);
172
+ });
173
+
174
+ it("close ref is stable after state change (uncontrolled)", async () => {
175
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: true }));
176
+ const before = result.current[1].close;
177
+ await act(async () => {
178
+ result.current[1].close();
179
+ });
180
+ expect(result.current[1].close).toBe(before);
181
+ });
182
+
183
+ it("toggle ref is stable after state change (uncontrolled)", async () => {
184
+ const { result } = renderHook(() => useDisclosure());
185
+ const before = result.current[1].toggle;
186
+ await act(async () => {
187
+ result.current[1].toggle();
188
+ });
189
+ expect(result.current[1].toggle).toBe(before);
190
+ });
191
+
192
+ it("setOpen ref is stable after state change (uncontrolled)", async () => {
193
+ const { result } = renderHook(() => useDisclosure());
194
+ const before = result.current[1].setOpen;
195
+ await act(async () => {
196
+ result.current[1].setOpen(true);
197
+ });
198
+ expect(result.current[1].setOpen).toBe(before);
199
+ });
64
200
  });
65
201
 
66
- it("does not call onOpen when already open", () => {
67
- const onOpen = vi.fn();
68
- const { result } = renderHook(() => useDisclosure(true, { onOpen }));
69
- act(() => result.current[1].open());
70
- expect(onOpen).not.toHaveBeenCalled();
202
+ // ── Cleanup on unmount ────────────────────────────────────────────────────
203
+ describe("cleanup on unmount", () => {
204
+ it("does not call onOpenChange after unmount", async () => {
205
+ const onOpenChange = vi.fn();
206
+ const { result, unmount } = renderHook(() =>
207
+ useDisclosure({ onOpenChange }),
208
+ );
209
+ unmount();
210
+ await act(async () => {
211
+ try {
212
+ result.current[1].open();
213
+ } catch {
214
+ // suppress any unmount-related errors
215
+ }
216
+ });
217
+ expect(onOpenChange).not.toHaveBeenCalled();
218
+ });
71
219
  });
72
220
  });
@@ -1,36 +1,56 @@
1
- import { useCallback, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useLatestRef } from "../internal";
3
+
4
+ export type UseDisclosureProps = {
5
+ /** Initial open value (default: false). */
6
+ defaultOpen?: boolean;
7
+ /** Called once per real state transition. Not called on no-op calls. */
8
+ onOpenChange?: (open: boolean) => void;
9
+ };
10
+
11
+ export type UseDisclosureActions = {
12
+ /** Transitions state to true. No-op (and onOpenChange not called) if already open. */
13
+ open: () => void;
14
+ /** Transitions state to false. No-op (and onOpenChange not called) if already closed. */
15
+ close: () => void;
16
+ /** Flips the current boolean state. */
17
+ toggle: () => void;
18
+ /** Sets state to the given value. No-op (and onOpenChange not called) if value is unchanged. */
19
+ setOpen: (open: boolean) => void;
20
+ };
2
21
 
3
22
  export function useDisclosure(
4
- initialState = false,
5
- callbacks?: { onOpen?: () => void; onClose?: () => void },
6
- ) {
7
- const { onOpen, onClose } = callbacks || {};
8
- const [isOpen, setIsOpen] = useState(initialState);
23
+ props?: UseDisclosureProps,
24
+ ): readonly [boolean, UseDisclosureActions] {
25
+ const [opened, setOpenState] = useState<boolean>(props?.defaultOpen ?? false);
26
+ const onOpenChangeRef = useLatestRef(props?.onOpenChange);
27
+ const mountedRef = useRef(true);
9
28
 
10
- const open = useCallback(() => {
11
- setIsOpen((isOpened) => {
12
- if (!isOpened) {
13
- onOpen?.();
14
- return true;
15
- }
16
- return isOpened;
17
- });
18
- }, [onOpen]);
29
+ useEffect(() => {
30
+ mountedRef.current = true;
31
+ return () => {
32
+ mountedRef.current = false;
33
+ };
34
+ }, []);
19
35
 
20
- const close = useCallback(() => {
21
- setIsOpen((isOpened) => {
22
- if (isOpened) {
23
- onClose?.();
24
- return false;
25
- }
26
- return isOpened;
27
- });
28
- }, [onClose]);
36
+ const actions = useMemo<UseDisclosureActions>(() => {
37
+ const transition = (compute: (prev: boolean) => boolean) =>
38
+ setOpenState((prev) => {
39
+ const next = compute(prev);
40
+ if (next === prev) return prev; // no-op: silent, no re-render
41
+ if (mountedRef.current) {
42
+ onOpenChangeRef.current?.(next);
43
+ }
44
+ return next;
45
+ });
29
46
 
30
- const toggle = useCallback(() => {
31
- isOpen ? close() : open();
32
- }, [close, open, isOpen]);
47
+ return {
48
+ open: () => transition(() => true),
49
+ close: () => transition(() => false),
50
+ toggle: () => transition((prev) => !prev),
51
+ setOpen: (next) => transition(() => next),
52
+ };
53
+ }, []); // setOpenState, onOpenChangeRef, and mountedRef are stable
33
54
 
34
- return [isOpen, { open, close, toggle }] as const;
55
+ return [opened, actions] as const;
35
56
  }
36
-
@@ -70,3 +70,57 @@ export const Default: Story = {
70
70
  );
71
71
  },
72
72
  };
73
+
74
+ /**
75
+ * Pass a `template` with a `%s` placeholder to wrap every title in a
76
+ * suffix or prefix — e.g. append the app name to the page name.
77
+ *
78
+ * ```tsx
79
+ * useDocumentTitle(pageName, { template: "%s | Acme" });
80
+ * ```
81
+ */
82
+ export const Template: Story = {
83
+ render: () => {
84
+ const [page, setPage] = useState("Inbox");
85
+ useDocumentTitle(page, { template: "%s | Acme" });
86
+
87
+ return (
88
+ <Stack direction="vertical" gap={12} style={{ minWidth: 360 }}>
89
+ <Input value={page} onValueChange={setPage} placeholder="Page name…" />
90
+ <code className="rounded-md bg-muted px-3 py-2 text-xs">
91
+ {`document.title = "${page.trim() || "…"} | Acme"`}
92
+ </code>
93
+ </Stack>
94
+ );
95
+ },
96
+ };
97
+
98
+ /**
99
+ * Set `enabled: false` to make the hook a no-op. The title is only
100
+ * written while `enabled` is `true`, so a component can own the tab
101
+ * title conditionally without unmounting.
102
+ */
103
+ export const Enabled: Story = {
104
+ render: () => {
105
+ const [enabled, setEnabled] = useState(true);
106
+ useDocumentTitle("Live title", { enabled });
107
+
108
+ return (
109
+ <Stack direction="vertical" gap={12} style={{ minWidth: 360 }}>
110
+ <label className="flex items-center gap-2 text-sm">
111
+ <input
112
+ type="checkbox"
113
+ checked={enabled}
114
+ onChange={(e) => setEnabled(e.target.checked)}
115
+ />
116
+ enabled
117
+ </label>
118
+ <code className="rounded-md bg-muted px-3 py-2 text-xs">
119
+ {enabled
120
+ ? 'document.title = "Live title"'
121
+ : "(disabled — current title preserved)"}
122
+ </code>
123
+ </Stack>
124
+ );
125
+ },
126
+ };
@@ -59,6 +59,32 @@ describe("useDocumentTitle", () => {
59
59
  expect(document.title).toBe("Original");
60
60
  });
61
61
 
62
+ it("applies a template with the %s placeholder", () => {
63
+ renderHook(() => useDocumentTitle("Inbox", { template: "%s | Acme" }));
64
+ expect(document.title).toBe("Inbox | Acme");
65
+ });
66
+
67
+ it("trims before applying the template", () => {
68
+ renderHook(() => useDocumentTitle(" Inbox ", { template: "%s | Acme" }));
69
+ expect(document.title).toBe("Inbox | Acme");
70
+ });
71
+
72
+ it("does not touch the title when enabled is false", () => {
73
+ renderHook(() => useDocumentTitle("New", { enabled: false }));
74
+ expect(document.title).toBe("Original");
75
+ });
76
+
77
+ it("starts writing once enabled flips to true", () => {
78
+ const { rerender } = renderHook(
79
+ ({ enabled }) => useDocumentTitle("New", { enabled }),
80
+ { initialProps: { enabled: false } },
81
+ );
82
+ expect(document.title).toBe("Original");
83
+
84
+ rerender({ enabled: true });
85
+ expect(document.title).toBe("New");
86
+ });
87
+
62
88
  it("captures the original title at mount, not the latest one", () => {
63
89
  document.title = "First";
64
90
  const { unmount, rerender } = renderHook(
@@ -1,20 +1,33 @@
1
1
  import { useEffect, useRef } from "react";
2
+ import { isBrowser } from "../internal";
2
3
 
3
4
  export interface UseDocumentTitleInput {
4
5
  /** Restore the previous `document.title` when the component unmounts. Defaults to `false`. */
5
6
  restoreOnUnmount?: boolean;
7
+ /**
8
+ * Template applied to the title. The `%s` placeholder is replaced with
9
+ * the (trimmed) title value. Example: `"%s | Acme"` turns `"Inbox"`
10
+ * into `"Inbox | Acme"`. When omitted, the title is written as-is.
11
+ */
12
+ template?: string;
13
+ /**
14
+ * When `false`, the hook does not touch `document.title`. Defaults to
15
+ * `true`. Useful to conditionally own the title.
16
+ */
17
+ enabled?: boolean;
6
18
  }
7
19
 
8
20
  export function useDocumentTitle(
9
21
  title: string,
10
22
  options: UseDocumentTitleInput = {},
11
23
  ): void {
12
- const { restoreOnUnmount = false } = options;
24
+ const { restoreOnUnmount = false, template, enabled = true } = options;
13
25
  const restoreRef = useRef(restoreOnUnmount);
14
26
  restoreRef.current = restoreOnUnmount;
15
27
 
16
28
  // biome-ignore lint/correctness/useExhaustiveDependencies: mount/unmount only
17
29
  useEffect(() => {
30
+ if (!isBrowser) return;
18
31
  const previous = document.title;
19
32
  return () => {
20
33
  if (restoreRef.current) {
@@ -24,9 +37,10 @@ export function useDocumentTitle(
24
37
  }, []);
25
38
 
26
39
  useEffect(() => {
40
+ if (!isBrowser || !enabled) return;
27
41
  if (typeof title !== "string") return;
28
42
  const trimmed = title.trim();
29
43
  if (trimmed.length === 0) return;
30
- document.title = trimmed;
31
- }, [title]);
44
+ document.title = template ? template.replace("%s", trimmed) : trimmed;
45
+ }, [title, template, enabled]);
32
46
  }
@@ -1,28 +1,124 @@
1
- import { Meta } from "@storybook/react-vite";
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useRef, useState } from "react";
2
3
  import { action } from "storybook/actions";
3
4
  import { Button } from "../../components";
4
5
  import { useEventListener } from "../use-event-listener";
5
6
 
6
7
  /**
7
- * Hook que facilita el manejor de eventos
8
+ * Subscribe to a DOM event with automatic add/remove, without writing a
9
+ * manual `useEffect` + cleanup.
8
10
  *
9
- * Evita tener que hacer el `useEffect` con el cleanup
11
+ * The `listener` is held in an internal ref (handler-stable pattern): passing
12
+ * an inline function does **not** re-attach the underlying listener on every
13
+ * render, and the listener always sees fresh props/state when it fires.
14
+ *
15
+ * The target is **explicit** and chosen by overload — there is no returned
16
+ * ref to wire up:
17
+ *
18
+ * ```tsx
19
+ * // window — omit the target
20
+ * useEventListener("resize", onResize);
21
+ *
22
+ * // an element — create your own ref and pass it
23
+ * const ref = useRef<HTMLButtonElement>(null);
24
+ * useEventListener("click", onClick, ref);
25
+ *
26
+ * // document — pass it directly (global key handlers, etc.)
27
+ * useEventListener("keydown", onKeyDown, document);
28
+ *
29
+ * // a MediaQueryList — react to media query changes
30
+ * const mql = window.matchMedia("(min-width: 768px)");
31
+ * useEventListener("change", onChange, mql);
32
+ * ```
33
+ *
34
+ * The fourth argument forwards the native `addEventListener` options
35
+ * (`{ capture, once, passive, signal }` or a boolean). The listener is
36
+ * re-attached when the `capture` / `once` / `passive` flags change.
37
+ *
38
+ * SSR-safe: the effect no-ops when there is no DOM.
39
+ *
40
+ * Migrating from v1: the hook no longer returns a ref. Replace
41
+ * `const ref = useEventListener("click", cb)` with an explicit ref —
42
+ * `const ref = useRef(null); useEventListener("click", cb, ref)`.
10
43
  */
11
44
  const meta: Meta = {
12
45
  title: "hooks/useEventListener",
13
46
  };
14
47
 
15
48
  export default meta;
49
+ type Story = StoryObj<typeof meta>;
16
50
 
17
51
  /**
18
- * ```tsx
19
- * const ref = useEventListener<HTMLELEMENT>("EVENT_KEY", callback);
20
- * return <HTMLELEMENT ref={ref} />;
21
- * ```
52
+ * Listen on an element by creating a ref yourself and passing it as the
53
+ * third argument. The hook attaches the listener once the element is mounted.
22
54
  */
23
- export const Default = {
55
+ export const ElementByRef: Story = {
24
56
  render: () => {
25
- const ref = useEventListener("click", action("click"));
57
+ const ref = useRef<HTMLButtonElement>(null);
58
+ useEventListener("click", action("click"), ref);
26
59
  return <Button ref={ref}>click me</Button>;
27
60
  },
28
61
  };
62
+
63
+ /**
64
+ * Omitting the target listens on `window`. Resize the preview pane to watch
65
+ * the value update — no manual subscription or cleanup required.
66
+ */
67
+ export const WindowResize: Story = {
68
+ render: () => {
69
+ const [width, setWidth] = useState(window.innerWidth);
70
+ useEventListener("resize", () => setWidth(window.innerWidth));
71
+ return (
72
+ <p>
73
+ window width: <strong>{width}px</strong>
74
+ </p>
75
+ );
76
+ },
77
+ };
78
+
79
+ /**
80
+ * Pass `document` directly for document-level events such as global keyboard
81
+ * shortcuts that should fire regardless of focus. The listener receives the
82
+ * native `KeyboardEvent`.
83
+ */
84
+ export const DocumentKeydown: Story = {
85
+ render: () => {
86
+ const [key, setKey] = useState("—");
87
+ useEventListener("keydown", (event) => setKey(event.key), document);
88
+ return (
89
+ <p>
90
+ last key pressed: <kbd>{key}</kbd>
91
+ </p>
92
+ );
93
+ },
94
+ };
95
+
96
+ /**
97
+ * Pass a `MediaQueryList` (from `window.matchMedia`) to react to viewport
98
+ * changes. Create the `MediaQueryList` once so its identity stays stable and
99
+ * the listener is not re-attached on every render.
100
+ */
101
+ export const MediaQueryChange: Story = {
102
+ render: () => {
103
+ const [mql] = useState(() => window.matchMedia("(min-width: 768px)"));
104
+ const [matches, setMatches] = useState(mql.matches);
105
+ useEventListener("change", (event) => setMatches(event.matches), mql);
106
+ return <p>{matches ? "≥ 768px (desktop)" : "< 768px (mobile)"}</p>;
107
+ },
108
+ };
109
+
110
+ /**
111
+ * The fourth argument is forwarded to `addEventListener`. Here `{ once: true }`
112
+ * makes the listener fire a single time and then detach itself — the counter
113
+ * never goes past one regardless of how many times you click.
114
+ */
115
+ export const OnceOption: Story = {
116
+ render: () => {
117
+ const ref = useRef<HTMLButtonElement>(null);
118
+ const [count, setCount] = useState(0);
119
+ useEventListener("click", () => setCount((c) => c + 1), ref, {
120
+ once: true,
121
+ });
122
+ return <Button ref={ref}>clicked {count} time(s)</Button>;
123
+ },
124
+ };