@astryxdesign/core 0.1.0 → 0.1.1-canary.080d887

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 (170) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +119 -1
  3. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  4. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  6. package/dist/Banner/Banner.d.ts +7 -0
  7. package/dist/Banner/Banner.d.ts.map +1 -1
  8. package/dist/Banner/Banner.js +9 -2
  9. package/dist/Button/Button.d.ts +12 -2
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +51 -13
  12. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  13. package/dist/Chat/ChatLayoutScrollButton.js +5 -1
  14. package/dist/ContextMenu/ContextMenu.js +2 -2
  15. package/dist/DropdownMenu/DropdownMenu.js +2 -2
  16. package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
  17. package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
  18. package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
  19. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  20. package/dist/EmptyState/EmptyState.js +7 -1
  21. package/dist/HoverCard/HoverCard.d.ts +2 -2
  22. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  23. package/dist/HoverCard/HoverCard.js +18 -6
  24. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  25. package/dist/HoverCard/useHoverCard.js +6 -3
  26. package/dist/Kbd/Kbd.d.ts.map +1 -1
  27. package/dist/Kbd/Kbd.js +1 -1
  28. package/dist/Layer/useLayer.d.ts +13 -0
  29. package/dist/Layer/useLayer.d.ts.map +1 -1
  30. package/dist/Layer/useLayer.js +7 -2
  31. package/dist/Layout/Layout.d.ts +10 -1
  32. package/dist/Layout/Layout.d.ts.map +1 -1
  33. package/dist/Layout/Layout.js +5 -1
  34. package/dist/Markdown/Markdown.d.ts.map +1 -1
  35. package/dist/Markdown/Markdown.js +13 -3
  36. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  37. package/dist/MobileNav/MobileNav.js +13 -0
  38. package/dist/Outline/Outline.d.ts +3 -2
  39. package/dist/Outline/Outline.d.ts.map +1 -1
  40. package/dist/Outline/Outline.js +23 -4
  41. package/dist/Outline/useScrollSpy.d.ts +14 -1
  42. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  43. package/dist/Outline/useScrollSpy.js +161 -50
  44. package/dist/Pagination/Pagination.d.ts.map +1 -1
  45. package/dist/Pagination/Pagination.js +31 -27
  46. package/dist/Resizable/useResizable.d.ts.map +1 -1
  47. package/dist/Resizable/useResizable.js +1 -5
  48. package/dist/Selector/Selector.d.ts.map +1 -1
  49. package/dist/Selector/Selector.js +1 -1
  50. package/dist/Slider/Slider.js +2 -2
  51. package/dist/Table/BaseTable.d.ts.map +1 -1
  52. package/dist/Table/BaseTable.js +26 -8
  53. package/dist/Table/Table.d.ts.map +1 -1
  54. package/dist/Table/Table.js +30 -7
  55. package/dist/Table/index.d.ts +3 -1
  56. package/dist/Table/index.d.ts.map +1 -1
  57. package/dist/Table/index.js +1 -0
  58. package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
  59. package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
  60. package/dist/Table/plugins/stickyColumns/index.js +3 -0
  61. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
  62. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
  63. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
  64. package/dist/Table/types.d.ts +90 -5
  65. package/dist/Table/types.d.ts.map +1 -1
  66. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  67. package/dist/Table/useBaseTablePlugins.js +1 -1
  68. package/dist/Timestamp/Timestamp.d.ts +1 -1
  69. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  70. package/dist/Timestamp/Timestamp.js +28 -3
  71. package/dist/ToggleButton/ToggleButton.d.ts +10 -5
  72. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  73. package/dist/ToggleButton/ToggleButton.js +40 -17
  74. package/dist/astryx.css +20 -0
  75. package/dist/astryx.umd.js +147 -0
  76. package/dist/astryx.umd.js.map +7 -0
  77. package/dist/theme/Theme.js +1 -1
  78. package/dist/theme/defineTheme.d.ts +1 -1
  79. package/dist/theme/defineTheme.d.ts.map +1 -1
  80. package/dist/theme/defineTheme.js +1 -1
  81. package/dist/theme/index.d.ts +1 -1
  82. package/dist/theme/index.d.ts.map +1 -1
  83. package/dist/theme/index.js +1 -1
  84. package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
  85. package/dist/theme/tokens.d.ts +1 -1
  86. package/dist/theme/tokens.js +4 -4
  87. package/dist/theme/useTheme.d.ts +2 -2
  88. package/dist/utils/dateParser.d.ts.map +1 -1
  89. package/dist/utils/dateParser.js +15 -2
  90. package/package.json +7 -3
  91. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
  92. package/src/Banner/Banner.test.tsx +16 -7
  93. package/src/Banner/Banner.tsx +9 -2
  94. package/src/Button/Button.doc.mjs +6 -0
  95. package/src/Button/Button.test.tsx +105 -11
  96. package/src/Button/Button.tsx +73 -5
  97. package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
  98. package/src/Collapsible/useCollapsible.doc.mjs +2 -2
  99. package/src/ContextMenu/ContextMenu.tsx +2 -2
  100. package/src/DateInput/DateInput.test.tsx +68 -20
  101. package/src/Divider/Divider.doc.mjs +1 -1
  102. package/src/DropdownMenu/DropdownMenu.tsx +2 -2
  103. package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
  104. package/src/EmptyState/EmptyState.test.tsx +4 -2
  105. package/src/EmptyState/EmptyState.tsx +6 -2
  106. package/src/FormLayout/FormLayout.doc.mjs +3 -3
  107. package/src/HoverCard/HoverCard.doc.mjs +3 -0
  108. package/src/HoverCard/HoverCard.test.tsx +178 -2
  109. package/src/HoverCard/HoverCard.tsx +20 -16
  110. package/src/HoverCard/useHoverCard.tsx +12 -10
  111. package/src/Icon/Icon.doc.mjs +12 -4
  112. package/src/Item/Item.doc.mjs +2 -2
  113. package/src/Kbd/Kbd.tsx +2 -9
  114. package/src/Layer/useLayer.doc.mjs +7 -2
  115. package/src/Layer/useLayer.tsx +19 -2
  116. package/src/Layout/Layout.doc.mjs +2 -1
  117. package/src/Layout/Layout.tsx +15 -1
  118. package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
  119. package/src/Lightbox/Lightbox.doc.mjs +0 -2
  120. package/src/Link/Link.doc.mjs +3 -3
  121. package/src/Link/LinkProvider.doc.mjs +3 -3
  122. package/src/Markdown/Markdown.doc.mjs +6 -4
  123. package/src/Markdown/Markdown.test.tsx +17 -26
  124. package/src/Markdown/Markdown.tsx +16 -6
  125. package/src/MobileNav/MobileNav.doc.mjs +8 -8
  126. package/src/MobileNav/MobileNav.tsx +13 -0
  127. package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
  128. package/src/Outline/Outline.doc.mjs +1 -1
  129. package/src/Outline/Outline.test.tsx +76 -38
  130. package/src/Outline/Outline.tsx +23 -4
  131. package/src/Outline/useScrollSpy.ts +196 -63
  132. package/src/Pagination/Pagination.test.tsx +137 -13
  133. package/src/Pagination/Pagination.tsx +33 -28
  134. package/src/Resizable/Resizable.doc.mjs +3 -3
  135. package/src/Resizable/useResizable.ts +1 -7
  136. package/src/Selector/Selector.doc.mjs +4 -0
  137. package/src/Selector/Selector.tsx +5 -6
  138. package/src/Skeleton/Skeleton.doc.mjs +11 -1
  139. package/src/Slider/Slider.doc.mjs +4 -0
  140. package/src/Slider/Slider.test.tsx +3 -16
  141. package/src/Slider/Slider.tsx +1 -1
  142. package/src/Table/BaseTable.tsx +50 -24
  143. package/src/Table/Table.doc.mjs +15 -3
  144. package/src/Table/Table.tsx +22 -1
  145. package/src/Table/index.ts +3 -0
  146. package/src/Table/plugins/stickyColumns/index.ts +4 -0
  147. package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
  148. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
  149. package/src/Table/types.ts +96 -4
  150. package/src/Table/useBaseTablePlugins.ts +1 -0
  151. package/src/Text/Heading.doc.mjs +13 -0
  152. package/src/Timestamp/Timestamp.test.tsx +44 -18
  153. package/src/Timestamp/Timestamp.tsx +30 -10
  154. package/src/ToggleButton/ToggleButton.doc.mjs +4 -4
  155. package/src/ToggleButton/ToggleButton.test.tsx +172 -9
  156. package/src/ToggleButton/ToggleButton.tsx +57 -31
  157. package/src/Toolbar/Toolbar.doc.mjs +1 -1
  158. package/src/hooks/useEntryAnimation.doc.mjs +3 -3
  159. package/src/hooks/useMediaQuery.doc.mjs +2 -2
  160. package/src/hooks/useStreamingText.doc.mjs +3 -3
  161. package/src/theme/Theme.doc.mjs +2 -2
  162. package/src/theme/Theme.tsx +1 -1
  163. package/src/theme/defineTheme.ts +1 -1
  164. package/src/theme/index.ts +1 -1
  165. package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
  166. package/src/theme/tokens.ts +4 -4
  167. package/src/theme/useTheme.ts +2 -2
  168. package/src/utils/dateParser.test.ts +26 -0
  169. package/src/utils/dateParser.ts +16 -2
  170. package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
@@ -33,10 +33,30 @@ describe('Timestamp', () => {
33
33
  expect(screen.getByText('2 hours ago')).toBeInTheDocument();
34
34
  });
35
35
 
36
- it('renders "just now" for very recent times', () => {
36
+ it('renders "now" for very recent times', () => {
37
37
  const fiveSecondsAgo = Date.now() / 1000 - 5;
38
38
  render(<Timestamp value={fiveSecondsAgo} format="relative" />);
39
- expect(screen.getByText('just now')).toBeInTheDocument();
39
+ expect(screen.getByText('now')).toBeInTheDocument();
40
+ });
41
+
42
+ it('renders "now" for the current instant (not a future phrase)', () => {
43
+ // A value equal to "right now". Because the internal `now` baseline is
44
+ // captured at render time, it can lag the value by a fraction of a second,
45
+ // producing a tiny negative delta that must not be treated as the future.
46
+ render(<Timestamp value={Date.now() / 1000} format="relative" />);
47
+ expect(screen.queryByText(/^in /)).not.toBeInTheDocument();
48
+ expect(screen.getByText('now')).toBeInTheDocument();
49
+ });
50
+
51
+ it('renders "now" for a value a hair in the future (clock skew)', () => {
52
+ // Real-world clock / captured-now skew can make a current-ish value land a
53
+ // fraction of a second in the future relative to the component's internal
54
+ // `now`. This must read as the present ("now"), never "in a few
55
+ // seconds". Regression test for the right-now -> future-phrase bug.
56
+ const aHairInTheFuture = Date.now() / 1000 + 0.6;
57
+ render(<Timestamp value={aHairInTheFuture} format="relative" />);
58
+ expect(screen.queryByText(/^in /)).not.toBeInTheDocument();
59
+ expect(screen.getByText('now')).toBeInTheDocument();
40
60
  });
41
61
 
42
62
  it('renders "yesterday" for times ~1 day ago', () => {
@@ -49,11 +69,7 @@ describe('Timestamp', () => {
49
69
 
50
70
  it('renders date format', () => {
51
71
  render(
52
- <Timestamp
53
- value="2026-02-19T17:00:00Z"
54
- format="date"
55
- data-testid="ts"
56
- />,
72
+ <Timestamp value="2026-02-19T17:00:00Z" format="date" data-testid="ts" />,
57
73
  );
58
74
  const el = screen.getByTestId('ts');
59
75
  expect(el.textContent).toContain('2026');
@@ -77,11 +93,7 @@ describe('Timestamp', () => {
77
93
 
78
94
  it('renders time format', () => {
79
95
  render(
80
- <Timestamp
81
- value="2026-02-19T17:00:00Z"
82
- format="time"
83
- data-testid="ts"
84
- />,
96
+ <Timestamp value="2026-02-19T17:00:00Z" format="time" data-testid="ts" />,
85
97
  );
86
98
  const el = screen.getByTestId('ts');
87
99
  // Should contain time but not year
@@ -197,7 +209,7 @@ describe('Timestamp', () => {
197
209
  it('live updates relative time', () => {
198
210
  const now = Date.now() / 1000;
199
211
  render(<Timestamp value={now - 5} format="relative" isLive />);
200
- expect(screen.getByText('just now')).toBeInTheDocument();
212
+ expect(screen.getByText('now')).toBeInTheDocument();
201
213
 
202
214
  act(() => {
203
215
  vi.advanceTimersByTime(30_000);
@@ -210,11 +222,7 @@ describe('Timestamp', () => {
210
222
  it('forwards ref', () => {
211
223
  const ref = {current: null as HTMLTimeElement | null};
212
224
  render(
213
- <Timestamp
214
- ref={ref}
215
- value="2026-03-25T10:00:00Z"
216
- format="date_time"
217
- />,
225
+ <Timestamp ref={ref} value="2026-03-25T10:00:00Z" format="date_time" />,
218
226
  );
219
227
  expect(ref.current).toBeInstanceOf(HTMLTimeElement);
220
228
  });
@@ -240,6 +248,24 @@ describe('Timestamp', () => {
240
248
  expect(screen.getByText('in 1 hour')).toBeInTheDocument();
241
249
  });
242
250
 
251
+ it('renders "now" for a value a few seconds in the future (clock skew)', () => {
252
+ // Beyond the sub-second render lag but still within the skew tolerance: a
253
+ // value ~20s ahead of our clock is almost always skew (the value's clock
254
+ // running fast), not a genuine future event, so it should read as the
255
+ // present rather than "in a few seconds".
256
+ const twentySecondsFromNow = Date.now() / 1000 + 20;
257
+ render(<Timestamp value={twentySecondsFromNow} format="relative" />);
258
+ expect(screen.queryByText(/^in /)).not.toBeInTheDocument();
259
+ expect(screen.getByText('now')).toBeInTheDocument();
260
+ });
261
+
262
+ it('renders a genuine near-future time beyond the skew tolerance', () => {
263
+ // Past the skew window — this is a real upcoming time, not clock drift.
264
+ const fortyFiveSecondsFromNow = Date.now() / 1000 + 45;
265
+ render(<Timestamp value={fortyFiveSecondsFromNow} format="relative" />);
266
+ expect(screen.getByText('in a few seconds')).toBeInTheDocument();
267
+ });
268
+
243
269
  // --- Long-ago relative ---
244
270
 
245
271
  it('renders months ago for dates older than 30 days', () => {
@@ -19,12 +19,7 @@
19
19
  import {useEffect, useRef, useState, lazy, Suspense} from 'react';
20
20
  import * as stylex from '@stylexjs/stylex';
21
21
  import {Text} from '../Text';
22
- import type {
23
- TextType,
24
- TextSize,
25
- TextColor,
26
- TextWeight,
27
- } from '../theme/types';
22
+ import type {TextType, TextSize, TextColor, TextWeight} from '../theme/types';
28
23
  import {mergeProps, mergeRefs} from '../utils';
29
24
  import type {BaseProps} from '../BaseProps';
30
25
  import {themeProps} from '../utils/themeProps';
@@ -54,7 +49,7 @@ export interface TimestampProps extends BaseProps<HTMLTimeElement> {
54
49
  value: string | number;
55
50
  /**
56
51
  * Display format.
57
- * - `'relative'`: "2 hours ago", "yesterday", "just now"
52
+ * - `'relative'`: "2 hours ago", "yesterday", "now"
58
53
  * - `'auto'`: Relative for recent times, `date_time` for older
59
54
  * - `'date'`: "Mar 21, 2025"
60
55
  * - `'date_time'`: "Mar 21, 2025, 2:51 PM"
@@ -138,6 +133,18 @@ const YEAR = 365 * DAY;
138
133
  /** Default auto threshold: 7 days in seconds */
139
134
  const DEFAULT_AUTO_THRESHOLD = 7 * DAY;
140
135
 
136
+ /**
137
+ * Tolerance (in seconds) for treating a *future* timestamp as the present.
138
+ * A value only a handful of seconds ahead of our reference clock is almost
139
+ * always clock skew — the displayed `now` lagging the real clock, or the value
140
+ * being produced on a slightly faster clock — not a genuine future event, so
141
+ * it reads as "now" rather than a confusing "in a few seconds". The future
142
+ * side gets a wider window than the past (which only needs to absorb the
143
+ * sub-second render-time lag) because future drift is far more likely to be
144
+ * skew than real.
145
+ */
146
+ const FUTURE_SKEW_TOLERANCE = 30;
147
+
141
148
  function parseValue(value: string | number): Date {
142
149
  if (typeof value === 'number') {
143
150
  // Heuristic: if the number is less than 1e12, treat as seconds; otherwise ms.
@@ -150,9 +157,25 @@ function parseValue(value: string | number): Date {
150
157
  function getRelativeTimeString(date: Date, now: Date): string {
151
158
  const diffSeconds = Math.round((now.getTime() - date.getTime()) / 1000);
152
159
 
160
+ // Treat values at (or a hair before/after) the present as "now". The
161
+ // internal `now` reference is captured at render time, so it can lag the
162
+ // real clock; a value equal to "right now" can land a fraction of a second
163
+ // in the future and round to a small negative delta. Without this clamp,
164
+ // such values fall into the future branch and render "in a few seconds".
165
+ if (Math.abs(diffSeconds) < 10) {
166
+ return 'now';
167
+ }
168
+
153
169
  if (diffSeconds < 0) {
154
170
  // Future dates
155
171
  const absDiff = Math.abs(diffSeconds);
172
+ // A value only a few seconds ahead of our clock is almost always skew, not
173
+ // a genuine future event — render it as the present rather than a
174
+ // confusing "in a few seconds". Wider than the past window above on
175
+ // purpose (see FUTURE_SKEW_TOLERANCE).
176
+ if (absDiff <= FUTURE_SKEW_TOLERANCE) {
177
+ return 'now';
178
+ }
156
179
  if (absDiff < MINUTE) {
157
180
  return 'in a few seconds';
158
181
  }
@@ -176,9 +199,6 @@ function getRelativeTimeString(date: Date, now: Date): string {
176
199
  return `in ${years} ${years === 1 ? 'year' : 'years'}`;
177
200
  }
178
201
 
179
- if (diffSeconds < 10) {
180
- return 'just now';
181
- }
182
202
  if (diffSeconds < MINUTE) {
183
203
  return `${diffSeconds} seconds ago`;
184
204
  }
@@ -34,13 +34,13 @@ export const docs = {
34
34
  },
35
35
  {
36
36
  name: 'onPressedChange',
37
- type: '(isPressed: boolean) => void',
38
- description: 'Called when pressed state should change. Ignored when inside a group.',
37
+ type: '(isPressed: boolean, event: MouseEvent) => void',
38
+ description: 'Called when pressed state should change. Receives the next state and the click event; call event.preventDefault() to skip pressedChangeAction. Ignored when inside a group.',
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 spinner while pending; the button stays interruptible by re-clicks.',
44
44
  },
45
45
  {
46
46
  name: 'size',
@@ -9,9 +9,9 @@
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} from '@testing-library/react';
13
13
  import userEvent from '@testing-library/user-event';
14
- import {useState} from 'react';
14
+ import {useState, type MouseEvent} from 'react';
15
15
  import {ToggleButton} from './ToggleButton';
16
16
  import {ToggleButtonGroup} from './ToggleButtonGroup';
17
17
 
@@ -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
  });
@@ -92,7 +88,7 @@ describe('ToggleButton', () => {
92
88
  );
93
89
 
94
90
  await user.click(screen.getByRole('button'));
95
- expect(handleChange).toHaveBeenCalledWith(true);
91
+ expect(handleChange).toHaveBeenCalledWith(true, expect.anything());
96
92
  });
97
93
 
98
94
  it('calls onPressedChange with false when clicking pressed button', async () => {
@@ -107,7 +103,7 @@ describe('ToggleButton', () => {
107
103
  );
108
104
 
109
105
  await user.click(screen.getByRole('button'));
110
- expect(handleChange).toHaveBeenCalledWith(false);
106
+ expect(handleChange).toHaveBeenCalledWith(false, expect.anything());
111
107
  });
112
108
 
113
109
  it('renders pressedIcon when pressed', () => {
@@ -196,6 +192,173 @@ describe('ToggleButton', () => {
196
192
  );
197
193
  expect(screen.getByTestId('bold-toggle')).toBeInTheDocument();
198
194
  });
195
+
196
+ it('shows the optimistic pressed state and stays interruptible while pending', 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 and the spinner shows via
221
+ // aria-busy, but the button is never disabled — it stays clickable so the
222
+ // action can be interrupted by another click.
223
+ expect(pressedChangeAction).toHaveBeenCalledWith(true);
224
+ expect(button).toHaveAttribute('aria-pressed', 'true');
225
+ expect(button).toHaveAttribute('aria-busy', 'true');
226
+ expect(button).not.toBeDisabled();
227
+
228
+ // Settle the action so the pending transition doesn't leak into later tests.
229
+ await act(async () => {
230
+ resolveAction?.();
231
+ await Promise.resolve();
232
+ });
233
+ });
234
+
235
+ it('clears the loading state once the action settles', async () => {
236
+ const user = userEvent.setup();
237
+ let resolveAction: (() => void) | undefined;
238
+ const pressedChangeAction = vi.fn(
239
+ async () =>
240
+ new Promise<void>(resolve => {
241
+ resolveAction = resolve;
242
+ }),
243
+ );
244
+
245
+ render(
246
+ <ToggleButton
247
+ label="Favorite"
248
+ isPressed={false}
249
+ onPressedChange={() => {}}
250
+ pressedChangeAction={pressedChangeAction}
251
+ />,
252
+ );
253
+
254
+ const button = screen.getByRole('button', {name: 'Favorite'});
255
+ await user.click(button);
256
+
257
+ expect(button).toHaveAttribute('aria-busy', 'true');
258
+ expect(button).not.toBeDisabled();
259
+
260
+ await act(async () => {
261
+ resolveAction?.();
262
+ await Promise.resolve();
263
+ });
264
+ expect(button).not.toHaveAttribute('aria-busy', 'true');
265
+ expect(button).not.toBeDisabled();
266
+ });
267
+
268
+ it('interrupts an in-flight action on re-click (true -> false -> true)', async () => {
269
+ // Each click interrupts the previous transition. The actions are resolved
270
+ // at the end so the pending transition doesn't leak into later tests.
271
+ const resolvers: (() => void)[] = [];
272
+ const pressedChangeAction = vi.fn(
273
+ async () =>
274
+ new Promise<void>(resolve => {
275
+ resolvers.push(resolve);
276
+ }),
277
+ );
278
+
279
+ render(
280
+ <ToggleButton
281
+ label="Favorite"
282
+ isPressed={false}
283
+ onPressedChange={() => {}}
284
+ pressedChangeAction={pressedChangeAction}
285
+ />,
286
+ );
287
+
288
+ const button = screen.getByRole('button', {name: 'Favorite'});
289
+
290
+ // Each click derives the next state from the optimistic (in-progress)
291
+ // value, so rapid clicks toggle rather than being dropped. The button is
292
+ // never disabled while pending, so every click lands and interrupts.
293
+ await act(async () => {
294
+ fireEvent.click(button);
295
+ });
296
+ expect(button).toHaveAttribute('aria-pressed', 'true');
297
+ await act(async () => {
298
+ fireEvent.click(button);
299
+ });
300
+ expect(button).toHaveAttribute('aria-pressed', 'false');
301
+ await act(async () => {
302
+ fireEvent.click(button);
303
+ });
304
+ expect(button).toHaveAttribute('aria-pressed', 'true');
305
+
306
+ expect(pressedChangeAction).toHaveBeenCalledTimes(3);
307
+ expect(pressedChangeAction).toHaveBeenNthCalledWith(1, true);
308
+ expect(pressedChangeAction).toHaveBeenNthCalledWith(2, false);
309
+ expect(pressedChangeAction).toHaveBeenNthCalledWith(3, true);
310
+
311
+ await act(async () => {
312
+ resolvers.forEach(resolve => resolve());
313
+ await Promise.resolve();
314
+ });
315
+ });
316
+
317
+ it('supports a synchronous pressedChangeAction', async () => {
318
+ const user = userEvent.setup();
319
+ // A sync handler (e.g. a router navigation) with no returned promise.
320
+ const pressedChangeAction = vi.fn((_next: boolean) => {});
321
+ const onPressedChange = vi.fn();
322
+
323
+ render(
324
+ <ToggleButton
325
+ label="Favorite"
326
+ isPressed={false}
327
+ onPressedChange={onPressedChange}
328
+ pressedChangeAction={pressedChangeAction}
329
+ />,
330
+ );
331
+
332
+ const button = screen.getByRole('button', {name: 'Favorite'});
333
+ await user.click(button);
334
+
335
+ expect(onPressedChange).toHaveBeenCalledWith(true, expect.anything());
336
+ expect(pressedChangeAction).toHaveBeenCalledWith(true);
337
+ });
338
+
339
+ it('skips pressedChangeAction when onPressedChange calls preventDefault', async () => {
340
+ const user = userEvent.setup();
341
+ const pressedChangeAction = vi.fn();
342
+ const onPressedChange = vi.fn(
343
+ (_next: boolean, event: MouseEvent<HTMLButtonElement>) => {
344
+ event.preventDefault();
345
+ },
346
+ );
347
+
348
+ render(
349
+ <ToggleButton
350
+ label="Favorite"
351
+ isPressed={false}
352
+ onPressedChange={onPressedChange}
353
+ pressedChangeAction={pressedChangeAction}
354
+ />,
355
+ );
356
+
357
+ await user.click(screen.getByRole('button', {name: 'Favorite'}));
358
+
359
+ expect(onPressedChange).toHaveBeenCalledWith(true, expect.anything());
360
+ expect(pressedChangeAction).not.toHaveBeenCalled();
361
+ });
199
362
  });
200
363
 
201
364
  // =============================================================================
@@ -20,7 +20,7 @@
20
20
  * - /packages/cli/templates/blocks/components/ToggleButton/ (showcase blocks)
21
21
  */
22
22
 
23
- import React, {useCallback, type ReactNode} from 'react';
23
+ import React, {useOptimistic, type ReactNode} from 'react';
24
24
  import * as stylex from '@stylexjs/stylex';
25
25
  import {colorVars, fontWeightVars} from '../theme/tokens.stylex';
26
26
 
@@ -84,15 +84,23 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
84
84
  isPressed?: boolean;
85
85
 
86
86
  /**
87
- * Called when the pressed state should change.
87
+ * Called when the pressed state should change. Receives the next pressed
88
+ * state and the originating click event. Call `event.preventDefault()` to
89
+ * opt out of running `pressedChangeAction` (e.g. to handle the toggle
90
+ * entirely in this callback).
88
91
  * When used inside ToggleButtonGroup, this is handled by the group
89
92
  * and this prop is ignored.
90
93
  */
91
- onPressedChange?: (isPressed: boolean) => void;
94
+ onPressedChange?: (
95
+ isPressed: boolean,
96
+ event: React.MouseEvent<HTMLButtonElement>,
97
+ ) => void;
92
98
 
93
99
  /**
94
- * Async action handler for API-backed toggles.
95
- * The button shows a loading spinner while the promise is pending.
100
+ * Action handler for API- or navigation-backed toggles, run inside a
101
+ * transition by Button. The button shows a loading spinner while the action
102
+ * is pending — whether it returns a promise or synchronously triggers a
103
+ * suspending update (e.g. a router navigation that suspends on data).
96
104
  *
97
105
  * @example
98
106
  * ```
@@ -106,7 +114,7 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
106
114
  * />
107
115
  * ```
108
116
  */
109
- pressedChangeAction?: (isPressed: boolean) => Promise<void>;
117
+ pressedChangeAction?: (isPressed: boolean) => void | Promise<void>;
110
118
 
111
119
  /**
112
120
  * The size of the toggle button.
@@ -218,46 +226,62 @@ export function ToggleButton({
218
226
  style,
219
227
  ...props
220
228
  }: ToggleButtonProps): ReactNode {
221
- // Read group context if inside a group
222
229
  const group = useToggleButtonGroup();
223
230
 
224
- // Resolve state from group or props
225
- const isPressed =
231
+ const committedPressed =
226
232
  group && value != null
227
233
  ? group.selectedValues.has(value)
228
234
  : (isPressedProp ?? false);
229
235
  const size = sizeProp ?? group?.size ?? 'md';
230
236
  const isDisabled = group?.isDisabled ?? isDisabledProp;
231
237
 
238
+ // Track the pressed state optimistically so the button reflects the intended
239
+ // state immediately while an async action is pending. The optimistic update
240
+ // is applied inside Button's clickAction transition (below), satisfying
241
+ // React's "useOptimistic must run in a transition" rule.
242
+ const [optimisticPressed, setOptimisticPressed] =
243
+ useOptimistic(committedPressed);
244
+ const isPressed = optimisticPressed;
245
+
246
+ // Next state derives from the *optimistic* value, not the committed one, so a
247
+ // re-click while an action is pending reads the in-flight intent and toggles
248
+ // (true -> false -> true) rather than recomputing from the stale committed
249
+ // state. handleClick and clickAction share this so the callback and the
250
+ // optimistic update always agree within a single click.
251
+ const nextPressed = !isPressed;
252
+
232
253
  const resolvedIcon = isPressed && pressedIcon ? pressedIcon : icon;
233
254
 
234
- const handleClick = useCallback(() => {
235
- if (isDisabled || isLoading) {
255
+ // Synchronous part of the toggle. Button calls onClick before clickAction and
256
+ // skips clickAction when the event is defaultPrevented, so calling
257
+ // preventDefault() inside onPressedChange opts out of pressedChangeAction.
258
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
259
+ if (isDisabled) {
236
260
  return;
237
261
  }
238
-
239
262
  if (group && value != null) {
240
- // Delegate to group context
263
+ // Group mode delegates selection to the group; no async-action path.
241
264
  group.toggle(value);
242
- } else if (onPressedChangeProp) {
243
- // Standalone toggle
244
- const newState = !isPressed;
245
- onPressedChangeProp(newState);
246
- if (pressedChangeAction) {
247
- void pressedChangeAction(newState);
248
- }
265
+ event.preventDefault();
266
+ return;
249
267
  }
250
- }, [
251
- isDisabled,
252
- isLoading,
253
- group,
254
- value,
255
- onPressedChangeProp,
256
- pressedChangeAction,
257
- isPressed,
258
- ]);
259
-
260
- // Label with font weight shift and width reservation
268
+ onPressedChangeProp?.(nextPressed, event);
269
+ };
270
+
271
+ // Async part of the toggle, run inside Button's transition (so the optimistic
272
+ // update is valid and Button's isPending drives the pending spinner). Skipped
273
+ // automatically by Button when handleClick called preventDefault, and never
274
+ // wired in group mode (where there is no async action). Button is rendered
275
+ // interruptible, so it stays clickable while pending and a re-click starts a
276
+ // fresh transition that interrupts the previous one.
277
+ const clickAction =
278
+ group && value != null
279
+ ? undefined
280
+ : async () => {
281
+ setOptimisticPressed(nextPressed);
282
+ await pressedChangeAction?.(nextPressed);
283
+ };
284
+
261
285
  // isIconOnly prop is the source of truth for icon-only rendering.
262
286
  const labelContent =
263
287
  children != null ? (
@@ -290,6 +314,7 @@ export function ToggleButton({
290
314
  size={size}
291
315
  isDisabled={isDisabled}
292
316
  isLoading={isLoading}
317
+ isInterruptible
293
318
  isIconOnly={isIconOnly}
294
319
  aria-pressed={isPressed}
295
320
  icon={resolvedIcon}
@@ -300,6 +325,7 @@ export function ToggleButton({
300
325
  xstyle={[isPressed ? pressedStyles.background : undefined, xstyle]}
301
326
  style={style}
302
327
  onClick={handleClick}
328
+ clickAction={clickAction}
303
329
  {...props}>
304
330
  {labelContent}
305
331
  </Button>
@@ -58,7 +58,7 @@ export const docs = {
58
58
  name: 'gap',
59
59
  type: 'SpacingStep',
60
60
  description: 'Gap between items within each slot.',
61
- default: '2',
61
+ default: '1',
62
62
  },
63
63
  {
64
64
  name: 'orientation',
@@ -23,7 +23,7 @@ export const docs = {
23
23
  ],
24
24
  usage: {
25
25
  description:
26
- 'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses XDS motion tokens (duration, easing) for consistent animation timing. Requires "use client"; does not support SSR.',
26
+ 'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses Astryx motion tokens (duration, easing) for consistent animation timing. Requires "use client"; does not support SSR.',
27
27
  bestPractices: [
28
28
  { guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, or expanding sections.' },
29
29
  { guidance: true, description: 'Spread the returned style into stylex.props() alongside other styles.' },
@@ -39,7 +39,7 @@ export const docs = {
39
39
  /** @type {import('../docs-types').HookTranslationDoc} */
40
40
  export const docsDense = {
41
41
  description:
42
- 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses XDS motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
42
+ 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
43
43
  paramDescriptions: {
44
44
  preset: 'animation preset applied on mount.',
45
45
  },
@@ -48,7 +48,7 @@ export const docsDense = {
48
48
  },
49
49
  usage: {
50
50
  description:
51
- 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses XDS motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
51
+ 'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Requires "use client"; does not support SSR.',
52
52
  bestPractices: [
53
53
  { guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, expanding sections.' },
54
54
  { guidance: true, description: 'Spread returned style into stylex.props() alongside other styles.' },
@@ -24,7 +24,7 @@ export const docs = {
24
24
  description: 'SSR-safe media query hook that subscribes to window.matchMedia changes. Returns whether the given media query matches. Always returns false on first render for SSR compatibility.',
25
25
  bestPractices: [
26
26
  { guidance: true, description: 'Use for responsive layout switching based on viewport width, color scheme, or motion preferences.' },
27
- { guidance: true, description: 'Prefer XDS responsive tokens and component props over manual breakpoint logic when possible.' },
27
+ { guidance: true, description: 'Prefer Astryx responsive tokens and component props over manual breakpoint logic when possible.' },
28
28
  { guidance: false, description: 'Use for server-rendered content that must match on first paint; the hook always returns false initially.' },
29
29
  ],
30
30
  },
@@ -47,7 +47,7 @@ export const docsDense = {
47
47
  description: 'SSR-safe media query hook subscribing to window.matchMedia changes. Returns whether given media query matches. Always returns false on first render for SSR compatibility.',
48
48
  bestPractices: [
49
49
  { guidance: true, description: 'Use for responsive layout switching based on viewport width, color scheme, or motion preferences.' },
50
- { guidance: true, description: 'Prefer XDS responsive tokens + component props over manual breakpoint logic when possible.' },
50
+ { guidance: true, description: 'Prefer Astryx responsive tokens + component props over manual breakpoint logic when possible.' },
51
51
  { guidance: false, description: 'Use for server-rendered content that must match on first paint; hook always returns false initially.' },
52
52
  ],
53
53
  },
@@ -41,7 +41,7 @@ export const docs = {
41
41
  ],
42
42
  usage: {
43
43
  description:
44
- 'Smooths bursty streamed text into a steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word and syntax boundaries to avoid slicing mid-markdown or mid-word, preventing visual glitches with markdown renderers. Animation timing derives from XDS motion tokens via useTheme when available, with sensible fallbacks outside a theme provider. Snaps to full text when isStreaming becomes false.',
44
+ 'Smooths bursty streamed text into a steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word and syntax boundaries to avoid slicing mid-markdown or mid-word, preventing visual glitches with markdown renderers. Animation timing derives from Astryx motion tokens via useTheme when available, with sensible fallbacks outside a theme provider. Snaps to full text when isStreaming becomes false.',
45
45
  bestPractices: [
46
46
  { guidance: true, description: 'Pass the accumulated text (not individual chunks) as targetText; the hook handles incremental reveal internally.' },
47
47
  { guidance: true, description: 'Set isStreaming to false when the stream completes to snap to the final text.' },
@@ -58,7 +58,7 @@ export const docs = {
58
58
  /** @type {import('../docs-types').HookTranslationDoc} */
59
59
  export const docsDense = {
60
60
  description:
61
- 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from XDS motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
61
+ 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from Astryx motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
62
62
  paramDescriptions: {
63
63
  targetText: 'full target text to reveal. As new chunks arrive, update this value w/ accumulated text.',
64
64
  isStreaming: 'whether text currently being streamed. When false, hook returns full targetText immediately.',
@@ -70,7 +70,7 @@ export const docsDense = {
70
70
  },
71
71
  usage: {
72
72
  description:
73
- 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from XDS motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
73
+ 'Smooths bursty streamed text into steady character-by-character reveal using requestAnimationFrame. Decouples arrival rate from display rate. Advances on word + syntax boundaries to avoid slicing mid-markdown / mid-word, preventing visual glitches w/ markdown renderers. Animation timing derives from Astryx motion tokens via useTheme when available, w/ sensible fallbacks outside theme provider. Snaps to full text when isStreaming becomes false.',
74
74
  bestPractices: [
75
75
  { guidance: true, description: 'Pass accumulated text (not individual chunks) as targetText; hook handles incremental reveal internally.' },
76
76
  { guidance: true, description: 'Set isStreaming to false when stream completes to snap to final text.' },