@primitiv-ui/react 0.1.29 → 0.1.30

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 (213) hide show
  1. package/README.md +15 -2
  2. package/package.json +1 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
  4. package/src/Accordion/Accordion.tsx +7 -7
  5. package/src/Accordion/README.md +2 -2
  6. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
  7. package/src/Breadcrumb/Breadcrumb.tsx +7 -7
  8. package/src/Breadcrumb/README.md +2 -2
  9. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
  10. package/src/Breadcrumb/types.ts +1 -1
  11. package/src/Button/__tests__/Button.contract.test.tsx +1 -1
  12. package/src/Carousel/Carousel.tsx +6 -6
  13. package/src/Carousel/README.md +22 -22
  14. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
  15. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
  16. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
  17. package/src/Carousel/loopEngine.ts +1 -1
  18. package/src/Carousel/types.ts +1 -1
  19. package/src/Checkbox/Checkbox.tsx +2 -2
  20. package/src/CheckboxCard/CheckboxCard.tsx +1 -1
  21. package/src/CheckboxCard/README.md +1 -1
  22. package/src/Collapsible/README.md +2 -2
  23. package/src/Combobox/Combobox.tsx +508 -0
  24. package/src/Combobox/ComboboxContext.ts +23 -0
  25. package/src/Combobox/README.md +170 -0
  26. package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
  27. package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
  28. package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
  29. package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
  30. package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
  31. package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
  32. package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
  33. package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
  34. package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
  35. package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
  36. package/src/Combobox/hooks/index.ts +1 -0
  37. package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
  38. package/src/Combobox/index.ts +3 -0
  39. package/src/Combobox/types.ts +133 -0
  40. package/src/ContextMenu/ContextMenu.tsx +3 -3
  41. package/src/ContextMenu/README.md +1 -1
  42. package/src/DirectionProvider/DirectionProvider.tsx +5 -5
  43. package/src/DirectionProvider/README.md +4 -4
  44. package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
  45. package/src/Drawer/Drawer.tsx +5 -5
  46. package/src/Drawer/README.md +2 -2
  47. package/src/Dropdown/Dropdown.tsx +2 -2
  48. package/src/Dropdown/README.md +7 -5
  49. package/src/EmptyState/README.md +2 -2
  50. package/src/Field/README.md +1 -1
  51. package/src/Field/hooks/useFieldProps.ts +1 -1
  52. package/src/Fieldset/Fieldset.tsx +3 -3
  53. package/src/Fieldset/README.md +2 -2
  54. package/src/Input/Input.tsx +1 -1
  55. package/src/Input/README.md +2 -2
  56. package/src/Listbox/Listbox.tsx +475 -0
  57. package/src/Listbox/ListboxContext.ts +48 -0
  58. package/src/Listbox/README.md +248 -0
  59. package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
  60. package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
  61. package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
  62. package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
  63. package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
  64. package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
  65. package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
  66. package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
  67. package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
  68. package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
  69. package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
  70. package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
  71. package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
  72. package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
  73. package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
  74. package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
  75. package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
  76. package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
  77. package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
  78. package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
  79. package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
  80. package/src/Listbox/constants.ts +2 -0
  81. package/src/Listbox/hooks/index.ts +1 -0
  82. package/src/Listbox/hooks/useListboxRoot.ts +407 -0
  83. package/src/Listbox/index.ts +3 -0
  84. package/src/Listbox/types.ts +238 -0
  85. package/src/MillerColumns/MillerColumns.tsx +104 -30
  86. package/src/MillerColumns/README.md +90 -20
  87. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
  88. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
  89. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
  90. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
  91. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
  92. package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
  93. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
  94. package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
  95. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
  96. package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
  97. package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
  98. package/src/MillerColumns/constants.ts +2 -0
  99. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
  100. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
  101. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
  102. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  103. package/src/MillerColumns/index.ts +4 -0
  104. package/src/MillerColumns/types.ts +56 -6
  105. package/src/MillerColumns/utils.ts +69 -3
  106. package/src/Modal/Modal.tsx +4 -4
  107. package/src/Modal/README.md +5 -5
  108. package/src/Modal/types.ts +1 -1
  109. package/src/NavigationMenu/NavigationMenu.tsx +6 -6
  110. package/src/NavigationMenu/README.md +3 -3
  111. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
  112. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
  113. package/src/Popover/Popover.tsx +2 -2
  114. package/src/Popover/README.md +1 -1
  115. package/src/Portal/Portal.tsx +2 -2
  116. package/src/Portal/README.md +2 -2
  117. package/src/Progress/README.md +1 -1
  118. package/src/Radio/Radio.tsx +1 -1
  119. package/src/RadioGroup/README.md +2 -2
  120. package/src/RadioGroup/RadioGroup.tsx +1 -1
  121. package/src/SegmentedControl/README.md +2 -2
  122. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  123. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  124. package/src/Select/README.md +2 -2
  125. package/src/Select/Select.tsx +8 -8
  126. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  127. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  128. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  129. package/src/Select/types.ts +2 -2
  130. package/src/SkipNav/README.md +3 -3
  131. package/src/SkipNav/SkipNav.tsx +5 -5
  132. package/src/Slider/README.md +37 -7
  133. package/src/Slider/Slider.tsx +9 -3
  134. package/src/Slot/Slot.tsx +2 -2
  135. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  136. package/src/Slot/types.ts +1 -1
  137. package/src/SplitButton/README.md +225 -0
  138. package/src/SplitButton/SplitButton.tsx +457 -0
  139. package/src/SplitButton/SplitButtonContext.ts +26 -0
  140. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  141. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  142. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  143. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  144. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  145. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  146. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  147. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  148. package/src/SplitButton/index.ts +2 -0
  149. package/src/SplitButton/types.ts +186 -0
  150. package/src/Switch/README.md +1 -1
  151. package/src/Switch/Switch.tsx +2 -2
  152. package/src/Switch/types.ts +1 -1
  153. package/src/Table/README.md +110 -8
  154. package/src/Table/Table.tsx +206 -10
  155. package/src/Table/TableExpandableContext.ts +7 -0
  156. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  157. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  158. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  159. package/src/Table/types.ts +125 -0
  160. package/src/Tabs/README.md +4 -4
  161. package/src/Tabs/Tabs.tsx +5 -4
  162. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  163. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  164. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  165. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  166. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  167. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  168. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  169. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  170. package/src/ToggleGroup/README.md +4 -4
  171. package/src/Tooltip/README.md +7 -7
  172. package/src/Tooltip/Tooltip.tsx +2 -2
  173. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  174. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  175. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  176. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  177. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  178. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  179. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  180. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  181. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  182. package/src/Tree/README.md +69 -5
  183. package/src/Tree/Tree.tsx +1 -1
  184. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  185. package/src/Tree/hooks/useTreePath.ts +33 -1
  186. package/src/Tree/index.ts +6 -1
  187. package/src/Tree/utils.ts +46 -4
  188. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  189. package/src/hooks/index.ts +1 -0
  190. package/src/hooks/useTypeahead.ts +95 -0
  191. package/src/index.ts +6 -0
  192. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  193. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  194. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  196. package/src/useLocalStorage/index.ts +1 -0
  197. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  198. package/src/useMediaQuery/README.md +66 -0
  199. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  200. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  201. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  202. package/src/useMediaQuery/index.ts +1 -0
  203. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  204. package/src/usePagination/README.md +170 -0
  205. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  206. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  207. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  208. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  209. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  210. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  211. package/src/usePagination/index.ts +1 -0
  212. package/src/usePagination/usePagination.ts +292 -0
  213. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -0,0 +1,170 @@
1
+ # usePagination
2
+
3
+ Headless pagination state: the page arithmetic every paged surface needs, with
4
+ no opinion about where the data comes from or how the row is rendered.
5
+
6
+ ```tsx
7
+ import { usePagination } from "@primitiv-ui/react";
8
+
9
+ const { page, pageCount, items, next, previous, canNext, canPrevious } =
10
+ usePagination({ totalItems: 237, pageSize: 10 });
11
+ ```
12
+
13
+ ## Works with any data source
14
+
15
+ The hook never fetches, holds or slices data — it hands back **indices**, which
16
+ is what makes it source-agnostic.
17
+
18
+ **Client-side** — slice an array you already have:
19
+
20
+ ```tsx
21
+ const { startIndex, endIndex } = usePagination({
22
+ totalItems: rows.length,
23
+ pageSize: 10,
24
+ });
25
+ const visible = rows.slice(startIndex, endIndex);
26
+ ```
27
+
28
+ **Server-side** — feed the same indices to a query. Here the server owns the
29
+ total, so pass `totalItems` through as it arrives:
30
+
31
+ ```tsx
32
+ const { page, startIndex, pageSize } = usePagination({
33
+ totalItems: data?.total,
34
+ pageSize: 10,
35
+ });
36
+ const { data } = useQuery(["rows", page], () =>
37
+ fetch(`/api/rows?offset=${startIndex}&limit=10`),
38
+ );
39
+ ```
40
+
41
+ If the server reports a page **count** rather than an item total, give
42
+ `pageCount` directly and skip `totalItems` entirely.
43
+
44
+ `startIndex`/`endIndex` are a **half-open** range — `[start, end)` — so they
45
+ drop into `Array.slice` and into `LIMIT`/`OFFSET` with no off-by-one at the call
46
+ site.
47
+
48
+ ## Controlled and uncontrolled
49
+
50
+ Uncontrolled by default, holding its own page:
51
+
52
+ ```tsx
53
+ const pagination = usePagination({ pageCount: 20, defaultPage: 3 });
54
+ ```
55
+
56
+ Pass `page` to drive it yourself — from a URL query, say:
57
+
58
+ ```tsx
59
+ const pagination = usePagination({
60
+ pageCount: 20,
61
+ page: Number(searchParams.get("page") ?? 1),
62
+ onPageChange: (next) => setSearchParams({ page: String(next) }),
63
+ });
64
+ ```
65
+
66
+ `onPageChange` always reports the **clamped** page, never a raw out-of-range
67
+ request, and is not called when the page hasn't actually moved.
68
+
69
+ ## Out-of-range pages are clamped, not rejected
70
+
71
+ A page index restored from a URL or `localStorage` can easily outlive the data
72
+ it pointed at. Every route into the page — `defaultPage`, a controlled `page`,
73
+ and `setPage` — is clamped to `1...pageCount`, so `page` is always safe to read
74
+ and to index with.
75
+
76
+ `pageCount` is never less than `1`: a 1-based page needs somewhere to stand, and
77
+ reporting `0` pages would put `page` out of range the moment it was read.
78
+
79
+ ## Options
80
+
81
+ | Option | Type | Default | Notes |
82
+ |---|---|---|---|
83
+ | `totalItems` | `number` | — | Total items across all pages. Pairs with `pageSize`. |
84
+ | `pageSize` | `number` | — | Items per page. Pairs with `totalItems`. |
85
+ | `pageCount` | `number` | — | Supply directly instead of deriving from the pair. |
86
+ | `page` | `number` | — | Controlled current page, 1-based. |
87
+ | `defaultPage` | `number` | `1` | Starting page when uncontrolled. |
88
+ | `onPageChange` | `(page: number) => void` | — | Fired with the clamped page. |
89
+ | `window` | `"paged" \| "sliding"` | `"paged"` | See Windowing below. |
90
+ | `blockSize` | `number` | `5` | `paged` only — pages per block. |
91
+ | `siblingCount` | `number` | `1` | `sliding` only — pages either side of the current one. |
92
+ | `boundaryCount` | `number` | `1` | Pages pinned at each end, both strategies. |
93
+
94
+ Given neither `pageCount` nor a complete `totalItems` + `pageSize` pair, the
95
+ hook reports a single page.
96
+
97
+ ## Returns
98
+
99
+ | Key | Type | Notes |
100
+ |---|---|---|
101
+ | `page` | `number` | Current page, always within `1...pageCount`. |
102
+ | `pageCount` | `number` | Never less than 1. |
103
+ | `setPage` | `(page: number) => void` | Out-of-range requests clamp. |
104
+ | `next` / `previous` | `() => void` | No-ops at the respective end. |
105
+ | `first` / `last` | `() => void` | Jump to either end. |
106
+ | `canPrevious` / `canNext` | `boolean` | Wire to a control's `disabled`. |
107
+ | `startIndex` / `endIndex` | `number` | Half-open slice bounds; `0` when no `pageSize`. |
108
+ | `items` | `PaginationRangeItem[]` | The range to render — see below. |
109
+
110
+ ## Windowing — why the default is `paged`
111
+
112
+ As the current page moves, something has to decide which numbers stay on screen.
113
+
114
+ **`paged`** (the default) holds a fixed block and only advances when the current
115
+ page steps outside it:
116
+
117
+ ```
118
+ page 6: 1 ... 6 7 8 9 10 ... 20
119
+ page 7: 1 ... 6 7 8 9 10 ... 20 ← identical; only the active cell moved
120
+ page 10: 1 ... 6 7 8 9 10 ... 20
121
+ page 11: 1 ... 11 12 13 14 15 ... 20 ← one deliberate jump
122
+ ```
123
+
124
+ **`sliding`** follows the current page continuously:
125
+
126
+ ```
127
+ page 6: 1 ... 5 6 7 ... 20
128
+ page 7: 1 ... 6 7 8 ... 20 ← every cell re-labelled
129
+ page 8: 1 ... 7 8 9 ... 20 ← again
130
+ ```
131
+
132
+ Sliding is the more common default elsewhere, but it re-labels the numbers
133
+ directly under the reader's cursor on every single step — the cell you are
134
+ about to click has become a different page by the time you get there, and the
135
+ whole row appears to flash. Paged trades that for one visible jump per block,
136
+ which is both calmer and safer to click.
137
+
138
+ Set `blockSize` to control how many pages a block holds (default 5). Choose
139
+ `sliding` explicitly if you want the conventional behaviour:
140
+
141
+ ```tsx
142
+ usePagination({ totalItems: 500, pageSize: 10, window: "sliding", siblingCount: 2 });
143
+ ```
144
+
145
+ `siblingCount` applies only to `sliding`; `blockSize` only to `paged`.
146
+ `boundaryCount` applies to both.
147
+
148
+ ## `items` and `paginationRange`
149
+
150
+ `items` is the range to render: each entry is either a page or a `gap` carrying
151
+ the pages it hides, so an overflow menu can offer them.
152
+
153
+ ```tsx
154
+ items.map((item) =>
155
+ item.type === "page" ? renderPage(item.page) : renderGap(item.pages),
156
+ );
157
+ ```
158
+
159
+ A run of exactly **one** hidden page is returned as that page instead — hiding a
160
+ single number behind a menu costs a click and saves no width.
161
+
162
+ `paginationRange(page, pageCount, options?)` is exported separately and is
163
+ pure, so a consumer holding its own page state can reuse the arithmetic without
164
+ the hook. It takes the same `window` / `blockSize` / `siblingCount` /
165
+ `boundaryCount` options.
166
+
167
+ ## Styled surface
168
+
169
+ The `pagination` registry component composes this hook's output into a styled
170
+ row. See `registry/components/pagination/README.md`.
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { usePagination } from "../usePagination.ts";
5
+ import type { UsePaginationOptions } from "../usePagination.ts";
6
+
7
+ function NavigationProbe(options: UsePaginationOptions) {
8
+ const { page, next, previous, first, last, canPrevious, canNext } = usePagination(options);
9
+ return (
10
+ <>
11
+ <span data-testid="page">{page}</span>
12
+ <span data-testid="flags">
13
+ {canPrevious ? "prev" : "-"}/{canNext ? "next" : "-"}
14
+ </span>
15
+ <button type="button" onClick={previous}>
16
+ previous
17
+ </button>
18
+ <button type="button" onClick={next}>
19
+ next
20
+ </button>
21
+ <button type="button" onClick={first}>
22
+ first
23
+ </button>
24
+ <button type="button" onClick={last}>
25
+ last
26
+ </button>
27
+ </>
28
+ );
29
+ }
30
+
31
+ describe("usePagination navigation", () => {
32
+ it("should step forward one page", async () => {
33
+ const user = userEvent.setup();
34
+ render(<NavigationProbe pageCount={10} defaultPage={3} />);
35
+
36
+ await user.click(screen.getByRole("button", { name: "next" }));
37
+
38
+ expect(screen.getByTestId("page")).toHaveTextContent("4");
39
+ });
40
+
41
+ it("should step back one page", async () => {
42
+ const user = userEvent.setup();
43
+ render(<NavigationProbe pageCount={10} defaultPage={3} />);
44
+
45
+ await user.click(screen.getByRole("button", { name: "previous" }));
46
+
47
+ expect(screen.getByTestId("page")).toHaveTextContent("2");
48
+ });
49
+
50
+ it("should stay on the last page when stepping forward from it", async () => {
51
+ const user = userEvent.setup();
52
+ render(<NavigationProbe pageCount={10} defaultPage={10} />);
53
+
54
+ await user.click(screen.getByRole("button", { name: "next" }));
55
+
56
+ expect(screen.getByTestId("page")).toHaveTextContent("10");
57
+ });
58
+
59
+ it("should stay on the first page when stepping back from it", async () => {
60
+ const user = userEvent.setup();
61
+ render(<NavigationProbe pageCount={10} defaultPage={1} />);
62
+
63
+ await user.click(screen.getByRole("button", { name: "previous" }));
64
+
65
+ expect(screen.getByTestId("page")).toHaveTextContent("1");
66
+ });
67
+
68
+ it("should jump to the first page", async () => {
69
+ const user = userEvent.setup();
70
+ render(<NavigationProbe pageCount={10} defaultPage={7} />);
71
+
72
+ await user.click(screen.getByRole("button", { name: "first" }));
73
+
74
+ expect(screen.getByTestId("page")).toHaveTextContent("1");
75
+ });
76
+
77
+ it("should jump to the last page", async () => {
78
+ const user = userEvent.setup();
79
+ render(<NavigationProbe pageCount={10} defaultPage={2} />);
80
+
81
+ await user.click(screen.getByRole("button", { name: "last" }));
82
+
83
+ expect(screen.getByTestId("page")).toHaveTextContent("10");
84
+ });
85
+
86
+ it("should report both directions available mid-range", () => {
87
+ render(<NavigationProbe pageCount={10} defaultPage={5} />);
88
+
89
+ expect(screen.getByTestId("flags")).toHaveTextContent("prev/next");
90
+ });
91
+
92
+ it("should report no previous on the first page", () => {
93
+ render(<NavigationProbe pageCount={10} defaultPage={1} />);
94
+
95
+ expect(screen.getByTestId("flags")).toHaveTextContent("-/next");
96
+ });
97
+
98
+ it("should report no next on the last page", () => {
99
+ render(<NavigationProbe pageCount={10} defaultPage={10} />);
100
+
101
+ expect(screen.getByTestId("flags")).toHaveTextContent("prev/-");
102
+ });
103
+
104
+ it("should report neither direction when there is only one page", () => {
105
+ render(<NavigationProbe pageCount={1} />);
106
+
107
+ expect(screen.getByTestId("flags")).toHaveTextContent("-/-");
108
+ });
109
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { usePagination } from "../usePagination.ts";
4
+ import type { UsePaginationOptions } from "../usePagination.ts";
5
+
6
+ function PageCountProbe(options: UsePaginationOptions) {
7
+ const { page, pageCount } = usePagination(options);
8
+ return (
9
+ <span data-testid="result">
10
+ {page}/{pageCount}
11
+ </span>
12
+ );
13
+ }
14
+
15
+ describe("usePagination page count", () => {
16
+ it("should derive pageCount by dividing totalItems by pageSize", () => {
17
+ render(<PageCountProbe totalItems={200} pageSize={10} />);
18
+
19
+ expect(screen.getByTestId("result")).toHaveTextContent("1/20");
20
+ });
21
+
22
+ it("should round a partial last page up so its items stay reachable", () => {
23
+ render(<PageCountProbe totalItems={237} pageSize={10} />);
24
+
25
+ expect(screen.getByTestId("result")).toHaveTextContent("1/24");
26
+ });
27
+
28
+ it("should take pageCount directly when the consumer supplies it", () => {
29
+ render(<PageCountProbe pageCount={7} />);
30
+
31
+ expect(screen.getByTestId("result")).toHaveTextContent("1/7");
32
+ });
33
+
34
+ it("should fall back to one page when given no sizing information at all", () => {
35
+ // Neither an explicit pageCount nor a totalItems/pageSize pair — there is
36
+ // nothing to derive a count from, so the range collapses to a single page
37
+ // rather than reporting 0 (which `page` could never sit inside).
38
+ render(<PageCountProbe />);
39
+
40
+ expect(screen.getByTestId("result")).toHaveTextContent("1/1");
41
+ });
42
+
43
+ it("should fall back to one page when only half the sizing pair is given", () => {
44
+ render(<PageCountProbe totalItems={200} />);
45
+
46
+ expect(screen.getByTestId("result")).toHaveTextContent("1/1");
47
+ });
48
+
49
+ it("should report a single empty page when there are no items at all", () => {
50
+ // A range has to have somewhere to stand: reporting 0 pages would make
51
+ // `page` (1-based) immediately out of range for every consumer.
52
+ render(<PageCountProbe totalItems={0} pageSize={10} />);
53
+
54
+ expect(screen.getByTestId("result")).toHaveTextContent("1/1");
55
+ });
56
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { usePagination } from "../usePagination.ts";
5
+ import type { UsePaginationOptions } from "../usePagination.ts";
6
+
7
+ function PageStateProbe(options: UsePaginationOptions & { goTo?: number }) {
8
+ const { goTo, ...rest } = options;
9
+ const { page, setPage } = usePagination(rest);
10
+ return (
11
+ <>
12
+ <span data-testid="page">{page}</span>
13
+ <button type="button" onClick={() => setPage(goTo ?? 1)}>
14
+ go
15
+ </button>
16
+ </>
17
+ );
18
+ }
19
+
20
+ describe("usePagination page state", () => {
21
+ it("should start on page 1 when no default is given", () => {
22
+ render(<PageStateProbe pageCount={10} />);
23
+
24
+ expect(screen.getByTestId("page")).toHaveTextContent("1");
25
+ });
26
+
27
+ it("should start on defaultPage when uncontrolled", () => {
28
+ render(<PageStateProbe pageCount={10} defaultPage={4} />);
29
+
30
+ expect(screen.getByTestId("page")).toHaveTextContent("4");
31
+ });
32
+
33
+ it("should move to the requested page", async () => {
34
+ const user = userEvent.setup();
35
+ render(<PageStateProbe pageCount={10} goTo={6} />);
36
+
37
+ await user.click(screen.getByRole("button", { name: "go" }));
38
+
39
+ expect(screen.getByTestId("page")).toHaveTextContent("6");
40
+ });
41
+
42
+ it("should clamp a request above the last page down to the last page", async () => {
43
+ const user = userEvent.setup();
44
+ render(<PageStateProbe pageCount={10} goTo={99} />);
45
+
46
+ await user.click(screen.getByRole("button", { name: "go" }));
47
+
48
+ expect(screen.getByTestId("page")).toHaveTextContent("10");
49
+ });
50
+
51
+ it("should clamp a request below the first page up to page 1", async () => {
52
+ const user = userEvent.setup();
53
+ render(<PageStateProbe pageCount={10} defaultPage={5} goTo={0} />);
54
+
55
+ await user.click(screen.getByRole("button", { name: "go" }));
56
+
57
+ expect(screen.getByTestId("page")).toHaveTextContent("1");
58
+ });
59
+
60
+ it("should clamp an out-of-range defaultPage", () => {
61
+ // A page index restored from a URL or localStorage can easily outlive the
62
+ // data it pointed at.
63
+ render(<PageStateProbe pageCount={10} defaultPage={999} />);
64
+
65
+ expect(screen.getByTestId("page")).toHaveTextContent("10");
66
+ });
67
+
68
+ it("should notify onPageChange with the clamped page, not the raw request", async () => {
69
+ const onPageChange = vi.fn();
70
+ const user = userEvent.setup();
71
+ render(<PageStateProbe pageCount={10} goTo={99} onPageChange={onPageChange} />);
72
+
73
+ await user.click(screen.getByRole("button", { name: "go" }));
74
+
75
+ expect(onPageChange).toHaveBeenCalledWith(10);
76
+ });
77
+
78
+ it("should not notify onPageChange when the page has not actually changed", async () => {
79
+ const onPageChange = vi.fn();
80
+ const user = userEvent.setup();
81
+ render(<PageStateProbe pageCount={10} defaultPage={3} goTo={3} onPageChange={onPageChange} />);
82
+
83
+ await user.click(screen.getByRole("button", { name: "go" }));
84
+
85
+ expect(onPageChange).not.toHaveBeenCalled();
86
+ });
87
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { paginationRange } from "../usePagination.ts";
3
+
4
+ /** Render a range compactly: pages as numbers, gaps as the pages they hide. */
5
+ function shape(items: ReturnType<typeof paginationRange>): string {
6
+ return items
7
+ .map((item) => (item.type === "page" ? String(item.page) : `[${item.pages.join(",")}]`))
8
+ .join(" ");
9
+ }
10
+
11
+ describe("paginationRange", () => {
12
+ it("should list every page when the window is wide enough to cover the range", () => {
13
+ // Note the defaults do NOT show all 5: siblingCount 1 + boundaryCount 1
14
+ // leaves {1,2,5} visible, collapsing [3,4]. Covering a 5-page range takes a
15
+ // wider window — asserted below so the default's tighter shape is a
16
+ // deliberate, visible choice rather than an accident.
17
+ expect(shape(paginationRange(3, 5, { window: "sliding", siblingCount: 2 }))).toBe("1 2 3 4 5");
18
+ });
19
+
20
+ it("should collapse even a short range at the default window", () => {
21
+ expect(shape(paginationRange(1, 5, { window: "sliding" }))).toBe("1 2 [3,4] 5");
22
+ });
23
+
24
+ it("should collapse the tail when the current page is near the start", () => {
25
+ expect(shape(paginationRange(2, 10, { window: "sliding" }))).toBe("1 2 3 [4,5,6,7,8,9] 10");
26
+ });
27
+
28
+ it("should collapse the head when the current page is near the end", () => {
29
+ expect(shape(paginationRange(9, 10, { window: "sliding" }))).toBe("1 [2,3,4,5,6,7] 8 9 10");
30
+ });
31
+
32
+ it("should collapse both ends when the current page is mid-range", () => {
33
+ const items = paginationRange(50, 100, { window: "sliding" });
34
+
35
+ expect(items.map((i) => i.type)).toEqual([
36
+ "page", // 1
37
+ "gap", // 2-48
38
+ "page", // 49
39
+ "page", // 50 (current)
40
+ "page", // 51
41
+ "gap", // 52-99
42
+ "page", // 100
43
+ ]);
44
+ });
45
+
46
+ it("should hide exactly the pages between the two shown either side of a gap", () => {
47
+ const items = paginationRange(50, 100, { window: "sliding" });
48
+ const [leading] = items.filter((i) => i.type === "gap");
49
+
50
+ // The gap must be contiguous and abut its neighbours — an off-by-one here
51
+ // would silently drop a page from the menu, unreachable anywhere.
52
+ expect(leading).toEqual({
53
+ type: "gap",
54
+ pages: Array.from({ length: 47 }, (_, i) => i + 2),
55
+ });
56
+ });
57
+
58
+ it("should show a lone hidden page instead of collapsing it", () => {
59
+ // Hiding one number behind a menu costs a click and saves no width — the
60
+ // ellipsis cell is the same square as the number it would replace.
61
+ expect(shape(paginationRange(1, 5, { window: "sliding", siblingCount: 1, boundaryCount: 2 }))).toBe("1 2 3 4 5");
62
+ });
63
+
64
+ it("should widen the window around the current page with siblingCount", () => {
65
+ expect(shape(paginationRange(50, 100, { window: "sliding", siblingCount: 2 }))).toContain("48 49 50 51 52");
66
+ });
67
+
68
+ it("should pin more pages at each end with boundaryCount", () => {
69
+ const items = shape(paginationRange(50, 100, { window: "sliding", siblingCount: 1, boundaryCount: 2 }));
70
+
71
+ expect(items.startsWith("1 2 ")).toBe(true);
72
+ expect(items.endsWith(" 99 100")).toBe(true);
73
+ });
74
+
75
+ it("should handle a single page without emitting a gap", () => {
76
+ expect(shape(paginationRange(1, 1, { window: "sliding" }))).toBe("1");
77
+ });
78
+
79
+ it("should not emit pages beyond the range when the count is tiny", () => {
80
+ expect(shape(paginationRange(1, 2, { window: "sliding", siblingCount: 3, boundaryCount: 3 }))).toBe("1 2");
81
+ });
82
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { usePagination } from "../usePagination.ts";
4
+ import type { UsePaginationOptions } from "../usePagination.ts";
5
+
6
+ function SliceProbe(options: UsePaginationOptions) {
7
+ const { startIndex, endIndex } = usePagination(options);
8
+ return (
9
+ <span data-testid="slice">
10
+ {startIndex}..{endIndex}
11
+ </span>
12
+ );
13
+ }
14
+
15
+ describe("usePagination slice bounds", () => {
16
+ it("should give a zero-based half-open range for the first page", () => {
17
+ // Half-open so it drops straight into Array.slice and into a
18
+ // LIMIT/OFFSET query without an off-by-one at the call site.
19
+ render(<SliceProbe totalItems={200} pageSize={10} />);
20
+
21
+ expect(screen.getByTestId("slice")).toHaveTextContent("0..10");
22
+ });
23
+
24
+ it("should offset the range by the current page", () => {
25
+ render(<SliceProbe totalItems={200} pageSize={10} defaultPage={3} />);
26
+
27
+ expect(screen.getByTestId("slice")).toHaveTextContent("20..30");
28
+ });
29
+
30
+ it("should stop the last page's range at the item count, not the page size", () => {
31
+ // 237 items at 10/page: the last page holds 7, so endIndex must not run
32
+ // past the data.
33
+ render(<SliceProbe totalItems={237} pageSize={10} defaultPage={24} />);
34
+
35
+ expect(screen.getByTestId("slice")).toHaveTextContent("230..237");
36
+ });
37
+
38
+ it("should collapse to an empty range when there are no items", () => {
39
+ render(<SliceProbe totalItems={0} pageSize={10} />);
40
+
41
+ expect(screen.getByTestId("slice")).toHaveTextContent("0..0");
42
+ });
43
+
44
+ it("should not clamp the range when the total is unknown", () => {
45
+ // A server that reports pageCount but not an exact total is common. With
46
+ // no total to clamp against, the range is just a full page wide.
47
+ render(<SliceProbe pageCount={10} pageSize={10} defaultPage={3} />);
48
+
49
+ expect(screen.getByTestId("slice")).toHaveTextContent("20..30");
50
+ });
51
+
52
+ it("should report an empty range when the page size is unknown", () => {
53
+ // pageCount given directly — the hook has no idea how big a page is, so it
54
+ // cannot invent slice bounds.
55
+ render(<SliceProbe pageCount={10} defaultPage={3} />);
56
+
57
+ expect(screen.getByTestId("slice")).toHaveTextContent("0..0");
58
+ });
59
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { paginationRange } from "../usePagination.ts";
3
+
4
+ function shape(items: ReturnType<typeof paginationRange>): string {
5
+ return items
6
+ .map((item) => (item.type === "page" ? String(item.page) : `[${item.pages.join(",")}]`))
7
+ .join(" ");
8
+ }
9
+
10
+ describe("paginationRange paged windowing", () => {
11
+ it("should hold the same block of pages steady while the current page moves within it", () => {
12
+ // The whole point: stepping 6 -> 7 -> 8 must not re-label a single cell,
13
+ // only move which one is active. A sliding window re-renders the numbers
14
+ // under the reader's cursor on every step, which reads as a flash.
15
+ const at6 = shape(paginationRange(6, 20, { window: "paged", blockSize: 5 }));
16
+ const at7 = shape(paginationRange(7, 20, { window: "paged", blockSize: 5 }));
17
+ const at8 = shape(paginationRange(8, 20, { window: "paged", blockSize: 5 }));
18
+
19
+ expect(at7).toBe(at6);
20
+ expect(at8).toBe(at6);
21
+ });
22
+
23
+ it("should show the block containing the current page, plus the boundaries", () => {
24
+ expect(shape(paginationRange(7, 20, { window: "paged", blockSize: 5 }))).toBe(
25
+ "1 [2,3,4,5] 6 7 8 9 10 [11,...,19] 20".replace(
26
+ "11,...,19",
27
+ "11,12,13,14,15,16,17,18,19",
28
+ ),
29
+ );
30
+ });
31
+
32
+ it("should advance to the next block when stepping past the block's last page", () => {
33
+ const atLast = shape(paginationRange(10, 20, { window: "paged", blockSize: 5 }));
34
+ const atNext = shape(paginationRange(11, 20, { window: "paged", blockSize: 5 }));
35
+
36
+ expect(atLast).toContain("6 7 8 9 10");
37
+ expect(atNext).toContain("11 12 13 14 15");
38
+ expect(atNext).not.toBe(atLast);
39
+ });
40
+
41
+ it("should fall back to the previous block when stepping before the block's first page", () => {
42
+ const atFirst = shape(paginationRange(6, 20, { window: "paged", blockSize: 5 }));
43
+ const atPrevious = shape(paginationRange(5, 20, { window: "paged", blockSize: 5 }));
44
+
45
+ expect(atFirst).toContain("6 7 8 9 10");
46
+ expect(atPrevious).toContain("1 2 3 4 5");
47
+ });
48
+
49
+ it("should not run the final block past the last page", () => {
50
+ // 20 pages at blockSize 5 divides evenly; 22 does not, so the last block
51
+ // holds only 21 and 22.
52
+ expect(shape(paginationRange(21, 22, { window: "paged", blockSize: 5 }))).toContain("21 22");
53
+ expect(shape(paginationRange(21, 22, { window: "paged", blockSize: 5 }))).not.toContain("23");
54
+ });
55
+
56
+ it("should still collapse a lone hidden page into a plain page", () => {
57
+ // Block 2-6 with boundary 1: nothing hidden at the head, so no gap.
58
+ expect(shape(paginationRange(3, 12, { window: "paged", blockSize: 5 }))).toBe(
59
+ "1 2 3 4 5 [6,7,8,9,10,11] 12",
60
+ );
61
+ });
62
+
63
+ it("should keep sliding behaviour available for consumers who prefer it", () => {
64
+ expect(shape(paginationRange(7, 20, { window: "sliding" }))).toBe(
65
+ "1 [2,3,4,5] 6 7 8 [9,...,19] 20".replace("9,...,19", "9,10,11,12,13,14,15,16,17,18,19"),
66
+ );
67
+ });
68
+ });
@@ -0,0 +1 @@
1
+ export * from "./usePagination.ts";