@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
@@ -11,22 +11,103 @@ describe("useDebouncedCallback", () => {
11
11
  vi.useRealTimers();
12
12
  });
13
13
 
14
- it("returns a stable function reference", () => {
14
+ // --- Return shape ---
15
+
16
+ it("returns a tuple [debounced, controls]", () => {
17
+ const cb = vi.fn();
18
+ const { result } = renderHook(() => useDebouncedCallback(cb, 200));
19
+ const [debounced, controls] = result.current;
20
+ expect(typeof debounced).toBe("function");
21
+ expect(typeof controls.cancel).toBe("function");
22
+ expect(typeof controls.flush).toBe("function");
23
+ expect(typeof controls.isPending).toBe("boolean");
24
+ });
25
+
26
+ // --- Stability ---
27
+
28
+ it("returns a stable debounced function reference across re-renders", () => {
15
29
  const cb = vi.fn();
16
30
  const { result, rerender } = renderHook(() =>
17
31
  useDebouncedCallback(cb, 200),
18
32
  );
19
- const first = result.current;
33
+ const [first] = result.current;
34
+ rerender();
35
+ const [second] = result.current;
36
+ expect(second).toBe(first);
37
+ });
38
+
39
+ it("inline callback does not reset debounced wrapper (stable reference with inline fn)", () => {
40
+ const { result, rerender } = renderHook(() =>
41
+ // inline arrow fn — identity changes every render
42
+ useDebouncedCallback(() => {}, 200),
43
+ );
44
+ const [first] = result.current;
45
+ rerender();
46
+ const [second] = result.current;
47
+ expect(second).toBe(first);
48
+ });
49
+
50
+ it("callback identity change does not reset a pending timer", () => {
51
+ const cbA = vi.fn();
52
+ const cbB = vi.fn();
53
+ let currentCb = cbA;
54
+
55
+ const { result, rerender } = renderHook(() =>
56
+ useDebouncedCallback(currentCb, 300),
57
+ );
58
+
59
+ act(() => {
60
+ const [debouncedFn] = result.current;
61
+ debouncedFn("arg");
62
+ });
63
+
64
+ // swap callback mid-flight
65
+ currentCb = cbB;
20
66
  rerender();
21
- expect(result.current).toBe(first);
67
+
68
+ act(() => {
69
+ vi.advanceTimersByTime(300);
70
+ });
71
+
72
+ // timer should have fired exactly once with the latest callback
73
+ expect(cbA).not.toHaveBeenCalled();
74
+ expect(cbB).toHaveBeenCalledOnce();
75
+ expect(cbB).toHaveBeenCalledWith("arg");
22
76
  });
23
77
 
78
+ it("delay change recreates the debounced function", () => {
79
+ const cb = vi.fn();
80
+ const { result, rerender } = renderHook(
81
+ ({ delay }) => useDebouncedCallback(cb, delay),
82
+ { initialProps: { delay: 200 } },
83
+ );
84
+ const [first] = result.current;
85
+ rerender({ delay: 400 });
86
+ const [second] = result.current;
87
+ expect(second).not.toBe(first);
88
+ });
89
+
90
+ it("cancel and flush have stable references across re-renders", () => {
91
+ const cb = vi.fn();
92
+ const { result, rerender } = renderHook(() =>
93
+ useDebouncedCallback(cb, 200),
94
+ );
95
+ const [, { cancel: cancel1, flush: flush1 }] = result.current;
96
+ rerender();
97
+ const [, { cancel: cancel2, flush: flush2 }] = result.current;
98
+ expect(cancel2).toBe(cancel1);
99
+ expect(flush2).toBe(flush1);
100
+ });
101
+
102
+ // --- Basic debounce behavior ---
103
+
24
104
  it("does not call callback before delay elapses", () => {
25
105
  const cb = vi.fn();
26
106
  const { result } = renderHook(() => useDebouncedCallback(cb, 300));
27
107
 
28
108
  act(() => {
29
- result.current("a");
109
+ const [debouncedFn] = result.current;
110
+ debouncedFn("a");
30
111
  });
31
112
 
32
113
  vi.advanceTimersByTime(299);
@@ -38,7 +119,8 @@ describe("useDebouncedCallback", () => {
38
119
  const { result } = renderHook(() => useDebouncedCallback(cb, 300));
39
120
 
40
121
  act(() => {
41
- result.current("hello", 42);
122
+ const [debouncedFn] = result.current;
123
+ debouncedFn("hello", 42);
42
124
  });
43
125
 
44
126
  act(() => {
@@ -54,9 +136,10 @@ describe("useDebouncedCallback", () => {
54
136
  const { result } = renderHook(() => useDebouncedCallback(cb, 300));
55
137
 
56
138
  act(() => {
57
- result.current("first");
139
+ const [debouncedFn] = result.current;
140
+ debouncedFn("first");
58
141
  vi.advanceTimersByTime(200);
59
- result.current("second");
142
+ debouncedFn("second");
60
143
  vi.advanceTimersByTime(200);
61
144
  });
62
145
 
@@ -75,9 +158,10 @@ describe("useDebouncedCallback", () => {
75
158
  const { result } = renderHook(() => useDebouncedCallback(cb, 100));
76
159
 
77
160
  act(() => {
78
- result.current("a");
161
+ const [debouncedFn] = result.current;
162
+ debouncedFn("a");
79
163
  vi.advanceTimersByTime(200);
80
- result.current("b");
164
+ debouncedFn("b");
81
165
  vi.advanceTimersByTime(200);
82
166
  });
83
167
 
@@ -85,4 +169,128 @@ describe("useDebouncedCallback", () => {
85
169
  expect(cb).toHaveBeenNthCalledWith(1, "a");
86
170
  expect(cb).toHaveBeenNthCalledWith(2, "b");
87
171
  });
172
+
173
+ // --- isPending ---
174
+
175
+ it("isPending is false initially", () => {
176
+ const { result } = renderHook(() => useDebouncedCallback(vi.fn(), 300));
177
+ const [, { isPending }] = result.current;
178
+ expect(isPending).toBe(false);
179
+ });
180
+
181
+ it("isPending becomes true after call, false after delay elapses", () => {
182
+ const cb = vi.fn();
183
+ const { result } = renderHook(() => useDebouncedCallback(cb, 300));
184
+
185
+ act(() => {
186
+ const [debouncedFn] = result.current;
187
+ debouncedFn("x");
188
+ });
189
+
190
+ expect(result.current[1].isPending).toBe(true);
191
+
192
+ act(() => {
193
+ vi.advanceTimersByTime(300);
194
+ });
195
+
196
+ expect(result.current[1].isPending).toBe(false);
197
+ });
198
+
199
+ // --- cancel ---
200
+
201
+ it("cancel() stops pending invocation and sets isPending to false", () => {
202
+ const cb = vi.fn();
203
+ const { result } = renderHook(() => useDebouncedCallback(cb, 300));
204
+
205
+ act(() => {
206
+ const [debouncedFn] = result.current;
207
+ debouncedFn("x");
208
+ });
209
+
210
+ expect(result.current[1].isPending).toBe(true);
211
+
212
+ act(() => {
213
+ result.current[1].cancel();
214
+ });
215
+
216
+ expect(result.current[1].isPending).toBe(false);
217
+
218
+ act(() => {
219
+ vi.advanceTimersByTime(300);
220
+ });
221
+
222
+ expect(cb).not.toHaveBeenCalled();
223
+ });
224
+
225
+ // --- flush ---
226
+
227
+ it("flush() invokes callback immediately with last args and sets isPending to false", () => {
228
+ const cb = vi.fn();
229
+ const { result } = renderHook(() => useDebouncedCallback(cb, 300));
230
+
231
+ act(() => {
232
+ const [debouncedFn] = result.current;
233
+ debouncedFn("flush-arg");
234
+ });
235
+
236
+ expect(result.current[1].isPending).toBe(true);
237
+
238
+ act(() => {
239
+ result.current[1].flush();
240
+ });
241
+
242
+ expect(cb).toHaveBeenCalledOnce();
243
+ expect(cb).toHaveBeenCalledWith("flush-arg");
244
+ expect(result.current[1].isPending).toBe(false);
245
+ });
246
+
247
+ it("flush() is a no-op when nothing is pending", () => {
248
+ const cb = vi.fn();
249
+ const { result } = renderHook(() => useDebouncedCallback(cb, 300));
250
+
251
+ act(() => {
252
+ result.current[1].flush();
253
+ });
254
+
255
+ expect(cb).not.toHaveBeenCalled();
256
+ });
257
+
258
+ // --- Unmount cleanup ---
259
+
260
+ it("unmount cancels pending timer — callback not called after unmount", () => {
261
+ const cb = vi.fn();
262
+ const { result, unmount } = renderHook(() => useDebouncedCallback(cb, 300));
263
+
264
+ act(() => {
265
+ const [debouncedFn] = result.current;
266
+ debouncedFn("will-be-cancelled");
267
+ });
268
+
269
+ unmount();
270
+
271
+ act(() => {
272
+ vi.advanceTimersByTime(300);
273
+ });
274
+
275
+ expect(cb).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it("isPending is false after unmount (no setState after unmount)", () => {
279
+ const cb = vi.fn();
280
+ const { result, unmount } = renderHook(() => useDebouncedCallback(cb, 300));
281
+
282
+ act(() => {
283
+ const [debouncedFn] = result.current;
284
+ debouncedFn("x");
285
+ });
286
+
287
+ expect(result.current[1].isPending).toBe(true);
288
+
289
+ expect(() => {
290
+ unmount();
291
+ act(() => {
292
+ vi.advanceTimersByTime(300);
293
+ });
294
+ }).not.toThrow();
295
+ });
88
296
  });
@@ -1,22 +1,82 @@
1
- import { useCallback, useRef } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useLatestRef } from "../internal/use-latest-ref";
2
3
 
4
+ export interface DebouncedControls {
5
+ /** Cancel the pending invocation without calling the callback. No-op if nothing is pending. */
6
+ cancel: () => void;
7
+ /** Immediately invoke the callback with the last queued arguments, clearing the timer. No-op if nothing is pending. */
8
+ flush: () => void;
9
+ /** Reactive boolean: `true` while a debounced call is scheduled, `false` otherwise. */
10
+ isPending: boolean;
11
+ }
12
+
13
+ /**
14
+ * Returns a debounced version of `callback` together with a controls object.
15
+ *
16
+ * The returned `debounced` function has a **stable identity** — it is only
17
+ * recreated when `delay` changes, never when `callback` changes. This means
18
+ * passing an inline arrow function is safe: it will not reset a pending timer
19
+ * on re-render.
20
+ *
21
+ * @returns `[debounced, { cancel, flush, isPending }]`
22
+ */
3
23
  export function useDebouncedCallback<T extends (...args: any[]) => any>(
4
24
  callback: T,
5
25
  delay: number,
6
- ): (...args: Parameters<T>) => void {
7
- const timeoutRef = useRef<NodeJS.Timeout | null>(null);
26
+ ): readonly [(...args: Parameters<T>) => void, DebouncedControls] {
27
+ const cbRef = useLatestRef(callback);
28
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+ const lastArgsRef = useRef<Parameters<T> | null>(null);
30
+ const mountedRef = useRef(true);
31
+ const [isPending, setIsPending] = useState(false);
8
32
 
9
- return useCallback(
10
- (...args: Parameters<T>) => {
11
- if (timeoutRef.current) {
12
- clearTimeout(timeoutRef.current);
13
- }
33
+ const clear = useCallback(() => {
34
+ if (timeoutRef.current !== null) {
35
+ clearTimeout(timeoutRef.current);
36
+ timeoutRef.current = null;
37
+ }
38
+ }, []);
14
39
 
40
+ const debounced = useCallback(
41
+ (...args: Parameters<T>) => {
42
+ clear();
43
+ lastArgsRef.current = args;
44
+ setIsPending(true);
15
45
  timeoutRef.current = setTimeout(() => {
16
- callback(...args);
46
+ timeoutRef.current = null;
47
+ if (mountedRef.current) setIsPending(false);
48
+ cbRef.current(...args);
17
49
  }, delay);
18
50
  },
19
- [callback, delay],
51
+ [delay, clear, cbRef],
20
52
  );
21
- }
22
53
 
54
+ const cancel = useCallback(() => {
55
+ clear();
56
+ if (mountedRef.current) setIsPending(false);
57
+ }, [clear]);
58
+
59
+ const flush = useCallback(() => {
60
+ if (timeoutRef.current === null) return;
61
+ clear();
62
+ if (mountedRef.current) setIsPending(false);
63
+ if (lastArgsRef.current !== null) {
64
+ cbRef.current(...lastArgsRef.current);
65
+ }
66
+ }, [clear, cbRef]);
67
+
68
+ useEffect(
69
+ () => () => {
70
+ mountedRef.current = false;
71
+ clear();
72
+ },
73
+ [clear],
74
+ );
75
+
76
+ const controls = useMemo(
77
+ () => ({ cancel, flush, isPending }),
78
+ [cancel, flush, isPending],
79
+ );
80
+
81
+ return [debounced, controls] as const;
82
+ }
@@ -1,74 +1,274 @@
1
1
  import { Meta, StoryObj } from "@storybook/react-vite";
2
- import { useState } from "react";
3
- import { Input } from "../../components";
2
+ import { useEffect, useState } from "react";
3
+ import { Input, Kbd, KbdGroup } from "../../components";
4
+ import { getHotkeyHandler } from "../use-hotkey";
4
5
  import { useDebouncedValue } from "./use-debounced-value";
5
6
 
6
- const meta: Meta = {
7
+ type Args = { delay: number };
8
+
9
+ const meta: Meta<Args> = {
7
10
  title: "hooks/useDebouncedValue",
11
+ argTypes: {
12
+ delay: {
13
+ control: { type: "range", min: 100, max: 5000, step: 100 },
14
+ description: "Debounce delay in milliseconds.",
15
+ },
16
+ },
17
+ args: { delay: 400 },
18
+ parameters: {
19
+ docs: {
20
+ description: {
21
+ component: [
22
+ "Returns the debounced version of `value` paired with controls.",
23
+ "",
24
+ "**Signature**: `[debounced, { cancel, flush, isPending }] = useDebouncedValue(value, delay)`",
25
+ "",
26
+ "- `cancel()` — discard the pending update without applying it.",
27
+ "- `flush()` — apply the pending update immediately.",
28
+ "- `isPending` — reactive boolean, `true` while an update is scheduled.",
29
+ "",
30
+ "Delegates entirely to `useDebouncedCallback` — no duplicated timer logic.",
31
+ ].join("\n"),
32
+ },
33
+ },
34
+ },
8
35
  };
9
36
 
10
37
  export default meta;
11
38
 
12
- export const Basic = {
13
- render: () => {
14
- const [value, setValue] = useState<string>("");
15
- const debouncedValue: string = useDebouncedValue(value, 400);
39
+ function StatePanel({
40
+ rows,
41
+ }: {
42
+ rows: { label: string; value: string; highlight?: "pending" | "idle" }[];
43
+ }) {
44
+ return (
45
+ <div className="overflow-hidden rounded-lg border border-slate-800 bg-slate-900 font-mono text-sm">
46
+ <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">
47
+ State
48
+ </div>
49
+ <div className="divide-y divide-slate-800/60">
50
+ {rows.map(({ label, value, highlight }) => (
51
+ <div key={label} className="flex items-center gap-4 px-4 py-2.5">
52
+ <span className="w-28 shrink-0 text-slate-500">{label}</span>
53
+ <span
54
+ className={
55
+ highlight === "pending"
56
+ ? "text-yellow-300"
57
+ : highlight === "idle"
58
+ ? "text-slate-500"
59
+ : "text-blue-300"
60
+ }
61
+ >
62
+ {value || "—"}
63
+ </span>
64
+ </div>
65
+ ))}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export const Basic: StoryObj<Args> = {
72
+ render: ({ delay }) => {
73
+ const [value, setValue] = useState("");
74
+ const [debouncedValue, { isPending, cancel }] = useDebouncedValue(
75
+ value,
76
+ delay,
77
+ );
16
78
 
17
79
  return (
18
- <div className="space-y-2">
19
- <pre className="rounded-md bg-slate-950 p-4 text-white">
20
- <code className="block">
21
- Valor actual: <span className="text-blue-300">{value}</span>
22
- </code>
23
- <code className="block">
24
- Valor debounced:{" "}
25
- <span className="text-blue-300">{debouncedValue}</span>
26
- </code>
27
- </pre>
80
+ <div className="flex max-w-sm flex-col gap-4">
28
81
  <Input
29
82
  value={value}
30
- onChange={setValue}
31
- placeholder="Escribe algo..."
83
+ onValueChange={setValue}
84
+ placeholder="Type something..."
85
+ />
86
+ <StatePanel
87
+ rows={[
88
+ { label: "input", value: value },
89
+ { label: "debounced", value: debouncedValue },
90
+ {
91
+ label: "status",
92
+ value: isPending ? "pending" : "idle",
93
+ highlight: isPending ? "pending" : "idle",
94
+ },
95
+ ]}
96
+ />
97
+ <button
98
+ type="button"
99
+ onClick={cancel}
100
+ disabled={!isPending}
101
+ className="self-start 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"
102
+ >
103
+ Cancel
104
+ </button>
105
+ </div>
106
+ );
107
+ },
108
+ };
109
+
110
+ type PokemonResult = { name: string; sprite: string | null };
111
+
112
+ export const AsyncSearch: StoryObj<Args> = {
113
+ args: { delay: 2000 },
114
+ name: "Flush & Cancel",
115
+ render: ({ delay }) => {
116
+ const [query, setQuery] = useState("");
117
+ const [debouncedQuery, { isPending, cancel, flush }] = useDebouncedValue(
118
+ query,
119
+ delay,
120
+ );
121
+ const [result, setResult] = useState<PokemonResult | null>(null);
122
+ const [loading, setLoading] = useState(false);
123
+ const [error, setError] = useState<string | null>(null);
124
+
125
+ useEffect(() => {
126
+ const name = debouncedQuery.trim().toLowerCase();
127
+ if (!name) {
128
+ setResult(null);
129
+ setError(null);
130
+ return;
131
+ }
132
+
133
+ let cancelled = false;
134
+ setLoading(true);
135
+ setError(null);
136
+
137
+ fetch(`https://pokeapi.co/api/v2/pokemon/${name}`)
138
+ .then((res) => {
139
+ if (!res.ok) throw new Error("not found");
140
+ return res.json() as Promise<{
141
+ name: string;
142
+ sprites: { front_default: string | null };
143
+ }>;
144
+ })
145
+ .then((data) => {
146
+ if (cancelled) return;
147
+ setResult({ name: data.name, sprite: data.sprites.front_default });
148
+ setLoading(false);
149
+ })
150
+ .catch(() => {
151
+ if (cancelled) return;
152
+ setError(`No Pokémon found for "${debouncedQuery}"`);
153
+ setResult(null);
154
+ setLoading(false);
155
+ });
156
+
157
+ return () => {
158
+ cancelled = true;
159
+ };
160
+ }, [debouncedQuery]);
161
+
162
+ return (
163
+ <div className="flex max-w-sm flex-col gap-4">
164
+ <div className="rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 space-y-1.5">
165
+ <p>
166
+ Delay is <strong>2 s</strong>.
167
+ </p>
168
+ <p>
169
+ <KbdGroup>
170
+ <Kbd>Enter</Kbd>
171
+ </KbdGroup>{" "}
172
+ — <strong>flush</strong>: skip the wait, search right now.
173
+ </p>
174
+ <p>
175
+ <KbdGroup>
176
+ <Kbd>Esc</Kbd>
177
+ </KbdGroup>{" "}
178
+ — <strong>cancel</strong>: discard the pending update, no request
179
+ fires.
180
+ </p>
181
+ </div>
182
+
183
+ <Input
184
+ value={query}
185
+ onValueChange={setQuery}
186
+ onKeyDown={getHotkeyHandler([
187
+ ["enter", flush],
188
+ ["escape", cancel],
189
+ ])}
190
+ placeholder="pikachu, bulbasaur, ditto..."
191
+ />
192
+
193
+ <StatePanel
194
+ rows={[
195
+ { label: "input", value: query },
196
+ { label: "debounced", value: debouncedQuery },
197
+ {
198
+ label: "status",
199
+ value: isPending ? "pending" : "idle",
200
+ highlight: isPending ? "pending" : "idle",
201
+ },
202
+ ]}
32
203
  />
204
+
205
+ {loading && <p className="text-sm text-slate-400">Fetching...</p>}
206
+
207
+ {error && (
208
+ <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">
209
+ {error}
210
+ </p>
211
+ )}
212
+
213
+ {result && (
214
+ <div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-3">
215
+ {result.sprite && (
216
+ <img
217
+ src={result.sprite}
218
+ alt={result.name}
219
+ className="h-16 w-16 [image-rendering:pixelated]"
220
+ />
221
+ )}
222
+ <span className="font-medium capitalize text-slate-800">
223
+ {result.name}
224
+ </span>
225
+ </div>
226
+ )}
33
227
  </div>
34
228
  );
35
229
  },
36
230
  };
37
231
 
38
- export const MultipleDelays: StoryObj = {
232
+ export const MultipleDelays: StoryObj<Args> = {
233
+ parameters: {
234
+ controls: { exclude: ["delay"] },
235
+ },
39
236
  render: () => {
40
- const [value, setValue] = useState<string>("");
41
- const debouncedFast: string = useDebouncedValue(value, 200);
42
- const debouncedMedium: string = useDebouncedValue(value, 500);
43
- const debouncedSlow: string = useDebouncedValue(value, 1000);
237
+ const [value, setValue] = useState("");
238
+ const [fast, { isPending: fastPending }] = useDebouncedValue(value, 200);
239
+ const [medium, { isPending: mediumPending }] = useDebouncedValue(
240
+ value,
241
+ 500,
242
+ );
243
+ const [slow, { isPending: slowPending }] = useDebouncedValue(value, 1000);
44
244
 
45
245
  return (
46
- <div className="space-y-4">
246
+ <div className="flex max-w-sm flex-col gap-4">
47
247
  <Input
48
248
  value={value}
49
- onChange={setValue}
50
- placeholder="Escribe para ver diferentes delays..."
249
+ onValueChange={setValue}
250
+ placeholder="Type to see different delays..."
251
+ />
252
+ <StatePanel
253
+ rows={[
254
+ { label: "input", value: value },
255
+ {
256
+ label: "200ms",
257
+ value: fast,
258
+ highlight: fastPending ? "pending" : undefined,
259
+ },
260
+ {
261
+ label: "500ms",
262
+ value: medium,
263
+ highlight: mediumPending ? "pending" : undefined,
264
+ },
265
+ {
266
+ label: "1000ms",
267
+ value: slow,
268
+ highlight: slowPending ? "pending" : undefined,
269
+ },
270
+ ]}
51
271
  />
52
-
53
- <div className="space-y-2">
54
- <pre className="rounded-md bg-slate-950 p-4 text-white">
55
- <code className="block">
56
- Valor actual: <span className="text-blue-300">{value}</span>
57
- </code>
58
- <code className="block">
59
- Debounced (200ms):{" "}
60
- <span className="text-blue-300">{debouncedFast}</span>
61
- </code>
62
- <code className="block">
63
- Debounced (500ms):{" "}
64
- <span className="text-blue-300">{debouncedMedium}</span>
65
- </code>
66
- <code className="block">
67
- Debounced (1000ms):{" "}
68
- <span className="text-blue-300">{debouncedSlow}</span>
69
- </code>
70
- </pre>
71
- </div>
72
272
  </div>
73
273
  );
74
274
  },