@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.
- package/dist/components/calendar/calendar.cjs.js +1 -1
- package/dist/components/calendar/calendar.es.js +43 -44
- package/dist/components/date-picker/date-input.cjs.js +1 -1
- package/dist/components/date-picker/date-input.es.js +160 -140
- package/dist/components/pagination/pagination.cjs.js +1 -1
- package/dist/components/pagination/pagination.es.js +37 -35
- package/dist/components/popover/popover.cjs.js +1 -1
- package/dist/components/popover/popover.es.js +1 -1
- package/dist/components/scroll-area/scroll-area.cjs.js +1 -1
- package/dist/components/scroll-area/scroll-area.es.js +4 -4
- package/dist/components/select/select.cjs.js +1 -1
- package/dist/components/select/select.es.js +94 -90
- package/dist/components/tag/tag.cjs.js +1 -1
- package/dist/components/tag/tag.es.js +37 -18
- package/dist/hooks/use-action/use-action.cjs.js +1 -0
- package/dist/hooks/use-action/use-action.es.js +41 -0
- package/dist/hooks/use-pagination/use-pagination.cjs.js +1 -1
- package/dist/hooks/use-pagination/use-pagination.es.js +77 -32
- package/dist/hooks/use-range-pagination/use-range-pagination.cjs.js +1 -1
- package/dist/hooks/use-range-pagination/use-range-pagination.es.js +8 -5
- package/dist/hooks/use-selection/use-selection.cjs.js +1 -1
- package/dist/hooks/use-selection/use-selection.es.js +95 -33
- package/dist/hooks/use-session-storage/use-session-storage.cjs.js +1 -0
- package/dist/hooks/use-session-storage/use-session-storage.es.js +57 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +61 -63
- package/dist/src/components/select/select.d.ts +9 -2
- package/dist/src/components/tag/tag.d.ts +2 -1
- package/dist/src/hooks/index.d.ts +2 -3
- package/dist/src/hooks/internal/index.d.ts +1 -0
- package/dist/src/hooks/internal/serializer.d.ts +4 -0
- package/dist/src/hooks/use-action/index.d.ts +1 -0
- package/dist/src/hooks/use-action/use-action.d.ts +22 -0
- package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +2 -4
- package/dist/src/hooks/use-pagination/use-pagination.d.ts +47 -32
- package/dist/src/hooks/use-range-pagination/use-range-pagination.d.ts +16 -10
- package/dist/src/hooks/use-selection/use-selection.d.ts +39 -45
- package/dist/src/hooks/use-session-storage/index.d.ts +1 -0
- package/dist/src/hooks/use-session-storage/use-session-storage.d.ts +11 -0
- package/package.json +1 -1
- package/src/components/calendar/calendar.tsx +10 -8
- package/src/components/combobox/combobox.stories.tsx +16 -0
- package/src/components/date-picker/date-input.tsx +23 -2
- package/src/components/form/form.tsx +3 -2
- package/src/components/pagination/pagination.tsx +5 -3
- package/src/components/popover/popover.tsx +1 -1
- package/src/components/scroll-area/scroll-area.tsx +2 -2
- package/src/components/select/select.tsx +14 -3
- package/src/components/tag/tag.stories.tsx +47 -2
- package/src/components/tag/tag.tsx +28 -6
- package/src/hooks/index.ts +2 -3
- package/src/hooks/internal/index.ts +1 -0
- package/src/hooks/internal/serializer.ts +4 -0
- package/src/hooks/use-action/index.ts +1 -0
- package/src/hooks/{use-mutation/use-mutation.stories.tsx → use-action/use-action.stories.tsx} +34 -34
- package/src/hooks/{use-mutation/use-mutation.test.ts → use-action/use-action.test.ts} +53 -53
- package/src/hooks/{use-mutation/use-mutation.ts → use-action/use-action.ts} +20 -20
- package/src/hooks/use-click-outside/use-click-outside.stories.tsx +0 -1
- package/src/hooks/use-clipboard/use-clipboard.stories.tsx +0 -1
- package/src/hooks/use-document-title/use-document-title.stories.tsx +0 -1
- package/src/hooks/use-is-visible/use-is-visible.test.tsx +1 -1
- package/src/hooks/use-local-storage/use-local-storage.stories.tsx +0 -1
- package/src/hooks/use-local-storage/use-local-storage.ts +2 -5
- package/src/hooks/use-media-query/use-media-query.stories.tsx +0 -1
- package/src/hooks/use-pagination/use-pagination.stories.tsx +720 -57
- package/src/hooks/use-pagination/use-pagination.test.tsx +560 -48
- package/src/hooks/use-pagination/use-pagination.ts +266 -0
- package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +0 -1
- package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +2 -2
- package/src/hooks/use-range-pagination/use-range-pagination.tsx +24 -21
- package/src/hooks/use-selection/use-selection.stories.tsx +339 -84
- package/src/hooks/use-selection/use-selection.test.tsx +417 -2
- package/src/hooks/use-selection/use-selection.ts +212 -102
- package/src/hooks/use-session-storage/index.ts +1 -0
- package/src/hooks/use-session-storage/use-session-storage.stories.tsx +122 -0
- package/src/hooks/use-session-storage/use-session-storage.test.ts +164 -0
- package/src/hooks/use-session-storage/use-session-storage.ts +115 -0
- package/dist/hooks/use-async/use-async.cjs.js +0 -1
- package/dist/hooks/use-async/use-async.es.js +0 -57
- package/dist/hooks/use-focus-trap/scope-tab.cjs.js +0 -1
- package/dist/hooks/use-focus-trap/scope-tab.es.js +0 -21
- package/dist/hooks/use-focus-trap/tabbable.cjs.js +0 -1
- package/dist/hooks/use-focus-trap/tabbable.es.js +0 -38
- package/dist/hooks/use-focus-trap/use-focus-trap.cjs.js +0 -1
- package/dist/hooks/use-focus-trap/use-focus-trap.es.js +0 -34
- package/dist/hooks/use-mutation/use-mutation.cjs.js +0 -1
- package/dist/hooks/use-mutation/use-mutation.es.js +0 -41
- package/dist/src/hooks/use-async/index.d.ts +0 -1
- package/dist/src/hooks/use-async/use-async.d.ts +0 -21
- package/dist/src/hooks/use-focus-trap/index.d.ts +0 -1
- package/dist/src/hooks/use-focus-trap/scope-tab.d.ts +0 -1
- package/dist/src/hooks/use-focus-trap/tabbable.d.ts +0 -4
- package/dist/src/hooks/use-focus-trap/use-focus-trap.d.ts +0 -1
- package/dist/src/hooks/use-mutation/index.d.ts +0 -1
- package/dist/src/hooks/use-mutation/use-mutation.d.ts +0 -22
- package/dist/src/hooks/use-mutation/use-mutation.test.d.ts +0 -1
- package/src/hooks/use-async/index.ts +0 -1
- package/src/hooks/use-async/use-async.stories.tsx +0 -272
- package/src/hooks/use-async/use-async.test.ts +0 -397
- package/src/hooks/use-async/use-async.ts +0 -135
- package/src/hooks/use-focus-trap/index.ts +0 -1
- package/src/hooks/use-focus-trap/scope-tab.ts +0 -38
- package/src/hooks/use-focus-trap/tabbable.ts +0 -70
- package/src/hooks/use-focus-trap/use-focus-trap.stories.tsx +0 -37
- package/src/hooks/use-focus-trap/use-focus-trap.test.ts +0 -355
- package/src/hooks/use-focus-trap/use-focus-trap.ts +0 -78
- package/src/hooks/use-mutation/index.ts +0 -1
- package/src/hooks/use-pagination/use-pagination.tsx +0 -84
- /package/dist/src/hooks/{use-async/use-async.test.d.ts → use-action/use-action.test.d.ts} +0 -0
- /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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
221
|
+
selected,
|
|
222
|
+
selectedKeys,
|
|
223
|
+
isAllSelected,
|
|
224
|
+
isSomeSelected,
|
|
225
|
+
isNoneSelected,
|
|
110
226
|
isSelected,
|
|
111
|
-
|
|
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
|
+
});
|