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

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 (102) 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/scroll-area/scroll-area.cjs.js +1 -1
  8. package/dist/components/scroll-area/scroll-area.es.js +4 -4
  9. package/dist/components/select/select.cjs.js +1 -1
  10. package/dist/components/select/select.es.js +94 -90
  11. package/dist/hooks/use-action/use-action.cjs.js +1 -0
  12. package/dist/hooks/use-action/use-action.es.js +41 -0
  13. package/dist/hooks/use-pagination/use-pagination.cjs.js +1 -1
  14. package/dist/hooks/use-pagination/use-pagination.es.js +77 -32
  15. package/dist/hooks/use-range-pagination/use-range-pagination.cjs.js +1 -1
  16. package/dist/hooks/use-range-pagination/use-range-pagination.es.js +8 -5
  17. package/dist/hooks/use-selection/use-selection.cjs.js +1 -1
  18. package/dist/hooks/use-selection/use-selection.es.js +95 -33
  19. package/dist/hooks/use-session-storage/use-session-storage.cjs.js +1 -0
  20. package/dist/hooks/use-session-storage/use-session-storage.es.js +57 -0
  21. package/dist/index.cjs.js +1 -1
  22. package/dist/index.es.js +61 -63
  23. package/dist/src/components/select/select.d.ts +9 -2
  24. package/dist/src/hooks/index.d.ts +2 -3
  25. package/dist/src/hooks/internal/index.d.ts +1 -0
  26. package/dist/src/hooks/internal/serializer.d.ts +4 -0
  27. package/dist/src/hooks/use-action/index.d.ts +1 -0
  28. package/dist/src/hooks/use-action/use-action.d.ts +22 -0
  29. package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +2 -4
  30. package/dist/src/hooks/use-pagination/use-pagination.d.ts +47 -32
  31. package/dist/src/hooks/use-range-pagination/use-range-pagination.d.ts +16 -10
  32. package/dist/src/hooks/use-selection/use-selection.d.ts +39 -45
  33. package/dist/src/hooks/use-session-storage/index.d.ts +1 -0
  34. package/dist/src/hooks/use-session-storage/use-session-storage.d.ts +11 -0
  35. package/package.json +1 -1
  36. package/src/components/calendar/calendar.tsx +10 -8
  37. package/src/components/combobox/combobox.stories.tsx +16 -0
  38. package/src/components/date-picker/date-input.tsx +23 -2
  39. package/src/components/form/form.tsx +3 -2
  40. package/src/components/pagination/pagination.tsx +5 -3
  41. package/src/components/scroll-area/scroll-area.tsx +2 -2
  42. package/src/components/select/select.tsx +14 -3
  43. package/src/hooks/index.ts +2 -3
  44. package/src/hooks/internal/index.ts +1 -0
  45. package/src/hooks/internal/serializer.ts +4 -0
  46. package/src/hooks/use-action/index.ts +1 -0
  47. package/src/hooks/{use-mutation/use-mutation.stories.tsx → use-action/use-action.stories.tsx} +34 -34
  48. package/src/hooks/{use-mutation/use-mutation.test.ts → use-action/use-action.test.ts} +53 -53
  49. package/src/hooks/{use-mutation/use-mutation.ts → use-action/use-action.ts} +20 -20
  50. package/src/hooks/use-click-outside/use-click-outside.stories.tsx +0 -1
  51. package/src/hooks/use-clipboard/use-clipboard.stories.tsx +0 -1
  52. package/src/hooks/use-document-title/use-document-title.stories.tsx +0 -1
  53. package/src/hooks/use-is-visible/use-is-visible.test.tsx +1 -1
  54. package/src/hooks/use-local-storage/use-local-storage.stories.tsx +0 -1
  55. package/src/hooks/use-local-storage/use-local-storage.ts +2 -5
  56. package/src/hooks/use-media-query/use-media-query.stories.tsx +0 -1
  57. package/src/hooks/use-pagination/use-pagination.stories.tsx +720 -57
  58. package/src/hooks/use-pagination/use-pagination.test.tsx +560 -48
  59. package/src/hooks/use-pagination/use-pagination.ts +266 -0
  60. package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +0 -1
  61. package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +2 -2
  62. package/src/hooks/use-range-pagination/use-range-pagination.tsx +24 -21
  63. package/src/hooks/use-selection/use-selection.stories.tsx +339 -84
  64. package/src/hooks/use-selection/use-selection.test.tsx +417 -2
  65. package/src/hooks/use-selection/use-selection.ts +212 -102
  66. package/src/hooks/use-session-storage/index.ts +1 -0
  67. package/src/hooks/use-session-storage/use-session-storage.stories.tsx +122 -0
  68. package/src/hooks/use-session-storage/use-session-storage.test.ts +164 -0
  69. package/src/hooks/use-session-storage/use-session-storage.ts +115 -0
  70. package/dist/hooks/use-async/use-async.cjs.js +0 -1
  71. package/dist/hooks/use-async/use-async.es.js +0 -57
  72. package/dist/hooks/use-focus-trap/scope-tab.cjs.js +0 -1
  73. package/dist/hooks/use-focus-trap/scope-tab.es.js +0 -21
  74. package/dist/hooks/use-focus-trap/tabbable.cjs.js +0 -1
  75. package/dist/hooks/use-focus-trap/tabbable.es.js +0 -38
  76. package/dist/hooks/use-focus-trap/use-focus-trap.cjs.js +0 -1
  77. package/dist/hooks/use-focus-trap/use-focus-trap.es.js +0 -34
  78. package/dist/hooks/use-mutation/use-mutation.cjs.js +0 -1
  79. package/dist/hooks/use-mutation/use-mutation.es.js +0 -41
  80. package/dist/src/hooks/use-async/index.d.ts +0 -1
  81. package/dist/src/hooks/use-async/use-async.d.ts +0 -21
  82. package/dist/src/hooks/use-focus-trap/index.d.ts +0 -1
  83. package/dist/src/hooks/use-focus-trap/scope-tab.d.ts +0 -1
  84. package/dist/src/hooks/use-focus-trap/tabbable.d.ts +0 -4
  85. package/dist/src/hooks/use-focus-trap/use-focus-trap.d.ts +0 -1
  86. package/dist/src/hooks/use-mutation/index.d.ts +0 -1
  87. package/dist/src/hooks/use-mutation/use-mutation.d.ts +0 -22
  88. package/dist/src/hooks/use-mutation/use-mutation.test.d.ts +0 -1
  89. package/src/hooks/use-async/index.ts +0 -1
  90. package/src/hooks/use-async/use-async.stories.tsx +0 -272
  91. package/src/hooks/use-async/use-async.test.ts +0 -397
  92. package/src/hooks/use-async/use-async.ts +0 -135
  93. package/src/hooks/use-focus-trap/index.ts +0 -1
  94. package/src/hooks/use-focus-trap/scope-tab.ts +0 -38
  95. package/src/hooks/use-focus-trap/tabbable.ts +0 -70
  96. package/src/hooks/use-focus-trap/use-focus-trap.stories.tsx +0 -37
  97. package/src/hooks/use-focus-trap/use-focus-trap.test.ts +0 -355
  98. package/src/hooks/use-focus-trap/use-focus-trap.ts +0 -78
  99. package/src/hooks/use-mutation/index.ts +0 -1
  100. package/src/hooks/use-pagination/use-pagination.tsx +0 -84
  101. /package/dist/src/hooks/{use-async/use-async.test.d.ts → use-action/use-action.test.d.ts} +0 -0
  102. /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,98 +1,610 @@
1
- import { act, renderHook, waitFor } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
1
+ import {
2
+ act,
3
+ fireEvent,
4
+ render,
5
+ renderHook,
6
+ screen,
7
+ } from "@testing-library/react";
8
+ import { useEffect, useState } from "react";
9
+ import { describe, expect, it, vi } from "vitest";
3
10
  import { usePagination } from "../use-pagination";
4
11
 
5
- describe("usePagination hook", () => {
6
- it("should be defined", () => {
7
- expect(usePagination).toBeDefined();
12
+ // ---------------------------------------------------------------------------
13
+ // Scenarios 1–5: Default Initial State
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe("usePagination — Default Initial State", () => {
17
+ it("Scenario 1 — default initialization: page=1, pageSize=10, pageCount=10", () => {
18
+ const { result } = renderHook(() => usePagination({ totalItems: 100 }));
19
+ expect(result.current.page).toBe(1);
20
+ expect(result.current.pageSize).toBe(10);
21
+ expect(result.current.pageCount).toBe(10);
22
+ });
23
+
24
+ it("Scenario 2 — defaultPage seeds initial page", () => {
25
+ const { result } = renderHook(() =>
26
+ usePagination({ totalItems: 100, defaultPage: 3, defaultPageSize: 10 }),
27
+ );
28
+ expect(result.current.page).toBe(3);
29
+ });
30
+
31
+ it("Scenario 3 — defaultPageSize seeds initial pageSize", () => {
32
+ const { result } = renderHook(() =>
33
+ usePagination({ totalItems: 100, defaultPageSize: 20 }),
34
+ );
35
+ expect(result.current.pageSize).toBe(20);
36
+ expect(result.current.pageCount).toBe(5);
8
37
  });
9
38
 
10
- it("should show total items and items per page", () => {
39
+ it("Scenario 4 totalItems=0 edge case", () => {
40
+ const { result } = renderHook(() => usePagination({ totalItems: 0 }));
41
+ expect(result.current.pageCount).toBe(0);
42
+ expect(result.current.page).toBe(1);
43
+ expect(result.current.isFirstPage).toBe(true);
44
+ expect(result.current.isLastPage).toBe(true);
45
+ expect(result.current.hasPrevPage).toBe(false);
46
+ expect(result.current.hasNextPage).toBe(false);
47
+ expect(result.current.range).toEqual({ start: 0, end: 0 });
48
+ });
49
+
50
+ it("Scenario 5 — pageSize=0 guard: coerced to 1, console.warn called", () => {
51
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
52
+ const { result } = renderHook(() =>
53
+ usePagination({ totalItems: 100, defaultPageSize: 0 }),
54
+ );
55
+ expect(result.current.pageSize).toBe(1);
56
+ expect(warnSpy).toHaveBeenCalled();
57
+ warnSpy.mockRestore();
58
+ });
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Scenarios 6–16: Navigation Actions
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe("usePagination — Navigation Actions", () => {
66
+ it("Scenario 6 — next() increments page", () => {
67
+ const onPageChange = vi.fn();
11
68
  const { result } = renderHook(() =>
12
69
  usePagination({
13
- totalItems: 100,
14
- pageSize: 10,
70
+ totalItems: 50,
71
+ defaultPageSize: 10,
72
+ defaultPage: 2,
73
+ onPageChange,
74
+ }),
75
+ );
76
+ act(() => result.current.next());
77
+ expect(result.current.page).toBe(3);
78
+ expect(onPageChange).toHaveBeenCalledWith(3);
79
+ });
80
+
81
+ it("Scenario 7 — next() no-op at last page", () => {
82
+ const onPageChange = vi.fn();
83
+ const { result } = renderHook(() =>
84
+ usePagination({
85
+ totalItems: 50,
86
+ defaultPageSize: 10,
87
+ defaultPage: 5,
88
+ onPageChange,
89
+ }),
90
+ );
91
+ act(() => result.current.next());
92
+ expect(result.current.page).toBe(5);
93
+ expect(onPageChange).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("Scenario 8 — prev() decrements page", () => {
97
+ const onPageChange = vi.fn();
98
+ const { result } = renderHook(() =>
99
+ usePagination({
100
+ totalItems: 50,
101
+ defaultPageSize: 10,
102
+ defaultPage: 3,
103
+ onPageChange,
104
+ }),
105
+ );
106
+ act(() => result.current.prev());
107
+ expect(result.current.page).toBe(2);
108
+ expect(onPageChange).toHaveBeenCalledWith(2);
109
+ });
110
+
111
+ it("Scenario 9 — prev() no-op at first page", () => {
112
+ const onPageChange = vi.fn();
113
+ const { result } = renderHook(() =>
114
+ usePagination({
115
+ totalItems: 50,
116
+ defaultPageSize: 10,
117
+ defaultPage: 1,
118
+ onPageChange,
119
+ }),
120
+ );
121
+ act(() => result.current.prev());
122
+ expect(result.current.page).toBe(1);
123
+ expect(onPageChange).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("Scenario 10 — firstPage() jumps to page 1", () => {
127
+ const onPageChange = vi.fn();
128
+ const { result } = renderHook(() =>
129
+ usePagination({
130
+ totalItems: 50,
131
+ defaultPageSize: 10,
132
+ defaultPage: 4,
133
+ onPageChange,
134
+ }),
135
+ );
136
+ act(() => result.current.firstPage());
137
+ expect(result.current.page).toBe(1);
138
+ expect(onPageChange).toHaveBeenCalledWith(1);
139
+ });
140
+
141
+ it("Scenario 10b — firstPage() no-op when already first", () => {
142
+ const onPageChange = vi.fn();
143
+ const { result } = renderHook(() =>
144
+ usePagination({
145
+ totalItems: 50,
146
+ defaultPageSize: 10,
147
+ defaultPage: 1,
148
+ onPageChange,
149
+ }),
150
+ );
151
+ act(() => result.current.firstPage());
152
+ expect(result.current.page).toBe(1);
153
+ expect(onPageChange).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("Scenario 11 — lastPage() jumps to pageCount", () => {
157
+ const onPageChange = vi.fn();
158
+ const { result } = renderHook(() =>
159
+ usePagination({
160
+ totalItems: 50,
161
+ defaultPageSize: 10,
162
+ defaultPage: 2,
163
+ onPageChange,
164
+ }),
165
+ );
166
+ act(() => result.current.lastPage());
167
+ expect(result.current.page).toBe(5);
168
+ expect(onPageChange).toHaveBeenCalledWith(5);
169
+ });
170
+
171
+ it("Scenario 11b — lastPage() no-op when already last", () => {
172
+ const onPageChange = vi.fn();
173
+ const { result } = renderHook(() =>
174
+ usePagination({
175
+ totalItems: 50,
176
+ defaultPageSize: 10,
177
+ defaultPage: 5,
178
+ onPageChange,
179
+ }),
180
+ );
181
+ act(() => result.current.lastPage());
182
+ expect(result.current.page).toBe(5);
183
+ expect(onPageChange).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("Scenario 12 — goTo(n) jumps to n", () => {
187
+ const onPageChange = vi.fn();
188
+ const { result } = renderHook(() =>
189
+ usePagination({
190
+ totalItems: 50,
191
+ defaultPageSize: 10,
192
+ defaultPage: 1,
193
+ onPageChange,
194
+ }),
195
+ );
196
+ act(() => result.current.goTo(3));
197
+ expect(result.current.page).toBe(3);
198
+ expect(onPageChange).toHaveBeenCalledWith(3);
199
+ });
200
+
201
+ it("Scenario 13 — goTo clamps above pageCount", () => {
202
+ const onPageChange = vi.fn();
203
+ const { result } = renderHook(() =>
204
+ usePagination({
205
+ totalItems: 50,
206
+ defaultPageSize: 10,
207
+ defaultPage: 1,
208
+ onPageChange,
15
209
  }),
16
210
  );
17
- const { maxPage, currentPage } = result.current;
211
+ act(() => result.current.goTo(99));
212
+ expect(result.current.page).toBe(5);
213
+ expect(onPageChange).toHaveBeenCalledWith(5);
214
+ });
18
215
 
19
- expect(maxPage).toBe(10);
20
- expect(currentPage).toBe(1);
216
+ it("Scenario 14 — goTo clamps below 1", () => {
217
+ const onPageChange = vi.fn();
218
+ const { result } = renderHook(() =>
219
+ usePagination({ totalItems: 50, defaultPageSize: 10, onPageChange }),
220
+ );
221
+ act(() => result.current.goTo(-2));
222
+ expect(result.current.page).toBe(1);
223
+ // Already at page 1, no change → no-op
224
+ expect(onPageChange).not.toHaveBeenCalled();
21
225
  });
22
226
 
23
- it("should recive initial current page", () => {
227
+ it("Scenario 15 goTo(currentPage) is no-op", () => {
228
+ const onPageChange = vi.fn();
229
+ const { result } = renderHook(() =>
230
+ usePagination({
231
+ totalItems: 50,
232
+ defaultPageSize: 10,
233
+ defaultPage: 3,
234
+ onPageChange,
235
+ }),
236
+ );
237
+ act(() => result.current.goTo(3));
238
+ expect(result.current.page).toBe(3);
239
+ expect(onPageChange).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("Scenario 16 — goTo when pageCount=0 is no-op", () => {
243
+ const onPageChange = vi.fn();
244
+ const { result } = renderHook(() =>
245
+ usePagination({ totalItems: 0, onPageChange }),
246
+ );
247
+ act(() => result.current.goTo(1));
248
+ expect(onPageChange).not.toHaveBeenCalled();
249
+ });
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Scenarios 17–19: Page Size Control
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe("usePagination — Page Size Control", () => {
257
+ it("Scenario 17 — setPageSize(n) changes pageSize and resets page to 1", () => {
258
+ const { result } = renderHook(() =>
259
+ usePagination({ totalItems: 100, defaultPageSize: 10, defaultPage: 3 }),
260
+ );
261
+ act(() => result.current.setPageSize(20));
262
+ expect(result.current.pageSize).toBe(20);
263
+ expect(result.current.page).toBe(1);
264
+ expect(result.current.pageCount).toBe(5);
265
+ });
266
+
267
+ it("Scenario 18 — setPageSize fires callbacks in order: onPageSizeChange then onPageChange(1)", () => {
268
+ const onPageSizeChange = vi.fn();
269
+ const onPageChange = vi.fn();
24
270
  const { result } = renderHook(() =>
25
271
  usePagination({
26
272
  totalItems: 100,
27
- pageSize: 10,
28
- initialCurrentPage: 5,
273
+ defaultPageSize: 10,
274
+ defaultPage: 3,
275
+ onPageSizeChange,
276
+ onPageChange,
29
277
  }),
30
278
  );
31
- const { currentPage } = result.current;
32
- expect(currentPage).toBe(5);
279
+ act(() => result.current.setPageSize(20));
280
+ expect(onPageSizeChange).toHaveBeenCalledWith(20);
281
+ expect(onPageChange).toHaveBeenCalledWith(1);
33
282
  });
34
283
 
35
- it("should go next", () => {
284
+ it("Scenario 19 — setPageSize same size is no-op", () => {
285
+ const onPageSizeChange = vi.fn();
286
+ const onPageChange = vi.fn();
36
287
  const { result } = renderHook(() =>
37
288
  usePagination({
38
289
  totalItems: 100,
39
- pageSize: 10,
290
+ defaultPageSize: 10,
291
+ onPageSizeChange,
292
+ onPageChange,
293
+ }),
294
+ );
295
+ act(() => result.current.setPageSize(10));
296
+ expect(onPageSizeChange).not.toHaveBeenCalled();
297
+ expect(onPageChange).not.toHaveBeenCalled();
298
+ });
299
+ });
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Scenarios 20–23: Controllable Page
303
+ // ---------------------------------------------------------------------------
304
+
305
+ describe("usePagination — Controllable Page", () => {
306
+ it("Scenario 20 — page prop controls current page", () => {
307
+ const { result } = renderHook(() =>
308
+ usePagination({ totalItems: 50, defaultPageSize: 10, page: 3 }),
309
+ );
310
+ expect(result.current.page).toBe(3);
311
+ });
312
+
313
+ it("Scenario 21 — onPageChange fires on navigation (controlled)", () => {
314
+ const onPageChange = vi.fn();
315
+ const { result } = renderHook(() =>
316
+ usePagination({
317
+ totalItems: 50,
318
+ defaultPageSize: 10,
319
+ page: 2,
320
+ onPageChange,
40
321
  }),
41
322
  );
42
- const { next } = result.current;
43
- act(() => next());
323
+ act(() => result.current.next());
324
+ expect(onPageChange).toHaveBeenCalledWith(3);
325
+ });
44
326
 
45
- const { currentPage } = result.current;
46
- expect(currentPage).toBe(2);
327
+ it("Scenario 22 onPageChange NOT fired on mount", () => {
328
+ const onPageChange = vi.fn();
329
+ renderHook(() =>
330
+ usePagination({
331
+ totalItems: 50,
332
+ defaultPageSize: 10,
333
+ page: 1,
334
+ onPageChange,
335
+ }),
336
+ );
337
+ expect(onPageChange).not.toHaveBeenCalled();
47
338
  });
48
339
 
49
- it("should go previous", () => {
340
+ it("Scenario 23 — onPageChange NOT fired for no-op actions", () => {
341
+ const onPageChange = vi.fn();
50
342
  const { result } = renderHook(() =>
51
343
  usePagination({
52
- totalItems: 100,
53
- pageSize: 10,
54
- initialCurrentPage: 3,
344
+ totalItems: 50,
345
+ defaultPageSize: 10,
346
+ page: 1,
347
+ onPageChange,
55
348
  }),
56
349
  );
57
- const { prev } = result.current;
58
- act(() => prev());
350
+ act(() => result.current.prev());
351
+ expect(onPageChange).not.toHaveBeenCalled();
352
+ });
353
+ });
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Scenarios 24–26: Controllable Page Size
357
+ // ---------------------------------------------------------------------------
358
+
359
+ describe("usePagination — Controllable Page Size", () => {
360
+ it("Scenario 24 — pageSize prop controls current pageSize", () => {
361
+ const { result } = renderHook(() =>
362
+ usePagination({ totalItems: 100, pageSize: 20 }),
363
+ );
364
+ expect(result.current.pageSize).toBe(20);
365
+ });
366
+
367
+ it("Scenario 25 — onPageSizeChange fires after setPageSize (controlled)", () => {
368
+ const onPageSizeChange = vi.fn();
369
+ const { result } = renderHook(() =>
370
+ usePagination({ totalItems: 100, pageSize: 10, onPageSizeChange }),
371
+ );
372
+ act(() => result.current.setPageSize(25));
373
+ expect(onPageSizeChange).toHaveBeenCalledWith(25);
374
+ });
59
375
 
60
- const { currentPage } = result.current;
61
- expect(currentPage).toBe(2);
376
+ it("Scenario 26 onPageSizeChange NOT fired on mount", () => {
377
+ const onPageSizeChange = vi.fn();
378
+ renderHook(() =>
379
+ usePagination({ totalItems: 100, pageSize: 10, onPageSizeChange }),
380
+ );
381
+ expect(onPageSizeChange).not.toHaveBeenCalled();
62
382
  });
63
383
 
64
- it("should go to page number", () => {
384
+ it("Scenario 26b setPageSize fires onPageChange(1) in controlled mode when page prop > 1", () => {
385
+ const onPageChange = vi.fn();
386
+ const { result } = renderHook(() =>
387
+ usePagination({ totalItems: 100, page: 3, onPageChange }),
388
+ );
389
+ act(() => result.current.setPageSize(20));
390
+ expect(onPageChange).toHaveBeenCalledWith(1);
391
+ });
392
+
393
+ it("Scenario 26c — fully controlled: setPageSize batches page+pageSize so a consumer effect keyed on [page, pageSize] runs once", () => {
394
+ const fetchSpy = vi.fn();
395
+
396
+ function Consumer() {
397
+ const [page, setPage] = useState(3);
398
+ const [pageSize, setPageSize] = useState(10);
399
+ const pagination = usePagination({
400
+ totalItems: 100,
401
+ page,
402
+ onPageChange: setPage,
403
+ pageSize,
404
+ onPageSizeChange: setPageSize,
405
+ });
406
+
407
+ useEffect(() => {
408
+ fetchSpy({ page: pagination.page, pageSize: pagination.pageSize });
409
+ }, [pagination.page, pagination.pageSize]);
410
+
411
+ return (
412
+ <button type="button" onClick={() => pagination.setPageSize(20)}>
413
+ change
414
+ </button>
415
+ );
416
+ }
417
+
418
+ render(<Consumer />);
419
+ // Ignore the mount fire — we only care about the transition.
420
+ fetchSpy.mockClear();
421
+
422
+ fireEvent.click(screen.getByText("change"));
423
+
424
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
425
+ expect(fetchSpy).toHaveBeenCalledWith({ page: 1, pageSize: 20 });
426
+ });
427
+ });
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Scenarios 26d–26g: Unified onPaginationChange
431
+ // ---------------------------------------------------------------------------
432
+
433
+ describe("usePagination — Unified onPaginationChange", () => {
434
+ it("Scenario 26d — NOT fired on mount", () => {
435
+ const onPaginationChange = vi.fn();
436
+ renderHook(() => usePagination({ totalItems: 100, onPaginationChange }));
437
+ expect(onPaginationChange).not.toHaveBeenCalled();
438
+ });
439
+
440
+ it("Scenario 26e — fires once with both values when navigating", () => {
441
+ const onPaginationChange = vi.fn();
65
442
  const { result } = renderHook(() =>
66
443
  usePagination({
67
444
  totalItems: 100,
68
- pageSize: 10,
445
+ defaultPageSize: 10,
446
+ onPaginationChange,
69
447
  }),
70
448
  );
71
- const { goTo, currentPage } = result.current;
72
- act(() => goTo(5));
73
-
74
- waitFor(() => {
75
- expect(currentPage).toBe(5);
76
- });
449
+ act(() => result.current.next());
450
+ expect(onPaginationChange).toHaveBeenCalledTimes(1);
451
+ expect(onPaginationChange).toHaveBeenCalledWith({ page: 2, pageSize: 10 });
77
452
  });
78
453
 
79
- it("should show isFirstPage and isLastPage", () => {
454
+ it("Scenario 26f fires once with both final values when setPageSize resets page", () => {
455
+ const onPaginationChange = vi.fn();
80
456
  const { result } = renderHook(() =>
81
457
  usePagination({
82
458
  totalItems: 100,
83
- pageSize: 10,
84
- initialCurrentPage: 5,
459
+ defaultPage: 3,
460
+ defaultPageSize: 10,
461
+ onPaginationChange,
85
462
  }),
86
463
  );
464
+ act(() => result.current.setPageSize(20));
465
+ expect(onPaginationChange).toHaveBeenCalledTimes(1);
466
+ expect(onPaginationChange).toHaveBeenCalledWith({ page: 1, pageSize: 20 });
467
+ });
468
+
469
+ it("Scenario 26g — fully controlled: setPageSize triggers a single onPaginationChange with both values", () => {
470
+ const changeSpy = vi.fn();
471
+
472
+ function Consumer() {
473
+ const [page, setPage] = useState(3);
474
+ const [pageSize, setPageSize] = useState(10);
475
+ const pagination = usePagination({
476
+ totalItems: 100,
477
+ page,
478
+ onPageChange: setPage,
479
+ pageSize,
480
+ onPageSizeChange: setPageSize,
481
+ onPaginationChange: changeSpy,
482
+ });
483
+
484
+ return (
485
+ <button type="button" onClick={() => pagination.setPageSize(20)}>
486
+ change
487
+ </button>
488
+ );
489
+ }
490
+
491
+ render(<Consumer />);
492
+ changeSpy.mockClear();
493
+
494
+ fireEvent.click(screen.getByText("change"));
495
+
496
+ expect(changeSpy).toHaveBeenCalledTimes(1);
497
+ expect(changeSpy).toHaveBeenCalledWith({ page: 1, pageSize: 20 });
498
+ });
499
+ });
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Scenarios 27–33: Derived State
503
+ // ---------------------------------------------------------------------------
504
+
505
+ describe("usePagination — Derived State", () => {
506
+ it("Scenario 27 — isFirstPage=true when page=1", () => {
507
+ const { result } = renderHook(() =>
508
+ usePagination({ totalItems: 50, defaultPageSize: 10, defaultPage: 1 }),
509
+ );
510
+ expect(result.current.isFirstPage).toBe(true);
511
+ expect(result.current.hasPrevPage).toBe(false);
512
+ });
513
+
514
+ it("Scenario 28 — isLastPage=true when page=pageCount", () => {
515
+ const { result } = renderHook(() =>
516
+ usePagination({ totalItems: 50, defaultPageSize: 10, defaultPage: 5 }),
517
+ );
518
+ expect(result.current.isLastPage).toBe(true);
519
+ expect(result.current.hasNextPage).toBe(false);
520
+ });
521
+
522
+ it("Scenario 29 — hasPrevPage is inverse of isFirstPage", () => {
523
+ const { result } = renderHook(() =>
524
+ usePagination({ totalItems: 50, defaultPageSize: 10, defaultPage: 3 }),
525
+ );
526
+ expect(result.current.hasPrevPage).toBe(!result.current.isFirstPage);
527
+ });
87
528
 
88
- const { isFirstPage, isLastPage, goTo, maxPage } = result.current;
89
- expect(isFirstPage).toBe(false);
90
- expect(isLastPage).toBe(false);
529
+ it("Scenario 30 hasNextPage is inverse of isLastPage", () => {
530
+ const { result } = renderHook(() =>
531
+ usePagination({ totalItems: 50, defaultPageSize: 10, defaultPage: 3 }),
532
+ );
533
+ expect(result.current.hasNextPage).toBe(!result.current.isLastPage);
534
+ });
535
+
536
+ it("Scenario 31 — range.start and range.end correct for mid-page", () => {
537
+ const { result } = renderHook(() =>
538
+ usePagination({ totalItems: 100, defaultPageSize: 10, defaultPage: 2 }),
539
+ );
540
+ expect(result.current.range).toEqual({ start: 11, end: 20 });
541
+ });
542
+
543
+ it("Scenario 32 — range.end clamped to totalItems on last partial page", () => {
544
+ const { result } = renderHook(() =>
545
+ usePagination({ totalItems: 25, defaultPageSize: 10, defaultPage: 3 }),
546
+ );
547
+ expect(result.current.range).toEqual({ start: 21, end: 25 });
548
+ });
549
+
550
+ it("Scenario 33 — pageCount = ceil(totalItems / pageSize)", () => {
551
+ const { result } = renderHook(() =>
552
+ usePagination({ totalItems: 25, defaultPageSize: 10 }),
553
+ );
554
+ expect(result.current.pageCount).toBe(3);
555
+ });
556
+ });
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Scenarios 34–35: Reference Stability
560
+ // ---------------------------------------------------------------------------
561
+
562
+ describe("usePagination — Reference Stability", () => {
563
+ it("Scenario 34 — action refs are stable across re-renders", () => {
564
+ const { result, rerender } = renderHook(
565
+ (props: { totalItems: number }) => usePagination(props),
566
+ { initialProps: { totalItems: 100 } },
567
+ );
568
+ const { next, prev, firstPage, lastPage, goTo, setPageSize } =
569
+ result.current;
570
+ rerender({ totalItems: 100 });
571
+ expect(result.current.next).toBe(next);
572
+ expect(result.current.prev).toBe(prev);
573
+ expect(result.current.firstPage).toBe(firstPage);
574
+ expect(result.current.lastPage).toBe(lastPage);
575
+ expect(result.current.goTo).toBe(goTo);
576
+ expect(result.current.setPageSize).toBe(setPageSize);
577
+ });
578
+
579
+ it("Scenario 35 — latest onPageChange is called even after inline function re-creation", () => {
580
+ let callCount = 0;
581
+ let lastCallValue = 0;
582
+
583
+ // Simulate a parent that passes a new inline function on every render
584
+ let currentHandler = (_page: number) => {
585
+ callCount++;
586
+ lastCallValue = _page;
587
+ };
588
+ const { result, rerender } = renderHook(
589
+ ({ handler }: { handler: (p: number) => void }) =>
590
+ usePagination({
591
+ totalItems: 50,
592
+ defaultPageSize: 10,
593
+ onPageChange: handler,
594
+ }),
595
+ { initialProps: { handler: currentHandler } },
596
+ );
91
597
 
92
- act(() => goTo(maxPage));
598
+ // Re-render with a new function reference
599
+ currentHandler = (_page: number) => {
600
+ callCount += 10;
601
+ lastCallValue = _page;
602
+ };
603
+ rerender({ handler: currentHandler });
93
604
 
94
- waitFor(() => {
95
- expect(isLastPage).toBe(true);
96
- });
605
+ act(() => result.current.next());
606
+ // Should call the LATEST handler (callCount would be 10, not 1)
607
+ expect(callCount).toBe(10);
608
+ expect(lastCallValue).toBe(2);
97
609
  });
98
610
  });