@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,6 +1,13 @@
1
1
  import { act, renderHook } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
3
- import { useSelection } from "../use-selection";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ type Key,
5
+ type UseSelectionOptions,
6
+ type UseSelectionReturn,
7
+ useSelection,
8
+ } from "../use-selection";
9
+
10
+ // ─── Existing tests (v1 baseline — kept for regression) ──────────────────────
4
11
 
5
12
  describe("useSelection hook", () => {
6
13
  it("initial state: isAllSelected=false, isNoneSelected=true", () => {
@@ -77,3 +84,411 @@ describe("useSelection hook", () => {
77
84
  expect(result.current.selected).toEqual([]);
78
85
  });
79
86
  });
87
+
88
+ // ─── Task 1.1 — New type exports exist ───────────────────────────────────────
89
+
90
+ describe("Type exports", () => {
91
+ it("exports Key, UseSelectionOptions, UseSelectionReturn types (compile-time)", () => {
92
+ // If these types don't exist the import at the top of this file will fail.
93
+ // This test is a runtime sentinel confirming the imports resolved.
94
+ const options: UseSelectionOptions<number> = { keyFn: (n) => n };
95
+ const { result } = renderHook(() => useSelection([1, 2, 3], options));
96
+ const ret: UseSelectionReturn<number> = result.current;
97
+ const _key: Key = 1;
98
+ expect(ret).toBeDefined();
99
+ expect(_key).toBe(1);
100
+ });
101
+ });
102
+
103
+ // ─── Task 1.2 — Key-based identity ───────────────────────────────────────────
104
+
105
+ describe("Key-based identity", () => {
106
+ it("default keyFn: item itself is its key (value identity for primitives)", () => {
107
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
108
+ act(() => result.current.select(2));
109
+ expect(result.current.isSelected(2)).toBe(true);
110
+ expect(result.current.isSelected(1)).toBe(false);
111
+ });
112
+
113
+ it("custom keyFn: selection survives items array re-creation", () => {
114
+ let items = [{ id: 1, name: "A" }];
115
+ const { result, rerender } = renderHook(() =>
116
+ useSelection(items, { keyFn: (item) => item.id }),
117
+ );
118
+ act(() => result.current.select({ id: 1, name: "A" }));
119
+ expect(result.current.isSelected({ id: 1, name: "A" })).toBe(true);
120
+
121
+ // Re-create items with same id but new reference and different name
122
+ items = [{ id: 1, name: "B" }];
123
+ rerender();
124
+
125
+ expect(result.current.isSelected({ id: 1, name: "B" })).toBe(true);
126
+ expect(result.current.selected).toEqual([{ id: 1, name: "B" }]);
127
+ });
128
+
129
+ it("custom keyFn: orphaned keys persist in selectedKeys but not in selected", () => {
130
+ let items = [{ id: 1 }, { id: 2 }];
131
+ const { result, rerender } = renderHook(() =>
132
+ useSelection(items, { keyFn: (item) => item.id }),
133
+ );
134
+ act(() => result.current.select({ id: 2 }));
135
+
136
+ // Remove id=2 from items
137
+ items = [{ id: 1 }];
138
+ rerender();
139
+
140
+ expect(result.current.selectedKeys.has(2)).toBe(true);
141
+ expect(result.current.selected).toEqual([]);
142
+
143
+ // Re-add id=2 — should reappear
144
+ items = [{ id: 1 }, { id: 2 }];
145
+ rerender();
146
+
147
+ expect(result.current.selected).toEqual([{ id: 2 }]);
148
+ });
149
+ });
150
+
151
+ // ─── Task 1.3 — defaultSelected ──────────────────────────────────────────────
152
+
153
+ describe("defaultSelected option", () => {
154
+ it("seeds initial selection on mount", () => {
155
+ const { result } = renderHook(() =>
156
+ useSelection([1, 2, 3], { defaultSelected: [2, 3] }),
157
+ );
158
+ expect(result.current.selected).toEqual([2, 3]);
159
+ expect(result.current.isSelected(2)).toBe(true);
160
+ expect(result.current.isSelected(1)).toBe(false);
161
+ });
162
+
163
+ it("is NOT re-applied when options reference changes on re-render", () => {
164
+ const { result, rerender } = renderHook(
165
+ ({ ds }) => useSelection([1, 2, 3], { defaultSelected: ds }),
166
+ { initialProps: { ds: [2, 3] } },
167
+ );
168
+ // Clear selection
169
+ act(() => result.current.clear());
170
+ expect(result.current.isNoneSelected).toBe(true);
171
+
172
+ // Rerender with same defaultSelected value — should NOT re-seed
173
+ rerender({ ds: [2, 3] });
174
+ expect(result.current.isNoneSelected).toBe(true);
175
+ });
176
+ });
177
+
178
+ // ─── Task 1.4 — onChange ─────────────────────────────────────────────────────
179
+
180
+ describe("onChange callback", () => {
181
+ it("does NOT fire on mount", () => {
182
+ const onChange = vi.fn();
183
+ renderHook(() => useSelection([1, 2, 3], { onChange }));
184
+ expect(onChange).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it("fires with selected items after select", async () => {
188
+ const onChange = vi.fn();
189
+ const { result } = renderHook(() => useSelection([1, 2, 3], { onChange }));
190
+ act(() => result.current.select(2));
191
+ // onChange fires in an effect — flush
192
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
193
+ expect(onChange).toHaveBeenCalledWith([2]);
194
+ });
195
+
196
+ it("does NOT fire for no-op select (already selected)", async () => {
197
+ const onChange = vi.fn();
198
+ const { result } = renderHook(() => useSelection([1, 2, 3], { onChange }));
199
+ act(() => result.current.select(2));
200
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
201
+ onChange.mockClear();
202
+
203
+ act(() => result.current.select(2));
204
+ // Give effects a chance to fire
205
+ await new Promise((r) => setTimeout(r, 10));
206
+ expect(onChange).not.toHaveBeenCalled();
207
+ });
208
+
209
+ it("does NOT fire for no-op unselect (item not selected)", async () => {
210
+ const onChange = vi.fn();
211
+ const { result } = renderHook(() => useSelection([1, 2, 3], { onChange }));
212
+ act(() => result.current.unselect(1));
213
+ await new Promise((r) => setTimeout(r, 10));
214
+ expect(onChange).not.toHaveBeenCalled();
215
+ });
216
+ });
217
+
218
+ // ─── Task 1.5 — selectedKeys return value ────────────────────────────────────
219
+
220
+ describe("selectedKeys return value", () => {
221
+ it("reflects selected item keys", () => {
222
+ const { result } = renderHook(() =>
223
+ useSelection([{ id: 1 }, { id: 2 }], { keyFn: (item) => item.id }),
224
+ );
225
+ act(() => result.current.select({ id: 1 }));
226
+ expect(result.current.selectedKeys.has(1)).toBe(true);
227
+ expect(result.current.selectedKeys.has(2)).toBe(false);
228
+ });
229
+
230
+ it("starts empty", () => {
231
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
232
+ expect(result.current.selectedKeys.size).toBe(0);
233
+ });
234
+ });
235
+
236
+ // ─── Task 1.6 — selectAll + invert ───────────────────────────────────────────
237
+
238
+ describe("selectAll", () => {
239
+ it("selects all items", () => {
240
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
241
+ act(() => result.current.selectAll());
242
+ expect(result.current.isAllSelected).toBe(true);
243
+ expect(result.current.selected).toEqual([1, 2, 3]);
244
+ });
245
+
246
+ it("is additive — does not clear previously selected keys outside items", () => {
247
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
248
+ act(() => result.current.select(1));
249
+ act(() => result.current.selectAll());
250
+ expect(result.current.isAllSelected).toBe(true);
251
+ });
252
+ });
253
+
254
+ describe("invert", () => {
255
+ it("invert on partial selection flips to complement", () => {
256
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
257
+ act(() => result.current.select(1));
258
+ act(() => result.current.invert());
259
+ expect(result.current.isSelected(1)).toBe(false);
260
+ expect(result.current.isSelected(2)).toBe(true);
261
+ expect(result.current.isSelected(3)).toBe(true);
262
+ });
263
+
264
+ it("invert on empty selection selects all", () => {
265
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
266
+ act(() => result.current.invert());
267
+ expect(result.current.selected).toEqual([1, 2, 3]);
268
+ });
269
+
270
+ it("invert on full selection clears all", () => {
271
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
272
+ act(() => result.current.selectAll());
273
+ act(() => result.current.invert());
274
+ expect(result.current.selected).toEqual([]);
275
+ expect(result.current.isNoneSelected).toBe(true);
276
+ });
277
+ });
278
+
279
+ // ─── Task 1.7 — selectRange ──────────────────────────────────────────────────
280
+
281
+ describe("selectRange", () => {
282
+ it("selects items in normal order [from, to] inclusive", () => {
283
+ const { result } = renderHook(() => useSelection(["a", "b", "c", "d"]));
284
+ act(() => result.current.selectRange(1, 3));
285
+ expect(result.current.selected).toEqual(["b", "c", "d"]);
286
+ });
287
+
288
+ it("normalizes reversed range (from > to)", () => {
289
+ const { result } = renderHook(() => useSelection(["a", "b", "c", "d"]));
290
+ act(() => result.current.selectRange(3, 1));
291
+ expect(result.current.selected).toEqual(["b", "c", "d"]);
292
+ });
293
+
294
+ it("clamps out-of-range indices", () => {
295
+ const { result } = renderHook(() => useSelection(["a", "b", "c"]));
296
+ act(() => result.current.selectRange(-5, 100));
297
+ expect(result.current.selected).toEqual(["a", "b", "c"]);
298
+ });
299
+
300
+ it("no-op on empty items", () => {
301
+ const { result } = renderHook(() => useSelection<string>([]));
302
+ expect(() => act(() => result.current.selectRange(0, 2))).not.toThrow();
303
+ expect(result.current.selected).toEqual([]);
304
+ });
305
+
306
+ it("is additive — does not replace existing selection", () => {
307
+ const { result } = renderHook(() => useSelection(["a", "b", "c", "d"]));
308
+ act(() => result.current.select("a"));
309
+ act(() => result.current.selectRange(2, 3));
310
+ expect(result.current.selected).toEqual(["a", "c", "d"]);
311
+ });
312
+
313
+ it("single index (from === to) selects one item", () => {
314
+ const { result } = renderHook(() => useSelection(["a", "b", "c"]));
315
+ act(() => result.current.selectRange(1, 1));
316
+ expect(result.current.selected).toEqual(["b"]);
317
+ });
318
+ });
319
+
320
+ // ─── Task 1.8 — isAllSelected / isSomeSelected / isNoneSelected ──────────────
321
+
322
+ describe("derived flags", () => {
323
+ it("isAllSelected is false when items is empty", () => {
324
+ const { result } = renderHook(() => useSelection<number>([]));
325
+ expect(result.current.isAllSelected).toBe(false);
326
+ });
327
+
328
+ it("flags: nothing selected", () => {
329
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
330
+ expect(result.current.isNoneSelected).toBe(true);
331
+ expect(result.current.isSomeSelected).toBe(false);
332
+ expect(result.current.isAllSelected).toBe(false);
333
+ });
334
+
335
+ it("flags: some selected", () => {
336
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
337
+ act(() => result.current.select(1));
338
+ expect(result.current.isNoneSelected).toBe(false);
339
+ expect(result.current.isSomeSelected).toBe(true);
340
+ expect(result.current.isAllSelected).toBe(false);
341
+ });
342
+
343
+ it("flags: all selected", () => {
344
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
345
+ act(() => result.current.selectAll());
346
+ expect(result.current.isNoneSelected).toBe(false);
347
+ expect(result.current.isSomeSelected).toBe(false);
348
+ expect(result.current.isAllSelected).toBe(true);
349
+ });
350
+
351
+ it("isAllSelected false when selectedKeys has orphaned keys beyond items.length", () => {
352
+ // Select 3 items, then reduce items to 2 — selectedKeys.size > items.length
353
+ // but not all PRESENT items are selected via the every() check.
354
+ let items = [1, 2, 3];
355
+ const { result, rerender } = renderHook(() => useSelection(items));
356
+ act(() => result.current.selectAll());
357
+ items = [1, 2];
358
+ rerender();
359
+ // All present items ARE selected — isAllSelected should be true here
360
+ expect(result.current.isAllSelected).toBe(true);
361
+ });
362
+ });
363
+
364
+ // ─── Task 1.9 — toggleAll ────────────────────────────────────────────────────
365
+
366
+ describe("toggleAll", () => {
367
+ it("toggleAll when all selected → clears", () => {
368
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
369
+ act(() => result.current.selectAll());
370
+ act(() => result.current.toggleAll());
371
+ expect(result.current.isNoneSelected).toBe(true);
372
+ expect(result.current.selected).toEqual([]);
373
+ });
374
+
375
+ it("toggleAll when partial → selects all", () => {
376
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
377
+ act(() => result.current.select(1));
378
+ act(() => result.current.toggleAll());
379
+ expect(result.current.isAllSelected).toBe(true);
380
+ });
381
+
382
+ it("toggleAll when none → selects all", () => {
383
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
384
+ act(() => result.current.toggleAll());
385
+ expect(result.current.isAllSelected).toBe(true);
386
+ expect(result.current.selected).toEqual([1, 2, 3]);
387
+ });
388
+ });
389
+
390
+ // ─── Task 1.10 — Stable refs ─────────────────────────────────────────────────
391
+
392
+ describe("Stable references", () => {
393
+ it("selected array reference is stable across unrelated re-renders", () => {
394
+ // Use a stable items reference so useMemo([items, selectedKeys]) doesn't recompute.
395
+ const stableItems = [1, 2, 3];
396
+ const { result, rerender } = renderHook(() => useSelection(stableItems));
397
+ act(() => result.current.select(1));
398
+ const selectedBefore = result.current.selected;
399
+
400
+ // Re-render without changing items or selection — selected ref must be same object
401
+ rerender();
402
+
403
+ expect(result.current.selected).toBe(selectedBefore);
404
+ });
405
+
406
+ it("selected ref updates when selection changes", () => {
407
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
408
+ act(() => result.current.select(1));
409
+ const ref1 = result.current.selected;
410
+ act(() => result.current.select(2));
411
+ const ref2 = result.current.selected;
412
+ expect(ref1).not.toBe(ref2);
413
+ });
414
+
415
+ it("isSelected function ref is stable when selection does not change", () => {
416
+ let items = [1, 2, 3];
417
+ const { result, rerender } = renderHook(() => useSelection(items));
418
+ const isSelectedBefore = result.current.isSelected;
419
+ // Re-render without changing selection — ref must be the same object
420
+ items = [1, 2, 3];
421
+ rerender();
422
+ expect(result.current.isSelected).toBe(isSelectedBefore);
423
+ });
424
+
425
+ it("isSelected function ref updates when selection changes", () => {
426
+ const { result } = renderHook(() => useSelection([1, 2, 3]));
427
+ const isSelectedBefore = result.current.isSelected;
428
+ act(() => result.current.select(1));
429
+ // ref must change so render-time usage reflects current selection
430
+ expect(result.current.isSelected).not.toBe(isSelectedBefore);
431
+ });
432
+
433
+ it("all action refs are stable when items changes", () => {
434
+ let items = [1, 2, 3];
435
+ const { result, rerender } = renderHook(() => useSelection(items));
436
+ const {
437
+ select,
438
+ unselect,
439
+ toggle,
440
+ setSelected,
441
+ selectAll,
442
+ toggleAll,
443
+ clear,
444
+ invert,
445
+ selectRange,
446
+ } = result.current;
447
+
448
+ items = [4, 5, 6];
449
+ rerender();
450
+
451
+ expect(result.current.select).toBe(select);
452
+ expect(result.current.unselect).toBe(unselect);
453
+ expect(result.current.toggle).toBe(toggle);
454
+ expect(result.current.setSelected).toBe(setSelected);
455
+ expect(result.current.selectAll).toBe(selectAll);
456
+ expect(result.current.toggleAll).toBe(toggleAll);
457
+ expect(result.current.clear).toBe(clear);
458
+ expect(result.current.invert).toBe(invert);
459
+ expect(result.current.selectRange).toBe(selectRange);
460
+ });
461
+ });
462
+
463
+ // ─── Task 1.11 — Dev-warn for objects without keyFn ──────────────────────────
464
+
465
+ describe("dev-warn for objects without keyFn", () => {
466
+ it("warns once for object items without keyFn in dev", () => {
467
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
468
+ const items = [{ id: 1 }];
469
+ const { rerender } = renderHook(() => useSelection(items));
470
+
471
+ expect(warnSpy).toHaveBeenCalledTimes(1);
472
+ expect(warnSpy.mock.calls[0][0]).toMatch(/keyFn/);
473
+
474
+ // Re-render should NOT warn again
475
+ rerender();
476
+ expect(warnSpy).toHaveBeenCalledTimes(1);
477
+
478
+ warnSpy.mockRestore();
479
+ });
480
+
481
+ it("does NOT warn when keyFn is provided", () => {
482
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
483
+ renderHook(() => useSelection([{ id: 1 }], { keyFn: (item) => item.id }));
484
+ expect(warnSpy).not.toHaveBeenCalled();
485
+ warnSpy.mockRestore();
486
+ });
487
+
488
+ it("does NOT warn for primitive items", () => {
489
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
490
+ renderHook(() => useSelection([1, 2, 3]));
491
+ expect(warnSpy).not.toHaveBeenCalled();
492
+ warnSpy.mockRestore();
493
+ });
494
+ });