@wordpress/dataviews 13.1.1-next.v.202603102151.0 → 14.0.0

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 (247) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +19 -3
  3. package/build/components/dataform-controls/datetime.cjs +8 -4
  4. package/build/components/dataform-controls/datetime.cjs.map +2 -2
  5. package/build/components/dataform-layouts/card/index.cjs +132 -128
  6. package/build/components/dataform-layouts/card/index.cjs.map +3 -3
  7. package/build/components/dataviews-bulk-actions/index.cjs +28 -5
  8. package/build/components/dataviews-bulk-actions/index.cjs.map +2 -2
  9. package/build/components/dataviews-context/index.cjs +2 -2
  10. package/build/components/dataviews-context/index.cjs.map +2 -2
  11. package/build/components/dataviews-footer/index.cjs +2 -3
  12. package/build/components/dataviews-footer/index.cjs.map +2 -2
  13. package/build/components/dataviews-layout/index.cjs +12 -3
  14. package/build/components/dataviews-layout/index.cjs.map +2 -2
  15. package/build/components/dataviews-layouts/grid/composite-grid.cjs +378 -245
  16. package/build/components/dataviews-layouts/grid/composite-grid.cjs.map +2 -2
  17. package/build/components/dataviews-layouts/index.cjs +3 -3
  18. package/build/components/dataviews-layouts/index.cjs.map +3 -3
  19. package/build/components/dataviews-layouts/picker-grid/index.cjs +76 -32
  20. package/build/components/dataviews-layouts/picker-grid/index.cjs.map +2 -2
  21. package/build/components/dataviews-layouts/picker-table/index.cjs +34 -22
  22. package/build/components/dataviews-layouts/picker-table/index.cjs.map +2 -2
  23. package/build/components/dataviews-layouts/table/index.cjs +97 -88
  24. package/build/components/dataviews-layouts/table/index.cjs.map +2 -2
  25. package/build/components/dataviews-layouts/table/{use-is-horizontal-scroll-end.cjs → use-scroll-state.cjs} +29 -33
  26. package/build/components/dataviews-layouts/table/use-scroll-state.cjs.map +7 -0
  27. package/build/components/dataviews-layouts/utils/density-picker.cjs.map +2 -2
  28. package/build/components/dataviews-layouts/utils/grid-config-options.cjs +45 -0
  29. package/build/components/dataviews-layouts/utils/grid-config-options.cjs.map +7 -0
  30. package/build/components/dataviews-layouts/utils/use-infinite-scroll.cjs +62 -0
  31. package/build/components/dataviews-layouts/utils/use-infinite-scroll.cjs.map +7 -0
  32. package/build/components/dataviews-picker-footer/index.cjs +23 -4
  33. package/build/components/dataviews-picker-footer/index.cjs.map +2 -2
  34. package/build/components/dataviews-search/index.cjs +2 -1
  35. package/build/components/dataviews-search/index.cjs.map +2 -2
  36. package/build/components/dataviews-selection-checkbox/index.cjs +3 -2
  37. package/build/components/dataviews-selection-checkbox/index.cjs.map +2 -2
  38. package/build/components/dataviews-view-config/index.cjs +0 -2
  39. package/build/components/dataviews-view-config/index.cjs.map +3 -3
  40. package/build/components/dataviews-view-config/infinite-scroll-toggle.cjs +0 -3
  41. package/build/components/dataviews-view-config/infinite-scroll-toggle.cjs.map +2 -2
  42. package/build/dataviews/index.cjs +38 -34
  43. package/build/dataviews/index.cjs.map +3 -3
  44. package/build/dataviews-picker/index.cjs +26 -25
  45. package/build/dataviews-picker/index.cjs.map +3 -3
  46. package/build/hooks/index.cjs +11 -2
  47. package/build/hooks/index.cjs.map +2 -2
  48. package/build/hooks/use-data.cjs +146 -9
  49. package/build/hooks/use-data.cjs.map +2 -2
  50. package/build/hooks/use-infinite-scroll.cjs +208 -0
  51. package/build/hooks/use-infinite-scroll.cjs.map +7 -0
  52. package/build/hooks/use-selected-items.cjs +57 -0
  53. package/build/hooks/use-selected-items.cjs.map +7 -0
  54. package/build/types/dataviews.cjs.map +1 -1
  55. package/build/types/field-api.cjs.map +1 -1
  56. package/build/utils/filter-sort-and-paginate.cjs +5 -1
  57. package/build/utils/filter-sort-and-paginate.cjs.map +2 -2
  58. package/build/utils/get-footer-message.cjs +8 -8
  59. package/build/utils/get-footer-message.cjs.map +2 -2
  60. package/build-module/components/dataform-controls/datetime.mjs +8 -4
  61. package/build-module/components/dataform-controls/datetime.mjs.map +2 -2
  62. package/build-module/components/dataform-layouts/card/index.mjs +132 -133
  63. package/build-module/components/dataform-layouts/card/index.mjs.map +2 -2
  64. package/build-module/components/dataviews-bulk-actions/index.mjs +28 -5
  65. package/build-module/components/dataviews-bulk-actions/index.mjs.map +2 -2
  66. package/build-module/components/dataviews-context/index.mjs +2 -2
  67. package/build-module/components/dataviews-context/index.mjs.map +2 -2
  68. package/build-module/components/dataviews-footer/index.mjs +2 -3
  69. package/build-module/components/dataviews-footer/index.mjs.map +2 -2
  70. package/build-module/components/dataviews-layout/index.mjs +12 -3
  71. package/build-module/components/dataviews-layout/index.mjs.map +2 -2
  72. package/build-module/components/dataviews-layouts/grid/composite-grid.mjs +387 -246
  73. package/build-module/components/dataviews-layouts/grid/composite-grid.mjs.map +2 -2
  74. package/build-module/components/dataviews-layouts/index.mjs +3 -3
  75. package/build-module/components/dataviews-layouts/index.mjs.map +2 -2
  76. package/build-module/components/dataviews-layouts/picker-grid/index.mjs +80 -33
  77. package/build-module/components/dataviews-layouts/picker-grid/index.mjs.map +2 -2
  78. package/build-module/components/dataviews-layouts/picker-table/index.mjs +34 -22
  79. package/build-module/components/dataviews-layouts/picker-table/index.mjs.map +2 -2
  80. package/build-module/components/dataviews-layouts/table/index.mjs +97 -88
  81. package/build-module/components/dataviews-layouts/table/index.mjs.map +2 -2
  82. package/build-module/components/dataviews-layouts/table/use-scroll-state.mjs +46 -0
  83. package/build-module/components/dataviews-layouts/table/use-scroll-state.mjs.map +7 -0
  84. package/build-module/components/dataviews-layouts/utils/density-picker.mjs.map +2 -2
  85. package/build-module/components/dataviews-layouts/utils/grid-config-options.mjs +14 -0
  86. package/build-module/components/dataviews-layouts/utils/grid-config-options.mjs.map +7 -0
  87. package/build-module/components/dataviews-layouts/utils/use-infinite-scroll.mjs +26 -0
  88. package/build-module/components/dataviews-layouts/utils/use-infinite-scroll.mjs.map +7 -0
  89. package/build-module/components/dataviews-picker-footer/index.mjs +23 -4
  90. package/build-module/components/dataviews-picker-footer/index.mjs.map +2 -2
  91. package/build-module/components/dataviews-search/index.mjs +2 -1
  92. package/build-module/components/dataviews-search/index.mjs.map +2 -2
  93. package/build-module/components/dataviews-selection-checkbox/index.mjs +3 -2
  94. package/build-module/components/dataviews-selection-checkbox/index.mjs.map +2 -2
  95. package/build-module/components/dataviews-view-config/index.mjs +0 -2
  96. package/build-module/components/dataviews-view-config/index.mjs.map +2 -2
  97. package/build-module/components/dataviews-view-config/infinite-scroll-toggle.mjs +0 -3
  98. package/build-module/components/dataviews-view-config/infinite-scroll-toggle.mjs.map +2 -2
  99. package/build-module/dataviews/index.mjs +46 -36
  100. package/build-module/dataviews/index.mjs.map +2 -2
  101. package/build-module/dataviews-picker/index.mjs +34 -27
  102. package/build-module/dataviews-picker/index.mjs.map +2 -2
  103. package/build-module/hooks/index.mjs +7 -1
  104. package/build-module/hooks/index.mjs.map +2 -2
  105. package/build-module/hooks/use-data.mjs +147 -10
  106. package/build-module/hooks/use-data.mjs.map +2 -2
  107. package/build-module/hooks/use-infinite-scroll.mjs +188 -0
  108. package/build-module/hooks/use-infinite-scroll.mjs.map +7 -0
  109. package/build-module/hooks/use-selected-items.mjs +36 -0
  110. package/build-module/hooks/use-selected-items.mjs.map +7 -0
  111. package/build-module/utils/filter-sort-and-paginate.mjs +5 -1
  112. package/build-module/utils/filter-sort-and-paginate.mjs.map +2 -2
  113. package/build-module/utils/get-footer-message.mjs +8 -8
  114. package/build-module/utils/get-footer-message.mjs.map +2 -2
  115. package/build-style/style-rtl.css +107 -41
  116. package/build-style/style.css +107 -41
  117. package/build-types/components/dataform-controls/datetime.d.ts +1 -1
  118. package/build-types/components/dataform-controls/datetime.d.ts.map +1 -1
  119. package/build-types/components/dataform-layouts/card/index.d.ts.map +1 -1
  120. package/build-types/components/dataviews-bulk-actions/index.d.ts +2 -1
  121. package/build-types/components/dataviews-bulk-actions/index.d.ts.map +1 -1
  122. package/build-types/components/dataviews-context/index.d.ts +1 -1
  123. package/build-types/components/dataviews-context/index.d.ts.map +1 -1
  124. package/build-types/components/dataviews-footer/index.d.ts.map +1 -1
  125. package/build-types/components/dataviews-layout/index.d.ts.map +1 -1
  126. package/build-types/components/dataviews-layouts/grid/composite-grid.d.ts.map +1 -1
  127. package/build-types/components/dataviews-layouts/index.d.ts +3 -3
  128. package/build-types/components/dataviews-layouts/index.d.ts.map +1 -1
  129. package/build-types/components/dataviews-layouts/picker-grid/index.d.ts.map +1 -1
  130. package/build-types/components/dataviews-layouts/picker-table/index.d.ts.map +1 -1
  131. package/build-types/components/dataviews-layouts/table/index.d.ts.map +1 -1
  132. package/build-types/components/dataviews-layouts/table/use-scroll-state.d.ts +25 -0
  133. package/build-types/components/dataviews-layouts/table/use-scroll-state.d.ts.map +1 -0
  134. package/build-types/components/dataviews-layouts/utils/density-picker.d.ts.map +1 -1
  135. package/build-types/components/dataviews-layouts/utils/grid-config-options.d.ts +2 -0
  136. package/build-types/components/dataviews-layouts/utils/grid-config-options.d.ts.map +1 -0
  137. package/build-types/components/dataviews-layouts/utils/use-infinite-scroll.d.ts +22 -0
  138. package/build-types/components/dataviews-layouts/utils/use-infinite-scroll.d.ts.map +1 -0
  139. package/build-types/components/dataviews-picker-footer/index.d.ts.map +1 -1
  140. package/build-types/components/dataviews-search/index.d.ts.map +1 -1
  141. package/build-types/components/dataviews-selection-checkbox/index.d.ts.map +1 -1
  142. package/build-types/components/dataviews-view-config/index.d.ts.map +1 -1
  143. package/build-types/components/dataviews-view-config/infinite-scroll-toggle.d.ts +1 -1
  144. package/build-types/components/dataviews-view-config/infinite-scroll-toggle.d.ts.map +1 -1
  145. package/build-types/dataviews/index.d.ts +0 -1
  146. package/build-types/dataviews/index.d.ts.map +1 -1
  147. package/build-types/dataviews/stories/empty.d.ts +1 -2
  148. package/build-types/dataviews/stories/empty.d.ts.map +1 -1
  149. package/build-types/dataviews/stories/fixtures.d.ts.map +1 -1
  150. package/build-types/dataviews/stories/free-composition.d.ts.map +1 -1
  151. package/build-types/dataviews/stories/index.story.d.ts +18 -10
  152. package/build-types/dataviews/stories/index.story.d.ts.map +1 -1
  153. package/build-types/dataviews/stories/infinite-scroll.d.ts.map +1 -1
  154. package/build-types/dataviews/stories/layout-activity.d.ts.map +1 -1
  155. package/build-types/dataviews/stories/layout-custom.d.ts +3 -1
  156. package/build-types/dataviews/stories/layout-custom.d.ts.map +1 -1
  157. package/build-types/dataviews/stories/layout-grid.d.ts.map +1 -1
  158. package/build-types/dataviews/stories/layout-list.d.ts.map +1 -1
  159. package/build-types/dataviews/stories/layout-table.d.ts.map +1 -1
  160. package/build-types/dataviews/stories/with-card.d.ts +3 -1
  161. package/build-types/dataviews/stories/with-card.d.ts.map +1 -1
  162. package/build-types/dataviews-picker/index.d.ts +0 -1
  163. package/build-types/dataviews-picker/index.d.ts.map +1 -1
  164. package/build-types/dataviews-picker/stories/fixtures.d.ts.map +1 -1
  165. package/build-types/dataviews-picker/stories/index.story.d.ts.map +1 -1
  166. package/build-types/field-types/stories/index.story.d.ts.map +1 -1
  167. package/build-types/hooks/index.d.ts +3 -0
  168. package/build-types/hooks/index.d.ts.map +1 -1
  169. package/build-types/hooks/test/use-data.d.ts +2 -0
  170. package/build-types/hooks/test/use-data.d.ts.map +1 -0
  171. package/build-types/hooks/use-data.d.ts +41 -3
  172. package/build-types/hooks/use-data.d.ts.map +1 -1
  173. package/build-types/hooks/use-infinite-scroll.d.ts +21 -0
  174. package/build-types/hooks/use-infinite-scroll.d.ts.map +1 -0
  175. package/build-types/hooks/use-selected-items.d.ts +19 -0
  176. package/build-types/hooks/use-selected-items.d.ts.map +1 -0
  177. package/build-types/types/dataviews.d.ts +15 -1
  178. package/build-types/types/dataviews.d.ts.map +1 -1
  179. package/build-types/types/field-api.d.ts +15 -4
  180. package/build-types/types/field-api.d.ts.map +1 -1
  181. package/build-types/utils/filter-sort-and-paginate.d.ts.map +1 -1
  182. package/build-types/utils/get-footer-message.d.ts +3 -2
  183. package/build-types/utils/get-footer-message.d.ts.map +1 -1
  184. package/build-wp/index.js +3202 -2761
  185. package/package.json +19 -19
  186. package/src/components/dataform-controls/datetime.tsx +19 -11
  187. package/src/components/dataform-layouts/card/index.tsx +171 -146
  188. package/src/components/dataform-layouts/card/style.scss +8 -5
  189. package/src/components/dataviews-bulk-actions/index.tsx +28 -1
  190. package/src/components/dataviews-context/index.ts +2 -2
  191. package/src/components/dataviews-footer/index.tsx +1 -6
  192. package/src/components/dataviews-layout/index.tsx +41 -19
  193. package/src/components/dataviews-layout/style.scss +8 -0
  194. package/src/components/dataviews-layouts/grid/composite-grid.tsx +433 -278
  195. package/src/components/dataviews-layouts/grid/style.scss +22 -2
  196. package/src/components/dataviews-layouts/index.ts +3 -3
  197. package/src/components/dataviews-layouts/picker-grid/index.tsx +70 -17
  198. package/src/components/dataviews-layouts/picker-grid/style.scss +10 -0
  199. package/src/components/dataviews-layouts/picker-table/index.tsx +42 -22
  200. package/src/components/dataviews-layouts/table/index.tsx +10 -4
  201. package/src/components/dataviews-layouts/table/style.scss +13 -0
  202. package/src/components/dataviews-layouts/table/use-scroll-state.ts +79 -0
  203. package/src/components/dataviews-layouts/utils/density-picker.tsx +12 -2
  204. package/src/components/dataviews-layouts/utils/grid-config-options.tsx +14 -0
  205. package/src/components/dataviews-layouts/utils/grid-items.scss +9 -1
  206. package/src/components/dataviews-layouts/utils/use-infinite-scroll.ts +64 -0
  207. package/src/components/dataviews-picker-footer/index.tsx +21 -1
  208. package/src/components/dataviews-search/index.tsx +2 -1
  209. package/src/components/dataviews-selection-checkbox/index.tsx +4 -2
  210. package/src/components/dataviews-view-config/index.tsx +0 -2
  211. package/src/components/dataviews-view-config/infinite-scroll-toggle.tsx +0 -5
  212. package/src/dataviews/index.tsx +58 -45
  213. package/src/dataviews/stories/empty.tsx +1 -3
  214. package/src/dataviews/stories/fixtures.tsx +288 -0
  215. package/src/dataviews/stories/free-composition.tsx +44 -45
  216. package/src/dataviews/stories/index.story.tsx +31 -8
  217. package/src/dataviews/stories/infinite-scroll.tsx +7 -93
  218. package/src/dataviews/stories/layout-activity.tsx +1 -0
  219. package/src/dataviews/stories/layout-custom.tsx +7 -3
  220. package/src/dataviews/stories/layout-grid.tsx +1 -0
  221. package/src/dataviews/stories/layout-list.tsx +1 -0
  222. package/src/dataviews/stories/layout-table.tsx +1 -0
  223. package/src/dataviews/stories/style.css +0 -5
  224. package/src/dataviews/stories/with-card.tsx +35 -24
  225. package/src/dataviews/style.scss +5 -8
  226. package/src/dataviews/test/dataviews.tsx +54 -1
  227. package/src/dataviews-picker/index.tsx +41 -35
  228. package/src/dataviews-picker/stories/fixtures.tsx +270 -0
  229. package/src/dataviews-picker/stories/index.story.tsx +62 -129
  230. package/src/field-types/stories/index.story.tsx +12 -0
  231. package/src/hooks/index.ts +3 -0
  232. package/src/hooks/test/use-data.ts +791 -0
  233. package/src/hooks/use-data.ts +288 -21
  234. package/src/hooks/use-infinite-scroll.ts +304 -0
  235. package/src/hooks/use-selected-items.ts +72 -0
  236. package/src/style.scss +1 -0
  237. package/src/types/dataviews.ts +18 -1
  238. package/src/types/field-api.ts +16 -3
  239. package/src/utils/filter-sort-and-paginate.ts +13 -1
  240. package/src/utils/get-footer-message.ts +12 -9
  241. package/src/utils/test/filter-sort-and-paginate.js +78 -54
  242. package/build/components/dataviews-layouts/table/use-is-horizontal-scroll-end.cjs.map +0 -7
  243. package/build-module/components/dataviews-layouts/table/use-is-horizontal-scroll-end.mjs +0 -50
  244. package/build-module/components/dataviews-layouts/table/use-is-horizontal-scroll-end.mjs.map +0 -7
  245. package/build-types/components/dataviews-layouts/table/use-is-horizontal-scroll-end.d.ts +0 -19
  246. package/build-types/components/dataviews-layouts/table/use-is-horizontal-scroll-end.d.ts.map +0 -1
  247. package/src/components/dataviews-layouts/table/use-is-horizontal-scroll-end.ts +0 -82
@@ -1,45 +1,312 @@
1
1
  /**
2
2
  * WordPress dependencies
3
3
  */
4
- import { useEffect, useRef, useState } from '@wordpress/element';
4
+ import { useState, useEffect, useMemo, useRef } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import type { View } from '../types';
5
10
 
6
11
  type PaginationInfo = {
7
12
  totalItems: number;
8
13
  totalPages: number;
9
- infiniteScrollHandler?: () => void;
10
14
  };
11
15
 
12
- export default function useData< Item >(
13
- data: Item[],
14
- isLoading: boolean | undefined,
15
- paginationInfo: PaginationInfo
16
- ): {
16
+ interface UseDataParams< Item > {
17
+ view: View;
17
18
  data: Item[];
19
+ getItemId: ( item: Item ) => string;
20
+ isLoading?: boolean;
21
+ paginationInfo: PaginationInfo;
22
+ selection?: string[];
23
+ }
24
+
25
+ interface UseDataResult< Item > {
26
+ data: ( Item & { position?: number } )[];
18
27
  paginationInfo: PaginationInfo;
19
28
  hasInitiallyLoaded: boolean;
20
- } {
21
- const previousDataRef = useRef< Item[] >( data );
22
- const previousPaginationInfoRef =
23
- useRef< PaginationInfo >( paginationInfo );
29
+ setVisibleEntries?: React.Dispatch< React.SetStateAction< number[] > >;
30
+ }
31
+
32
+ /**
33
+ * Hook to manage data for DataViews.
34
+ *
35
+ * When infinite scroll is enabled, this hook handles:
36
+ * - Loading more data when scrolling up or down
37
+ * - Maintaining stable positions for items
38
+ * - Unloading items that are no longer visible (with a buffer)
39
+ *
40
+ * When infinite scroll is disabled, it preserves the previous data and
41
+ * pagination info while loading, so the UI doesn't flash empty.
42
+ *
43
+ * In both cases, it tracks whether data has initially loaded.
44
+ *
45
+ * @param params - Configuration parameters
46
+ * @param params.view - Current view configuration
47
+ * @param params.data - Current page of data
48
+ * @param params.getItemId - Function to extract item ID
49
+ * @param params.isLoading - Whether data is currently loading
50
+ * @param params.paginationInfo - Pagination info (totalItems, totalPages)
51
+ * @param params.selection - Currently selected item IDs
52
+ * @return Object containing data, paginationInfo, hasInitiallyLoaded,
53
+ * and optional setVisibleEntries callback
54
+ */
55
+ export default function useData< Item >( {
56
+ view,
57
+ data: shownData,
58
+ getItemId,
59
+ isLoading,
60
+ paginationInfo,
61
+ selection,
62
+ }: UseDataParams< Item > ): UseDataResult< Item > {
63
+ const isInfiniteScrollEnabled = view.infiniteScrollEnabled;
64
+
24
65
  const [ hasInitiallyLoaded, setHasInitiallyLoaded ] = useState(
25
66
  ! isLoading
26
67
  );
27
68
  useEffect( () => {
28
69
  if ( ! isLoading ) {
29
- previousDataRef.current = data;
30
- previousPaginationInfoRef.current = paginationInfo;
31
70
  setHasInitiallyLoaded( true );
32
71
  }
33
- }, [ data, isLoading, paginationInfo ] );
34
- return {
35
- data:
72
+ }, [ isLoading ] );
73
+
74
+ const previousDataRef = useRef< Item[] >( shownData );
75
+ const previousPaginationInfoRef =
76
+ useRef< PaginationInfo >( paginationInfo );
77
+ useEffect( () => {
78
+ if ( ! isLoading ) {
79
+ previousDataRef.current = shownData;
80
+ previousPaginationInfoRef.current = paginationInfo;
81
+ }
82
+ }, [ shownData, isLoading, paginationInfo ] );
83
+
84
+ // Infinite scroll state.
85
+ const [ visibleEntries, setVisibleEntries ] = useState< number[] >( [] );
86
+
87
+ // Track the mapping of item IDs to their positions in the full dataset
88
+ const positionMapRef = useRef< Map< string, number > >( new Map() );
89
+
90
+ // Store accumulated records in a ref for persistence across renders
91
+ const allLoadedRecordsRef = useRef< Item[] >( [] );
92
+
93
+ // Track previous view parameters to detect when we need to reset
94
+ const prevViewParamsRef = useRef< {
95
+ search: string | undefined;
96
+ filters: string | undefined;
97
+ perPage: number | undefined;
98
+ } >( {
99
+ search: undefined,
100
+ filters: undefined,
101
+ perPage: undefined,
102
+ } );
103
+
104
+ // Determine scroll direction based on position changes
105
+ const scrollDirectionRef = useRef< 'up' | 'down' | undefined >( undefined );
106
+ const prevStartPositionRef = useRef< number | undefined >( undefined );
107
+
108
+ // Track whether we've done initial load
109
+ const hasInitializedRef = useRef( false );
110
+
111
+ // Compute data synchronously during render using useMemo
112
+ // This ensures the returned data is always in sync with shownData
113
+ const allLoadedRecords = useMemo( () => {
114
+ // Update scroll direction based on position changes
115
+ if (
116
+ view.startPosition !== undefined &&
117
+ prevStartPositionRef.current !== undefined
118
+ ) {
119
+ if ( view.startPosition < prevStartPositionRef.current ) {
120
+ scrollDirectionRef.current = 'up';
121
+ } else if ( view.startPosition > prevStartPositionRef.current ) {
122
+ scrollDirectionRef.current = 'down';
123
+ }
124
+ }
125
+ prevStartPositionRef.current = view.startPosition;
126
+
127
+ // Serialize filters for comparison
128
+ const currentFiltersKey = JSON.stringify( view.filters ?? [] );
129
+ const prevFiltersKey = prevViewParamsRef.current.filters;
130
+
131
+ // Check if view parameters that require a reset have changed
132
+ const shouldReset =
133
+ ! hasInitializedRef.current ||
134
+ ! view.infiniteScrollEnabled ||
135
+ view.search !== prevViewParamsRef.current.search ||
136
+ currentFiltersKey !== prevFiltersKey ||
137
+ view.perPage !== prevViewParamsRef.current.perPage;
138
+ hasInitializedRef.current = true;
139
+ // Update tracked view parameters
140
+ prevViewParamsRef.current = {
141
+ search: view.search,
142
+ filters: currentFiltersKey,
143
+ perPage: view.perPage,
144
+ };
145
+
146
+ if ( shouldReset ) {
147
+ // Reset - clear position map and replace all data
148
+ positionMapRef.current.clear();
149
+ // Reset scroll direction to prevent stale directional filtering
150
+ scrollDirectionRef.current = undefined;
151
+ // Use the view's startPosition if defined, otherwise default to 1
152
+ const startPosition = view.search ? 1 : view.startPosition ?? 1;
153
+ const records = shownData.map( ( record, index ) => {
154
+ const position = startPosition + index;
155
+ positionMapRef.current.set( getItemId( record ), position );
156
+ return {
157
+ ...record,
158
+ position,
159
+ };
160
+ } );
161
+ allLoadedRecordsRef.current = records;
162
+ return records;
163
+ }
164
+
165
+ // Subsequent pages - merge with existing data
166
+ const prev = allLoadedRecordsRef.current;
167
+ const shownDataIds = new Set( shownData.map( getItemId ) );
168
+ const scrollDirection = scrollDirectionRef.current;
169
+
170
+ // The position for each item in shownData should be based on the
171
+ // current startPosition from the view, which reflects the actual
172
+ // offset in the dataset. This ensures aria-posinset values are
173
+ // semantically correct - if startPosition is 40, there are exactly
174
+ // 39 items before the first item in shownData.
175
+ // When there's an active search, always start from position 1 since
176
+ // search results are a filtered subset, not a paginated view.
177
+ const basePosition = view.search ? 1 : view.startPosition ?? 1;
178
+ const newRecords = shownData.map( ( record, index ) => {
179
+ const itemId = getItemId( record );
180
+ const position = view.infiniteScrollEnabled
181
+ ? basePosition + index
182
+ : undefined;
183
+
184
+ // Always update the position map with the correct position
185
+ // based on the current query's startPosition
186
+ if ( position !== undefined ) {
187
+ positionMapRef.current.set( itemId, position );
188
+ }
189
+
190
+ return {
191
+ ...record,
192
+ position,
193
+ };
194
+ } );
195
+
196
+ if ( newRecords.length === 0 ) {
197
+ return prev;
198
+ }
199
+
200
+ // Remove duplicates from prev, keeping only records not in shownData
201
+ const prevWithoutDuplicates = prev.filter(
202
+ ( record ) => ! shownDataIds.has( getItemId( record ) )
203
+ );
204
+
205
+ // Update the loaded range
206
+ const allRecords =
207
+ scrollDirection === 'up'
208
+ ? [ ...newRecords, ...prevWithoutDuplicates ]
209
+ : [ ...prevWithoutDuplicates, ...newRecords ];
210
+
211
+ // Sort all records by position to ensure correct order
212
+ // This is crucial when items are reloaded after scrolling in different directions
213
+ allRecords.sort( ( a, b ) => {
214
+ const posA = ( a as Item & { position: number } ).position;
215
+ const posB = ( b as Item & { position: number } ).position;
216
+ return posA - posB;
217
+ } );
218
+
219
+ let result = allRecords;
220
+
221
+ if ( visibleEntries.length > 0 ) {
222
+ const visibleMin = Math.min( ...visibleEntries );
223
+ const visibleMax = Math.max( ...visibleEntries );
224
+ // Buffer size balances allowing new items to render (when prepended
225
+ // during scroll up) while unloading items no longer on screen.
226
+ // Use a larger buffer to prevent scrollbar from jumping when items
227
+ // are unloaded, which could trigger unwanted scroll events.
228
+ const buffer = 20;
229
+
230
+ const recordPositions = allRecords.map(
231
+ ( r ) => ( r as Item & { position: number } ).position
232
+ );
233
+ const minRecordPos = Math.min( ...recordPositions );
234
+ const maxRecordPos = Math.max( ...recordPositions );
235
+
236
+ // Check if there's any overlap between visible range and actual record positions
237
+ // to avoid filtering when visibleEntries are stale (e.g., after search/filter reset)
238
+ const hasOverlap = ! (
239
+ maxRecordPos < visibleMin - buffer ||
240
+ minRecordPos > visibleMax + buffer
241
+ );
242
+
243
+ if ( hasOverlap ) {
244
+ result = allRecords.filter( ( record ) => {
245
+ const itemId = getItemId( record );
246
+ const isSelected = selection?.includes( itemId );
247
+ // Never unload selected items, even if outside visible range
248
+ if ( isSelected ) {
249
+ return true;
250
+ }
251
+
252
+ const itemPosition = (
253
+ record as Item & { position: number }
254
+ ).position;
255
+ // When scrolling, only trim items from the end we're scrolling away from
256
+ if ( scrollDirection === 'up' ) {
257
+ // When scrolling up, only trim items below the visible range
258
+ return itemPosition <= visibleMax + buffer;
259
+ } else if ( scrollDirection === 'down' ) {
260
+ // When scrolling down, only trim items above the visible range
261
+ return itemPosition >= visibleMin - buffer;
262
+ }
263
+ // When not scrolling or first load, keep items within buffer range
264
+ return (
265
+ itemPosition >= visibleMin - buffer &&
266
+ itemPosition <= visibleMax + buffer
267
+ );
268
+ } );
269
+ }
270
+ }
271
+
272
+ allLoadedRecordsRef.current = result;
273
+ return result;
274
+ }, [
275
+ shownData,
276
+ view.search,
277
+ view.filters,
278
+ view.perPage,
279
+ view.startPosition,
280
+ view.infiniteScrollEnabled,
281
+ visibleEntries,
282
+ selection,
283
+ getItemId,
284
+ ] );
285
+
286
+ // When infinite scroll is disabled, preserve previous data while loading
287
+ if ( ! isInfiniteScrollEnabled ) {
288
+ const dataToReturn =
36
289
  isLoading && previousDataRef.current?.length
37
290
  ? previousDataRef.current
38
- : data,
39
- paginationInfo:
40
- isLoading && previousDataRef.current?.length
41
- ? previousPaginationInfoRef.current
42
- : paginationInfo,
291
+ : shownData;
292
+ return {
293
+ data: dataToReturn.map( ( item ) => ( {
294
+ ...item,
295
+ position: undefined,
296
+ } ) ) as ( Item & { position?: number } )[],
297
+ paginationInfo:
298
+ isLoading && previousDataRef.current?.length
299
+ ? previousPaginationInfoRef.current
300
+ : paginationInfo,
301
+ hasInitiallyLoaded,
302
+ setVisibleEntries: undefined,
303
+ };
304
+ }
305
+
306
+ return {
307
+ data: allLoadedRecords as ( Item & { position?: number } )[],
308
+ paginationInfo,
43
309
  hasInitiallyLoaded,
310
+ setVisibleEntries,
44
311
  };
45
312
  }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ } from '@wordpress/element';
10
+ import { throttle } from '@wordpress/compose';
11
+
12
+ /**
13
+ * Internal dependencies
14
+ */
15
+ import type { View } from '../types';
16
+
17
+ /**
18
+ * Captures an anchor element for scroll position preservation.
19
+ * Finds the element closest to the center of the viewport and stores its position.
20
+ *
21
+ * @param container The scrollable container element.
22
+ * @param anchorElementRef Ref to store the anchor element data.
23
+ * @param direction The scroll direction ('up' or 'down').
24
+ * @return Whether an anchor element was successfully captured.
25
+ */
26
+ function captureAnchorElement(
27
+ container: HTMLElement,
28
+ anchorElementRef: React.MutableRefObject< {
29
+ posinset: number;
30
+ viewportOffset: number;
31
+ direction: 'up' | 'down' | null;
32
+ } | null >,
33
+ direction: 'up' | 'down'
34
+ ): boolean {
35
+ // Find a visible element to use as anchor - prefer one in the middle of the viewport
36
+ const containerRect = container.getBoundingClientRect();
37
+ const centerY = containerRect.top + containerRect.height / 2;
38
+
39
+ // Query all items with aria-posinset and find the one closest to center
40
+ const items = Array.from( container.querySelectorAll( '[aria-posinset]' ) );
41
+
42
+ if ( items.length === 0 ) {
43
+ return false;
44
+ }
45
+
46
+ // Find the item closest to the center of the viewport
47
+ const bestAnchor = items.reduce( ( best, item ) => {
48
+ const itemRect = item.getBoundingClientRect();
49
+ const itemCenterY = itemRect.top + itemRect.height / 2;
50
+ const distance = Math.abs( itemCenterY - centerY );
51
+
52
+ const bestRect = best.getBoundingClientRect();
53
+ const bestCenterY = bestRect.top + bestRect.height / 2;
54
+ const bestDistance = Math.abs( bestCenterY - centerY );
55
+
56
+ return distance < bestDistance ? item : best;
57
+ } );
58
+
59
+ const posinset = Number( bestAnchor.getAttribute( 'aria-posinset' ) );
60
+ const anchorRect = bestAnchor.getBoundingClientRect();
61
+ anchorElementRef.current = {
62
+ posinset,
63
+ viewportOffset: anchorRect.top - containerRect.top,
64
+ direction,
65
+ };
66
+ return true;
67
+ }
68
+
69
+ type UseInfiniteScrollProps = {
70
+ view: View;
71
+ onChangeView: ( view: View ) => void;
72
+ isLoading: boolean;
73
+ paginationInfo: {
74
+ totalItems: number;
75
+ totalPages: number;
76
+ };
77
+ containerRef: React.MutableRefObject< HTMLDivElement | null >;
78
+ setVisibleEntries?: React.Dispatch< React.SetStateAction< number[] > >;
79
+ };
80
+
81
+ type UseInfiniteScrollResult = {
82
+ intersectionObserver?: IntersectionObserver | null;
83
+ };
84
+
85
+ export function useInfiniteScroll( {
86
+ view,
87
+ onChangeView,
88
+ isLoading,
89
+ paginationInfo,
90
+ containerRef,
91
+ setVisibleEntries,
92
+ }: UseInfiniteScrollProps ): UseInfiniteScrollResult {
93
+ // Track an anchor element for scroll position preservation
94
+ // This approach is robust even when items are added/removed from both ends simultaneously
95
+ const anchorElementRef = useRef< {
96
+ posinset: number;
97
+ viewportOffset: number;
98
+ direction: 'up' | 'down' | null;
99
+ } | null >( null );
100
+ const viewRef = useRef( view );
101
+ const isLoadingRef = useRef( isLoading );
102
+ const onChangeViewRef = useRef( onChangeView );
103
+ const totalItemsRef = useRef( paginationInfo.totalItems );
104
+
105
+ useLayoutEffect( () => {
106
+ viewRef.current = view;
107
+ isLoadingRef.current = isLoading;
108
+ onChangeViewRef.current = onChangeView;
109
+ totalItemsRef.current = paginationInfo.totalItems;
110
+ }, [ view, isLoading, onChangeView, paginationInfo.totalItems ] );
111
+
112
+ const intersectionObserverCallback: IntersectionObserverCallback =
113
+ useCallback(
114
+ ( entries: IntersectionObserverEntry[] ) => {
115
+ // Calculate new visible entries outside of setState
116
+ if ( ! setVisibleEntries ) {
117
+ return;
118
+ }
119
+ setVisibleEntries( ( prev: number[] ) => {
120
+ const newVisibleEntries = new Set( prev );
121
+ let hasChanged = false;
122
+
123
+ entries.forEach( ( entry ) => {
124
+ const posInSet = Number(
125
+ entry.target?.attributes?.getNamedItem(
126
+ 'aria-posinset'
127
+ )?.value
128
+ );
129
+ if ( isNaN( posInSet ) ) {
130
+ return;
131
+ }
132
+ if ( entry.isIntersecting ) {
133
+ if ( ! newVisibleEntries.has( posInSet ) ) {
134
+ newVisibleEntries.add( posInSet );
135
+ hasChanged = true;
136
+ }
137
+ } else if ( newVisibleEntries.has( posInSet ) ) {
138
+ newVisibleEntries.delete( posInSet );
139
+ hasChanged = true;
140
+ }
141
+ } );
142
+
143
+ // Only return new array if something actually changed
144
+ return hasChanged
145
+ ? Array.from( newVisibleEntries ).sort()
146
+ : prev;
147
+ } );
148
+ },
149
+ [ setVisibleEntries ]
150
+ );
151
+
152
+ // Preserve scroll position when items are added or removed during infinite scroll
153
+ // Uses anchor element approach: find the same element after render and restore its viewport position
154
+ useLayoutEffect( () => {
155
+ const container = containerRef.current;
156
+ const anchor = anchorElementRef.current;
157
+
158
+ if (
159
+ ! container ||
160
+ ! view.infiniteScrollEnabled ||
161
+ ! anchor ||
162
+ isLoading
163
+ ) {
164
+ return;
165
+ }
166
+
167
+ // Find the anchor element by its posinset
168
+ const anchorElement = container.querySelector(
169
+ `[aria-posinset="${ anchor.posinset }"]`
170
+ );
171
+
172
+ if ( anchorElement ) {
173
+ const containerRect = container.getBoundingClientRect();
174
+ const anchorRect = anchorElement.getBoundingClientRect();
175
+ const currentOffset = anchorRect.top - containerRect.top;
176
+
177
+ // Calculate how much the anchor has moved and adjust scroll to compensate
178
+ const scrollAdjustment = currentOffset - anchor.viewportOffset;
179
+
180
+ if ( Math.abs( scrollAdjustment ) > 1 ) {
181
+ container.scrollTop += scrollAdjustment;
182
+ }
183
+ }
184
+
185
+ // Reset the anchor state now that we've adjusted
186
+ anchorElementRef.current = null;
187
+ }, [ containerRef, isLoading, view.infiniteScrollEnabled ] );
188
+
189
+ // Create and expose a shared IntersectionObserver for provider-level reuse.
190
+ const intersectionObserverRef = useRef< IntersectionObserver | null >(
191
+ null
192
+ );
193
+ useEffect( () => {
194
+ if ( ! view.infiniteScrollEnabled || ! intersectionObserverCallback ) {
195
+ if ( intersectionObserverRef.current ) {
196
+ intersectionObserverRef.current.disconnect();
197
+ intersectionObserverRef.current = null;
198
+ }
199
+ return;
200
+ }
201
+
202
+ intersectionObserverRef.current = new IntersectionObserver(
203
+ intersectionObserverCallback,
204
+ { root: null, rootMargin: '0px', threshold: 0.1 }
205
+ );
206
+
207
+ return () => {
208
+ if ( intersectionObserverRef.current ) {
209
+ intersectionObserverRef.current.disconnect();
210
+ intersectionObserverRef.current = null;
211
+ }
212
+ };
213
+ }, [ view.infiniteScrollEnabled, intersectionObserverCallback ] );
214
+
215
+ // Attach scroll event listener for infinite scroll
216
+ useEffect( () => {
217
+ if ( ! view.infiniteScrollEnabled || ! containerRef.current ) {
218
+ return;
219
+ }
220
+
221
+ let lastScrollTop = 0;
222
+ // Use larger thresholds to trigger loading earlier during fast scrolling
223
+ const BOTTOM_THRESHOLD = 600; // px from bottom to trigger load
224
+ const TOP_THRESHOLD = 800; // px from top to trigger load
225
+
226
+ const handleScroll = throttle( ( event: unknown ) => {
227
+ const currentView = viewRef.current;
228
+ const totalItems = totalItemsRef.current;
229
+ const target = ( event as Event ).target as HTMLElement;
230
+ const scrollTop = target.scrollTop;
231
+ const scrollHeight = target.scrollHeight;
232
+ const clientHeight = target.clientHeight;
233
+
234
+ // Determine scroll direction
235
+ const scrollDirection = scrollTop > lastScrollTop ? 'down' : 'up';
236
+ lastScrollTop = scrollTop;
237
+
238
+ // Don't trigger if already loading
239
+ if ( isLoadingRef.current ) {
240
+ return;
241
+ }
242
+
243
+ const currentStartPosition = currentView.startPosition || 1;
244
+ const batchSize = currentView.perPage || 10;
245
+ const currentEndPosition = Math.min(
246
+ currentStartPosition + batchSize,
247
+ totalItems
248
+ );
249
+
250
+ // Check if user has scrolled near the bottom
251
+ if (
252
+ scrollDirection === 'down' &&
253
+ scrollTop + clientHeight >= scrollHeight - BOTTOM_THRESHOLD
254
+ ) {
255
+ // Check if there's more data to load
256
+ if ( currentEndPosition < totalItems ) {
257
+ const newStartPosition = currentEndPosition;
258
+
259
+ // Capture anchor element for scroll position preservation
260
+ captureAnchorElement( target, anchorElementRef, 'down' );
261
+
262
+ onChangeViewRef.current( {
263
+ ...currentView,
264
+ startPosition: newStartPosition,
265
+ } );
266
+ }
267
+ }
268
+
269
+ // Check if user has scrolled near the top
270
+ if ( scrollDirection === 'up' && scrollTop <= TOP_THRESHOLD ) {
271
+ // Check if there's more data to load
272
+ if ( currentStartPosition > 1 ) {
273
+ // Round to 1 if we're close to the beginning to avoid tiny batches
274
+ const calculatedStartPosition =
275
+ currentStartPosition - batchSize;
276
+ const newStartPosition =
277
+ calculatedStartPosition < 6
278
+ ? 1
279
+ : calculatedStartPosition;
280
+
281
+ // Capture anchor element for scroll position preservation
282
+ captureAnchorElement( target, anchorElementRef, 'up' );
283
+
284
+ onChangeViewRef.current( {
285
+ ...currentView,
286
+ startPosition: newStartPosition,
287
+ } );
288
+ }
289
+ }
290
+ }, 50 ); // Faster throttle (50ms) for better response to fast scrolling
291
+
292
+ const container = containerRef.current;
293
+ container.addEventListener( 'scroll', handleScroll );
294
+
295
+ return () => {
296
+ container.removeEventListener( 'scroll', handleScroll );
297
+ handleScroll.cancel(); // Cancel any pending throttled calls
298
+ };
299
+ }, [ containerRef, view.infiniteScrollEnabled ] );
300
+
301
+ return {
302
+ intersectionObserver: intersectionObserverRef.current,
303
+ };
304
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useMemo, useRef } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import type { View } from '../types';
10
+
11
+ /**
12
+ * Hook to get selected items, with support for infinite scroll.
13
+ *
14
+ * When infinite scroll is enabled, items that scroll out of view are cached
15
+ * so they remain available for bulk actions even when not in the current data set.
16
+ *
17
+ * @param view The current view configuration.
18
+ * @param data The current page of data items.
19
+ * @param selection Array of selected item IDs.
20
+ * @param getItemId Function to get the ID of an item.
21
+ * @param filterFn Optional filter function to apply to selected items (e.g., for selectability).
22
+ * @return Array of selected items.
23
+ */
24
+ export default function useSelectedItems< Item >(
25
+ view: View,
26
+ data: Item[],
27
+ selection: string[],
28
+ getItemId: ( item: Item ) => string,
29
+ filterFn?: ( item: Item ) => boolean
30
+ ): Item[] {
31
+ // With infinite scroll enabled items scroll out of view, but we want to keep the selection unaltered,
32
+ // unlike page-based navigation where we might clear selection upon navigating to a different page.
33
+ const selectedItemsCacheRef = useRef< Map< string, Item > >( new Map() );
34
+
35
+ return useMemo( () => {
36
+ const selectionSet = new Set( selection );
37
+
38
+ if ( view.infiniteScrollEnabled ) {
39
+ // Selection array contains selected item IDs
40
+ // Cache selected items so they remain available when scrolled out of view
41
+ data.forEach( ( item ) => {
42
+ const id = getItemId( item );
43
+ if ( selectionSet.has( id ) ) {
44
+ const passesFilter = filterFn ? filterFn( item ) : true;
45
+ if ( passesFilter ) {
46
+ selectedItemsCacheRef.current.set( id, item );
47
+ }
48
+ }
49
+ } );
50
+
51
+ // Remove items from cache that are no longer selected
52
+ selectedItemsCacheRef.current.forEach( ( _, id ) => {
53
+ if ( ! selectionSet.has( id ) ) {
54
+ selectedItemsCacheRef.current.delete( id );
55
+ }
56
+ } );
57
+
58
+ // Return all cached selected items
59
+ return Array.from( selectedItemsCacheRef.current.values() );
60
+ }
61
+
62
+ // Non-infinite scroll mode
63
+ return data.filter( ( item ) => {
64
+ const id = getItemId( item );
65
+ if ( ! selectionSet.has( id ) ) {
66
+ return false;
67
+ }
68
+ // Apply optional filter (e.g., selectability check for bulk actions)
69
+ return filterFn ? filterFn( item ) : true;
70
+ } );
71
+ }, [ view.infiniteScrollEnabled, selection, getItemId, data, filterFn ] );
72
+ }