@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.
- package/CHANGELOG.md +66 -0
- package/README.md +68 -0
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
- package/dist/Banner/Banner.d.ts +7 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +9 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +2 -0
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +5 -1
- package/dist/ContextMenu/ContextMenu.js +2 -2
- package/dist/DropdownMenu/DropdownMenu.js +2 -2
- package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
- package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
- package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js +7 -1
- package/dist/HoverCard/HoverCard.d.ts +2 -2
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +18 -6
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -3
- package/dist/Layer/useLayer.d.ts +13 -0
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +7 -2
- package/dist/Layout/Layout.d.ts +10 -1
- package/dist/Layout/Layout.d.ts.map +1 -1
- package/dist/Layout/Layout.js +5 -1
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +13 -3
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +13 -0
- package/dist/Outline/Outline.d.ts +3 -2
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +23 -4
- package/dist/Outline/useScrollSpy.d.ts +14 -1
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +161 -50
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +31 -27
- package/dist/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +1 -5
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +1 -1
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +26 -8
- package/dist/Table/Table.d.ts.map +1 -1
- package/dist/Table/Table.js +30 -7
- package/dist/Table/index.d.ts +3 -1
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +1 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/index.js +3 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
- package/dist/Table/types.d.ts +90 -5
- package/dist/Table/types.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +1 -1
- package/dist/ToggleButton/ToggleButton.d.ts +10 -3
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +64 -18
- package/dist/astryx.css +11 -0
- package/dist/astryx.umd.js +147 -0
- package/dist/astryx.umd.js.map +7 -0
- package/dist/theme/Theme.js +1 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
- package/dist/theme/tokens.d.ts +1 -1
- package/dist/theme/tokens.js +4 -4
- package/dist/theme/useTheme.d.ts +2 -2
- package/dist/utils/dateParser.d.ts.map +1 -1
- package/dist/utils/dateParser.js +15 -2
- package/package.json +7 -3
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
- package/src/Banner/Banner.test.tsx +16 -7
- package/src/Banner/Banner.tsx +9 -2
- package/src/Button/Button.test.tsx +26 -11
- package/src/Button/Button.tsx +2 -0
- package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
- package/src/Collapsible/useCollapsible.doc.mjs +2 -2
- package/src/ContextMenu/ContextMenu.tsx +2 -2
- package/src/DateInput/DateInput.test.tsx +68 -20
- package/src/Divider/Divider.doc.mjs +1 -1
- package/src/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
- package/src/EmptyState/EmptyState.test.tsx +4 -2
- package/src/EmptyState/EmptyState.tsx +6 -2
- package/src/FormLayout/FormLayout.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.doc.mjs +3 -0
- package/src/HoverCard/HoverCard.test.tsx +178 -2
- package/src/HoverCard/HoverCard.tsx +20 -16
- package/src/HoverCard/useHoverCard.tsx +12 -10
- package/src/Icon/Icon.doc.mjs +4 -4
- package/src/Item/Item.doc.mjs +2 -2
- package/src/Layer/useLayer.doc.mjs +7 -2
- package/src/Layer/useLayer.tsx +19 -2
- package/src/Layout/Layout.doc.mjs +2 -1
- package/src/Layout/Layout.tsx +15 -1
- package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
- package/src/Lightbox/Lightbox.doc.mjs +0 -2
- package/src/Link/Link.doc.mjs +3 -3
- package/src/Link/LinkProvider.doc.mjs +3 -3
- package/src/Markdown/Markdown.doc.mjs +6 -4
- package/src/Markdown/Markdown.test.tsx +17 -26
- package/src/Markdown/Markdown.tsx +16 -6
- package/src/MobileNav/MobileNav.doc.mjs +8 -8
- package/src/MobileNav/MobileNav.tsx +13 -0
- package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
- package/src/Outline/Outline.doc.mjs +1 -1
- package/src/Outline/Outline.test.tsx +76 -38
- package/src/Outline/Outline.tsx +23 -4
- package/src/Outline/useScrollSpy.ts +196 -63
- package/src/Pagination/Pagination.test.tsx +137 -13
- package/src/Pagination/Pagination.tsx +33 -28
- package/src/Resizable/Resizable.doc.mjs +3 -3
- package/src/Resizable/useResizable.ts +1 -7
- package/src/Selector/Selector.doc.mjs +4 -0
- package/src/Selector/Selector.tsx +5 -6
- package/src/Skeleton/Skeleton.doc.mjs +11 -1
- package/src/Table/BaseTable.tsx +50 -24
- package/src/Table/Table.doc.mjs +3 -3
- package/src/Table/Table.tsx +22 -1
- package/src/Table/index.ts +3 -0
- package/src/Table/plugins/stickyColumns/index.ts +4 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
- package/src/Table/types.ts +96 -4
- package/src/Table/useBaseTablePlugins.ts +1 -0
- package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
- package/src/ToggleButton/ToggleButton.test.tsx +148 -6
- package/src/ToggleButton/ToggleButton.tsx +83 -20
- package/src/Toolbar/Toolbar.doc.mjs +1 -1
- package/src/hooks/useEntryAnimation.doc.mjs +3 -3
- package/src/hooks/useMediaQuery.doc.mjs +2 -2
- package/src/hooks/useStreamingText.doc.mjs +3 -3
- package/src/theme/Theme.doc.mjs +2 -2
- package/src/theme/Theme.tsx +1 -1
- package/src/theme/defineTheme.ts +1 -1
- package/src/theme/index.ts +1 -1
- package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
- package/src/theme/tokens.ts +4 -4
- package/src/theme/useTheme.ts +2 -2
- package/src/utils/dateParser.test.ts +26 -0
- package/src/utils/dateParser.ts +16 -2
- package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
package/src/Outline/Outline.tsx
CHANGED
|
@@ -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
|
|
221
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
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):
|
|
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
|
|
159
|
+
if (isControlled || typeof window === 'undefined') {
|
|
64
160
|
return;
|
|
65
161
|
}
|
|
66
162
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
.filter((element): element is HTMLElement => element != null);
|
|
163
|
+
const scrollRoot = getScrollableAncestor(rootRef.current);
|
|
164
|
+
const scrollTarget: HTMLElement | Window = scrollRoot ?? window;
|
|
70
165
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
129
|
-
visibleHeadingIds.clear();
|
|
130
|
-
headingTop.clear();
|
|
203
|
+
releaseSuppressionRef.current?.();
|
|
131
204
|
};
|
|
132
|
-
}, [
|
|
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
|
-
|
|
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'}));
|