@boxcustodia/library 2.0.0-alpha.22 → 2.0.0-alpha.24

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 (110) hide show
  1. package/dist/components/calendar/calendar.cjs.js +1 -1
  2. package/dist/components/calendar/calendar.es.js +43 -44
  3. package/dist/components/date-picker/date-input.cjs.js +1 -1
  4. package/dist/components/date-picker/date-input.es.js +160 -140
  5. package/dist/components/pagination/pagination.cjs.js +1 -1
  6. package/dist/components/pagination/pagination.es.js +37 -35
  7. package/dist/components/popover/popover.cjs.js +1 -1
  8. package/dist/components/popover/popover.es.js +1 -1
  9. package/dist/components/scroll-area/scroll-area.cjs.js +1 -1
  10. package/dist/components/scroll-area/scroll-area.es.js +4 -4
  11. package/dist/components/select/select.cjs.js +1 -1
  12. package/dist/components/select/select.es.js +94 -90
  13. package/dist/components/tag/tag.cjs.js +1 -1
  14. package/dist/components/tag/tag.es.js +37 -18
  15. package/dist/hooks/use-action/use-action.cjs.js +1 -0
  16. package/dist/hooks/use-action/use-action.es.js +41 -0
  17. package/dist/hooks/use-pagination/use-pagination.cjs.js +1 -1
  18. package/dist/hooks/use-pagination/use-pagination.es.js +77 -32
  19. package/dist/hooks/use-range-pagination/use-range-pagination.cjs.js +1 -1
  20. package/dist/hooks/use-range-pagination/use-range-pagination.es.js +8 -5
  21. package/dist/hooks/use-selection/use-selection.cjs.js +1 -1
  22. package/dist/hooks/use-selection/use-selection.es.js +95 -33
  23. package/dist/hooks/use-session-storage/use-session-storage.cjs.js +1 -0
  24. package/dist/hooks/use-session-storage/use-session-storage.es.js +57 -0
  25. package/dist/index.cjs.js +1 -1
  26. package/dist/index.es.js +61 -63
  27. package/dist/src/components/select/select.d.ts +9 -2
  28. package/dist/src/components/tag/tag.d.ts +2 -1
  29. package/dist/src/hooks/index.d.ts +2 -3
  30. package/dist/src/hooks/internal/index.d.ts +1 -0
  31. package/dist/src/hooks/internal/serializer.d.ts +4 -0
  32. package/dist/src/hooks/use-action/index.d.ts +1 -0
  33. package/dist/src/hooks/use-action/use-action.d.ts +22 -0
  34. package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +2 -4
  35. package/dist/src/hooks/use-pagination/use-pagination.d.ts +47 -32
  36. package/dist/src/hooks/use-range-pagination/use-range-pagination.d.ts +16 -10
  37. package/dist/src/hooks/use-selection/use-selection.d.ts +39 -45
  38. package/dist/src/hooks/use-session-storage/index.d.ts +1 -0
  39. package/dist/src/hooks/use-session-storage/use-session-storage.d.ts +11 -0
  40. package/package.json +1 -1
  41. package/src/components/calendar/calendar.tsx +10 -8
  42. package/src/components/combobox/combobox.stories.tsx +16 -0
  43. package/src/components/date-picker/date-input.tsx +23 -2
  44. package/src/components/form/form.tsx +3 -2
  45. package/src/components/pagination/pagination.tsx +5 -3
  46. package/src/components/popover/popover.tsx +1 -1
  47. package/src/components/scroll-area/scroll-area.tsx +2 -2
  48. package/src/components/select/select.tsx +14 -3
  49. package/src/components/tag/tag.stories.tsx +47 -2
  50. package/src/components/tag/tag.tsx +28 -6
  51. package/src/hooks/index.ts +2 -3
  52. package/src/hooks/internal/index.ts +1 -0
  53. package/src/hooks/internal/serializer.ts +4 -0
  54. package/src/hooks/use-action/index.ts +1 -0
  55. package/src/hooks/{use-mutation/use-mutation.stories.tsx → use-action/use-action.stories.tsx} +34 -34
  56. package/src/hooks/{use-mutation/use-mutation.test.ts → use-action/use-action.test.ts} +53 -53
  57. package/src/hooks/{use-mutation/use-mutation.ts → use-action/use-action.ts} +20 -20
  58. package/src/hooks/use-click-outside/use-click-outside.stories.tsx +0 -1
  59. package/src/hooks/use-clipboard/use-clipboard.stories.tsx +0 -1
  60. package/src/hooks/use-document-title/use-document-title.stories.tsx +0 -1
  61. package/src/hooks/use-is-visible/use-is-visible.test.tsx +1 -1
  62. package/src/hooks/use-local-storage/use-local-storage.stories.tsx +0 -1
  63. package/src/hooks/use-local-storage/use-local-storage.ts +2 -5
  64. package/src/hooks/use-media-query/use-media-query.stories.tsx +0 -1
  65. package/src/hooks/use-pagination/use-pagination.stories.tsx +720 -57
  66. package/src/hooks/use-pagination/use-pagination.test.tsx +560 -48
  67. package/src/hooks/use-pagination/use-pagination.ts +266 -0
  68. package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +0 -1
  69. package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +2 -2
  70. package/src/hooks/use-range-pagination/use-range-pagination.tsx +24 -21
  71. package/src/hooks/use-selection/use-selection.stories.tsx +339 -84
  72. package/src/hooks/use-selection/use-selection.test.tsx +417 -2
  73. package/src/hooks/use-selection/use-selection.ts +212 -102
  74. package/src/hooks/use-session-storage/index.ts +1 -0
  75. package/src/hooks/use-session-storage/use-session-storage.stories.tsx +122 -0
  76. package/src/hooks/use-session-storage/use-session-storage.test.ts +164 -0
  77. package/src/hooks/use-session-storage/use-session-storage.ts +115 -0
  78. package/dist/hooks/use-async/use-async.cjs.js +0 -1
  79. package/dist/hooks/use-async/use-async.es.js +0 -57
  80. package/dist/hooks/use-focus-trap/scope-tab.cjs.js +0 -1
  81. package/dist/hooks/use-focus-trap/scope-tab.es.js +0 -21
  82. package/dist/hooks/use-focus-trap/tabbable.cjs.js +0 -1
  83. package/dist/hooks/use-focus-trap/tabbable.es.js +0 -38
  84. package/dist/hooks/use-focus-trap/use-focus-trap.cjs.js +0 -1
  85. package/dist/hooks/use-focus-trap/use-focus-trap.es.js +0 -34
  86. package/dist/hooks/use-mutation/use-mutation.cjs.js +0 -1
  87. package/dist/hooks/use-mutation/use-mutation.es.js +0 -41
  88. package/dist/src/hooks/use-async/index.d.ts +0 -1
  89. package/dist/src/hooks/use-async/use-async.d.ts +0 -21
  90. package/dist/src/hooks/use-focus-trap/index.d.ts +0 -1
  91. package/dist/src/hooks/use-focus-trap/scope-tab.d.ts +0 -1
  92. package/dist/src/hooks/use-focus-trap/tabbable.d.ts +0 -4
  93. package/dist/src/hooks/use-focus-trap/use-focus-trap.d.ts +0 -1
  94. package/dist/src/hooks/use-mutation/index.d.ts +0 -1
  95. package/dist/src/hooks/use-mutation/use-mutation.d.ts +0 -22
  96. package/dist/src/hooks/use-mutation/use-mutation.test.d.ts +0 -1
  97. package/src/hooks/use-async/index.ts +0 -1
  98. package/src/hooks/use-async/use-async.stories.tsx +0 -272
  99. package/src/hooks/use-async/use-async.test.ts +0 -397
  100. package/src/hooks/use-async/use-async.ts +0 -135
  101. package/src/hooks/use-focus-trap/index.ts +0 -1
  102. package/src/hooks/use-focus-trap/scope-tab.ts +0 -38
  103. package/src/hooks/use-focus-trap/tabbable.ts +0 -70
  104. package/src/hooks/use-focus-trap/use-focus-trap.stories.tsx +0 -37
  105. package/src/hooks/use-focus-trap/use-focus-trap.test.ts +0 -355
  106. package/src/hooks/use-focus-trap/use-focus-trap.ts +0 -78
  107. package/src/hooks/use-mutation/index.ts +0 -1
  108. package/src/hooks/use-pagination/use-pagination.tsx +0 -84
  109. /package/dist/src/hooks/{use-async/use-async.test.d.ts → use-action/use-action.test.d.ts} +0 -0
  110. /package/dist/src/hooks/{use-focus-trap/use-focus-trap.test.d.ts → use-session-storage/use-session-storage.test.d.ts} +0 -0
@@ -1,119 +1,229 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useLatestRef } from "../internal/use-latest-ref";
2
3
 
3
- type Return<T> = {
4
- /**
5
- * Indica si todos los elementos están seleccionados.
6
- */
7
- isAllSelected: boolean;
4
+ /** Stable identity projected from an item. */
5
+ export type Key = string | number | symbol;
8
6
 
7
+ export interface UseSelectionOptions<T> {
9
8
  /**
10
- * Indica si algunos elementos están seleccionados.
9
+ * Projects a stable key from an item. Frozen on mount.
10
+ * When omitted, the item itself is used as its key (reference identity for
11
+ * objects, value identity for primitives). Pass this for object lists that
12
+ * are re-created across renders (e.g. after a fetch) so selection survives.
11
13
  */
12
- isSomeSelected: boolean;
14
+ keyFn?: (item: T) => Key;
15
+ /** Initial selection. Frozen on mount — never re-applied on re-render. */
16
+ defaultSelected?: T[];
17
+ /** Called after every selection change. NOT called on initial mount. */
18
+ onChange?: (selected: T[]) => void;
19
+ }
13
20
 
14
- /**
15
- * Indica si ningun elemento está seleccionado.
16
- */
21
+ export interface UseSelectionReturn<T> {
22
+ /** Currently-selected items present in `items`. Stable reference. */
23
+ selected: T[];
24
+ /** All selected keys, including keys whose item is absent (sticky). */
25
+ selectedKeys: ReadonlySet<Key>;
26
+ /** True when every item in `items` is selected (false when empty). */
27
+ isAllSelected: boolean;
28
+ /** True when at least one — but not all — items are selected. */
29
+ isSomeSelected: boolean;
30
+ /** True when nothing is selected. */
17
31
  isNoneSelected: boolean;
18
-
19
- /**
20
- * Función que devuelve true si el elemento dado está seleccionado, false de lo contrario.
21
- * @param item El elemento a verificar.
22
- * @returns true si el elemento está seleccionado, false de lo contrario.
23
- */
32
+ /** Stable fn ref: true if `item` is selected. */
24
33
  isSelected: (item: T) => boolean;
25
-
26
- /**
27
- * Array de elementos seleccionados.
28
- */
29
- selected: T[];
30
-
31
- /**
32
- * Establece los elementos seleccionados.
33
- * @param items Los elementos seleccionados.
34
- */
35
- setSelected: (items: T[]) => void;
36
-
37
- /**
38
- * Selecciona un elemento.
39
- * @param item El elemento a seleccionar.
40
- */
34
+ /** Selects one item. No-op if already selected. */
41
35
  select: (item: T) => void;
42
-
43
- /**
44
- * Deselecciona un elemento.
45
- * @param item El elemento a deseleccionar.
46
- */
36
+ /** Unselects one item. No-op if not selected. */
47
37
  unselect: (item: T) => void;
48
-
49
- /**
50
- * Alterna la selección de un elemento (si está seleccionado, lo deselecciona y viceversa).
51
- * @param item El elemento cuya selección se alternará.
52
- */
38
+ /** Toggles one item's selection. */
53
39
  toggle: (item: T) => void;
54
-
55
- /**
56
- * Alterna la selección de todos los elementos (si todos están seleccionados, los deselecciona; de lo contrario, los selecciona).
57
- */
40
+ /** Replaces the entire selection with `items`. */
41
+ setSelected: (items: T[]) => void;
42
+ /** Selects every item currently in `items` (additive). */
43
+ selectAll: () => void;
44
+ /** Selects all if not all selected, otherwise clears. */
58
45
  toggleAll: () => void;
59
-
60
- /**
61
- * Deselecciona todos los elementos.
62
- */
46
+ /** Clears the entire selection (including sticky orphaned keys). */
63
47
  clear: () => void;
64
- };
65
- export function useSelection<T>(items: T[]): Return<T> {
66
- const [selected, setSelected] = useState(new Set<T>());
67
-
68
- const isSelected = useCallback((item: T) => selected.has(item), [selected]);
69
-
70
- const select = useCallback((item: T) => {
71
- setSelected((prevSelected) => new Set([...prevSelected, item]));
72
- }, []);
73
-
74
- const unselect = useCallback((item: T) => {
75
- setSelected((prevSelected) => {
76
- const newSelected = new Set(prevSelected);
77
- newSelected.delete(item);
78
- return newSelected;
79
- });
80
- }, []);
81
-
82
- const toggle = useCallback((item: T) => {
83
- setSelected((prevSelected) => {
84
- const newSelected = new Set(prevSelected);
85
- if (newSelected.has(item)) {
86
- newSelected.delete(item);
87
- } else {
88
- newSelected.add(item);
89
- }
90
- return newSelected;
91
- });
92
- }, []);
93
-
94
- const setItems = useCallback((items: T[]) => {
95
- setSelected(new Set(items));
96
- }, []);
97
-
98
- const toggleAll = useCallback(() => {
99
- setSelected((prevSelected) =>
100
- prevSelected.size === items.length ? new Set() : new Set(items),
101
- );
102
- }, [items]);
103
-
104
- const clear = useCallback(() => setSelected(new Set()), []);
48
+ /** Inverts selection relative to current `items`. */
49
+ invert: () => void;
50
+ /** Selects items in the inclusive index range [from, to] of `items` (additive). */
51
+ selectRange: (from: number, to: number) => void;
52
+ }
53
+
54
+ const defaultGetKey = <T>(item: T): Key => item as unknown as Key;
55
+
56
+ export function useSelection<T>(
57
+ items: T[],
58
+ options?: UseSelectionOptions<T>,
59
+ ): UseSelectionReturn<T> {
60
+ // Frozen on mount — caller passes a stable function; re-passing is a no-op.
61
+ const getKeyRef = useRef<(item: T) => Key>(options?.keyFn ?? defaultGetKey);
62
+ const getKey = getKeyRef.current;
63
+
64
+ const [selectedKeys, setSelectedKeys] = useState<Set<Key>>(
65
+ // Frozen initial selection — seeded once.
66
+ () => new Set((options?.defaultSelected ?? []).map(getKeyRef.current)),
67
+ );
68
+
69
+ // Always current — actions and the onChange effect read the live list
70
+ // without taking `items` as a memo/effect dependency.
71
+ const itemsRef = useLatestRef(items);
72
+ // Always current selection — lets `isSelected` stay a stable fn ref.
73
+ const keysRef = useLatestRef(selectedKeys);
74
+ // Always the latest handler — never a memo/effect dep.
75
+ const onChangeRef = useLatestRef(options?.onChange);
76
+
77
+ // Fire onChange on every selection change EXCEPT initial mount.
78
+ const mountedRef = useRef(false);
79
+ useEffect(() => {
80
+ if (!mountedRef.current) {
81
+ mountedRef.current = true;
82
+ return;
83
+ }
84
+ const live = itemsRef.current;
85
+ const fn = getKeyRef.current;
86
+ onChangeRef.current?.(live.filter((item) => selectedKeys.has(fn(item))));
87
+ // Keyed on selectedKeys only: "onChange = selection changed".
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [selectedKeys]);
90
+
91
+ // Dev-only: warn once per instance when object items lack a keyFn (DEF-2).
92
+ const warnedRef = useRef(false);
93
+ useEffect(() => {
94
+ if (
95
+ process.env.NODE_ENV !== "production" &&
96
+ !options?.keyFn &&
97
+ !warnedRef.current &&
98
+ items.length > 0 &&
99
+ typeof items[0] === "object" &&
100
+ items[0] !== null
101
+ ) {
102
+ warnedRef.current = true;
103
+ console.warn(
104
+ "useSelection: items are objects but no `keyFn` was provided. " +
105
+ "Selection is tracked by object reference and will be lost when " +
106
+ "`items` is re-created (e.g. after a fetch). Pass `keyFn` to track " +
107
+ "by a stable key.",
108
+ );
109
+ }
110
+ // eslint-disable-next-line react-hooks/exhaustive-deps
111
+ }, [items, options?.keyFn]);
112
+
113
+ // Closes over `selectedKeys` state — must reflect current selection during render.
114
+ // A new fn ref on each selection change is intentional and correct.
115
+ const isSelected = useCallback(
116
+ (item: T) => selectedKeys.has(getKey(item)),
117
+ [selectedKeys],
118
+ );
119
+
120
+ const actions = useMemo(
121
+ () => ({
122
+ select: (item: T) =>
123
+ setSelectedKeys((prev) => {
124
+ const key = getKeyRef.current(item);
125
+ if (prev.has(key)) return prev;
126
+ const next = new Set(prev);
127
+ next.add(key);
128
+ return next;
129
+ }),
130
+
131
+ unselect: (item: T) =>
132
+ setSelectedKeys((prev) => {
133
+ const key = getKeyRef.current(item);
134
+ if (!prev.has(key)) return prev;
135
+ const next = new Set(prev);
136
+ next.delete(key);
137
+ return next;
138
+ }),
139
+
140
+ toggle: (item: T) =>
141
+ setSelectedKeys((prev) => {
142
+ const key = getKeyRef.current(item);
143
+ const next = new Set(prev);
144
+ if (next.has(key)) next.delete(key);
145
+ else next.add(key);
146
+ return next;
147
+ }),
148
+
149
+ setSelected: (next: T[]) =>
150
+ setSelectedKeys(new Set(next.map(getKeyRef.current))),
151
+
152
+ selectAll: () =>
153
+ setSelectedKeys((prev) => {
154
+ const next = new Set(prev);
155
+ for (const item of itemsRef.current)
156
+ next.add(getKeyRef.current(item));
157
+ return next;
158
+ }),
159
+
160
+ toggleAll: () =>
161
+ setSelectedKeys((prev) => {
162
+ const live = itemsRef.current;
163
+ const fn = getKeyRef.current;
164
+ const allSelected =
165
+ live.length > 0 && live.every((item) => prev.has(fn(item)));
166
+ if (allSelected) {
167
+ const next = new Set(prev);
168
+ for (const item of live) next.delete(fn(item));
169
+ return next;
170
+ }
171
+ const next = new Set(prev);
172
+ for (const item of live) next.add(fn(item));
173
+ return next;
174
+ }),
175
+
176
+ clear: () => setSelectedKeys(new Set()),
177
+
178
+ invert: () =>
179
+ setSelectedKeys((prev) => {
180
+ const fn = getKeyRef.current;
181
+ const next = new Set(prev);
182
+ for (const item of itemsRef.current) {
183
+ const key = fn(item);
184
+ if (next.has(key)) next.delete(key);
185
+ else next.add(key);
186
+ }
187
+ return next;
188
+ }),
189
+
190
+ selectRange: (from: number, to: number) =>
191
+ setSelectedKeys((prev) => {
192
+ const live = itemsRef.current;
193
+ if (live.length === 0) return prev;
194
+ const fn = getKeyRef.current;
195
+ const lo = Math.max(0, Math.min(from, to));
196
+ const hi = Math.min(live.length - 1, Math.max(from, to));
197
+ const next = new Set(prev);
198
+ for (let i = lo; i <= hi; i++) next.add(fn(live[i]));
199
+ return next;
200
+ }),
201
+ }),
202
+ // keysRef/getKeyRef/itemsRef are stable refs → memo computes once.
203
+ // setSelectedKeys is stable for component lifetime.
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ [keysRef, itemsRef],
206
+ );
207
+
208
+ // Stable `selected` reference: recomputes only on items/selection change.
209
+ const selected = useMemo(
210
+ () => items.filter((item) => selectedKeys.has(getKey(item))),
211
+ // eslint-disable-next-line react-hooks/exhaustive-deps
212
+ [items, selectedKeys],
213
+ );
214
+
215
+ const isAllSelected =
216
+ items.length > 0 && items.every((item) => selectedKeys.has(getKey(item)));
217
+ const isNoneSelected = selectedKeys.size === 0;
218
+ const isSomeSelected = !isNoneSelected && !isAllSelected;
105
219
 
106
220
  return {
107
- isAllSelected: selected.size === items.length,
108
- isSomeSelected: selected.size > 0 && selected.size < items.length,
109
- isNoneSelected: selected.size === 0,
221
+ selected,
222
+ selectedKeys,
223
+ isAllSelected,
224
+ isSomeSelected,
225
+ isNoneSelected,
110
226
  isSelected,
111
- selected: [...selected],
112
- setSelected: setItems,
113
- select,
114
- unselect,
115
- toggle,
116
- toggleAll,
117
- clear,
227
+ ...actions,
118
228
  };
119
229
  }
@@ -0,0 +1 @@
1
+ export * from "./use-session-storage";
@@ -0,0 +1,122 @@
1
+ import type { Meta } from "@storybook/react-vite";
2
+ import { Trash } from "lucide-react";
3
+ import { Button, Input } from "../../components";
4
+ import { useSessionStorage } from "../use-session-storage";
5
+
6
+ /**
7
+ * Persists a value in `window.sessionStorage` for the lifetime of the browser
8
+ * tab — cleared automatically when the tab is closed. Syncs across every
9
+ * instance reading the same key within the same tab. Returns
10
+ * `[value, setValue, remove]`. SSR-safe: during server render the value falls
11
+ * back to `initialValue` and nothing is written. Values are JSON-serialized by
12
+ * default; pass a `serializer` option to customize.
13
+ *
14
+ * Prefer `useSessionStorage` over `useLocalStorage` for temporary drafts
15
+ * (wizards, multi-step forms) that should not persist across sessions.
16
+ */
17
+ const meta: Meta = {
18
+ title: "hooks/useSessionStorage",
19
+ tags: ["new"],
20
+ };
21
+
22
+ export default meta;
23
+
24
+ export const Default = {
25
+ render: () => {
26
+ const [value, setValue, removeValue] = useSessionStorage<string>(
27
+ "demo:session-name",
28
+ "",
29
+ );
30
+
31
+ return (
32
+ <div className="space-y-2">
33
+ <Input value={value} onValueChange={setValue} placeholder="Type…" />
34
+ <Button onClick={removeValue}>Clear</Button>
35
+ </div>
36
+ );
37
+ },
38
+ };
39
+
40
+ /**
41
+ * `setValue` accepts a functional updater that receives the previous value,
42
+ * matching the `useState` signature.
43
+ */
44
+ export const FunctionalUpdater = {
45
+ render: () => {
46
+ const [count, setCount, removeCount] = useSessionStorage<number>(
47
+ "demo:session-count",
48
+ 0,
49
+ );
50
+
51
+ return (
52
+ <div className="space-y-2">
53
+ <p>Count: {count}</p>
54
+ <div className="flex gap-2">
55
+ <Button onClick={() => setCount((prev) => (prev ?? 0) + 1)}>
56
+ Increment
57
+ </Button>
58
+ <Button onClick={() => setCount((prev) => (prev ?? 0) - 1)}>
59
+ Decrement
60
+ </Button>
61
+ <Button onClick={removeCount}>Reset</Button>
62
+ </div>
63
+ </div>
64
+ );
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Two hooks reading the same key stay synchronized in the same tab.
70
+ * Unlike `useLocalStorage`, changes do NOT propagate to other tabs —
71
+ * each tab has its own isolated sessionStorage.
72
+ */
73
+ export const CrossInstanceSync = {
74
+ render: () => {
75
+ const [a, setA] = useSessionStorage<string>("demo:session-shared", "");
76
+ const [b, setB] = useSessionStorage<string>("demo:session-shared", "");
77
+
78
+ return (
79
+ <div className="space-y-4">
80
+ <div className="space-y-1">
81
+ <p className="text-muted-foreground text-sm">Instance A</p>
82
+ <Input value={a} onValueChange={setA} />
83
+ </div>
84
+ <div className="space-y-1">
85
+ <p className="text-muted-foreground text-sm">Instance B</p>
86
+ <Input value={b} onValueChange={setB} />
87
+ </div>
88
+ </div>
89
+ );
90
+ },
91
+ };
92
+
93
+ /**
94
+ * Works with any JSON-serializable shape. Useful for persisting a multi-step
95
+ * form draft per step.
96
+ */
97
+ export const ObjectValue = {
98
+ render: () => {
99
+ const [draft, setDraft, clearDraft] = useSessionStorage<{
100
+ nombre: string;
101
+ apellido: string;
102
+ }>("demo:session-draft", { nombre: "", apellido: "" });
103
+
104
+ return (
105
+ <div className="space-y-2">
106
+ <Input
107
+ value={draft.nombre}
108
+ onValueChange={(v) => setDraft((prev) => ({ ...prev!, nombre: v }))}
109
+ placeholder="Nombre"
110
+ />
111
+ <Input
112
+ value={draft.apellido}
113
+ onValueChange={(v) => setDraft((prev) => ({ ...prev!, apellido: v }))}
114
+ placeholder="Apellido"
115
+ />
116
+ <Button onClick={clearDraft}>
117
+ <Trash /> Clear draft
118
+ </Button>
119
+ </div>
120
+ );
121
+ },
122
+ };
@@ -0,0 +1,164 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useSessionStorage } from "../use-session-storage";
4
+
5
+ describe("useSessionStorage hook", () => {
6
+ const key = "testKey";
7
+
8
+ beforeEach(() => {
9
+ sessionStorage.clear();
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("should be defined", () => {
18
+ expect(useSessionStorage).toBeDefined();
19
+ });
20
+
21
+ it("returns initialValue when sessionStorage is empty", () => {
22
+ const { result } = renderHook(() =>
23
+ useSessionStorage<string>(key, "initialValue"),
24
+ );
25
+ expect(result.current[0]).toBe("initialValue");
26
+ });
27
+
28
+ it("does NOT write initialValue to sessionStorage on mount", () => {
29
+ renderHook(() => useSessionStorage<string>(key, "initialValue"));
30
+ expect(sessionStorage.getItem(key)).toBeNull();
31
+ });
32
+
33
+ it("reads an existing stored value from sessionStorage", () => {
34
+ sessionStorage.setItem(key, JSON.stringify("storedValue"));
35
+ const { result } = renderHook(() =>
36
+ useSessionStorage<string>(key, "initialValue"),
37
+ );
38
+ expect(result.current[0]).toBe("storedValue");
39
+ });
40
+
41
+ it("setValue updates the value and writes to sessionStorage", () => {
42
+ const { result } = renderHook(() =>
43
+ useSessionStorage<string>(key, "initialValue"),
44
+ );
45
+
46
+ act(() => {
47
+ result.current[1]("newValue");
48
+ });
49
+
50
+ expect(result.current[0]).toBe("newValue");
51
+ expect(sessionStorage.getItem(key)).toBe(JSON.stringify("newValue"));
52
+ });
53
+
54
+ it("setValue accepts a functional updater", () => {
55
+ const { result } = renderHook(() => useSessionStorage<number>(key, 1));
56
+
57
+ act(() => {
58
+ result.current[1]((prev) => (prev ?? 0) + 5);
59
+ });
60
+
61
+ expect(result.current[0]).toBe(6);
62
+ expect(sessionStorage.getItem(key)).toBe("6");
63
+ });
64
+
65
+ it("remove() deletes the key and falls back to initialValue", () => {
66
+ sessionStorage.setItem(key, JSON.stringify("storedValue"));
67
+ const { result } = renderHook(() => useSessionStorage<string>(key, ""));
68
+
69
+ act(() => {
70
+ result.current[2]();
71
+ });
72
+
73
+ expect(result.current[0]).toBe("");
74
+ expect(sessionStorage.getItem(key)).toBeNull();
75
+ });
76
+
77
+ it("falls back to initialValue and logs when stored value is not JSON", () => {
78
+ const consoleErrorSpy = vi
79
+ .spyOn(console, "error")
80
+ .mockImplementation(() => {});
81
+ sessionStorage.setItem(key, "non-JSON-string");
82
+
83
+ const { result } = renderHook(() =>
84
+ useSessionStorage<string>(key, "initialValue"),
85
+ );
86
+
87
+ expect(result.current[0]).toBe("initialValue");
88
+ expect(consoleErrorSpy).toHaveBeenCalled();
89
+ });
90
+
91
+ it("logs when sessionStorage.setItem throws during setValue", () => {
92
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
93
+ vi.stubGlobal("sessionStorage", {
94
+ getItem: vi.fn().mockReturnValue(null),
95
+ setItem: vi.fn().mockImplementation(() => {
96
+ throw new Error("Storage full");
97
+ }),
98
+ removeItem: vi.fn(),
99
+ clear: vi.fn(),
100
+ });
101
+
102
+ const { result } = renderHook(() =>
103
+ useSessionStorage<string>("err-key", "value"),
104
+ );
105
+
106
+ act(() => {
107
+ result.current[1]("next");
108
+ });
109
+
110
+ expect(errorSpy).toHaveBeenCalled();
111
+ });
112
+
113
+ it("logs when sessionStorage.removeItem throws during remove()", () => {
114
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
115
+ vi.stubGlobal("sessionStorage", {
116
+ getItem: vi.fn().mockReturnValue(JSON.stringify("value")),
117
+ setItem: vi.fn(),
118
+ removeItem: vi.fn().mockImplementation(() => {
119
+ throw new Error("remove error");
120
+ }),
121
+ clear: vi.fn(),
122
+ });
123
+
124
+ const { result } = renderHook(() =>
125
+ useSessionStorage<string>("rm-key", "value"),
126
+ );
127
+
128
+ act(() => {
129
+ result.current[2]();
130
+ });
131
+
132
+ expect(errorSpy).toHaveBeenCalled();
133
+ });
134
+
135
+ it("syncs two instances of the hook on the same key in the same tab", () => {
136
+ const a = renderHook(() => useSessionStorage<string>(key, "init"));
137
+ const b = renderHook(() => useSessionStorage<string>(key, "init"));
138
+
139
+ act(() => {
140
+ a.result.current[1]("from-a");
141
+ });
142
+
143
+ expect(a.result.current[0]).toBe("from-a");
144
+ expect(b.result.current[0]).toBe("from-a");
145
+ });
146
+
147
+ it("supports a custom serializer", () => {
148
+ const serializer = {
149
+ read: (raw: string) => raw.toUpperCase(),
150
+ write: (value: string) => value.toLowerCase(),
151
+ };
152
+
153
+ const { result } = renderHook(() =>
154
+ useSessionStorage<string>(key, "INITIAL", { serializer }),
155
+ );
156
+
157
+ act(() => {
158
+ result.current[1]("MixedCase");
159
+ });
160
+
161
+ expect(sessionStorage.getItem(key)).toBe("mixedcase");
162
+ expect(result.current[0]).toBe("MIXEDCASE");
163
+ });
164
+ });