@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,292 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { useControllableState } from "../hooks/useControllableState.ts";
3
+
4
+ /** One entry in a rendered page range: either a page, or a collapsed run. */
5
+ export type PaginationRangeItem =
6
+ | { type: "page"; page: number }
7
+ | { type: "gap"; pages: number[] };
8
+
9
+ /**
10
+ * How the visible page window behaves as the current page moves.
11
+ *
12
+ * - **paged** — the window holds a fixed block of pages and only advances when
13
+ * the current page steps outside it. Within a block nothing re-labels; only
14
+ * which cell is active changes. Stepping past the block's last page loads the
15
+ * next block in one deliberate jump.
16
+ * - **sliding** — the window follows the current page continuously, keeping
17
+ * `siblingCount` pages either side of it. The conventional behaviour, but the
18
+ * numbers under the reader's cursor re-label on EVERY step, which reads as a
19
+ * flash.
20
+ */
21
+ export type PaginationWindow = "paged" | "sliding";
22
+
23
+ /** Shape of the range — see {@link paginationRange}. */
24
+ export type PaginationRangeOptions = {
25
+ /**
26
+ * Which windowing strategy to use.
27
+ * @default "paged"
28
+ */
29
+ window?: PaginationWindow;
30
+ /**
31
+ * `paged` only — how many pages a block holds.
32
+ * @default 5
33
+ */
34
+ blockSize?: number;
35
+ /**
36
+ * `sliding` only — pages shown either side of the current one.
37
+ * @default 1
38
+ */
39
+ siblingCount?: number;
40
+ /**
41
+ * Pages pinned at each end of the range, in both strategies.
42
+ * @default 1
43
+ */
44
+ boundaryCount?: number;
45
+ };
46
+
47
+ /**
48
+ * Work out which pages to show, and which to collapse behind an overflow menu.
49
+ *
50
+ * Pins `boundaryCount` pages at each end, adds the window around the current
51
+ * page (see {@link PaginationWindow}), then walks the sorted result and
52
+ * collapses each remaining run into a `gap` carrying the pages it hides, so a
53
+ * menu can offer them.
54
+ *
55
+ * A run of exactly ONE page is returned as that page instead: hiding a single
56
+ * number behind a menu costs the reader a click and saves no width, since the
57
+ * ellipsis cell is the same square as the number it would replace. Same
58
+ * reasoning as `breadcrumb-overflow`, which only truncates once there is more
59
+ * than one crumb to hide.
60
+ *
61
+ * Pure — exported separately from the hook so a consumer holding its own page
62
+ * state can still reuse the arithmetic.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * paginationRange(7, 20);
67
+ * // 1 [2,3,4,5] 6 7 8 9 10 [11...19] 20 — the 6-10 block, steady while you
68
+ * // move within it
69
+ * ```
70
+ */
71
+ export function paginationRange(
72
+ page: number,
73
+ pageCount: number,
74
+ options: PaginationRangeOptions = {},
75
+ ): PaginationRangeItem[] {
76
+ const { window = "paged", blockSize = 5, siblingCount = 1, boundaryCount = 1 } = options;
77
+
78
+ const visible = new Set<number>();
79
+ for (let i = 1; i <= Math.min(boundaryCount, pageCount); i++) visible.add(i);
80
+ for (let i = Math.max(1, pageCount - boundaryCount + 1); i <= pageCount; i++) visible.add(i);
81
+
82
+ if (window === "paged") {
83
+ // Which block the current page falls in. Because this is derived from the
84
+ // page rather than centred on it, every page in the block yields the same
85
+ // window — that is what stops the cells re-labelling as you step.
86
+ const blockStart = Math.floor((page - 1) / blockSize) * blockSize + 1;
87
+ const blockEnd = Math.min(blockStart + blockSize - 1, pageCount);
88
+ for (let i = blockStart; i <= blockEnd; i++) visible.add(i);
89
+ } else {
90
+ for (let i = Math.max(1, page - siblingCount); i <= Math.min(pageCount, page + siblingCount); i++) {
91
+ visible.add(i);
92
+ }
93
+ }
94
+
95
+ const sorted = [...visible].sort((a, b) => a - b);
96
+ const items: PaginationRangeItem[] = [];
97
+
98
+ sorted.forEach((current, i) => {
99
+ if (i > 0) {
100
+ const missing: number[] = [];
101
+ for (let p = sorted[i - 1] + 1; p < current; p++) missing.push(p);
102
+ if (missing.length === 1) items.push({ type: "page", page: missing[0] });
103
+ else if (missing.length > 1) items.push({ type: "gap", pages: missing });
104
+ }
105
+ items.push({ type: "page", page: current });
106
+ });
107
+
108
+ return items;
109
+ }
110
+
111
+ export type UsePaginationOptions = {
112
+ /**
113
+ * How many pages there are. Supply this directly when the page count comes
114
+ * from elsewhere (a server response, say); otherwise give `totalItems` and
115
+ * `pageSize` and let the hook derive it.
116
+ */
117
+ pageCount?: number;
118
+ /** Total number of items across every page. Pairs with `pageSize`. */
119
+ totalItems?: number;
120
+ /** How many items each page holds. Pairs with `totalItems`. */
121
+ pageSize?: number;
122
+ /**
123
+ * The current page, 1-based — supply this to drive the hook yourself
124
+ * (controlled). Leave it out to let the hook hold its own state.
125
+ */
126
+ page?: number;
127
+ /**
128
+ * Starting page when uncontrolled. Read once on mount.
129
+ * @default 1
130
+ */
131
+ defaultPage?: number;
132
+ /**
133
+ * Notified whenever the page changes, with the **clamped** page — never a
134
+ * raw out-of-range request. Not called when the page hasn't actually moved.
135
+ */
136
+ onPageChange?: (page: number) => void;
137
+ /**
138
+ * How the visible window behaves as the page moves. See
139
+ * {@link PaginationWindow} — `paged` holds a block steady, `sliding` follows
140
+ * the current page.
141
+ * @default "paged"
142
+ */
143
+ window?: PaginationWindow;
144
+ /**
145
+ * `paged` only — how many pages a block holds.
146
+ * @default 5
147
+ */
148
+ blockSize?: number;
149
+ /**
150
+ * `sliding` only — pages shown either side of the current one.
151
+ * @default 1
152
+ */
153
+ siblingCount?: number;
154
+ /**
155
+ * How many pages `items` pins at each end of the range.
156
+ * @default 1
157
+ */
158
+ boundaryCount?: number;
159
+ };
160
+
161
+ /**
162
+ * What {@link usePagination} hands back.
163
+ */
164
+ export type UsePaginationResult = {
165
+ /** The current page, 1-based. Always within `1...pageCount`. */
166
+ page: number;
167
+ /** How many pages there are. Never less than 1. */
168
+ pageCount: number;
169
+ /** Go to a page. Out-of-range requests are clamped, not rejected. */
170
+ setPage: (page: number) => void;
171
+ /** Step forward one page. A no-op on the last page. */
172
+ next: () => void;
173
+ /** Step back one page. A no-op on the first page. */
174
+ previous: () => void;
175
+ /** Jump to page 1. */
176
+ first: () => void;
177
+ /** Jump to the last page. */
178
+ last: () => void;
179
+ /** Whether there is a page before this one — wire to a prev control's `disabled`. */
180
+ canPrevious: boolean;
181
+ /** Whether there is a page after this one — wire to a next control's `disabled`. */
182
+ canNext: boolean;
183
+ /**
184
+ * Zero-based index of this page's first item. Together with `endIndex` this
185
+ * is a **half-open** range, so it drops straight into `Array.slice(start,
186
+ * end)` or a LIMIT/OFFSET query with no off-by-one at the call site.
187
+ *
188
+ * Both are `0` when the hook has no `pageSize` to reason from.
189
+ */
190
+ startIndex: number;
191
+ /** Zero-based index one past this page's last item. Never exceeds `totalItems`. */
192
+ endIndex: number;
193
+ /**
194
+ * The range to render: each entry is either a page or a collapsed `gap`
195
+ * carrying the pages it hides. See {@link paginationRange}.
196
+ */
197
+ items: PaginationRangeItem[];
198
+ };
199
+
200
+ function clamp(page: number, pageCount: number): number {
201
+ return Math.min(Math.max(page, 1), pageCount);
202
+ }
203
+
204
+ /**
205
+ * Headless pagination state: the page arithmetic every paged surface needs,
206
+ * with no opinion about where the data comes from or how the row is rendered.
207
+ *
208
+ * @see https://primitiv-ui.dev/docs/hooks/use-pagination
209
+ */
210
+ export function usePagination(options: UsePaginationOptions): UsePaginationResult {
211
+ const {
212
+ pageCount: explicitPageCount,
213
+ totalItems,
214
+ pageSize,
215
+ page: controlledPage,
216
+ defaultPage = 1,
217
+ onPageChange,
218
+ window = "paged",
219
+ blockSize = 5,
220
+ siblingCount = 1,
221
+ boundaryCount = 1,
222
+ } = options;
223
+
224
+ const derived =
225
+ explicitPageCount !== undefined
226
+ ? explicitPageCount
227
+ : totalItems !== undefined && pageSize !== undefined
228
+ ? Math.ceil(totalItems / pageSize)
229
+ : 1;
230
+
231
+ // A range needs somewhere to stand: with 0 pages, a 1-based `page` would be
232
+ // out of range the moment it is read.
233
+ const pageCount = Math.max(derived, 1);
234
+
235
+ const [rawPage, setRawPage] = useControllableState(controlledPage, defaultPage, onPageChange);
236
+
237
+ // Clamped on the way OUT as well as on the way in: `defaultPage` is read once
238
+ // on mount, and a controlled `page` comes straight from the consumer, so
239
+ // neither has passed through `setPage`. Both can outlive the data they
240
+ // pointed at (a page index restored from a URL, a list that shrank).
241
+ const page = clamp(rawPage, pageCount);
242
+
243
+ const setPage = useCallback(
244
+ (next: number) => {
245
+ const target = clamp(next, pageCount);
246
+ // useControllableState deliberately does not dedupe, so the guard lives
247
+ // here — a consumer re-selecting the page they are already on should not
248
+ // see a change notification.
249
+ if (target === page) return;
250
+ setRawPage(target);
251
+ },
252
+ [page, pageCount, setRawPage],
253
+ );
254
+
255
+ // Every mover goes through setPage, so clamping and the change guard are
256
+ // stated once — stepping past either end is a no-op rather than an error.
257
+ const next = useCallback(() => setPage(page + 1), [page, setPage]);
258
+ const previous = useCallback(() => setPage(page - 1), [page, setPage]);
259
+ const first = useCallback(() => setPage(1), [setPage]);
260
+ const last = useCallback(() => setPage(pageCount), [pageCount, setPage]);
261
+
262
+ // Only meaningful when the hook knows a page size. Given `pageCount` alone it
263
+ // cannot invent slice bounds, so it reports an empty range rather than a
264
+ // plausible-looking guess a consumer might slice with.
265
+ const canSlice = pageSize !== undefined;
266
+ const startIndex = canSlice ? (page - 1) * pageSize : 0;
267
+ const endIndex = canSlice
268
+ ? // Clamped to the real item count so the final, partial page's range stops
269
+ // at the data instead of a page-size multiple past it.
270
+ Math.min(startIndex + pageSize, totalItems ?? startIndex + pageSize)
271
+ : 0;
272
+
273
+ const items = useMemo(
274
+ () => paginationRange(page, pageCount, { window, blockSize, siblingCount, boundaryCount }),
275
+ [page, pageCount, window, blockSize, siblingCount, boundaryCount],
276
+ );
277
+
278
+ return {
279
+ page,
280
+ pageCount,
281
+ setPage,
282
+ items,
283
+ startIndex,
284
+ endIndex,
285
+ next,
286
+ previous,
287
+ first,
288
+ last,
289
+ canPrevious: page > 1,
290
+ canNext: page < pageCount,
291
+ };
292
+ }
@@ -44,7 +44,7 @@ type GetKeyToActionMapOptions = {
44
44
  * "activate") into its own movement / activation logic.
45
45
  *
46
46
  * Returns a plain `Record<string, RovingKeyAction>` so call sites can do
47
- * `const action = map[event.key]; if (action === "next") …`.
47
+ * `const action = map[event.key]; if (action === "next") ...`.
48
48
  *
49
49
  * @example Tabs trigger (horizontal, RTL, all features)
50
50
  * ```ts