@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,5 +1,5 @@
1
1
  import { act, renderHook } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
3
  import { useArray } from "../use-array";
4
4
 
5
5
  describe("useArray hook", () => {
@@ -76,16 +76,6 @@ describe("useArray hook", () => {
76
76
  expect(result.current[0]).toEqual(["First", "Second", "New"]);
77
77
  });
78
78
 
79
- it("should update item based on predicate", () => {
80
- const initialList = ["First", "Second", "Third"];
81
- const { result } = renderHook(() => useArray(initialList));
82
- const [, { update }] = result.current;
83
-
84
- act(() => update((item) => item === "Second", "Updated"));
85
-
86
- expect(result.current[0]).toEqual(["First", "Updated", "Third"]);
87
- });
88
-
89
79
  it("pop removes last item", () => {
90
80
  const { result } = renderHook(() => useArray(["a", "b", "c"]));
91
81
  act(() => result.current[1].pop());
@@ -98,15 +88,408 @@ describe("useArray hook", () => {
98
88
  expect(result.current[0]).toEqual(["x", "y", "z"]);
99
89
  });
100
90
 
101
- it("update with function callback transforms matched item", () => {
91
+ // T-1.1: rename update replace in existing update-based tests
92
+ it("replace with value updates first matching item", () => {
93
+ const { result } = renderHook(() => useArray(["First", "Second", "Third"]));
94
+ const [, { replace }] = result.current;
95
+
96
+ act(() => replace((item) => item === "Second", "Updated"));
97
+
98
+ expect(result.current[0]).toEqual(["First", "Updated", "Third"]);
99
+ });
100
+
101
+ it("replace with function callback transforms matched item", () => {
102
102
  const { result } = renderHook(() => useArray([1, 2, 3]));
103
- act(() => result.current[1].update((n) => n === 2, (n) => n * 10));
103
+ act(() =>
104
+ result.current[1].replace(
105
+ (n) => n === 2,
106
+ (n) => n * 10,
107
+ ),
108
+ );
104
109
  expect(result.current[0]).toEqual([1, 20, 3]);
105
110
  });
106
111
 
107
- it("update does nothing when predicate matches nothing", () => {
112
+ it("replace does nothing when predicate matches nothing", () => {
113
+ const { result } = renderHook(() => useArray([1, 2, 3]));
114
+ act(() => result.current[1].replace((n) => n === 99, 0));
115
+ expect(result.current[0]).toEqual([1, 2, 3]);
116
+ });
117
+ });
118
+
119
+ // T-1.2: existing actions — immutability
120
+ describe("existing actions — immutability", () => {
121
+ it("push does not mutate prior array ref", () => {
122
+ const { result } = renderHook(() => useArray([1, 2]));
123
+ const prevRef = result.current[0];
124
+ act(() => result.current[1].push(3));
125
+ expect(prevRef).toEqual([1, 2]);
126
+ expect(result.current[0]).toEqual([1, 2, 3]);
127
+ expect(result.current[0]).not.toBe(prevRef);
128
+ });
129
+
130
+ it("pop on empty array is a no-op", () => {
131
+ const { result } = renderHook(() => useArray<number>([]));
132
+ act(() => result.current[1].pop());
133
+ expect(result.current[0]).toHaveLength(0);
134
+ });
135
+
136
+ it("sort does not mutate prior array ref", () => {
137
+ const { result } = renderHook(() => useArray([3, 1, 2]));
138
+ const prevRef = result.current[0];
139
+ act(() => result.current[1].sort((a, b) => a - b));
140
+ expect(prevRef).toEqual([3, 1, 2]);
141
+ expect(result.current[0]).toEqual([1, 2, 3]);
142
+ });
143
+
144
+ it("reverse does not mutate prior array ref", () => {
108
145
  const { result } = renderHook(() => useArray([1, 2, 3]));
109
- act(() => result.current[1].update((n) => n === 99, 0));
146
+ const prevRef = result.current[0];
147
+ act(() => result.current[1].reverse());
148
+ expect(prevRef).toEqual([1, 2, 3]);
149
+ expect(result.current[0]).toEqual([3, 2, 1]);
150
+ });
151
+ });
152
+
153
+ // T-1.3: new actions — replace
154
+ describe("new actions — replace", () => {
155
+ it("replace by value replaces first matching element", () => {
156
+ const { result } = renderHook(() => useArray([{ id: 1 }, { id: 2 }]));
157
+ act(() => result.current[1].replace((x) => x.id === 2, { id: 99 }));
158
+ expect(result.current[0]).toEqual([{ id: 1 }, { id: 99 }]);
159
+ });
160
+
161
+ it("replace with updater function transforms matched item", () => {
162
+ const { result } = renderHook(() => useArray([{ id: 1, count: 5 }]));
163
+ act(() =>
164
+ result.current[1].replace(
165
+ (x) => x.id === 1,
166
+ (x) => ({ ...x, count: x.count + 1 }),
167
+ ),
168
+ );
169
+ expect(result.current[0]).toEqual([{ id: 1, count: 6 }]);
170
+ });
171
+
172
+ it("replace no-match returns same array ref — no re-render trigger", () => {
173
+ const { result } = renderHook(() => useArray([{ id: 1 }]));
174
+ const prevRef = result.current[0];
175
+ act(() => result.current[1].replace((x) => x.id === 99, { id: 99 }));
176
+ expect(result.current[0]).toBe(prevRef);
177
+ });
178
+ });
179
+
180
+ // T-1.4: new actions — move/swap
181
+ describe("new actions — move/swap", () => {
182
+ it("move(0, 2) on [a,b,c] produces [b,c,a]", () => {
183
+ const { result } = renderHook(() => useArray(["a", "b", "c"]));
184
+ act(() => result.current[1].move(0, 2));
185
+ expect(result.current[0]).toEqual(["b", "c", "a"]);
186
+ });
187
+
188
+ it("move(2, 0) on [a,b,c] produces [c,a,b]", () => {
189
+ const { result } = renderHook(() => useArray(["a", "b", "c"]));
190
+ act(() => result.current[1].move(2, 0));
191
+ expect(result.current[0]).toEqual(["c", "a", "b"]);
192
+ });
193
+
194
+ it("move same-index leaves list content unchanged", () => {
195
+ const { result } = renderHook(() => useArray(["a", "b", "c"]));
196
+ act(() => result.current[1].move(1, 1));
197
+ expect(result.current[0]).toEqual(["a", "b", "c"]);
198
+ });
199
+
200
+ it("move same-index returns same array ref (no re-render trigger)", () => {
201
+ const { result } = renderHook(() => useArray(["a", "b", "c"]));
202
+ const prevRef = result.current[0];
203
+ act(() => result.current[1].move(1, 1));
204
+ expect(result.current[0]).toBe(prevRef);
205
+ });
206
+
207
+ it("swap(0, 2) on [a,b,c] produces [c,b,a]", () => {
208
+ const { result } = renderHook(() => useArray(["a", "b", "c"]));
209
+ act(() => result.current[1].swap(0, 2));
210
+ expect(result.current[0]).toEqual(["c", "b", "a"]);
211
+ });
212
+
213
+ it("swap same-index leaves list content unchanged", () => {
214
+ const { result } = renderHook(() => useArray([1, 2, 3]));
215
+ act(() => result.current[1].swap(1, 1));
216
+ expect(result.current[0]).toEqual([1, 2, 3]);
217
+ });
218
+
219
+ it("swap same-index returns same array ref (no re-render trigger)", () => {
220
+ const { result } = renderHook(() => useArray([1, 2, 3]));
221
+ const prevRef = result.current[0];
222
+ act(() => result.current[1].swap(1, 1));
223
+ expect(result.current[0]).toBe(prevRef);
224
+ });
225
+ });
226
+
227
+ // T-1.5: new actions — filter/removeWhere
228
+ describe("new actions — filter/removeWhere", () => {
229
+ it("filter retains matching items", () => {
230
+ const { result } = renderHook(() => useArray([1, 2, 3, 4]));
231
+ act(() => result.current[1].filter((x) => x % 2 === 0));
232
+ expect(result.current[0]).toEqual([2, 4]);
233
+ });
234
+
235
+ it("filter returns empty array when nothing matches", () => {
236
+ const { result } = renderHook(() => useArray([1, 3, 5]));
237
+ act(() => result.current[1].filter((x) => x % 2 === 0));
238
+ expect(result.current[0]).toEqual([]);
239
+ });
240
+
241
+ it("removeWhere removes all matching elements", () => {
242
+ const { result } = renderHook(() => useArray([1, 2, 3, 4]));
243
+ act(() => result.current[1].removeWhere((x) => x % 2 === 0));
244
+ expect(result.current[0]).toEqual([1, 3]);
245
+ });
246
+
247
+ it("removeWhere no-op when nothing matches", () => {
248
+ const { result } = renderHook(() => useArray([1, 3, 5]));
249
+ act(() => result.current[1].removeWhere((x) => x % 2 === 0));
250
+ expect(result.current[0]).toEqual([1, 3, 5]);
251
+ });
252
+ });
253
+
254
+ // T-1.6: new actions — sort/reverse
255
+ describe("new actions — sort/reverse", () => {
256
+ it("sort without comparator sorts alphabetically", () => {
257
+ const { result } = renderHook(() =>
258
+ useArray(["banana", "apple", "cherry"]),
259
+ );
260
+ act(() => result.current[1].sort());
261
+ expect(result.current[0]).toEqual(["apple", "banana", "cherry"]);
262
+ });
263
+
264
+ it("sort with custom comparator sorts numerically", () => {
265
+ const { result } = renderHook(() => useArray([3, 1, 2]));
266
+ act(() => result.current[1].sort((a, b) => a - b));
110
267
  expect(result.current[0]).toEqual([1, 2, 3]);
111
268
  });
269
+
270
+ it("sort does NOT mutate the previous state array ref", () => {
271
+ const { result } = renderHook(() => useArray([3, 1, 2]));
272
+ const prevRef = result.current[0];
273
+ act(() => result.current[1].sort((a, b) => a - b));
274
+ expect(prevRef).toEqual([3, 1, 2]);
275
+ });
276
+
277
+ it("reverse reverses order", () => {
278
+ const { result } = renderHook(() => useArray([1, 2, 3]));
279
+ act(() => result.current[1].reverse());
280
+ expect(result.current[0]).toEqual([3, 2, 1]);
281
+ });
282
+
283
+ it("reverse does NOT mutate the previous state array ref", () => {
284
+ const { result } = renderHook(() => useArray([1, 2, 3]));
285
+ const prevRef = result.current[0];
286
+ act(() => result.current[1].reverse());
287
+ expect(prevRef).toEqual([1, 2, 3]);
288
+ });
289
+ });
290
+
291
+ // T-1.7: new actions — pushMany
292
+ describe("new actions — pushMany", () => {
293
+ it("pushMany appends all items", () => {
294
+ const { result } = renderHook(() => useArray([1, 2]));
295
+ act(() => result.current[1].pushMany([3, 4, 5]));
296
+ expect(result.current[0]).toEqual([1, 2, 3, 4, 5]);
297
+ });
298
+
299
+ it("pushMany with empty array does not change length", () => {
300
+ const { result } = renderHook(() => useArray([1, 2]));
301
+ act(() => result.current[1].pushMany([]));
302
+ expect(result.current[0]).toEqual([1, 2]);
303
+ });
304
+
305
+ it("pushMany with empty array returns same array ref (no re-render trigger)", () => {
306
+ const { result } = renderHook(() => useArray([1, 2]));
307
+ const prevRef = result.current[0];
308
+ act(() => result.current[1].pushMany([]));
309
+ expect(result.current[0]).toBe(prevRef);
310
+ });
311
+ });
312
+
313
+ // T-1.8: new actions — find
314
+ describe("new actions — find", () => {
315
+ it("find returns first matching item", () => {
316
+ const { result } = renderHook(() => useArray([1, 2, 3]));
317
+ const found = result.current[1].find((x) => x > 1);
318
+ expect(found).toBe(2);
319
+ });
320
+
321
+ it("find returns undefined when no match", () => {
322
+ const { result } = renderHook(() => useArray([1, 2, 3]));
323
+ const found = result.current[1].find((x) => x > 10);
324
+ expect(found).toBeUndefined();
325
+ });
326
+
327
+ it("find reflects state after a mutation", () => {
328
+ const { result } = renderHook(() => useArray([1, 2]));
329
+ act(() => result.current[1].push(3));
330
+ const found = result.current[1].find((x) => x === 3);
331
+ expect(found).toBe(3);
332
+ });
333
+ });
334
+
335
+ // T-1.9: action stability
336
+ describe("action stability", () => {
337
+ it("all action refs are identical before and after a push", () => {
338
+ const { result } = renderHook(() => useArray([1, 2, 3]));
339
+
340
+ const actionsBefore = result.current[1];
341
+ const {
342
+ push,
343
+ pop,
344
+ insertAt,
345
+ removeAt,
346
+ updateAt,
347
+ clear,
348
+ reset,
349
+ set,
350
+ move,
351
+ swap,
352
+ filter,
353
+ sort,
354
+ reverse,
355
+ replace,
356
+ removeWhere,
357
+ pushMany,
358
+ find,
359
+ } = actionsBefore;
360
+
361
+ act(() => result.current[1].push(4));
362
+
363
+ const actionsAfter = result.current[1];
364
+ expect(actionsAfter.push).toBe(push);
365
+ expect(actionsAfter.pop).toBe(pop);
366
+ expect(actionsAfter.insertAt).toBe(insertAt);
367
+ expect(actionsAfter.removeAt).toBe(removeAt);
368
+ expect(actionsAfter.updateAt).toBe(updateAt);
369
+ expect(actionsAfter.clear).toBe(clear);
370
+ expect(actionsAfter.reset).toBe(reset);
371
+ expect(actionsAfter.set).toBe(set);
372
+ expect(actionsAfter.move).toBe(move);
373
+ expect(actionsAfter.swap).toBe(swap);
374
+ expect(actionsAfter.filter).toBe(filter);
375
+ expect(actionsAfter.sort).toBe(sort);
376
+ expect(actionsAfter.reverse).toBe(reverse);
377
+ expect(actionsAfter.replace).toBe(replace);
378
+ expect(actionsAfter.removeWhere).toBe(removeWhere);
379
+ expect(actionsAfter.pushMany).toBe(pushMany);
380
+ expect(actionsAfter.find).toBe(find);
381
+ });
382
+
383
+ it("the actions object ref itself is stable", () => {
384
+ const { result } = renderHook(() => useArray([1, 2, 3]));
385
+ const actionsBefore = result.current[1];
386
+ act(() => result.current[1].push(4));
387
+ expect(result.current[1]).toBe(actionsBefore);
388
+ });
389
+ });
390
+
391
+ // T-1.10: onChange
392
+ describe("onChange", () => {
393
+ it("onChange does NOT fire when replace finds no match", () => {
394
+ const spy = vi.fn();
395
+ const { result } = renderHook(() => useArray([1, 2, 3], { onChange: spy }));
396
+ act(() => result.current[1].replace((n) => n === 99, 0));
397
+ expect(spy).not.toHaveBeenCalled();
398
+ });
399
+ it("onChange fires after push with updated list", () => {
400
+ const spy = vi.fn();
401
+ const { result } = renderHook(() => useArray([1], { onChange: spy }));
402
+ act(() => result.current[1].push(2));
403
+ expect(spy).toHaveBeenCalledTimes(1);
404
+ expect(spy).toHaveBeenCalledWith([1, 2]);
405
+ });
406
+
407
+ it("onChange fires after pop", () => {
408
+ const spy = vi.fn();
409
+ const { result } = renderHook(() => useArray([1, 2], { onChange: spy }));
410
+ act(() => result.current[1].pop());
411
+ expect(spy).toHaveBeenCalledTimes(1);
412
+ expect(spy).toHaveBeenCalledWith([1]);
413
+ });
414
+
415
+ it("onChange does NOT fire on initial mount", () => {
416
+ const spy = vi.fn();
417
+ renderHook(() => useArray([1, 2, 3], { onChange: spy }));
418
+ expect(spy).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it("changing the onChange function reference does not cause double-fires", () => {
422
+ const spy1 = vi.fn();
423
+ const spy2 = vi.fn();
424
+ let onChange = spy1;
425
+ const { result, rerender } = renderHook(() => useArray([1], { onChange }));
426
+ onChange = spy2;
427
+ rerender();
428
+ act(() => result.current[1].push(2));
429
+ expect(spy1).toHaveBeenCalledTimes(0);
430
+ expect(spy2).toHaveBeenCalledTimes(1);
431
+ });
432
+ });
433
+
434
+ // T-1.11: reset — frozen initial
435
+ describe("reset — frozen initial", () => {
436
+ it("multiple mutations → reset restores mount-time value", () => {
437
+ const initial = ["a", "b", "c"];
438
+ const { result } = renderHook(() => useArray(initial));
439
+ act(() => result.current[1].push("d"));
440
+ act(() => result.current[1].push("e"));
441
+ act(() => result.current[1].pop());
442
+ act(() => result.current[1].reset());
443
+ expect(result.current[0]).toEqual(["a", "b", "c"]);
444
+ });
445
+
446
+ it("reset still restores original mount-time value when prop changes after mount", () => {
447
+ const initial = ["a", "b"];
448
+ const { result, rerender } = renderHook(
449
+ (props: string[]) => useArray(props),
450
+ {
451
+ initialProps: initial,
452
+ },
453
+ );
454
+ rerender(["x", "y", "z"]);
455
+ act(() => result.current[1].reset());
456
+ expect(result.current[0]).toEqual(["a", "b"]);
457
+ });
458
+ });
459
+
460
+ // T-1.12: edge cases
461
+ describe("edge cases", () => {
462
+ it("insertAt(0, item) inserts at beginning", () => {
463
+ const { result } = renderHook(() => useArray(["b", "c"]));
464
+ act(() => result.current[1].insertAt(0, "a"));
465
+ expect(result.current[0]).toEqual(["a", "b", "c"]);
466
+ });
467
+
468
+ it("insertAt(list.length, item) inserts at end", () => {
469
+ const { result } = renderHook(() => useArray(["a", "b"]));
470
+ act(() => result.current[1].insertAt(2, "c"));
471
+ expect(result.current[0]).toEqual(["a", "b", "c"]);
472
+ });
473
+
474
+ it("removeAt out-of-bounds does not crash", () => {
475
+ const { result } = renderHook(() => useArray(["a", "b"]));
476
+ expect(() => act(() => result.current[1].removeAt(99))).not.toThrow();
477
+ });
478
+
479
+ it("updateAt out-of-bounds does not crash", () => {
480
+ const { result } = renderHook(() => useArray(["a", "b"]));
481
+ expect(() => act(() => result.current[1].updateAt(99, "x"))).not.toThrow();
482
+ });
483
+
484
+ it("sort on empty array does not crash", () => {
485
+ const { result } = renderHook(() => useArray<number>([]));
486
+ expect(() => act(() => result.current[1].sort())).not.toThrow();
487
+ expect(result.current[0]).toEqual([]);
488
+ });
489
+
490
+ it("filter-all returns a new empty array", () => {
491
+ const { result } = renderHook(() => useArray([1, 2, 3]));
492
+ act(() => result.current[1].filter(() => false));
493
+ expect(result.current[0]).toEqual([]);
494
+ });
112
495
  });
@@ -1,74 +1,113 @@
1
- import { useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useLatestRef } from "../internal/use-latest-ref";
2
3
 
3
- export function useArray<T>(defaultProp: T[]) {
4
- const [list = [], setList] = useState(defaultProp);
5
-
6
- const insertAt = (index: number, item: T) => {
7
- setList((prevList = []) => {
8
- const newList = [...prevList];
9
- newList.splice(index, 0, item);
10
- return newList;
11
- });
12
- };
13
-
14
- const removeAt = (index: number) => {
15
- setList((prevList = []) => {
16
- const newList = [...prevList];
17
- newList.splice(index, 1);
18
- return newList;
19
- });
20
- };
21
-
22
- const updateAt = (index: number, newItem: T) => {
23
- setList((prevList = []) => {
24
- const newList = [...prevList];
25
- newList[index] = newItem;
26
- return newList;
27
- });
28
- };
29
-
30
- const clear = () => setList([]);
4
+ export interface UseArrayOptions<T> {
5
+ /** Called after every list change. NOT called on initial mount. */
6
+ onChange?: (items: T[]) => void;
7
+ }
31
8
 
32
- const reset = () => setList(defaultProp);
9
+ export interface UseArrayActions<T> {
10
+ push(item: T): void;
11
+ pop(): void;
12
+ insertAt(index: number, item: T): void;
13
+ removeAt(index: number): void;
14
+ updateAt(index: number, item: T): void;
15
+ clear(): void;
16
+ reset(): void;
17
+ set(items: T[]): void;
18
+ move(from: number, to: number): void;
19
+ swap(indexA: number, indexB: number): void;
20
+ filter(predicate: (item: T, index: number, array: T[]) => boolean): void;
21
+ sort(compareFn?: (a: T, b: T) => number): void;
22
+ reverse(): void;
23
+ replace(predicate: (item: T) => boolean, newItem: T | ((item: T) => T)): void;
24
+ removeWhere(predicate: (item: T) => boolean): void;
25
+ pushMany(items: T[]): void;
26
+ find(
27
+ predicate: (item: T, index: number, array: T[]) => boolean,
28
+ ): T | undefined;
29
+ }
33
30
 
34
- const push = (item: T) => setList((prevList = []) => [...prevList, item]);
31
+ export function useArray<T>(
32
+ initialValue: T[],
33
+ options?: UseArrayOptions<T>,
34
+ ): readonly [T[], UseArrayActions<T>] {
35
+ const [list, setList] = useState<T[]>(initialValue);
35
36
 
36
- const set = (newList: T[]) => setList(newList);
37
+ // Frozen on mount reset always restores the original value.
38
+ const initialRef = useRef(initialValue);
39
+ // Always current — find reads this without invalidating the memo.
40
+ const listRef = useLatestRef(list);
41
+ // Always the latest handler — never a memo/effect dep.
42
+ const onChangeRef = useLatestRef(options?.onChange);
37
43
 
38
- const pop = () => {
39
- setList((prevList = []) => {
40
- const newList = [...prevList];
41
- newList.pop();
42
- return newList;
43
- });
44
- };
44
+ // Fire onChange on every change EXCEPT the initial mount.
45
+ const mountedRef = useRef(false);
46
+ useEffect(() => {
47
+ if (!mountedRef.current) {
48
+ mountedRef.current = true;
49
+ return;
50
+ }
51
+ onChangeRef.current?.(list);
52
+ }, [list]);
45
53
 
46
- const update = (
47
- predicate: (item: T) => boolean,
48
- newItem: T | ((item: T) => T),
49
- ) => {
50
- setList((prevList = []) => {
51
- const newList = [...prevList];
52
- const index = newList.findIndex(predicate);
53
- if (index === -1) return newList;
54
- newList[index] = typeof newItem === "function" ? (newItem as (item: T) => T)(newList[index]) : newItem;
55
- return newList;
56
- });
57
- };
54
+ const actions = useMemo<UseArrayActions<T>>(
55
+ () => ({
56
+ push: (item) => setList((prev) => [...prev, item]),
57
+ pop: () => setList((prev) => prev.slice(0, -1)),
58
+ insertAt: (index, item) =>
59
+ setList((prev) => [
60
+ ...prev.slice(0, index),
61
+ item,
62
+ ...prev.slice(index),
63
+ ]),
64
+ removeAt: (index) =>
65
+ setList((prev) => prev.filter((_, idx) => idx !== index)),
66
+ updateAt: (index, item) =>
67
+ setList((prev) => prev.map((x, idx) => (idx === index ? item : x))),
68
+ clear: () => setList([]),
69
+ reset: () => setList(initialRef.current),
70
+ set: (items) => setList(items),
71
+ move: (from, to) =>
72
+ setList((prev) => {
73
+ if (from === to) return prev;
74
+ const next = [...prev];
75
+ const [moved] = next.splice(from, 1);
76
+ next.splice(to, 0, moved);
77
+ return next;
78
+ }),
79
+ swap: (a, b) =>
80
+ setList((prev) => {
81
+ if (a === b) return prev;
82
+ const next = [...prev];
83
+ [next[a], next[b]] = [next[b], next[a]];
84
+ return next;
85
+ }),
86
+ filter: (predicate) => setList((prev) => prev.filter(predicate)),
87
+ sort: (compareFn) => setList((prev) => [...prev].sort(compareFn)),
88
+ reverse: () => setList((prev) => [...prev].reverse()),
89
+ replace: (predicate, newItem) =>
90
+ setList((prev) => {
91
+ const index = prev.findIndex(predicate);
92
+ if (index === -1) return prev;
93
+ return prev.map((x, idx) =>
94
+ idx === index
95
+ ? typeof newItem === "function"
96
+ ? (newItem as (item: T) => T)(x)
97
+ : newItem
98
+ : x,
99
+ );
100
+ }),
101
+ removeWhere: (predicate) =>
102
+ setList((prev) => prev.filter((v) => !predicate(v))),
103
+ pushMany: (items) => {
104
+ if (items.length === 0) return;
105
+ setList((prev) => [...prev, ...items]);
106
+ },
107
+ find: (predicate) => listRef.current.find(predicate),
108
+ }),
109
+ [setList], // setList is stable for component lifetime → memo computes once
110
+ );
58
111
 
59
- return [
60
- list,
61
- {
62
- insertAt,
63
- removeAt,
64
- updateAt,
65
- clear,
66
- reset,
67
- push,
68
- set,
69
- pop,
70
- update,
71
- },
72
- ] as const;
112
+ return [list, actions] as const;
73
113
  }
74
-