@astryxdesign/core 0.1.0-canary.cfbdec3 → 0.1.0-canary.d1e1201
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/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/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +1 -5
- 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/package.json +1 -1
- 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/Resizable/useResizable.ts +1 -7
- package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
- package/src/ToggleButton/ToggleButton.test.tsx +148 -6
- package/src/ToggleButton/ToggleButton.tsx +83 -20
|
@@ -29,8 +29,9 @@ export interface OutlineProps extends BaseProps<HTMLElement> {
|
|
|
29
29
|
* indentation based on each heading level. Features a sliding indicator
|
|
30
30
|
* track that animates to the active item.
|
|
31
31
|
*
|
|
32
|
-
* When `activeId` is omitted, it
|
|
33
|
-
*
|
|
32
|
+
* When `activeId` is omitted, it tracks scroll position and marks the last
|
|
33
|
+
* heading whose top has passed its activation line (its scroll-margin-top)
|
|
34
|
+
* active — defaulting to the first item at the top and the last at the bottom.
|
|
34
35
|
*
|
|
35
36
|
* @example
|
|
36
37
|
* ```
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Outline.d.ts","sourceRoot":"","sources":["../../src/Outline/Outline.tsx"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,SAAS,CAAC;AAGzC,YAAY,EAAC,WAAW,EAAC,MAAM,SAAS,CAAC;AAEzC,MAAM,WAAW,YAAa,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC1D,6CAA6C;IAC7C,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAE7B,+CAA+C;IAC/C,KAAK,EAAE,WAAW,EAAE,CAAC;IAErB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAEhC,sCAAsC;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAgJD
|
|
1
|
+
{"version":3,"file":"Outline.d.ts","sourceRoot":"","sources":["../../src/Outline/Outline.tsx"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,SAAS,CAAC;AAGzC,YAAY,EAAC,WAAW,EAAC,MAAM,SAAS,CAAC;AAEzC,MAAM,WAAW,YAAa,SAAQ,SAAS,CAAC,WAAW,CAAC;IAC1D,6CAA6C;IAC7C,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAE7B,+CAA+C;IAC/C,KAAK,EAAE,WAAW,EAAE,CAAC;IAErB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAEhC,sCAAsC;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAgJD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,OAAO,CAAC,EACtB,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,KAA2B,EAC3B,OAAmB,EACnB,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,aAAa,EAAE,MAAM,EACrB,GAAG,KAAK,EACT,EAAE,YAAY,+BAyGd;yBArHe,OAAO"}
|
package/dist/Outline/Outline.js
CHANGED
|
@@ -133,8 +133,9 @@ function getIndentStyle(level) {
|
|
|
133
133
|
* indentation based on each heading level. Features a sliding indicator
|
|
134
134
|
* track that animates to the active item.
|
|
135
135
|
*
|
|
136
|
-
* When `activeId` is omitted, it
|
|
137
|
-
*
|
|
136
|
+
* When `activeId` is omitted, it tracks scroll position and marks the last
|
|
137
|
+
* heading whose top has passed its activation line (its scroll-margin-top)
|
|
138
|
+
* active — defaulting to the first item at the top and the last at the bottom.
|
|
138
139
|
*
|
|
139
140
|
* @example
|
|
140
141
|
* ```
|
|
@@ -162,7 +163,12 @@ export function Outline({
|
|
|
162
163
|
}) {
|
|
163
164
|
const rootRef = useRef(null);
|
|
164
165
|
const LinkComponent = useLinkComponent();
|
|
165
|
-
const
|
|
166
|
+
const isControlled = activeId !== undefined;
|
|
167
|
+
const {
|
|
168
|
+
activeId: resolvedActiveId,
|
|
169
|
+
setActiveId,
|
|
170
|
+
lockActiveId
|
|
171
|
+
} = useScrollSpy({
|
|
166
172
|
activeId,
|
|
167
173
|
items,
|
|
168
174
|
onActiveIdChange,
|
|
@@ -170,12 +176,25 @@ export function Outline({
|
|
|
170
176
|
});
|
|
171
177
|
const handleClick = id => event => {
|
|
172
178
|
const target = document.getElementById(id);
|
|
173
|
-
|
|
179
|
+
|
|
180
|
+
// Let the browser handle modified clicks (open in new tab, etc.) and
|
|
181
|
+
// missing targets without touching the active state.
|
|
174
182
|
if (target == null || event.defaultPrevented || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
|
|
175
183
|
return;
|
|
176
184
|
}
|
|
177
185
|
event.preventDefault();
|
|
178
186
|
window.history.pushState(null, '', `#${id}`);
|
|
187
|
+
|
|
188
|
+
// Move the indicator to the clicked item in a single step. Controlled
|
|
189
|
+
// consumers own the active state (notify only); uncontrolled mode pins
|
|
190
|
+
// the active id and suppresses scroll-spy until the next manual scroll,
|
|
191
|
+
// so the click is honored — even for short/last sections — and the
|
|
192
|
+
// indicator doesn't chase the smooth scroll through other sections.
|
|
193
|
+
if (isControlled) {
|
|
194
|
+
setActiveId(id);
|
|
195
|
+
} else {
|
|
196
|
+
lockActiveId(id);
|
|
197
|
+
}
|
|
179
198
|
target.scrollIntoView({
|
|
180
199
|
behavior: 'smooth',
|
|
181
200
|
block: 'start'
|
|
@@ -5,6 +5,19 @@ interface UseScrollSpyOptions {
|
|
|
5
5
|
onActiveIdChange?: (id: string) => void;
|
|
6
6
|
rootRef: React.RefObject<HTMLElement | null>;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
interface UseScrollSpyResult {
|
|
9
|
+
activeId: string | undefined;
|
|
10
|
+
/** Set the active id (notifies onActiveIdChange). For controlled consumers. */
|
|
11
|
+
setActiveId: (id: string) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Handle a click on the outline item with id `id`. Delays moving the
|
|
14
|
+
* indicator: scroll-spy is suppressed during the programmatic smooth scroll
|
|
15
|
+
* so the indicator doesn't chase it, then the indicator moves once to the
|
|
16
|
+
* clicked item when the scroll settles. If the user scrolls manually mid-way,
|
|
17
|
+
* scroll-position tracking resumes immediately instead.
|
|
18
|
+
*/
|
|
19
|
+
lockActiveId: (id: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function useScrollSpy({ activeId, items, onActiveIdChange, rootRef, }: UseScrollSpyOptions): UseScrollSpyResult;
|
|
9
22
|
export {};
|
|
10
23
|
//# sourceMappingURL=useScrollSpy.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useScrollSpy.d.ts","sourceRoot":"","sources":["../../src/Outline/useScrollSpy.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useScrollSpy.d.ts","sourceRoot":"","sources":["../../src/Outline/useScrollSpy.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,SAAS,CAAC;AAuFzC,UAAU,mBAAmB;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CAC9C;AAED,UAAU,kBAAkB;IAC1B,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,+EAA+E;IAC/E,WAAW,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;;;OAMG;IACH,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,wBAAgB,YAAY,CAAC,EAC3B,QAAQ,EACR,KAAK,EACL,gBAAgB,EAChB,OAAO,GACR,EAAE,mBAAmB,GAAG,kBAAkB,CA2I1C"}
|
|
@@ -4,13 +4,23 @@
|
|
|
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
|
-
import { useEffect, useRef, useState } from 'react';
|
|
21
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
22
|
+
/** Keys that scroll the viewport — used to detect a manual scroll intent. */
|
|
23
|
+
const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ', 'Spacebar']);
|
|
14
24
|
function getScrollableAncestor(element) {
|
|
15
25
|
let current = element?.parentElement ?? null;
|
|
16
26
|
while (current != null) {
|
|
@@ -24,6 +34,41 @@ function getScrollableAncestor(element) {
|
|
|
24
34
|
}
|
|
25
35
|
return null;
|
|
26
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the active heading id from current scroll position.
|
|
40
|
+
*
|
|
41
|
+
* A heading is "passed" once its top reaches its activation line — the scroll
|
|
42
|
+
* root's top plus the heading's own scroll-margin-top. The active heading is
|
|
43
|
+
* the last passed one (headings are in document order). When none have passed
|
|
44
|
+
* (scrolled above the first), the first item is active; at the bottom, the
|
|
45
|
+
* last item is active.
|
|
46
|
+
*/
|
|
47
|
+
function resolveActiveId(items, scrollRoot) {
|
|
48
|
+
if (items.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const rootTop = scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0;
|
|
52
|
+
const atBottom = scrollRoot != null ? scrollRoot.scrollTop + scrollRoot.clientHeight >= scrollRoot.scrollHeight - 2 : window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
|
|
53
|
+
if (atBottom) {
|
|
54
|
+
return items[items.length - 1].id;
|
|
55
|
+
}
|
|
56
|
+
let activeId = items[0].id;
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const element = document.getElementById(item.id);
|
|
59
|
+
if (element == null) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const top = element.getBoundingClientRect().top;
|
|
63
|
+
const marginTop = Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0;
|
|
64
|
+
if (top <= rootTop + marginTop + 1) {
|
|
65
|
+
activeId = item.id;
|
|
66
|
+
} else {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return activeId;
|
|
71
|
+
}
|
|
27
72
|
export function useScrollSpy({
|
|
28
73
|
activeId,
|
|
29
74
|
items,
|
|
@@ -32,72 +77,138 @@ export function useScrollSpy({
|
|
|
32
77
|
}) {
|
|
33
78
|
const isControlled = activeId !== undefined;
|
|
34
79
|
const [uncontrolledActiveId, setUncontrolledActiveId] = useState(items[0]?.id);
|
|
35
|
-
const visibleHeadingIdsRef = useRef(new Set());
|
|
36
|
-
const headingTopRef = useRef(new Map());
|
|
37
80
|
const activeIdRef = useRef(activeId);
|
|
81
|
+
// While true, scroll-spy ignores scroll updates because a click is driving a
|
|
82
|
+
// programmatic scroll. Released when that scroll settles or the user scrolls.
|
|
83
|
+
const suppressRef = useRef(false);
|
|
84
|
+
const releaseSuppressionRef = useRef(null);
|
|
85
|
+
// Latest scroll-position resolver, so the click handler can resume tracking
|
|
86
|
+
// when the user scrolls during a programmatic scroll.
|
|
87
|
+
const syncRef = useRef(null);
|
|
88
|
+
// Keep latest items/callback in refs so the scroll listener effect doesn't
|
|
89
|
+
// re-subscribe on every render (items is a fresh array each render).
|
|
90
|
+
const itemsRef = useRef(items);
|
|
91
|
+
itemsRef.current = items;
|
|
92
|
+
const onActiveIdChangeRef = useRef(onActiveIdChange);
|
|
93
|
+
onActiveIdChangeRef.current = onActiveIdChange;
|
|
38
94
|
const itemIds = items.map(item => item.id).join('\n');
|
|
39
95
|
activeIdRef.current = isControlled ? activeId : uncontrolledActiveId;
|
|
40
96
|
useEffect(() => {
|
|
41
|
-
if (isControlled || typeof
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
const headingElements = items.map(item => document.getElementById(item.id)).filter(element => element != null);
|
|
45
|
-
if (headingElements.length === 0) {
|
|
97
|
+
if (isControlled || typeof window === 'undefined') {
|
|
46
98
|
return;
|
|
47
99
|
}
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
100
|
+
const scrollRoot = getScrollableAncestor(rootRef.current);
|
|
101
|
+
const scrollTarget = scrollRoot ?? window;
|
|
102
|
+
let frame = 0;
|
|
103
|
+
const update = () => {
|
|
104
|
+
frame = 0;
|
|
105
|
+
if (suppressRef.current) {
|
|
52
106
|
return;
|
|
53
107
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let nextActiveId;
|
|
60
|
-
let nextTop = Number.POSITIVE_INFINITY;
|
|
61
|
-
for (const id of visibleHeadingIds) {
|
|
62
|
-
const top = headingTop.get(id) ?? Number.POSITIVE_INFINITY;
|
|
63
|
-
if (top < nextTop) {
|
|
64
|
-
nextTop = top;
|
|
65
|
-
nextActiveId = id;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (nextActiveId != null) {
|
|
69
|
-
setNextActiveId(nextActiveId);
|
|
108
|
+
const nextActiveId = resolveActiveId(itemsRef.current, scrollRoot);
|
|
109
|
+
if (nextActiveId != null && nextActiveId !== activeIdRef.current) {
|
|
110
|
+
activeIdRef.current = nextActiveId;
|
|
111
|
+
setUncontrolledActiveId(nextActiveId);
|
|
112
|
+
onActiveIdChangeRef.current?.(nextActiveId);
|
|
70
113
|
}
|
|
71
114
|
};
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
headingTop.set(id, entry.boundingClientRect.top);
|
|
76
|
-
if (entry.isIntersecting) {
|
|
77
|
-
visibleHeadingIds.add(id);
|
|
78
|
-
} else {
|
|
79
|
-
visibleHeadingIds.delete(id);
|
|
80
|
-
}
|
|
115
|
+
const onScroll = () => {
|
|
116
|
+
if (frame === 0) {
|
|
117
|
+
frame = requestAnimationFrame(update);
|
|
81
118
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
};
|
|
120
|
+
syncRef.current = update;
|
|
121
|
+
update();
|
|
122
|
+
scrollTarget.addEventListener('scroll', onScroll, {
|
|
123
|
+
passive: true
|
|
124
|
+
});
|
|
125
|
+
window.addEventListener('resize', onScroll, {
|
|
126
|
+
passive: true
|
|
86
127
|
});
|
|
87
|
-
for (const headingElement of headingElements) {
|
|
88
|
-
observer.observe(headingElement);
|
|
89
|
-
}
|
|
90
128
|
return () => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
syncRef.current = null;
|
|
130
|
+
scrollTarget.removeEventListener('scroll', onScroll);
|
|
131
|
+
window.removeEventListener('resize', onScroll);
|
|
132
|
+
if (frame !== 0) {
|
|
133
|
+
cancelAnimationFrame(frame);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}, [isControlled, itemIds, rootRef]);
|
|
137
|
+
|
|
138
|
+
// Tear down any pending suppression listeners when the Outline unmounts.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
return () => {
|
|
141
|
+
releaseSuppressionRef.current?.();
|
|
94
142
|
};
|
|
95
|
-
}, [
|
|
143
|
+
}, []);
|
|
96
144
|
const setActiveId = nextActiveId => {
|
|
97
145
|
if (!isControlled) {
|
|
98
146
|
setUncontrolledActiveId(nextActiveId);
|
|
99
147
|
}
|
|
100
148
|
onActiveIdChange?.(nextActiveId);
|
|
101
149
|
};
|
|
102
|
-
|
|
150
|
+
const lockActiveId = useCallback(clickedId => {
|
|
151
|
+
if (typeof window === 'undefined') {
|
|
152
|
+
setUncontrolledActiveId(clickedId);
|
|
153
|
+
activeIdRef.current = clickedId;
|
|
154
|
+
onActiveIdChangeRef.current?.(clickedId);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Freeze the indicator during the programmatic smooth scroll instead of
|
|
159
|
+
// moving it immediately — it lands on the clicked item once the scroll
|
|
160
|
+
// settles, so it doesn't chase the scroll through intervening sections.
|
|
161
|
+
suppressRef.current = true;
|
|
162
|
+
// Replace any in-flight handlers from a previous click.
|
|
163
|
+
releaseSuppressionRef.current?.();
|
|
164
|
+
let settleTimer = 0;
|
|
165
|
+
const cleanup = () => {
|
|
166
|
+
window.removeEventListener('scrollend', onSettle);
|
|
167
|
+
window.removeEventListener('wheel', onManual);
|
|
168
|
+
window.removeEventListener('touchmove', onManual);
|
|
169
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
170
|
+
if (settleTimer !== 0) {
|
|
171
|
+
clearTimeout(settleTimer);
|
|
172
|
+
settleTimer = 0;
|
|
173
|
+
}
|
|
174
|
+
releaseSuppressionRef.current = null;
|
|
175
|
+
};
|
|
176
|
+
// Programmatic scroll finished: move the indicator to the clicked item.
|
|
177
|
+
const onSettle = () => {
|
|
178
|
+
cleanup();
|
|
179
|
+
suppressRef.current = false;
|
|
180
|
+
setUncontrolledActiveId(clickedId);
|
|
181
|
+
activeIdRef.current = clickedId;
|
|
182
|
+
onActiveIdChangeRef.current?.(clickedId);
|
|
183
|
+
};
|
|
184
|
+
// User scrolled mid-flight: hand control back to scroll-position tracking.
|
|
185
|
+
const onManual = () => {
|
|
186
|
+
cleanup();
|
|
187
|
+
suppressRef.current = false;
|
|
188
|
+
syncRef.current?.();
|
|
189
|
+
};
|
|
190
|
+
const onKeyDown = event => {
|
|
191
|
+
if (SCROLL_KEYS.has(event.key)) {
|
|
192
|
+
onManual();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
window.addEventListener('scrollend', onSettle, {
|
|
196
|
+
once: true
|
|
197
|
+
});
|
|
198
|
+
window.addEventListener('wheel', onManual, {
|
|
199
|
+
passive: true
|
|
200
|
+
});
|
|
201
|
+
window.addEventListener('touchmove', onManual, {
|
|
202
|
+
passive: true
|
|
203
|
+
});
|
|
204
|
+
window.addEventListener('keydown', onKeyDown);
|
|
205
|
+
// Fallback when scrollend is unsupported or no scroll is needed.
|
|
206
|
+
settleTimer = window.setTimeout(onSettle, 1200);
|
|
207
|
+
releaseSuppressionRef.current = cleanup;
|
|
208
|
+
}, []);
|
|
209
|
+
return {
|
|
210
|
+
activeId: isControlled ? activeId : uncontrolledActiveId,
|
|
211
|
+
setActiveId,
|
|
212
|
+
lockActiveId
|
|
213
|
+
};
|
|
103
214
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useResizable.d.ts","sourceRoot":"","sources":["../../src/Resizable/useResizable.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,qBAAqB;IACpC,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,wBAAyB,SAAQ,qBAAqB;IACrE,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,IAAI,CAAC;CACnD;AAED,MAAM,WAAW,uBAAuB;IACtC,8CAA8C;IAC9C,SAAS,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACtC,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;IAC/C,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,WAAW,EAAE,OAAO,CAAC;IACrB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,mCAAmC;IACnC,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,wCAAwC;IACxC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,uEAAuE;IACvE,KAAK,EAAE,cAAc,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IAEd,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,8CAA8C;IAE9C,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,IAAI,CAAC;CACzB;
|
|
1
|
+
{"version":3,"file":"useResizable.d.ts","sourceRoot":"","sources":["../../src/Resizable/useResizable.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,qBAAqB;IACpC,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,wBAAyB,SAAQ,qBAAqB;IACrE,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,IAAI,CAAC;CACnD;AAED,MAAM,WAAW,uBAAuB;IACtC,8CAA8C;IAC9C,SAAS,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACtC,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;IAC/C,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,WAAW,EAAE,OAAO,CAAC;IACrB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,mCAAmC;IACnC,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,wCAAwC;IACxC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,uEAAuE;IACvE,KAAK,EAAE,cAAc,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IAEd,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,8CAA8C;IAE9C,YAAY,EAAE,OAAO,CAAC;IACtB,iBAAiB,EAAE,IAAI,CAAC;CACzB;AAyQD,wBAAgB,YAAY,CAAC,MAAM,EAAE,wBAAwB,GAAG,eAAe,CAAC;AAChF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,uBAAuB,GAC9B,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC"}
|
|
@@ -27,10 +27,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
27
27
|
const DEFAULT_MIN = 50;
|
|
28
28
|
const DEFAULT_COLLAPSED_SIZE = 40;
|
|
29
29
|
const STORAGE_PREFIX = 'astryx-resizable:';
|
|
30
|
-
// Legacy key prefix read during the compat window so persisted panel sizes
|
|
31
|
-
// survive the xds -> astryx rename. Read-only fallback; we always write the
|
|
32
|
-
// new prefix. Removed at final cutover.
|
|
33
|
-
const LEGACY_STORAGE_PREFIX = 'xds-resizable:';
|
|
34
30
|
|
|
35
31
|
// =============================================================================
|
|
36
32
|
// Helpers
|
|
@@ -60,7 +56,7 @@ function loadPersistedSize(key) {
|
|
|
60
56
|
return null;
|
|
61
57
|
}
|
|
62
58
|
try {
|
|
63
|
-
const raw = localStorage.getItem(STORAGE_PREFIX + key)
|
|
59
|
+
const raw = localStorage.getItem(STORAGE_PREFIX + key);
|
|
64
60
|
if (raw != null) {
|
|
65
61
|
const parsed = JSON.parse(raw);
|
|
66
62
|
if (typeof parsed === 'number') {
|
|
@@ -38,8 +38,15 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
|
|
|
38
38
|
*/
|
|
39
39
|
onPressedChange?: (isPressed: boolean) => void;
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
42
|
-
* The button shows a loading spinner while the
|
|
41
|
+
* Action handler for API- or navigation-backed toggles, run inside a
|
|
42
|
+
* transition. The button shows a loading spinner while the action is
|
|
43
|
+
* pending — whether it returns a promise or synchronously triggers a
|
|
44
|
+
* suspending update (e.g. a router navigation that suspends on data).
|
|
45
|
+
*
|
|
46
|
+
* Because it runs in a transition, the toggle is *interruptible*: clicking
|
|
47
|
+
* again while an action is pending starts a new transition with the next
|
|
48
|
+
* optimistic state, so the action reflects the latest intent rather than
|
|
49
|
+
* being dropped.
|
|
43
50
|
*
|
|
44
51
|
* @example
|
|
45
52
|
* ```
|
|
@@ -53,7 +60,7 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
|
|
|
53
60
|
* />
|
|
54
61
|
* ```
|
|
55
62
|
*/
|
|
56
|
-
pressedChangeAction?: (isPressed: boolean) => Promise<void>;
|
|
63
|
+
pressedChangeAction?: (isPressed: boolean) => void | Promise<void>;
|
|
57
64
|
/**
|
|
58
65
|
* The size of the toggle button.
|
|
59
66
|
* When used inside ToggleButtonGroup, defaults to the group's size.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ToggleButton.d.ts","sourceRoot":"","sources":["../../src/ToggleButton/ToggleButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"ToggleButton.d.ts","sourceRoot":"","sources":["../../src/ToggleButton/ToggleButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,EAAS,KAAK,UAAU,EAAC,MAAM,WAAW,CAAC;AAElD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAyE5C,MAAM,WAAW,iBAAkB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACrE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IAE/C;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,mBAAmB,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnE;;;;OAIG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAElB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IAEjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,SAAS,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IAErB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,CAAC,EAC3B,GAAG,EACH,KAAK,EACL,SAAS,EAAE,aAAa,EACxB,eAAe,EAAE,mBAAmB,EACpC,mBAAmB,EACnB,IAAI,EAAE,QAAQ,EACd,UAAU,EAAE,cAAsB,EAClC,SAAiB,EACjB,IAAI,EACJ,UAAkB,EAClB,WAAW,EACX,QAAQ,EACR,OAAO,EACP,KAAK,EACL,MAAM,EACN,SAAS,EAAE,UAAU,EACrB,KAAK,EACL,GAAG,KAAK,EACT,EAAE,iBAAiB,GAAG,SAAS,CAyG/B;yBA5He,YAAY"}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - /apps/storybook/stories/ToggleButton.stories.tsx
|
|
20
20
|
* - /packages/cli/templates/blocks/components/ToggleButton/ (showcase blocks)
|
|
21
21
|
*/
|
|
22
|
-
import React, { useCallback } from 'react';
|
|
22
|
+
import React, { useCallback, useEffect, useOptimistic, useState, useTransition } from 'react';
|
|
23
23
|
import * as stylex from '@stylexjs/stylex';
|
|
24
24
|
import "../theme/tokens.stylex.js";
|
|
25
25
|
import { colorVars, fontWeightVars } from "../theme/tokens.stylex.js";
|
|
@@ -27,6 +27,38 @@ import { Button } from "../Button/index.js";
|
|
|
27
27
|
import { useToggleButtonGroup } from "./ToggleButtonGroup.js";
|
|
28
28
|
import { themeProps } from "../utils/themeProps.js";
|
|
29
29
|
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Constants & helpers
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The spinner only appears once the action has been pending for this long.
|
|
36
|
+
* A fast action shows the optimistic pressed state immediately with no spinner
|
|
37
|
+
* flash, and rapid re-clicks can interrupt the in-flight action before the
|
|
38
|
+
* button locks behind the spinner.
|
|
39
|
+
*/
|
|
40
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
41
|
+
const PENDING_SPINNER_DELAY_MS = 150;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns `true` only once `active` has stayed `true` for `delayMs`.
|
|
45
|
+
* Used to debounce the loading spinner so the optimistic state shows first.
|
|
46
|
+
*/
|
|
47
|
+
function useDelayed(active, delayMs) {
|
|
48
|
+
const [delayed, setDelayed] = useState(false);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!active) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const timer = setTimeout(() => setDelayed(true), delayMs);
|
|
54
|
+
return () => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
setDelayed(false);
|
|
57
|
+
};
|
|
58
|
+
}, [active, delayMs]);
|
|
59
|
+
return active && delayed;
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
// =============================================================================
|
|
31
63
|
// Styles
|
|
32
64
|
// =============================================================================
|
|
@@ -36,7 +68,6 @@ import { themeProps } from "../utils/themeProps.js";
|
|
|
36
68
|
* A hidden span renders the same text at semibold weight to reserve
|
|
37
69
|
* the wider width, preventing layout shift when toggling.
|
|
38
70
|
*/
|
|
39
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
40
71
|
const pressedStyles = {
|
|
41
72
|
background: {
|
|
42
73
|
kWkggS: "xi89dp7",
|
|
@@ -93,32 +124,47 @@ export function ToggleButton({
|
|
|
93
124
|
style,
|
|
94
125
|
...props
|
|
95
126
|
}) {
|
|
96
|
-
// Read group context if inside a group
|
|
97
127
|
const group = useToggleButtonGroup();
|
|
98
|
-
|
|
99
|
-
// Resolve state from group or props
|
|
100
|
-
const isPressed = group && value != null ? group.selectedValues.has(value) : isPressedProp ?? false;
|
|
128
|
+
const committedPressed = group && value != null ? group.selectedValues.has(value) : isPressedProp ?? false;
|
|
101
129
|
const size = sizeProp ?? group?.size ?? 'md';
|
|
102
130
|
const isDisabled = group?.isDisabled ?? isDisabledProp;
|
|
131
|
+
|
|
132
|
+
// Track the pressed state optimistically. While an action is pending, the
|
|
133
|
+
// button reflects the intended (optimistic) state immediately, and a click
|
|
134
|
+
// mid-flight derives its next state from this value — so rapid toggles read
|
|
135
|
+
// true -> false -> true rather than stalling on the last committed value.
|
|
136
|
+
const [optimisticPressed, setOptimisticPressed] = useOptimistic(committedPressed);
|
|
137
|
+
const isPressed = optimisticPressed;
|
|
103
138
|
const resolvedIcon = isPressed && pressedIcon ? pressedIcon : icon;
|
|
139
|
+
|
|
140
|
+
// Run the toggle inside a transition. The action is interruptible: clicking
|
|
141
|
+
// again while it is pending starts a fresh transition with the next
|
|
142
|
+
// optimistic state instead of being dropped, so there is no re-entry guard.
|
|
143
|
+
// Both onPressedChange and pressedChangeAction run inside the transition,
|
|
144
|
+
// which means a synchronous-but-suspending handler (e.g. a router navigation
|
|
145
|
+
// that suspends on data) also drives the pending state — not just promises.
|
|
146
|
+
const [isPending, startTransition] = useTransition();
|
|
147
|
+
// Debounce the spinner so a fast action shows the optimistic state without a
|
|
148
|
+
// spinner flash, and rapid re-clicks can interrupt before the button locks.
|
|
149
|
+
const showSpinner = useDelayed(isPending, PENDING_SPINNER_DELAY_MS);
|
|
150
|
+
const isLoadingState = isLoading || showSpinner;
|
|
104
151
|
const handleClick = useCallback(() => {
|
|
105
|
-
if (isDisabled
|
|
152
|
+
if (isDisabled) {
|
|
106
153
|
return;
|
|
107
154
|
}
|
|
108
155
|
if (group && value != null) {
|
|
109
|
-
//
|
|
156
|
+
// Group mode delegates selection to the group; no async-action path.
|
|
110
157
|
group.toggle(value);
|
|
111
|
-
|
|
112
|
-
// Standalone toggle
|
|
113
|
-
const newState = !isPressed;
|
|
114
|
-
onPressedChangeProp(newState);
|
|
115
|
-
if (pressedChangeAction) {
|
|
116
|
-
void pressedChangeAction(newState);
|
|
117
|
-
}
|
|
158
|
+
return;
|
|
118
159
|
}
|
|
119
|
-
|
|
160
|
+
const newState = !optimisticPressed;
|
|
161
|
+
startTransition(async () => {
|
|
162
|
+
setOptimisticPressed(newState);
|
|
163
|
+
onPressedChangeProp?.(newState);
|
|
164
|
+
await pressedChangeAction?.(newState);
|
|
165
|
+
});
|
|
166
|
+
}, [isDisabled, group, value, optimisticPressed, onPressedChangeProp, pressedChangeAction, setOptimisticPressed]);
|
|
120
167
|
|
|
121
|
-
// Label with font weight shift and width reservation
|
|
122
168
|
// isIconOnly prop is the source of truth for icon-only rendering.
|
|
123
169
|
const labelContent = children != null ? /*#__PURE__*/_jsxs("span", {
|
|
124
170
|
...{
|
|
@@ -165,7 +211,7 @@ export function ToggleButton({
|
|
|
165
211
|
variant: "ghost",
|
|
166
212
|
size: size,
|
|
167
213
|
isDisabled: isDisabled,
|
|
168
|
-
isLoading:
|
|
214
|
+
isLoading: isLoadingState,
|
|
169
215
|
isIconOnly: isIconOnly,
|
|
170
216
|
"aria-pressed": isPressed,
|
|
171
217
|
icon: resolvedIcon,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.1.0-canary.
|
|
3
|
+
"version": "0.1.0-canary.d1e1201",
|
|
4
4
|
"displayName": "XDS Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -228,7 +228,7 @@ export const docsZh = {
|
|
|
228
228
|
/** @type {import('../docs-types').TranslationDoc} */
|
|
229
229
|
export const docsDense = {
|
|
230
230
|
description:
|
|
231
|
-
'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy
|
|
231
|
+
'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy by scroll position (last heading past its scroll-margin-top line; first item at top, last at bottom); controlled with activeId; smooth-scroll on click that pins the active item until the next manual scroll.',
|
|
232
232
|
usage: {
|
|
233
233
|
description:
|
|
234
234
|
'A table-of-contents sidebar for documentation pages, help centers, wikis, and long settings pages. Use it for navigation within a single page, not for app routes.',
|