@astryxdesign/core 0.1.0-canary.f94dd07 → 0.1.1-canary.a514b99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  3. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  4. package/dist/ContextMenu/ContextMenu.js +2 -2
  5. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  6. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  7. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  8. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  9. package/dist/Layout/Layout.d.ts +10 -1
  10. package/dist/Layout/Layout.d.ts.map +1 -1
  11. package/dist/Layout/Layout.js +5 -1
  12. package/dist/Outline/Outline.d.ts +3 -2
  13. package/dist/Outline/Outline.d.ts.map +1 -1
  14. package/dist/Outline/Outline.js +23 -4
  15. package/dist/Outline/useScrollSpy.d.ts +14 -1
  16. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  17. package/dist/Outline/useScrollSpy.js +161 -50
  18. package/dist/Resizable/useResizable.d.ts.map +1 -1
  19. package/dist/Resizable/useResizable.js +1 -5
  20. package/dist/Selector/Selector.d.ts.map +1 -1
  21. package/dist/Selector/Selector.js +1 -1
  22. package/dist/ToggleButton/ToggleButton.d.ts +10 -3
  23. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  24. package/dist/ToggleButton/ToggleButton.js +64 -18
  25. package/dist/theme/defineTheme.d.ts +1 -1
  26. package/dist/theme/defineTheme.d.ts.map +1 -1
  27. package/dist/theme/defineTheme.js +1 -1
  28. package/dist/theme/index.d.ts +1 -1
  29. package/dist/theme/index.d.ts.map +1 -1
  30. package/dist/theme/index.js +1 -1
  31. package/dist/theme/tokens.d.ts +1 -1
  32. package/dist/theme/tokens.js +4 -4
  33. package/dist/theme/useTheme.d.ts +2 -2
  34. package/dist/utils/dateParser.d.ts.map +1 -1
  35. package/dist/utils/dateParser.js +15 -2
  36. package/package.json +2 -2
  37. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  38. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  39. package/src/ContextMenu/ContextMenu.tsx +2 -2
  40. package/src/DateInput/DateInput.test.tsx +68 -20
  41. package/src/Divider/Divider.doc.mjs +1 -1
  42. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  43. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  44. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  45. package/src/Icon/Icon.doc.mjs +4 -4
  46. package/src/Item/Item.doc.mjs +2 -2
  47. package/src/Layout/Layout.doc.mjs +2 -1
  48. package/src/Layout/Layout.tsx +15 -1
  49. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  50. package/src/Link/Link.doc.mjs +3 -3
  51. package/src/Link/LinkProvider.doc.mjs +3 -3
  52. package/src/Markdown/Markdown.doc.mjs +4 -4
  53. package/src/Outline/Outline.doc.mjs +1 -1
  54. package/src/Outline/Outline.test.tsx +76 -38
  55. package/src/Outline/Outline.tsx +23 -4
  56. package/src/Outline/useScrollSpy.ts +196 -63
  57. package/src/Resizable/Resizable.doc.mjs +2 -2
  58. package/src/Resizable/useResizable.ts +1 -7
  59. package/src/Selector/Selector.tsx +5 -6
  60. package/src/Table/Table.doc.mjs +3 -3
  61. package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
  62. package/src/ToggleButton/ToggleButton.test.tsx +148 -6
  63. package/src/ToggleButton/ToggleButton.tsx +83 -20
  64. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  65. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  66. package/src/hooks/useStreamingText.doc.mjs +3 -3
  67. package/src/theme/Theme.doc.mjs +2 -2
  68. package/src/theme/defineTheme.ts +1 -1
  69. package/src/theme/index.ts +1 -1
  70. package/src/theme/tokens.ts +4 -4
  71. package/src/theme/useTheme.ts +2 -2
  72. package/src/utils/dateParser.test.ts +26 -0
  73. package/src/utils/dateParser.ts +16 -2
  74. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -4,17 +4,39 @@
4
4
 
5
5
  /**
6
6
  * @file useScrollSpy.ts
7
- * @input Uses React, IntersectionObserver, OutlineItem type
7
+ * @input Uses React, scroll position of heading elements, OutlineItem type
8
8
  * @output Exports internal useScrollSpy hook
9
9
  * @position Internal behavior hook; consumed by Outline.tsx
10
10
  *
11
+ * Drives the active outline item from scroll position. On each scroll
12
+ * (rAF-throttled) it reads live heading positions and marks the last heading
13
+ * whose top has passed its activation line (its own scroll-margin-top, i.e.
14
+ * where it lands when navigated to). This is stable — it never compares stale
15
+ * cached positions — so the indicator moves monotonically instead of jumping.
16
+ * Defaults to the first item at the top and the last item at the bottom so
17
+ * short final sections still activate.
18
+ *
11
19
  * SYNC: When modified, update /packages/core/src/Outline/Outline.tsx
12
20
  */
13
21
 
14
- import {useEffect, useRef, useState} from 'react';
22
+ import {useCallback, useEffect, useRef, useState} from 'react';
15
23
  import type {OutlineItem} from './types';
16
24
 
17
- function getScrollableAncestor(element: HTMLElement | null): Element | null {
25
+ /** Keys that scroll the viewport used to detect a manual scroll intent. */
26
+ const SCROLL_KEYS = new Set([
27
+ 'ArrowUp',
28
+ 'ArrowDown',
29
+ 'PageUp',
30
+ 'PageDown',
31
+ 'Home',
32
+ 'End',
33
+ ' ',
34
+ 'Spacebar',
35
+ ]);
36
+
37
+ function getScrollableAncestor(
38
+ element: HTMLElement | null,
39
+ ): HTMLElement | null {
18
40
  let current = element?.parentElement ?? null;
19
41
 
20
42
  while (current != null) {
@@ -36,6 +58,55 @@ function getScrollableAncestor(element: HTMLElement | null): Element | null {
36
58
  return null;
37
59
  }
38
60
 
61
+ /**
62
+ * Resolve the active heading id from current scroll position.
63
+ *
64
+ * A heading is "passed" once its top reaches its activation line — the scroll
65
+ * root's top plus the heading's own scroll-margin-top. The active heading is
66
+ * the last passed one (headings are in document order). When none have passed
67
+ * (scrolled above the first), the first item is active; at the bottom, the
68
+ * last item is active.
69
+ */
70
+ function resolveActiveId(
71
+ items: OutlineItem[],
72
+ scrollRoot: HTMLElement | null,
73
+ ): string | undefined {
74
+ if (items.length === 0) {
75
+ return undefined;
76
+ }
77
+
78
+ const rootTop =
79
+ scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0;
80
+
81
+ const atBottom =
82
+ scrollRoot != null
83
+ ? scrollRoot.scrollTop + scrollRoot.clientHeight >=
84
+ scrollRoot.scrollHeight - 2
85
+ : window.innerHeight + window.scrollY >=
86
+ document.documentElement.scrollHeight - 2;
87
+ if (atBottom) {
88
+ return items[items.length - 1].id;
89
+ }
90
+
91
+ let activeId = items[0].id;
92
+ for (const item of items) {
93
+ const element = document.getElementById(item.id);
94
+ if (element == null) {
95
+ continue;
96
+ }
97
+ const top = element.getBoundingClientRect().top;
98
+ const marginTop =
99
+ Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0;
100
+
101
+ if (top <= rootTop + marginTop + 1) {
102
+ activeId = item.id;
103
+ } else {
104
+ break;
105
+ }
106
+ }
107
+ return activeId;
108
+ }
109
+
39
110
  interface UseScrollSpyOptions {
40
111
  activeId?: string;
41
112
  items: OutlineItem[];
@@ -43,93 +114,95 @@ interface UseScrollSpyOptions {
43
114
  rootRef: React.RefObject<HTMLElement | null>;
44
115
  }
45
116
 
117
+ interface UseScrollSpyResult {
118
+ activeId: string | undefined;
119
+ /** Set the active id (notifies onActiveIdChange). For controlled consumers. */
120
+ setActiveId: (id: string) => void;
121
+ /**
122
+ * Handle a click on the outline item with id `id`. Delays moving the
123
+ * indicator: scroll-spy is suppressed during the programmatic smooth scroll
124
+ * so the indicator doesn't chase it, then the indicator moves once to the
125
+ * clicked item when the scroll settles. If the user scrolls manually mid-way,
126
+ * scroll-position tracking resumes immediately instead.
127
+ */
128
+ lockActiveId: (id: string) => void;
129
+ }
130
+
46
131
  export function useScrollSpy({
47
132
  activeId,
48
133
  items,
49
134
  onActiveIdChange,
50
135
  rootRef,
51
- }: UseScrollSpyOptions): [string | undefined, (id: string) => void] {
136
+ }: UseScrollSpyOptions): UseScrollSpyResult {
52
137
  const isControlled = activeId !== undefined;
53
138
  const [uncontrolledActiveId, setUncontrolledActiveId] = useState<
54
139
  string | undefined
55
140
  >(items[0]?.id);
56
- const visibleHeadingIdsRef = useRef<Set<string>>(new Set());
57
- const headingTopRef = useRef<Map<string, number>>(new Map());
58
141
  const activeIdRef = useRef<string | undefined>(activeId);
142
+ // While true, scroll-spy ignores scroll updates because a click is driving a
143
+ // programmatic scroll. Released when that scroll settles or the user scrolls.
144
+ const suppressRef = useRef(false);
145
+ const releaseSuppressionRef = useRef<(() => void) | null>(null);
146
+ // Latest scroll-position resolver, so the click handler can resume tracking
147
+ // when the user scrolls during a programmatic scroll.
148
+ const syncRef = useRef<(() => void) | null>(null);
149
+ // Keep latest items/callback in refs so the scroll listener effect doesn't
150
+ // re-subscribe on every render (items is a fresh array each render).
151
+ const itemsRef = useRef(items);
152
+ itemsRef.current = items;
153
+ const onActiveIdChangeRef = useRef(onActiveIdChange);
154
+ onActiveIdChangeRef.current = onActiveIdChange;
59
155
  const itemIds = items.map(item => item.id).join('\n');
60
156
  activeIdRef.current = isControlled ? activeId : uncontrolledActiveId;
61
157
 
62
158
  useEffect(() => {
63
- if (isControlled || typeof IntersectionObserver === 'undefined') {
159
+ if (isControlled || typeof window === 'undefined') {
64
160
  return;
65
161
  }
66
162
 
67
- const headingElements = items
68
- .map(item => document.getElementById(item.id))
69
- .filter((element): element is HTMLElement => element != null);
163
+ const scrollRoot = getScrollableAncestor(rootRef.current);
164
+ const scrollTarget: HTMLElement | Window = scrollRoot ?? window;
70
165
 
71
- if (headingElements.length === 0) {
72
- return;
73
- }
74
-
75
- const visibleHeadingIds = visibleHeadingIdsRef.current;
76
- const headingTop = headingTopRef.current;
77
-
78
- const setNextActiveId = (nextActiveId: string) => {
79
- if (activeIdRef.current === nextActiveId) {
166
+ let frame = 0;
167
+ const update = () => {
168
+ frame = 0;
169
+ if (suppressRef.current) {
80
170
  return;
81
171
  }
82
- activeIdRef.current = nextActiveId;
83
- setUncontrolledActiveId(nextActiveId);
84
- onActiveIdChange?.(nextActiveId);
172
+ const nextActiveId = resolveActiveId(itemsRef.current, scrollRoot);
173
+ if (nextActiveId != null && nextActiveId !== activeIdRef.current) {
174
+ activeIdRef.current = nextActiveId;
175
+ setUncontrolledActiveId(nextActiveId);
176
+ onActiveIdChangeRef.current?.(nextActiveId);
177
+ }
85
178
  };
86
-
87
- const chooseActiveHeading = () => {
88
- let nextActiveId: string | undefined;
89
- let nextTop = Number.POSITIVE_INFINITY;
90
-
91
- for (const id of visibleHeadingIds) {
92
- const top = headingTop.get(id) ?? Number.POSITIVE_INFINITY;
93
- if (top < nextTop) {
94
- nextTop = top;
95
- nextActiveId = id;
96
- }
179
+ const onScroll = () => {
180
+ if (frame === 0) {
181
+ frame = requestAnimationFrame(update);
97
182
  }
183
+ };
184
+
185
+ syncRef.current = update;
186
+ update();
187
+ scrollTarget.addEventListener('scroll', onScroll, {passive: true});
188
+ window.addEventListener('resize', onScroll, {passive: true});
98
189
 
99
- if (nextActiveId != null) {
100
- setNextActiveId(nextActiveId);
190
+ return () => {
191
+ syncRef.current = null;
192
+ scrollTarget.removeEventListener('scroll', onScroll);
193
+ window.removeEventListener('resize', onScroll);
194
+ if (frame !== 0) {
195
+ cancelAnimationFrame(frame);
101
196
  }
102
197
  };
198
+ }, [isControlled, itemIds, rootRef]);
103
199
 
104
- const observer = new IntersectionObserver(
105
- entries => {
106
- for (const entry of entries) {
107
- const id = entry.target.id;
108
- headingTop.set(id, entry.boundingClientRect.top);
109
- if (entry.isIntersecting) {
110
- visibleHeadingIds.add(id);
111
- } else {
112
- visibleHeadingIds.delete(id);
113
- }
114
- }
115
- chooseActiveHeading();
116
- },
117
- {
118
- root: getScrollableAncestor(rootRef.current),
119
- threshold: 0,
120
- },
121
- );
122
-
123
- for (const headingElement of headingElements) {
124
- observer.observe(headingElement);
125
- }
126
-
200
+ // Tear down any pending suppression listeners when the Outline unmounts.
201
+ useEffect(() => {
127
202
  return () => {
128
- observer.disconnect();
129
- visibleHeadingIds.clear();
130
- headingTop.clear();
203
+ releaseSuppressionRef.current?.();
131
204
  };
132
- }, [isControlled, itemIds, items, onActiveIdChange, rootRef]);
205
+ }, []);
133
206
 
134
207
  const setActiveId = (nextActiveId: string) => {
135
208
  if (!isControlled) {
@@ -138,5 +211,65 @@ export function useScrollSpy({
138
211
  onActiveIdChange?.(nextActiveId);
139
212
  };
140
213
 
141
- return [isControlled ? activeId : uncontrolledActiveId, setActiveId];
214
+ const lockActiveId = useCallback((clickedId: string) => {
215
+ if (typeof window === 'undefined') {
216
+ setUncontrolledActiveId(clickedId);
217
+ activeIdRef.current = clickedId;
218
+ onActiveIdChangeRef.current?.(clickedId);
219
+ return;
220
+ }
221
+
222
+ // Freeze the indicator during the programmatic smooth scroll instead of
223
+ // moving it immediately — it lands on the clicked item once the scroll
224
+ // settles, so it doesn't chase the scroll through intervening sections.
225
+ suppressRef.current = true;
226
+ // Replace any in-flight handlers from a previous click.
227
+ releaseSuppressionRef.current?.();
228
+
229
+ let settleTimer = 0;
230
+ const cleanup = () => {
231
+ window.removeEventListener('scrollend', onSettle);
232
+ window.removeEventListener('wheel', onManual);
233
+ window.removeEventListener('touchmove', onManual);
234
+ window.removeEventListener('keydown', onKeyDown);
235
+ if (settleTimer !== 0) {
236
+ clearTimeout(settleTimer);
237
+ settleTimer = 0;
238
+ }
239
+ releaseSuppressionRef.current = null;
240
+ };
241
+ // Programmatic scroll finished: move the indicator to the clicked item.
242
+ const onSettle = () => {
243
+ cleanup();
244
+ suppressRef.current = false;
245
+ setUncontrolledActiveId(clickedId);
246
+ activeIdRef.current = clickedId;
247
+ onActiveIdChangeRef.current?.(clickedId);
248
+ };
249
+ // User scrolled mid-flight: hand control back to scroll-position tracking.
250
+ const onManual = () => {
251
+ cleanup();
252
+ suppressRef.current = false;
253
+ syncRef.current?.();
254
+ };
255
+ const onKeyDown = (event: KeyboardEvent) => {
256
+ if (SCROLL_KEYS.has(event.key)) {
257
+ onManual();
258
+ }
259
+ };
260
+
261
+ window.addEventListener('scrollend', onSettle, {once: true});
262
+ window.addEventListener('wheel', onManual, {passive: true});
263
+ window.addEventListener('touchmove', onManual, {passive: true});
264
+ window.addEventListener('keydown', onKeyDown);
265
+ // Fallback when scrollend is unsupported or no scroll is needed.
266
+ settleTimer = window.setTimeout(onSettle, 1200);
267
+ releaseSuppressionRef.current = cleanup;
268
+ }, []);
269
+
270
+ return {
271
+ activeId: isControlled ? activeId : uncontrolledActiveId,
272
+ setActiveId,
273
+ lockActiveId,
274
+ };
142
275
  }
@@ -27,7 +27,7 @@ export const docs = {
27
27
  {
28
28
  guidance: true,
29
29
  description:
30
- 'Use useResizable() with existing XDS layout components. ' +
30
+ 'Use useResizable() with existing Astryx layout components. ' +
31
31
  'Pass the returned props to the resizable prop on LayoutPanel or SideNav.',
32
32
  },
33
33
  {
@@ -195,7 +195,7 @@ export const docsDense = {
195
195
  description:
196
196
  'Hook-based resizable panel system. useResizable() manages size state; ResizeHandle provides interactive pill-grip separator. Pass resize props to existing layout components via their resizable prop.',
197
197
  bestPractices: [
198
- {guidance: true, description: 'Use useResizable() w/ existing XDS layout components. Pass returned props to resizable prop on LayoutPanel or SideNav.'},
198
+ {guidance: true, description: 'Use useResizable() w/ existing Astryx layout components. Pass returned props to resizable prop on LayoutPanel or SideNav.'},
199
199
  {guidance: true, description: 'Provide accessible label on each ResizeHandle when multiple handles exist (e.g. "Resize sidebar", "Resize terminal").'},
200
200
  {guidance: false, description: 'Wrap panels in extra container components for resize. Hook-first architecture avoids extra DOM; use it directly on existing components.'},
201
201
  ],
@@ -107,10 +107,6 @@ export interface ResizableProps {
107
107
  const DEFAULT_MIN = 50;
108
108
  const DEFAULT_COLLAPSED_SIZE = 40;
109
109
  const STORAGE_PREFIX = 'astryx-resizable:';
110
- // Legacy key prefix read during the compat window so persisted panel sizes
111
- // survive the xds -> astryx rename. Read-only fallback; we always write the
112
- // new prefix. Removed at final cutover.
113
- const LEGACY_STORAGE_PREFIX = 'xds-resizable:';
114
110
 
115
111
  // =============================================================================
116
112
  // Helpers
@@ -147,9 +143,7 @@ function loadPersistedSize(key: string): number | null {
147
143
  return null;
148
144
  }
149
145
  try {
150
- const raw =
151
- localStorage.getItem(STORAGE_PREFIX + key) ??
152
- localStorage.getItem(LEGACY_STORAGE_PREFIX + key);
146
+ const raw = localStorage.getItem(STORAGE_PREFIX + key);
153
147
  if (raw != null) {
154
148
  const parsed = JSON.parse(raw);
155
149
  if (typeof parsed === 'number') {
@@ -107,12 +107,11 @@ const styles = stylex.create({
107
107
  lineHeight: 'inherit',
108
108
  color: 'inherit',
109
109
  cursor: 'pointer',
110
- outline: {
111
- default: 'none',
112
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
113
- },
114
- outlineOffset: '0',
115
- borderRadius: radiusVars['--radius-element'],
110
+ // The wrapper (inputWrapperStyles.base) renders the focus ring via
111
+ // :focus-within when this button is focused, matching TextInput/NumberInput.
112
+ // The button must not draw its own :focus-visible outline or the two stack
113
+ // into a doubled ring over the trigger.
114
+ outline: 'none',
116
115
  },
117
116
  triggerPlaceholder: {
118
117
  color: colorVars['--color-text-secondary'],
@@ -104,7 +104,7 @@ export const docs = {
104
104
  'Table displays structured data in rows and columns with consistent dimensionality. It supports rich cell content, sorting, selection, pagination, and column management through a composable plugin system. Use Table for data sets with uniform structure; for simpler or inconsistent data, consider a list or card layout instead.',
105
105
  bestPractices: [
106
106
  { guidance: true, description: 'Use density and divider variants to match the information density and scanning needs of your data.' },
107
- { guidance: true, description: 'Compose rich cell content with XDS components like Badge, StatusDot, and Avatar via renderCell.' },
107
+ { guidance: true, description: 'Compose rich cell content with Astryx components like Badge, StatusDot, and Avatar via renderCell.' },
108
108
  { guidance: true, description: 'Set explicit width on every column using proportional() or pixel(). proportional(1) gives equal flex distribution with a 120px minimum that prevents columns from collapsing on narrow viewports. Omitting width skips the minimum.' },
109
109
  { guidance: false, description: 'Use a table for data without consistent columns. Use a list or card layout for heterogeneous content.' },
110
110
  { guidance: false, description: 'Enable every plugin at once. Add only the features your use case requires to keep the interface focused.' },
@@ -128,7 +128,7 @@ export const docsZh = {
128
128
  'Table displays structured data in rows and columns with consistent dimensionality. It supports rich cell content, sorting, selection, pagination, and column management through a composable plugin system. Use Table for data sets with uniform structure; for simpler or inconsistent data, consider a list or card layout instead.',
129
129
  bestPractices: [
130
130
  { guidance: true, description: 'Use density and divider variants to match the information density and scanning needs of your data.' },
131
- { guidance: true, description: 'Compose rich cell content with XDS components like Badge, StatusDot, and Avatar via renderCell.' },
131
+ { guidance: true, description: 'Compose rich cell content with Astryx components like Badge, StatusDot, and Avatar via renderCell.' },
132
132
  { guidance: false, description: 'Use a table for data without consistent columns. Use a list or card layout for heterogeneous content.' },
133
133
  { guidance: false, description: 'Enable every plugin at once. Add only the features your use case requires to keep the interface focused.' },
134
134
  ],
@@ -151,7 +151,7 @@ export const docsDense = {
151
151
  'Table displays structured data in rows and columns with consistent dimensionality. It supports rich cell content, sorting, selection, pagination, and column management through a composable plugin system. Use Table for data sets with uniform structure; for simpler or inconsistent data, consider a list or card layout instead.',
152
152
  bestPractices: [
153
153
  { guidance: true, description: 'Use density and divider variants to match the information density and scanning needs of your data.' },
154
- { guidance: true, description: 'Compose rich cell content with XDS components like Badge, StatusDot, and Avatar via renderCell.' },
154
+ { guidance: true, description: 'Compose rich cell content with Astryx components like Badge, StatusDot, and Avatar via renderCell.' },
155
155
  { guidance: true, description: 'Set explicit width on every column via proportional() or pixel(). proportional(1) = equal flex w/ 120px min preventing collapse on narrow viewports. Omitting width skips the minimum.' },
156
156
  { guidance: false, description: 'Use a table for data without consistent columns. Use a list or card layout for heterogeneous content.' },
157
157
  { guidance: false, description: 'Enable every plugin at once. Add only the features your use case requires to keep the interface focused.' },
@@ -39,8 +39,8 @@ export const docs = {
39
39
  },
40
40
  {
41
41
  name: 'pressedChangeAction',
42
- type: '(isPressed: boolean) => Promise<void>',
43
- description: 'Async action handler for API-backed toggles. Shows loading spinner while pending.',
42
+ type: '(isPressed: boolean) => void | Promise<void>',
43
+ description: 'Action handler for API- or navigation-backed toggles, run in a transition. Shows an optimistic pressed state immediately and a (debounced) spinner while pending; interruptible by re-clicks.',
44
44
  },
45
45
  {
46
46
  name: 'size',
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import {describe, it, expect, vi} from 'vitest';
12
- import {render, screen} from '@testing-library/react';
12
+ import {render, screen, act, fireEvent, waitFor} from '@testing-library/react';
13
13
  import userEvent from '@testing-library/user-event';
14
14
  import {useState} from 'react';
15
15
  import {ToggleButton} from './ToggleButton';
@@ -71,11 +71,7 @@ describe('ToggleButton', () => {
71
71
 
72
72
  it('sets aria-pressed=true when pressed', () => {
73
73
  render(
74
- <ToggleButton
75
- label="Bold"
76
- isPressed={true}
77
- onPressedChange={() => {}}
78
- />,
74
+ <ToggleButton label="Bold" isPressed={true} onPressedChange={() => {}} />,
79
75
  );
80
76
  expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
81
77
  });
@@ -196,6 +192,152 @@ describe('ToggleButton', () => {
196
192
  );
197
193
  expect(screen.getByTestId('bold-toggle')).toBeInTheDocument();
198
194
  });
195
+
196
+ it('shows the optimistic pressed state immediately, before any spinner', async () => {
197
+ const user = userEvent.setup();
198
+ let resolveAction: (() => void) | undefined;
199
+ const pressedChangeAction = vi.fn(
200
+ async () =>
201
+ new Promise<void>(resolve => {
202
+ resolveAction = resolve;
203
+ }),
204
+ );
205
+
206
+ render(
207
+ <ToggleButton
208
+ label="Favorite"
209
+ isPressed={false}
210
+ onPressedChange={() => {}}
211
+ pressedChangeAction={pressedChangeAction}
212
+ />,
213
+ );
214
+
215
+ const button = screen.getByRole('button', {name: 'Favorite'});
216
+ expect(button).toHaveAttribute('aria-pressed', 'false');
217
+
218
+ await user.click(button);
219
+
220
+ // The optimistic state flips immediately. The spinner is debounced, so the
221
+ // button is not disabled or aria-busy yet — it stays interruptible.
222
+ expect(pressedChangeAction).toHaveBeenCalledWith(true);
223
+ expect(button).toHaveAttribute('aria-pressed', 'true');
224
+ expect(button).not.toBeDisabled();
225
+ expect(button).not.toHaveAttribute('aria-busy', 'true');
226
+
227
+ // Settle the action so the pending transition doesn't leak into later tests.
228
+ await act(async () => {
229
+ resolveAction?.();
230
+ await Promise.resolve();
231
+ });
232
+ });
233
+
234
+ it('shows a loading spinner once the action stays pending past the delay', async () => {
235
+ const user = userEvent.setup();
236
+ let resolveAction: (() => void) | undefined;
237
+ const pressedChangeAction = vi.fn(
238
+ async () =>
239
+ new Promise<void>(resolve => {
240
+ resolveAction = resolve;
241
+ }),
242
+ );
243
+
244
+ render(
245
+ <ToggleButton
246
+ label="Favorite"
247
+ isPressed={false}
248
+ onPressedChange={() => {}}
249
+ pressedChangeAction={pressedChangeAction}
250
+ />,
251
+ );
252
+
253
+ const button = screen.getByRole('button', {name: 'Favorite'});
254
+ await user.click(button);
255
+
256
+ // The optimistic state shows immediately; the spinner is debounced and
257
+ // appears only after the action stays pending past the delay window.
258
+ expect(button).toHaveAttribute('aria-pressed', 'true');
259
+ await waitFor(() => expect(button).toBeDisabled());
260
+ expect(button).toHaveAttribute('aria-busy', 'true');
261
+
262
+ await act(async () => {
263
+ resolveAction?.();
264
+ await Promise.resolve();
265
+ });
266
+ expect(button).not.toBeDisabled();
267
+ expect(button).not.toHaveAttribute('aria-busy', 'true');
268
+ });
269
+
270
+ it('interrupts an in-flight action on re-click (true -> false -> true)', async () => {
271
+ // Each click interrupts the previous transition. The actions are resolved
272
+ // at the end so the pending transition doesn't leak into later tests.
273
+ const resolvers: (() => void)[] = [];
274
+ const pressedChangeAction = vi.fn(
275
+ async () =>
276
+ new Promise<void>(resolve => {
277
+ resolvers.push(resolve);
278
+ }),
279
+ );
280
+
281
+ render(
282
+ <ToggleButton
283
+ label="Favorite"
284
+ isPressed={false}
285
+ onPressedChange={() => {}}
286
+ pressedChangeAction={pressedChangeAction}
287
+ />,
288
+ );
289
+
290
+ const button = screen.getByRole('button', {name: 'Favorite'});
291
+
292
+ // Each click derives the next state from the optimistic (in-progress)
293
+ // value, so rapid clicks toggle rather than being dropped. fireEvent keeps
294
+ // the clicks within the spinner debounce window so the button stays
295
+ // interruptible.
296
+ await act(async () => {
297
+ fireEvent.click(button);
298
+ });
299
+ expect(button).toHaveAttribute('aria-pressed', 'true');
300
+ await act(async () => {
301
+ fireEvent.click(button);
302
+ });
303
+ expect(button).toHaveAttribute('aria-pressed', 'false');
304
+ await act(async () => {
305
+ fireEvent.click(button);
306
+ });
307
+ expect(button).toHaveAttribute('aria-pressed', 'true');
308
+
309
+ expect(pressedChangeAction).toHaveBeenCalledTimes(3);
310
+ expect(pressedChangeAction).toHaveBeenNthCalledWith(1, true);
311
+ expect(pressedChangeAction).toHaveBeenNthCalledWith(2, false);
312
+ expect(pressedChangeAction).toHaveBeenNthCalledWith(3, true);
313
+
314
+ await act(async () => {
315
+ resolvers.forEach(resolve => resolve());
316
+ await Promise.resolve();
317
+ });
318
+ });
319
+
320
+ it('supports a synchronous pressedChangeAction', async () => {
321
+ const user = userEvent.setup();
322
+ // A sync handler (e.g. a router navigation) with no returned promise.
323
+ const pressedChangeAction = vi.fn((_next: boolean) => {});
324
+ const onPressedChange = vi.fn();
325
+
326
+ render(
327
+ <ToggleButton
328
+ label="Favorite"
329
+ isPressed={false}
330
+ onPressedChange={onPressedChange}
331
+ pressedChangeAction={pressedChangeAction}
332
+ />,
333
+ );
334
+
335
+ const button = screen.getByRole('button', {name: 'Favorite'});
336
+ await user.click(button);
337
+
338
+ expect(onPressedChange).toHaveBeenCalledWith(true);
339
+ expect(pressedChangeAction).toHaveBeenCalledWith(true);
340
+ });
199
341
  });
200
342
 
201
343
  // =============================================================================