@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,56 +1,61 @@
1
- import { Meta } from "@storybook/react-vite";
1
+ import type { Meta } from "@storybook/react-vite";
2
2
  import { Trash } from "lucide-react";
3
3
  import { Button, Input } from "../../components";
4
4
  import { useLocalStorage } from "../use-local-storage";
5
5
 
6
6
  /**
7
- * Hook que proporciona una interfaz sencilla para interactuar con el localStorage de manera tipada.
8
- *
9
- * Este hook permite almacenar, recuperar y eliminar valores en el localStorage de manera segura,
10
- * utilizando una API genérica que asegura la consistencia de tipos en TypeScript. Es compatible con cualquier
11
- * tipo de dato, desde strings hasta objetos complejos.
12
- *
13
- * @param key - La clave del localStorage donde se almacenará el valor.
14
- * @param initialValue - El valor inicial que se utilizará si no hay datos almacenados bajo la clave proporcionada.
15
- * @returns Una tupla con el valor almacenado, una función para actualizarlo y otra para eliminarlo.
16
- *
17
- * @example
18
- * const [name, setName, removeName] = useLocalStorage<string>("name", "John Doe");
19
- *
20
- * @example
21
- * const [count, setCount, removeCount] = useLocalStorage<number>("count", 0);
22
- *
23
- * @example
24
- * const [items, setItems, removeItems] = useLocalStorage<string[]>("items", ["apple", "banana"]);
7
+ * Persists a value in `window.localStorage` and stays in sync across
8
+ * every instance reading the same key (in the same tab and across tabs
9
+ * via the `storage` event). Returns `[value, setValue, remove]`. SSR-safe:
10
+ * during server render the value falls back to `initialValue` and nothing
11
+ * is written. Values are JSON-serialized by default; pass a `serializer`
12
+ * option to customize.
25
13
  */
26
14
  const meta: Meta = {
27
15
  title: "hooks/useLocalStorage",
16
+ tags: ["beta"],
28
17
  };
29
18
 
30
19
  export default meta;
31
20
 
32
21
  export const Default = {
33
22
  render: () => {
34
- const [value, setValue, removeValue] = useLocalStorage<string>("myKey", "");
23
+ const [value, setValue, removeValue] = useLocalStorage<string>(
24
+ "demo:name",
25
+ "",
26
+ );
35
27
 
36
28
  return (
37
29
  <div className="space-y-2">
38
- <Input value={value} onValueChange={setValue} />
30
+ <Input value={value} onValueChange={setValue} placeholder="Type…" />
39
31
  <Button onClick={removeValue}>Clear</Button>
40
32
  </div>
41
33
  );
42
34
  },
43
35
  };
44
36
 
45
- export const WithNumber = {
37
+ /**
38
+ * `setValue` accepts a functional updater that receives the previous
39
+ * value, matching the `useState` signature. Safe for rapid consecutive
40
+ * updates because the closure always reads the latest value.
41
+ */
42
+ export const FunctionalUpdater = {
46
43
  render: () => {
47
- const [count, setCount, removeCount] = useLocalStorage<number>("count", 0);
44
+ const [count, setCount, removeCount] = useLocalStorage<number>(
45
+ "demo:count",
46
+ 0,
47
+ );
48
48
 
49
49
  return (
50
50
  <div className="space-y-2">
51
51
  <p>Count: {count}</p>
52
52
  <div className="flex gap-2">
53
- <Button onClick={() => setCount(count + 1)}>Increment</Button>
53
+ <Button onClick={() => setCount((prev) => (prev ?? 0) + 1)}>
54
+ Increment
55
+ </Button>
56
+ <Button onClick={() => setCount((prev) => (prev ?? 0) - 1)}>
57
+ Decrement
58
+ </Button>
54
59
  <Button onClick={removeCount}>Reset</Button>
55
60
  </div>
56
61
  </div>
@@ -58,22 +63,117 @@ export const WithNumber = {
58
63
  },
59
64
  };
60
65
 
61
- export const WithArray = {
66
+ /**
67
+ * Two hooks reading the same key stay synchronized in the same tab.
68
+ * Updating from one component updates the other on the next render —
69
+ * no prop drilling required.
70
+ */
71
+ export const CrossInstanceSync = {
72
+ render: () => {
73
+ const [a, setA] = useLocalStorage<string>("demo:shared", "");
74
+ const [b, setB] = useLocalStorage<string>("demo:shared", "");
75
+
76
+ return (
77
+ <div className="space-y-4">
78
+ <div className="space-y-1">
79
+ <p className="text-muted-foreground text-sm">Instance A</p>
80
+ <Input value={a} onValueChange={setA} />
81
+ </div>
82
+ <div className="space-y-1">
83
+ <p className="text-muted-foreground text-sm">Instance B</p>
84
+ <Input value={b} onValueChange={setB} />
85
+ </div>
86
+ </div>
87
+ );
88
+ },
89
+ };
90
+
91
+ /**
92
+ * Open this story in two browser tabs at the same URL and edit the
93
+ * input in one tab. The other tab updates immediately via the native
94
+ * `storage` event.
95
+ */
96
+ export const CrossTabSync = {
97
+ render: () => {
98
+ const [value, setValue, removeValue] = useLocalStorage<string>(
99
+ "demo:cross-tab",
100
+ "",
101
+ );
102
+
103
+ return (
104
+ <div className="space-y-2">
105
+ <Input
106
+ value={value}
107
+ onValueChange={setValue}
108
+ placeholder="Edit in one tab, watch the other"
109
+ />
110
+ <Button onClick={removeValue}>Clear</Button>
111
+ </div>
112
+ );
113
+ },
114
+ };
115
+
116
+ /**
117
+ * Provide a custom `serializer` to control how values are written to
118
+ * and read from storage. Useful for non-JSON formats, date objects,
119
+ * or encrypted payloads.
120
+ *
121
+ * ```tsx
122
+ * const dateSerializer = {
123
+ * read: (raw: string) => new Date(raw),
124
+ * write: (value: Date) => value.toISOString(),
125
+ * };
126
+ *
127
+ * useLocalStorage<Date>("demo:date", new Date(), {
128
+ * serializer: dateSerializer,
129
+ * });
130
+ * ```
131
+ */
132
+ export const CustomSerializer = {
133
+ render: () => {
134
+ const upperCase = {
135
+ read: (raw: string) => raw.toUpperCase(),
136
+ write: (value: string) => value.toLowerCase(),
137
+ };
138
+
139
+ const [value, setValue, remove] = useLocalStorage<string>(
140
+ "demo:upper",
141
+ "INITIAL",
142
+ { serializer: upperCase },
143
+ );
144
+
145
+ return (
146
+ <div className="space-y-2">
147
+ <Input value={value} onValueChange={setValue} />
148
+ <p className="text-muted-foreground text-sm">
149
+ Stored on disk: written lowercase, read uppercase.
150
+ </p>
151
+ <Button onClick={remove}>Clear</Button>
152
+ </div>
153
+ );
154
+ },
155
+ };
156
+
157
+ /**
158
+ * Works with any JSON-serializable shape. Reading a non-JSON value
159
+ * logs a console error and falls back to `initialValue`.
160
+ */
161
+ export const ArrayValue = {
62
162
  render: () => {
63
163
  const [items, setItems, removeItems] = useLocalStorage<string[]>(
64
- "oranges",
164
+ "demo:oranges",
65
165
  ["🍊"],
66
166
  );
67
167
 
68
168
  return (
69
169
  <div className="space-y-2">
70
170
  <ul>
71
- {items.map((item, index) => (
72
- <li key={index}>{item}</li>
171
+ {items?.map((item, index) => (
172
+ <li key={`${item}-${index}`}>{item}</li>
73
173
  ))}
74
174
  </ul>
75
175
  <div className="flex gap-2">
76
- <Button onClick={() => setItems([...items, "🍊"])}>
176
+ <Button onClick={() => setItems((prev) => [...(prev ?? []), "🍊"])}>
77
177
  🍊 Add Orange
78
178
  </Button>
79
179
  <Button onClick={removeItems}>
@@ -1,5 +1,5 @@
1
1
  import { act, renderHook } from "@testing-library/react";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { useLocalStorage } from "../use-local-storage";
4
4
 
5
5
  describe("useLocalStorage hook", () => {
@@ -9,11 +9,16 @@ describe("useLocalStorage hook", () => {
9
9
  localStorage.clear();
10
10
  });
11
11
 
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ vi.restoreAllMocks();
15
+ });
16
+
12
17
  it("should be defined", () => {
13
18
  expect(useLocalStorage).toBeDefined();
14
19
  });
15
20
 
16
- it("should initialize with default value if localStorage is empty", () => {
21
+ it("returns initialValue when localStorage is empty", () => {
17
22
  const { result } = renderHook(() =>
18
23
  useLocalStorage<string>(key, "initialValue"),
19
24
  );
@@ -22,7 +27,12 @@ describe("useLocalStorage hook", () => {
22
27
  expect(storedValue).toBe("initialValue");
23
28
  });
24
29
 
25
- it("should retrieve stored value from localStorage", () => {
30
+ it("does NOT write initialValue to localStorage on mount", () => {
31
+ renderHook(() => useLocalStorage<string>(key, "initialValue"));
32
+ expect(localStorage.getItem(key)).toBeNull();
33
+ });
34
+
35
+ it("reads an existing stored value from localStorage", () => {
26
36
  localStorage.setItem(key, JSON.stringify("storedValue"));
27
37
 
28
38
  const { result } = renderHook(() =>
@@ -33,14 +43,13 @@ describe("useLocalStorage hook", () => {
33
43
  expect(storedValue).toBe("storedValue");
34
44
  });
35
45
 
36
- it("should update localStorage when value changes", () => {
46
+ it("setValue updates the value and writes to localStorage", () => {
37
47
  const { result } = renderHook(() =>
38
48
  useLocalStorage<string>(key, "initialValue"),
39
49
  );
40
50
 
41
- const [, setValue] = result.current;
42
-
43
51
  act(() => {
52
+ const [, setValue] = result.current;
44
53
  setValue("newValue");
45
54
  });
46
55
 
@@ -49,54 +58,47 @@ describe("useLocalStorage hook", () => {
49
58
  expect(localStorage.getItem(key)).toBe(JSON.stringify("newValue"));
50
59
  });
51
60
 
52
- it("should remove the value from localStorage when remove is called", () => {
61
+ it("setValue accepts a functional updater", () => {
62
+ const { result } = renderHook(() => useLocalStorage<number>(key, 1));
63
+
64
+ act(() => {
65
+ const [, setValue] = result.current;
66
+ setValue((prev) => (prev ?? 0) + 5);
67
+ });
68
+
69
+ expect(result.current[0]).toBe(6);
70
+ expect(localStorage.getItem(key)).toBe("6");
71
+ });
72
+
73
+ it("remove() deletes the key and falls back to initialValue", () => {
53
74
  localStorage.setItem(key, JSON.stringify("storedValue"));
54
75
 
55
76
  const { result } = renderHook(() => useLocalStorage<string>(key, ""));
56
77
 
57
- const [, , remove] = result.current;
58
-
59
78
  act(() => {
79
+ const [, , remove] = result.current;
60
80
  remove();
61
81
  });
62
82
 
63
- const [storedValue] = result.current;
64
- expect(storedValue).toBe("");
83
+ expect(result.current[0]).toBe("");
84
+ expect(localStorage.getItem(key)).toBeNull();
65
85
  });
66
86
 
67
- it("should handle non-JSON serializable values gracefully", () => {
87
+ it("falls back to initialValue and logs when stored value is not JSON", () => {
68
88
  const consoleErrorSpy = vi
69
89
  .spyOn(console, "error")
70
- .mockImplementation(() => {
71
- console.log("error");
72
- });
90
+ .mockImplementation(() => {});
73
91
  localStorage.setItem(key, "non-JSON-string");
74
92
 
75
93
  const { result } = renderHook(() =>
76
94
  useLocalStorage<string>(key, "initialValue"),
77
95
  );
78
96
 
79
- const [storedValue] = result.current;
80
- expect(storedValue).toBe("initialValue");
97
+ expect(result.current[0]).toBe("initialValue");
81
98
  expect(consoleErrorSpy).toHaveBeenCalled();
82
-
83
- consoleErrorSpy.mockRestore();
84
- });
85
-
86
- it("calls removeItem when storedValue is undefined (no initial value)", () => {
87
- const removeItemMock = vi.fn();
88
- vi.stubGlobal("localStorage", {
89
- getItem: vi.fn().mockReturnValue(null),
90
- setItem: vi.fn(),
91
- removeItem: removeItemMock,
92
- clear: vi.fn(),
93
- });
94
- renderHook(() => useLocalStorage<string>("empty-key"));
95
- expect(removeItemMock).toHaveBeenCalledWith("empty-key");
96
- vi.unstubAllGlobals();
97
99
  });
98
100
 
99
- it("handles localStorage.setItem errors in useEffect gracefully", () => {
101
+ it("logs when localStorage.setItem throws during setValue", () => {
100
102
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
101
103
  vi.stubGlobal("localStorage", {
102
104
  getItem: vi.fn().mockReturnValue(null),
@@ -106,13 +108,20 @@ describe("useLocalStorage hook", () => {
106
108
  removeItem: vi.fn(),
107
109
  clear: vi.fn(),
108
110
  });
109
- renderHook(() => useLocalStorage<string>("err-key", "value"));
111
+
112
+ const { result } = renderHook(() =>
113
+ useLocalStorage<string>("err-key", "value"),
114
+ );
115
+
116
+ act(() => {
117
+ const [, setValue] = result.current;
118
+ setValue("next");
119
+ });
120
+
110
121
  expect(errorSpy).toHaveBeenCalled();
111
- vi.unstubAllGlobals();
112
- errorSpy.mockRestore();
113
122
  });
114
123
 
115
- it("handles localStorage.removeItem errors in remove() gracefully", () => {
124
+ it("logs when localStorage.removeItem throws during remove()", () => {
116
125
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
117
126
  vi.stubGlobal("localStorage", {
118
127
  getItem: vi.fn().mockReturnValue(JSON.stringify("value")),
@@ -122,10 +131,66 @@ describe("useLocalStorage hook", () => {
122
131
  }),
123
132
  clear: vi.fn(),
124
133
  });
125
- const { result } = renderHook(() => useLocalStorage<string>("key", "value"));
126
- act(() => result.current[2]());
134
+
135
+ const { result } = renderHook(() =>
136
+ useLocalStorage<string>("rm-key", "value"),
137
+ );
138
+
139
+ act(() => {
140
+ result.current[2]();
141
+ });
142
+
127
143
  expect(errorSpy).toHaveBeenCalled();
128
- vi.unstubAllGlobals();
129
- errorSpy.mockRestore();
144
+ });
145
+
146
+ it("syncs two instances of the hook on the same key in the same tab", () => {
147
+ const a = renderHook(() => useLocalStorage<string>(key, "init"));
148
+ const b = renderHook(() => useLocalStorage<string>(key, "init"));
149
+
150
+ act(() => {
151
+ a.result.current[1]("from-a");
152
+ });
153
+
154
+ expect(a.result.current[0]).toBe("from-a");
155
+ expect(b.result.current[0]).toBe("from-a");
156
+ });
157
+
158
+ it("reacts to a cross-tab `storage` event", () => {
159
+ const { result } = renderHook(() =>
160
+ useLocalStorage<string>(key, "initial"),
161
+ );
162
+
163
+ act(() => {
164
+ localStorage.setItem(key, JSON.stringify("changed-externally"));
165
+ // jsdom rejects `storageArea` in StorageEventInit, so assign it after
166
+ // construction to satisfy the hook's `event.storageArea === localStorage`
167
+ // guard.
168
+ const event = new StorageEvent("storage", {
169
+ key,
170
+ newValue: JSON.stringify("changed-externally"),
171
+ });
172
+ Object.defineProperty(event, "storageArea", { value: localStorage });
173
+ window.dispatchEvent(event);
174
+ });
175
+
176
+ expect(result.current[0]).toBe("changed-externally");
177
+ });
178
+
179
+ it("supports a custom serializer", () => {
180
+ const serializer = {
181
+ read: (raw: string) => raw.toUpperCase(),
182
+ write: (value: string) => value.toLowerCase(),
183
+ };
184
+
185
+ const { result } = renderHook(() =>
186
+ useLocalStorage<string>(key, "INITIAL", { serializer }),
187
+ );
188
+
189
+ act(() => {
190
+ result.current[1]("MixedCase");
191
+ });
192
+
193
+ expect(localStorage.getItem(key)).toBe("mixedcase");
194
+ expect(result.current[0]).toBe("MIXEDCASE");
130
195
  });
131
196
  });
@@ -1,56 +1,125 @@
1
- import { useEffect, useState } from "react";
1
+ import { useCallback, useMemo, useSyncExternalStore } from "react";
2
+ import { isBrowser } from "../internal";
3
+
4
+ export interface Serializer<T> {
5
+ read: (raw: string) => T;
6
+ write: (value: T) => string;
7
+ }
8
+
9
+ export interface UseLocalStorageOptions<T> {
10
+ serializer?: Serializer<T>;
11
+ }
12
+
13
+ const defaultSerializer: Serializer<unknown> = {
14
+ read: (raw) => JSON.parse(raw) as unknown,
15
+ write: (value) => JSON.stringify(value),
16
+ };
17
+
18
+ type Listener = () => void;
19
+ const listeners = new Map<string, Set<Listener>>();
20
+
21
+ function notifyKey(key: string): void {
22
+ listeners.get(key)?.forEach((listener) => listener());
23
+ }
24
+
25
+ function subscribeKey(key: string, listener: Listener): () => void {
26
+ if (!isBrowser) return () => {};
27
+
28
+ let bucket = listeners.get(key);
29
+ if (!bucket) {
30
+ bucket = new Set();
31
+ listeners.set(key, bucket);
32
+ }
33
+ bucket.add(listener);
34
+
35
+ const onStorage = (event: StorageEvent) => {
36
+ if (event.storageArea !== localStorage) return;
37
+ if (event.key === key || event.key === null) listener();
38
+ };
39
+ window.addEventListener("storage", onStorage);
40
+
41
+ return () => {
42
+ bucket.delete(listener);
43
+ if (bucket.size === 0) listeners.delete(key);
44
+ window.removeEventListener("storage", onStorage);
45
+ };
46
+ }
2
47
 
3
48
  export function useLocalStorage<T>(
4
49
  key: string,
5
- ): [T | undefined, (value: T) => void, () => void];
50
+ ): [
51
+ T | undefined,
52
+ (value: T | ((prev: T | undefined) => T)) => void,
53
+ () => void,
54
+ ];
6
55
  export function useLocalStorage<T>(
7
56
  key: string,
8
57
  initialValue: T,
9
- ): [T, (value: T) => void, () => void];
58
+ options?: UseLocalStorageOptions<T>,
59
+ ): [T, (value: T | ((prev: T) => T)) => void, () => void];
10
60
  export function useLocalStorage<T>(
11
61
  key: string,
12
62
  initialValue?: T,
13
- ): [T | undefined, (value: T) => void, () => void] {
14
- const getStoredValue = (): T | undefined => {
63
+ options: UseLocalStorageOptions<T> = {},
64
+ ): [
65
+ T | undefined,
66
+ (value: T | ((prev: T | undefined) => T)) => void,
67
+ () => void,
68
+ ] {
69
+ const serializer = (options.serializer ?? defaultSerializer) as Serializer<T>;
70
+
71
+ const subscribe = useCallback(
72
+ (listener: Listener) => subscribeKey(key, listener),
73
+ [key],
74
+ );
75
+
76
+ const getSnapshot = useCallback((): string | null => {
77
+ if (!isBrowser) return null;
15
78
  try {
16
- const item = localStorage.getItem(key);
17
- if (item) {
18
- return JSON.parse(item) as T;
19
- }
20
- return initialValue !== undefined ? initialValue : undefined;
21
- } catch (error) {
22
- console.error(`Error parsing localStorage key "${key}":`, error);
23
- return initialValue !== undefined ? initialValue : undefined;
79
+ return localStorage.getItem(key);
80
+ } catch {
81
+ return null;
24
82
  }
25
- };
83
+ }, [key]);
26
84
 
27
- const [storedValue, setStoredValue] = useState<T | undefined>(getStoredValue);
85
+ const getServerSnapshot = useCallback((): string | null => null, []);
28
86
 
29
- useEffect(() => {
87
+ const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
88
+
89
+ const value = useMemo<T | undefined>(() => {
90
+ if (raw === null) return initialValue;
30
91
  try {
31
- if (storedValue !== undefined) {
32
- localStorage.setItem(key, JSON.stringify(storedValue));
33
- } else {
34
- localStorage.removeItem(key);
35
- }
92
+ return serializer.read(raw);
36
93
  } catch (error) {
37
- console.error(`Error setting localStorage key "${key}":`, error);
94
+ console.error(`Error parsing localStorage key "${key}":`, error);
95
+ return initialValue;
38
96
  }
39
- }, [key, storedValue]);
97
+ }, [raw, initialValue, key, serializer]);
40
98
 
41
- const setValue = (value: T) => {
42
- setStoredValue(value);
43
- };
99
+ const setValue = useCallback(
100
+ (next: T | ((prev: T | undefined) => T)) => {
101
+ try {
102
+ const resolved =
103
+ typeof next === "function"
104
+ ? (next as (prev: T | undefined) => T)(value)
105
+ : next;
106
+ localStorage.setItem(key, serializer.write(resolved));
107
+ notifyKey(key);
108
+ } catch (error) {
109
+ console.error(`Error setting localStorage key "${key}":`, error);
110
+ }
111
+ },
112
+ [key, value, serializer],
113
+ );
44
114
 
45
- const remove = () => {
115
+ const remove = useCallback(() => {
46
116
  try {
47
117
  localStorage.removeItem(key);
48
- setStoredValue(initialValue !== undefined ? initialValue : undefined);
118
+ notifyKey(key);
49
119
  } catch (error) {
50
120
  console.error(`Error removing localStorage key "${key}":`, error);
51
121
  }
52
- };
122
+ }, [key]);
53
123
 
54
- return [storedValue, setValue, remove];
124
+ return [value, setValue, remove];
55
125
  }
56
-