@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.
@@ -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 observes heading elements by id and marks
33
- * the topmost visible heading active.
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;;;;;;;;;;;;;;;;;;;;GAoBG;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,+BAuFd;yBAnGe,OAAO"}
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"}
@@ -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 observes heading elements by id and marks
137
- * the topmost visible heading active.
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 [resolvedActiveId, setActiveId] = useScrollSpy({
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
- setActiveId(id);
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
- export declare function useScrollSpy({ activeId, items, onActiveIdChange, rootRef, }: UseScrollSpyOptions): [string | undefined, (id: string) => void];
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":"AAcA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,SAAS,CAAC;AAwBzC,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,wBAAgB,YAAY,CAAC,EAC3B,QAAQ,EACR,KAAK,EACL,gBAAgB,EAChB,OAAO,GACR,EAAE,mBAAmB,GAAG,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC,CA2FlE"}
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, 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
- 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 IntersectionObserver === 'undefined') {
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 visibleHeadingIds = visibleHeadingIdsRef.current;
49
- const headingTop = headingTopRef.current;
50
- const setNextActiveId = nextActiveId => {
51
- if (activeIdRef.current === nextActiveId) {
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
- activeIdRef.current = nextActiveId;
55
- setUncontrolledActiveId(nextActiveId);
56
- onActiveIdChange?.(nextActiveId);
57
- };
58
- const chooseActiveHeading = () => {
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 observer = new IntersectionObserver(entries => {
73
- for (const entry of entries) {
74
- const id = entry.target.id;
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
- chooseActiveHeading();
83
- }, {
84
- root: getScrollableAncestor(rootRef.current),
85
- threshold: 0
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
- observer.disconnect();
92
- visibleHeadingIds.clear();
93
- headingTop.clear();
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
- }, [isControlled, itemIds, items, onActiveIdChange, rootRef]);
143
+ }, []);
96
144
  const setActiveId = nextActiveId => {
97
145
  if (!isControlled) {
98
146
  setUncontrolledActiveId(nextActiveId);
99
147
  }
100
148
  onActiveIdChange?.(nextActiveId);
101
149
  };
102
- return [isControlled ? activeId : uncontrolledActiveId, setActiveId];
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;AA+QD,wBAAgB,YAAY,CAAC,MAAM,EAAE,wBAAwB,GAAG,eAAe,CAAC;AAChF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,uBAAuB,GAC9B,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC"}
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) ?? localStorage.getItem(LEGACY_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
- * Async action handler for API-backed toggles.
42
- * The button shows a loading spinner while the promise is pending.
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,EAAc,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAIzD,OAAO,EAAS,KAAK,UAAU,EAAC,MAAM,WAAW,CAAC;AAElD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA0C5C,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;;;;;;;;;;;;;;;OAeG;IACH,mBAAmB,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAE5D;;;;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,CAuF/B;yBA1Ge,YAAY"}
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 || isLoading) {
152
+ if (isDisabled) {
106
153
  return;
107
154
  }
108
155
  if (group && value != null) {
109
- // Delegate to group context
156
+ // Group mode delegates selection to the group; no async-action path.
110
157
  group.toggle(value);
111
- } else if (onPressedChangeProp) {
112
- // Standalone toggle
113
- const newState = !isPressed;
114
- onPressedChangeProp(newState);
115
- if (pressedChangeAction) {
116
- void pressedChangeAction(newState);
117
- }
158
+ return;
118
159
  }
119
- }, [isDisabled, isLoading, group, value, onPressedChangeProp, pressedChangeAction, isPressed]);
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: 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.cfbdec3",
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 via IntersectionObserver topmost-visible-heading; controlled with activeId; smooth-scroll on click.',
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.',