@astryxdesign/core 0.1.0 → 0.1.1-canary.129bf0e

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 (155) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +68 -0
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts.map +1 -1
  10. package/dist/Button/Button.js +2 -0
  11. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  12. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  13. package/dist/ContextMenu/ContextMenu.js +2 -2
  14. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  15. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  16. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  17. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  18. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  19. package/dist/EmptyState/EmptyState.js +7 -1
  20. package/dist/HoverCard/HoverCard.d.ts +2 -2
  21. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  22. package/dist/HoverCard/HoverCard.js +18 -6
  23. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/useHoverCard.js +6 -3
  25. package/dist/Layer/useLayer.d.ts +13 -0
  26. package/dist/Layer/useLayer.d.ts.map +1 -1
  27. package/dist/Layer/useLayer.js +7 -2
  28. package/dist/Layout/Layout.d.ts +10 -1
  29. package/dist/Layout/Layout.d.ts.map +1 -1
  30. package/dist/Layout/Layout.js +5 -1
  31. package/dist/Markdown/Markdown.d.ts.map +1 -1
  32. package/dist/Markdown/Markdown.js +13 -3
  33. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  34. package/dist/MobileNav/MobileNav.js +13 -0
  35. package/dist/Outline/Outline.d.ts +3 -2
  36. package/dist/Outline/Outline.d.ts.map +1 -1
  37. package/dist/Outline/Outline.js +23 -4
  38. package/dist/Outline/useScrollSpy.d.ts +14 -1
  39. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  40. package/dist/Outline/useScrollSpy.js +161 -50
  41. package/dist/Pagination/Pagination.d.ts.map +1 -1
  42. package/dist/Pagination/Pagination.js +31 -27
  43. package/dist/Resizable/useResizable.d.ts.map +1 -1
  44. package/dist/Resizable/useResizable.js +1 -5
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +1 -1
  47. package/dist/Table/BaseTable.d.ts.map +1 -1
  48. package/dist/Table/BaseTable.js +26 -8
  49. package/dist/Table/Table.d.ts.map +1 -1
  50. package/dist/Table/Table.js +30 -7
  51. package/dist/Table/index.d.ts +3 -1
  52. package/dist/Table/index.d.ts.map +1 -1
  53. package/dist/Table/index.js +1 -0
  54. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  55. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  56. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  57. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  58. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  59. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
  60. package/dist/Table/types.d.ts +90 -5
  61. package/dist/Table/types.d.ts.map +1 -1
  62. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  63. package/dist/Table/useBaseTablePlugins.js +1 -1
  64. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  65. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  66. package/dist/ToggleButton/ToggleButton.js +64 -18
  67. package/dist/astryx.css +11 -0
  68. package/dist/astryx.umd.js +147 -0
  69. package/dist/astryx.umd.js.map +7 -0
  70. package/dist/theme/Theme.js +1 -1
  71. package/dist/theme/defineTheme.d.ts +1 -1
  72. package/dist/theme/defineTheme.d.ts.map +1 -1
  73. package/dist/theme/defineTheme.js +1 -1
  74. package/dist/theme/index.d.ts +1 -1
  75. package/dist/theme/index.d.ts.map +1 -1
  76. package/dist/theme/index.js +1 -1
  77. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  78. package/dist/theme/tokens.d.ts +1 -1
  79. package/dist/theme/tokens.js +4 -4
  80. package/dist/theme/useTheme.d.ts +2 -2
  81. package/dist/utils/dateParser.d.ts.map +1 -1
  82. package/dist/utils/dateParser.js +15 -2
  83. package/package.json +7 -3
  84. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  85. package/src/Banner/Banner.test.tsx +16 -7
  86. package/src/Banner/Banner.tsx +9 -2
  87. package/src/Button/Button.test.tsx +26 -11
  88. package/src/Button/Button.tsx +2 -0
  89. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  90. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  91. package/src/ContextMenu/ContextMenu.tsx +2 -2
  92. package/src/DateInput/DateInput.test.tsx +68 -20
  93. package/src/Divider/Divider.doc.mjs +1 -1
  94. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  95. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  96. package/src/EmptyState/EmptyState.test.tsx +4 -2
  97. package/src/EmptyState/EmptyState.tsx +6 -2
  98. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  99. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  100. package/src/HoverCard/HoverCard.test.tsx +178 -2
  101. package/src/HoverCard/HoverCard.tsx +20 -16
  102. package/src/HoverCard/useHoverCard.tsx +12 -10
  103. package/src/Icon/Icon.doc.mjs +4 -4
  104. package/src/Item/Item.doc.mjs +2 -2
  105. package/src/Layer/useLayer.doc.mjs +7 -2
  106. package/src/Layer/useLayer.tsx +19 -2
  107. package/src/Layout/Layout.doc.mjs +2 -1
  108. package/src/Layout/Layout.tsx +15 -1
  109. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  110. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  111. package/src/Link/Link.doc.mjs +3 -3
  112. package/src/Link/LinkProvider.doc.mjs +3 -3
  113. package/src/Markdown/Markdown.doc.mjs +6 -4
  114. package/src/Markdown/Markdown.test.tsx +17 -26
  115. package/src/Markdown/Markdown.tsx +16 -6
  116. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  117. package/src/MobileNav/MobileNav.tsx +13 -0
  118. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  119. package/src/Outline/Outline.doc.mjs +1 -1
  120. package/src/Outline/Outline.test.tsx +76 -38
  121. package/src/Outline/Outline.tsx +23 -4
  122. package/src/Outline/useScrollSpy.ts +196 -63
  123. package/src/Pagination/Pagination.test.tsx +137 -13
  124. package/src/Pagination/Pagination.tsx +33 -28
  125. package/src/Resizable/Resizable.doc.mjs +3 -3
  126. package/src/Resizable/useResizable.ts +1 -7
  127. package/src/Selector/Selector.doc.mjs +4 -0
  128. package/src/Selector/Selector.tsx +5 -6
  129. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  130. package/src/Table/BaseTable.tsx +50 -24
  131. package/src/Table/Table.doc.mjs +3 -3
  132. package/src/Table/Table.tsx +22 -1
  133. package/src/Table/index.ts +3 -0
  134. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  135. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  136. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
  137. package/src/Table/types.ts +96 -4
  138. package/src/Table/useBaseTablePlugins.ts +1 -0
  139. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  140. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  141. package/src/ToggleButton/ToggleButton.tsx +83 -20
  142. package/src/Toolbar/Toolbar.doc.mjs +1 -1
  143. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  144. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  145. package/src/hooks/useStreamingText.doc.mjs +3 -3
  146. package/src/theme/Theme.doc.mjs +2 -2
  147. package/src/theme/Theme.tsx +1 -1
  148. package/src/theme/defineTheme.ts +1 -1
  149. package/src/theme/index.ts +1 -1
  150. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  151. package/src/theme/tokens.ts +4 -4
  152. package/src/theme/useTheme.ts +2 -2
  153. package/src/utils/dateParser.test.ts +26 -0
  154. package/src/utils/dateParser.ts +16 -2
  155. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -217,8 +217,9 @@ function getIndentStyle(level: number) {
217
217
  * indentation based on each heading level. Features a sliding indicator
218
218
  * track that animates to the active item.
219
219
  *
220
- * When `activeId` is omitted, it observes heading elements by id and marks
221
- * the topmost visible heading active.
220
+ * When `activeId` is omitted, it tracks scroll position and marks the last
221
+ * heading whose top has passed its activation line (its scroll-margin-top)
222
+ * active — defaulting to the first item at the top and the last at the bottom.
222
223
  *
223
224
  * @example
224
225
  * ```
@@ -246,7 +247,12 @@ export function Outline({
246
247
  }: OutlineProps) {
247
248
  const rootRef = useRef<HTMLElement | null>(null);
248
249
  const LinkComponent = useLinkComponent();
249
- const [resolvedActiveId, setActiveId] = useScrollSpy({
250
+ const isControlled = activeId !== undefined;
251
+ const {
252
+ activeId: resolvedActiveId,
253
+ setActiveId,
254
+ lockActiveId,
255
+ } = useScrollSpy({
250
256
  activeId,
251
257
  items,
252
258
  onActiveIdChange,
@@ -256,8 +262,9 @@ export function Outline({
256
262
  const handleClick =
257
263
  (id: string) => (event: React.MouseEvent<HTMLElement>) => {
258
264
  const target = document.getElementById(id);
259
- setActiveId(id);
260
265
 
266
+ // Let the browser handle modified clicks (open in new tab, etc.) and
267
+ // missing targets without touching the active state.
261
268
  if (
262
269
  target == null ||
263
270
  event.defaultPrevented ||
@@ -271,6 +278,18 @@ export function Outline({
271
278
 
272
279
  event.preventDefault();
273
280
  window.history.pushState(null, '', `#${id}`);
281
+
282
+ // Move the indicator to the clicked item in a single step. Controlled
283
+ // consumers own the active state (notify only); uncontrolled mode pins
284
+ // the active id and suppresses scroll-spy until the next manual scroll,
285
+ // so the click is honored — even for short/last sections — and the
286
+ // indicator doesn't chase the smooth scroll through other sections.
287
+ if (isControlled) {
288
+ setActiveId(id);
289
+ } else {
290
+ lockActiveId(id);
291
+ }
292
+
274
293
  target.scrollIntoView({behavior: 'smooth', block: 'start'});
275
294
  };
276
295
 
@@ -4,17 +4,39 @@
4
4
 
5
5
  /**
6
6
  * @file useScrollSpy.ts
7
- * @input Uses React, IntersectionObserver, OutlineItem type
7
+ * @input Uses React, scroll position of heading elements, OutlineItem type
8
8
  * @output Exports internal useScrollSpy hook
9
9
  * @position Internal behavior hook; consumed by Outline.tsx
10
10
  *
11
+ * Drives the active outline item from scroll position. On each scroll
12
+ * (rAF-throttled) it reads live heading positions and marks the last heading
13
+ * whose top has passed its activation line (its own scroll-margin-top, i.e.
14
+ * where it lands when navigated to). This is stable — it never compares stale
15
+ * cached positions — so the indicator moves monotonically instead of jumping.
16
+ * Defaults to the first item at the top and the last item at the bottom so
17
+ * short final sections still activate.
18
+ *
11
19
  * SYNC: When modified, update /packages/core/src/Outline/Outline.tsx
12
20
  */
13
21
 
14
- import {useEffect, useRef, useState} from 'react';
22
+ import {useCallback, useEffect, useRef, useState} from 'react';
15
23
  import type {OutlineItem} from './types';
16
24
 
17
- function getScrollableAncestor(element: HTMLElement | null): Element | null {
25
+ /** Keys that scroll the viewport used to detect a manual scroll intent. */
26
+ const SCROLL_KEYS = new Set([
27
+ 'ArrowUp',
28
+ 'ArrowDown',
29
+ 'PageUp',
30
+ 'PageDown',
31
+ 'Home',
32
+ 'End',
33
+ ' ',
34
+ 'Spacebar',
35
+ ]);
36
+
37
+ function getScrollableAncestor(
38
+ element: HTMLElement | null,
39
+ ): HTMLElement | null {
18
40
  let current = element?.parentElement ?? null;
19
41
 
20
42
  while (current != null) {
@@ -36,6 +58,55 @@ function getScrollableAncestor(element: HTMLElement | null): Element | null {
36
58
  return null;
37
59
  }
38
60
 
61
+ /**
62
+ * Resolve the active heading id from current scroll position.
63
+ *
64
+ * A heading is "passed" once its top reaches its activation line — the scroll
65
+ * root's top plus the heading's own scroll-margin-top. The active heading is
66
+ * the last passed one (headings are in document order). When none have passed
67
+ * (scrolled above the first), the first item is active; at the bottom, the
68
+ * last item is active.
69
+ */
70
+ function resolveActiveId(
71
+ items: OutlineItem[],
72
+ scrollRoot: HTMLElement | null,
73
+ ): string | undefined {
74
+ if (items.length === 0) {
75
+ return undefined;
76
+ }
77
+
78
+ const rootTop =
79
+ scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0;
80
+
81
+ const atBottom =
82
+ scrollRoot != null
83
+ ? scrollRoot.scrollTop + scrollRoot.clientHeight >=
84
+ scrollRoot.scrollHeight - 2
85
+ : window.innerHeight + window.scrollY >=
86
+ document.documentElement.scrollHeight - 2;
87
+ if (atBottom) {
88
+ return items[items.length - 1].id;
89
+ }
90
+
91
+ let activeId = items[0].id;
92
+ for (const item of items) {
93
+ const element = document.getElementById(item.id);
94
+ if (element == null) {
95
+ continue;
96
+ }
97
+ const top = element.getBoundingClientRect().top;
98
+ const marginTop =
99
+ Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0;
100
+
101
+ if (top <= rootTop + marginTop + 1) {
102
+ activeId = item.id;
103
+ } else {
104
+ break;
105
+ }
106
+ }
107
+ return activeId;
108
+ }
109
+
39
110
  interface UseScrollSpyOptions {
40
111
  activeId?: string;
41
112
  items: OutlineItem[];
@@ -43,93 +114,95 @@ interface UseScrollSpyOptions {
43
114
  rootRef: React.RefObject<HTMLElement | null>;
44
115
  }
45
116
 
117
+ interface UseScrollSpyResult {
118
+ activeId: string | undefined;
119
+ /** Set the active id (notifies onActiveIdChange). For controlled consumers. */
120
+ setActiveId: (id: string) => void;
121
+ /**
122
+ * Handle a click on the outline item with id `id`. Delays moving the
123
+ * indicator: scroll-spy is suppressed during the programmatic smooth scroll
124
+ * so the indicator doesn't chase it, then the indicator moves once to the
125
+ * clicked item when the scroll settles. If the user scrolls manually mid-way,
126
+ * scroll-position tracking resumes immediately instead.
127
+ */
128
+ lockActiveId: (id: string) => void;
129
+ }
130
+
46
131
  export function useScrollSpy({
47
132
  activeId,
48
133
  items,
49
134
  onActiveIdChange,
50
135
  rootRef,
51
- }: UseScrollSpyOptions): [string | undefined, (id: string) => void] {
136
+ }: UseScrollSpyOptions): UseScrollSpyResult {
52
137
  const isControlled = activeId !== undefined;
53
138
  const [uncontrolledActiveId, setUncontrolledActiveId] = useState<
54
139
  string | undefined
55
140
  >(items[0]?.id);
56
- const visibleHeadingIdsRef = useRef<Set<string>>(new Set());
57
- const headingTopRef = useRef<Map<string, number>>(new Map());
58
141
  const activeIdRef = useRef<string | undefined>(activeId);
142
+ // While true, scroll-spy ignores scroll updates because a click is driving a
143
+ // programmatic scroll. Released when that scroll settles or the user scrolls.
144
+ const suppressRef = useRef(false);
145
+ const releaseSuppressionRef = useRef<(() => void) | null>(null);
146
+ // Latest scroll-position resolver, so the click handler can resume tracking
147
+ // when the user scrolls during a programmatic scroll.
148
+ const syncRef = useRef<(() => void) | null>(null);
149
+ // Keep latest items/callback in refs so the scroll listener effect doesn't
150
+ // re-subscribe on every render (items is a fresh array each render).
151
+ const itemsRef = useRef(items);
152
+ itemsRef.current = items;
153
+ const onActiveIdChangeRef = useRef(onActiveIdChange);
154
+ onActiveIdChangeRef.current = onActiveIdChange;
59
155
  const itemIds = items.map(item => item.id).join('\n');
60
156
  activeIdRef.current = isControlled ? activeId : uncontrolledActiveId;
61
157
 
62
158
  useEffect(() => {
63
- if (isControlled || typeof IntersectionObserver === 'undefined') {
159
+ if (isControlled || typeof window === 'undefined') {
64
160
  return;
65
161
  }
66
162
 
67
- const headingElements = items
68
- .map(item => document.getElementById(item.id))
69
- .filter((element): element is HTMLElement => element != null);
163
+ const scrollRoot = getScrollableAncestor(rootRef.current);
164
+ const scrollTarget: HTMLElement | Window = scrollRoot ?? window;
70
165
 
71
- if (headingElements.length === 0) {
72
- return;
73
- }
74
-
75
- const visibleHeadingIds = visibleHeadingIdsRef.current;
76
- const headingTop = headingTopRef.current;
77
-
78
- const setNextActiveId = (nextActiveId: string) => {
79
- if (activeIdRef.current === nextActiveId) {
166
+ let frame = 0;
167
+ const update = () => {
168
+ frame = 0;
169
+ if (suppressRef.current) {
80
170
  return;
81
171
  }
82
- activeIdRef.current = nextActiveId;
83
- setUncontrolledActiveId(nextActiveId);
84
- onActiveIdChange?.(nextActiveId);
172
+ const nextActiveId = resolveActiveId(itemsRef.current, scrollRoot);
173
+ if (nextActiveId != null && nextActiveId !== activeIdRef.current) {
174
+ activeIdRef.current = nextActiveId;
175
+ setUncontrolledActiveId(nextActiveId);
176
+ onActiveIdChangeRef.current?.(nextActiveId);
177
+ }
85
178
  };
86
-
87
- const chooseActiveHeading = () => {
88
- let nextActiveId: string | undefined;
89
- let nextTop = Number.POSITIVE_INFINITY;
90
-
91
- for (const id of visibleHeadingIds) {
92
- const top = headingTop.get(id) ?? Number.POSITIVE_INFINITY;
93
- if (top < nextTop) {
94
- nextTop = top;
95
- nextActiveId = id;
96
- }
179
+ const onScroll = () => {
180
+ if (frame === 0) {
181
+ frame = requestAnimationFrame(update);
97
182
  }
183
+ };
184
+
185
+ syncRef.current = update;
186
+ update();
187
+ scrollTarget.addEventListener('scroll', onScroll, {passive: true});
188
+ window.addEventListener('resize', onScroll, {passive: true});
98
189
 
99
- if (nextActiveId != null) {
100
- setNextActiveId(nextActiveId);
190
+ return () => {
191
+ syncRef.current = null;
192
+ scrollTarget.removeEventListener('scroll', onScroll);
193
+ window.removeEventListener('resize', onScroll);
194
+ if (frame !== 0) {
195
+ cancelAnimationFrame(frame);
101
196
  }
102
197
  };
198
+ }, [isControlled, itemIds, rootRef]);
103
199
 
104
- const observer = new IntersectionObserver(
105
- entries => {
106
- for (const entry of entries) {
107
- const id = entry.target.id;
108
- headingTop.set(id, entry.boundingClientRect.top);
109
- if (entry.isIntersecting) {
110
- visibleHeadingIds.add(id);
111
- } else {
112
- visibleHeadingIds.delete(id);
113
- }
114
- }
115
- chooseActiveHeading();
116
- },
117
- {
118
- root: getScrollableAncestor(rootRef.current),
119
- threshold: 0,
120
- },
121
- );
122
-
123
- for (const headingElement of headingElements) {
124
- observer.observe(headingElement);
125
- }
126
-
200
+ // Tear down any pending suppression listeners when the Outline unmounts.
201
+ useEffect(() => {
127
202
  return () => {
128
- observer.disconnect();
129
- visibleHeadingIds.clear();
130
- headingTop.clear();
203
+ releaseSuppressionRef.current?.();
131
204
  };
132
- }, [isControlled, itemIds, items, onActiveIdChange, rootRef]);
205
+ }, []);
133
206
 
134
207
  const setActiveId = (nextActiveId: string) => {
135
208
  if (!isControlled) {
@@ -138,5 +211,65 @@ export function useScrollSpy({
138
211
  onActiveIdChange?.(nextActiveId);
139
212
  };
140
213
 
141
- return [isControlled ? activeId : uncontrolledActiveId, setActiveId];
214
+ const lockActiveId = useCallback((clickedId: string) => {
215
+ if (typeof window === 'undefined') {
216
+ setUncontrolledActiveId(clickedId);
217
+ activeIdRef.current = clickedId;
218
+ onActiveIdChangeRef.current?.(clickedId);
219
+ return;
220
+ }
221
+
222
+ // Freeze the indicator during the programmatic smooth scroll instead of
223
+ // moving it immediately — it lands on the clicked item once the scroll
224
+ // settles, so it doesn't chase the scroll through intervening sections.
225
+ suppressRef.current = true;
226
+ // Replace any in-flight handlers from a previous click.
227
+ releaseSuppressionRef.current?.();
228
+
229
+ let settleTimer = 0;
230
+ const cleanup = () => {
231
+ window.removeEventListener('scrollend', onSettle);
232
+ window.removeEventListener('wheel', onManual);
233
+ window.removeEventListener('touchmove', onManual);
234
+ window.removeEventListener('keydown', onKeyDown);
235
+ if (settleTimer !== 0) {
236
+ clearTimeout(settleTimer);
237
+ settleTimer = 0;
238
+ }
239
+ releaseSuppressionRef.current = null;
240
+ };
241
+ // Programmatic scroll finished: move the indicator to the clicked item.
242
+ const onSettle = () => {
243
+ cleanup();
244
+ suppressRef.current = false;
245
+ setUncontrolledActiveId(clickedId);
246
+ activeIdRef.current = clickedId;
247
+ onActiveIdChangeRef.current?.(clickedId);
248
+ };
249
+ // User scrolled mid-flight: hand control back to scroll-position tracking.
250
+ const onManual = () => {
251
+ cleanup();
252
+ suppressRef.current = false;
253
+ syncRef.current?.();
254
+ };
255
+ const onKeyDown = (event: KeyboardEvent) => {
256
+ if (SCROLL_KEYS.has(event.key)) {
257
+ onManual();
258
+ }
259
+ };
260
+
261
+ window.addEventListener('scrollend', onSettle, {once: true});
262
+ window.addEventListener('wheel', onManual, {passive: true});
263
+ window.addEventListener('touchmove', onManual, {passive: true});
264
+ window.addEventListener('keydown', onKeyDown);
265
+ // Fallback when scrollend is unsupported or no scroll is needed.
266
+ settleTimer = window.setTimeout(onSettle, 1200);
267
+ releaseSuppressionRef.current = cleanup;
268
+ }, []);
269
+
270
+ return {
271
+ activeId: isControlled ? activeId : uncontrolledActiveId,
272
+ setActiveId,
273
+ lockActiveId,
274
+ };
142
275
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi} from 'vitest';
13
- import {render, screen, within} from '@testing-library/react';
13
+ import {render, screen, within, fireEvent, act} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {Pagination, generatePageRange} from './Pagination';
16
16
 
@@ -346,6 +346,140 @@ describe('Pagination', () => {
346
346
  });
347
347
  });
348
348
 
349
+ // ---------------------------------------------------------------------------
350
+ // changeAction (interruptible, optimistic)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe('changeAction', () => {
354
+ it('fires onChange then changeAction with the new page', async () => {
355
+ const user = userEvent.setup();
356
+ const order: string[] = [];
357
+ const onChange = vi.fn(() => order.push('onChange'));
358
+ const changeAction = vi.fn(() => {
359
+ order.push('changeAction');
360
+ });
361
+ render(
362
+ <Pagination
363
+ page={1}
364
+ onChange={onChange}
365
+ changeAction={changeAction}
366
+ totalPages={5}
367
+ />,
368
+ );
369
+ await user.click(screen.getByRole('button', {name: 'Go to next page'}));
370
+ expect(onChange).toHaveBeenCalledWith(2);
371
+ expect(changeAction).toHaveBeenCalledWith(2);
372
+ expect(order).toEqual(['onChange', 'changeAction']);
373
+ });
374
+
375
+ it('shows the optimistic page while changeAction is pending', async () => {
376
+ const user = userEvent.setup();
377
+ let resolveAction: (() => void) | undefined;
378
+ const changeAction = vi.fn(
379
+ async () =>
380
+ new Promise<void>(resolve => {
381
+ resolveAction = resolve;
382
+ }),
383
+ );
384
+ render(
385
+ <Pagination
386
+ page={1}
387
+ onChange={() => {}}
388
+ changeAction={changeAction}
389
+ totalPages={5}
390
+ variant="compact"
391
+ />,
392
+ );
393
+
394
+ // The committed `page` prop stays at 1, but the indicator optimistically
395
+ // reflects the page being navigated to.
396
+ await user.click(screen.getByRole('button', {name: 'Go to next page'}));
397
+ expect(changeAction).toHaveBeenCalledWith(2);
398
+ expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
399
+
400
+ await act(async () => {
401
+ resolveAction?.();
402
+ await Promise.resolve();
403
+ });
404
+ });
405
+
406
+ it('interrupts an in-flight action on rapid next clicks', async () => {
407
+ // Each click derives its target from the optimistic page, so clicking
408
+ // next twice before the action settles advances 1 -> 2 -> 3 instead of
409
+ // being dropped by a re-entry guard.
410
+ const resolvers: (() => void)[] = [];
411
+ const changeAction = vi.fn(
412
+ async () =>
413
+ new Promise<void>(resolve => {
414
+ resolvers.push(resolve);
415
+ }),
416
+ );
417
+ render(
418
+ <Pagination
419
+ page={1}
420
+ onChange={() => {}}
421
+ changeAction={changeAction}
422
+ totalPages={5}
423
+ variant="compact"
424
+ />,
425
+ );
426
+
427
+ const next = screen.getByRole('button', {name: 'Go to next page'});
428
+ await act(async () => {
429
+ fireEvent.click(next);
430
+ });
431
+ expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
432
+ await act(async () => {
433
+ fireEvent.click(next);
434
+ });
435
+ expect(screen.getByText('Page 3 of 5')).toBeInTheDocument();
436
+
437
+ expect(changeAction).toHaveBeenCalledTimes(2);
438
+ expect(changeAction).toHaveBeenNthCalledWith(1, 2);
439
+ expect(changeAction).toHaveBeenNthCalledWith(2, 3);
440
+
441
+ await act(async () => {
442
+ resolvers.forEach(resolve => resolve());
443
+ await Promise.resolve();
444
+ });
445
+ });
446
+
447
+ it('supports a synchronous changeAction', async () => {
448
+ const user = userEvent.setup();
449
+ const changeAction = vi.fn((_page: number) => {});
450
+ const onChange = vi.fn();
451
+ render(
452
+ <Pagination
453
+ page={2}
454
+ onChange={onChange}
455
+ changeAction={changeAction}
456
+ totalPages={5}
457
+ />,
458
+ );
459
+ await user.click(
460
+ screen.getByRole('button', {name: 'Go to previous page'}),
461
+ );
462
+ expect(onChange).toHaveBeenCalledWith(1);
463
+ expect(changeAction).toHaveBeenCalledWith(1);
464
+ });
465
+
466
+ it('does not fire changeAction when disabled', async () => {
467
+ const user = userEvent.setup();
468
+ const changeAction = vi.fn();
469
+ render(
470
+ <Pagination
471
+ page={1}
472
+ onChange={() => {}}
473
+ changeAction={changeAction}
474
+ totalPages={5}
475
+ isDisabled
476
+ />,
477
+ );
478
+ await user.click(screen.getByRole('button', {name: 'Go to next page'}));
479
+ expect(changeAction).not.toHaveBeenCalled();
480
+ });
481
+ });
482
+
349
483
  // ---------------------------------------------------------------------------
350
484
  // Boundary states
351
485
  // ---------------------------------------------------------------------------
@@ -459,12 +593,7 @@ describe('Pagination', () => {
459
593
  describe('disabled state', () => {
460
594
  it('disables all page buttons when isDisabled', () => {
461
595
  render(
462
- <Pagination
463
- page={3}
464
- onChange={() => {}}
465
- totalPages={5}
466
- isDisabled
467
- />,
596
+ <Pagination page={3} onChange={() => {}} totalPages={5} isDisabled />,
468
597
  );
469
598
  expect(
470
599
  screen.getByRole('button', {name: 'Go to previous page'}),
@@ -480,12 +609,7 @@ describe('Pagination', () => {
480
609
  const user = userEvent.setup();
481
610
  const onChange = vi.fn();
482
611
  render(
483
- <Pagination
484
- page={3}
485
- onChange={onChange}
486
- totalPages={5}
487
- isDisabled
488
- />,
612
+ <Pagination page={3} onChange={onChange} totalPages={5} isDisabled />,
489
613
  );
490
614
  // Disabled buttons can't be clicked
491
615
  await user.click(screen.getByRole('button', {name: 'Go to page 1'}));