@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.
- package/CHANGELOG.md +66 -0
- package/README.md +119 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
- package/dist/Banner/Banner.d.ts +7 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +9 -2
- package/dist/Button/Button.d.ts +12 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +51 -13
- package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
- package/dist/Chat/ChatLayoutScrollButton.js +5 -1
- package/dist/ContextMenu/ContextMenu.js +2 -2
- package/dist/DropdownMenu/DropdownMenu.js +2 -2
- package/dist/DropdownMenu/{renderXDSDropdownItems.d.ts → renderDropdownItems.d.ts} +3 -3
- package/dist/DropdownMenu/renderDropdownItems.d.ts.map +1 -0
- package/dist/DropdownMenu/{renderXDSDropdownItems.js → renderDropdownItems.js} +2 -2
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js +7 -1
- package/dist/HoverCard/HoverCard.d.ts +2 -2
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +18 -6
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -3
- package/dist/Kbd/Kbd.d.ts.map +1 -1
- package/dist/Kbd/Kbd.js +1 -1
- package/dist/Layer/useLayer.d.ts +13 -0
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +7 -2
- package/dist/Layout/Layout.d.ts +10 -1
- package/dist/Layout/Layout.d.ts.map +1 -1
- package/dist/Layout/Layout.js +5 -1
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +13 -3
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +13 -0
- package/dist/Outline/Outline.d.ts +3 -2
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +23 -4
- package/dist/Outline/useScrollSpy.d.ts +14 -1
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +161 -50
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +31 -27
- package/dist/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +1 -5
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +1 -1
- package/dist/Slider/Slider.js +2 -2
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +26 -8
- package/dist/Table/Table.d.ts.map +1 -1
- package/dist/Table/Table.js +30 -7
- package/dist/Table/index.d.ts +3 -1
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +1 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts +3 -0
- package/dist/Table/plugins/stickyColumns/index.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/index.js +3 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts +25 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -0
- package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +376 -0
- package/dist/Table/types.d.ts +90 -5
- package/dist/Table/types.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +1 -1
- package/dist/Timestamp/Timestamp.d.ts +1 -1
- package/dist/Timestamp/Timestamp.d.ts.map +1 -1
- package/dist/Timestamp/Timestamp.js +28 -3
- package/dist/ToggleButton/ToggleButton.d.ts +10 -5
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +40 -17
- package/dist/astryx.css +20 -0
- package/dist/astryx.umd.js +147 -0
- package/dist/astryx.umd.js.map +7 -0
- package/dist/theme/Theme.js +1 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/syntax/defineSyntaxTheme.js +1 -1
- package/dist/theme/tokens.d.ts +1 -1
- package/dist/theme/tokens.js +4 -4
- package/dist/theme/useTheme.d.ts +2 -2
- package/dist/utils/dateParser.d.ts.map +1 -1
- package/dist/utils/dateParser.js +15 -2
- package/package.json +7 -3
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -0
- package/src/Banner/Banner.test.tsx +16 -7
- package/src/Banner/Banner.tsx +9 -2
- package/src/Button/Button.doc.mjs +6 -0
- package/src/Button/Button.test.tsx +105 -11
- package/src/Button/Button.tsx +73 -5
- package/src/Chat/ChatLayoutScrollButton.tsx +7 -1
- package/src/Collapsible/useCollapsible.doc.mjs +2 -2
- package/src/ContextMenu/ContextMenu.tsx +2 -2
- package/src/DateInput/DateInput.test.tsx +68 -20
- package/src/Divider/Divider.doc.mjs +1 -1
- package/src/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/DropdownMenu/{renderXDSDropdownItems.tsx → renderDropdownItems.tsx} +2 -2
- package/src/EmptyState/EmptyState.test.tsx +4 -2
- package/src/EmptyState/EmptyState.tsx +6 -2
- package/src/FormLayout/FormLayout.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.doc.mjs +3 -0
- package/src/HoverCard/HoverCard.test.tsx +178 -2
- package/src/HoverCard/HoverCard.tsx +20 -16
- package/src/HoverCard/useHoverCard.tsx +12 -10
- package/src/Icon/Icon.doc.mjs +12 -4
- package/src/Item/Item.doc.mjs +2 -2
- package/src/Kbd/Kbd.tsx +2 -9
- package/src/Layer/useLayer.doc.mjs +7 -2
- package/src/Layer/useLayer.tsx +19 -2
- package/src/Layout/Layout.doc.mjs +2 -1
- package/src/Layout/Layout.tsx +15 -1
- package/src/Layout/__tests__/childrenAsContent.test.tsx +59 -0
- package/src/Lightbox/Lightbox.doc.mjs +0 -2
- package/src/Link/Link.doc.mjs +3 -3
- package/src/Link/LinkProvider.doc.mjs +3 -3
- package/src/Markdown/Markdown.doc.mjs +6 -4
- package/src/Markdown/Markdown.test.tsx +17 -26
- package/src/Markdown/Markdown.tsx +16 -6
- package/src/MobileNav/MobileNav.doc.mjs +8 -8
- package/src/MobileNav/MobileNav.tsx +13 -0
- package/src/MobileNav/MobileNavReopen.test.tsx +118 -0
- package/src/Outline/Outline.doc.mjs +1 -1
- package/src/Outline/Outline.test.tsx +76 -38
- package/src/Outline/Outline.tsx +23 -4
- package/src/Outline/useScrollSpy.ts +196 -63
- package/src/Pagination/Pagination.test.tsx +137 -13
- package/src/Pagination/Pagination.tsx +33 -28
- package/src/Resizable/Resizable.doc.mjs +3 -3
- package/src/Resizable/useResizable.ts +1 -7
- package/src/Selector/Selector.doc.mjs +4 -0
- package/src/Selector/Selector.tsx +5 -6
- package/src/Skeleton/Skeleton.doc.mjs +11 -1
- package/src/Slider/Slider.doc.mjs +4 -0
- package/src/Slider/Slider.test.tsx +3 -16
- package/src/Slider/Slider.tsx +1 -1
- package/src/Table/BaseTable.tsx +50 -24
- package/src/Table/Table.doc.mjs +15 -3
- package/src/Table/Table.tsx +22 -1
- package/src/Table/index.ts +3 -0
- package/src/Table/plugins/stickyColumns/index.ts +4 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.test.tsx +163 -0
- package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +414 -0
- package/src/Table/types.ts +96 -4
- package/src/Table/useBaseTablePlugins.ts +1 -0
- package/src/Text/Heading.doc.mjs +13 -0
- package/src/Timestamp/Timestamp.test.tsx +44 -18
- package/src/Timestamp/Timestamp.tsx +30 -10
- package/src/ToggleButton/ToggleButton.doc.mjs +4 -4
- package/src/ToggleButton/ToggleButton.test.tsx +172 -9
- package/src/ToggleButton/ToggleButton.tsx +57 -31
- package/src/Toolbar/Toolbar.doc.mjs +1 -1
- package/src/hooks/useEntryAnimation.doc.mjs +3 -3
- package/src/hooks/useMediaQuery.doc.mjs +2 -2
- package/src/hooks/useStreamingText.doc.mjs +3 -3
- package/src/theme/Theme.doc.mjs +2 -2
- package/src/theme/Theme.tsx +1 -1
- package/src/theme/defineTheme.ts +1 -1
- package/src/theme/index.ts +1 -1
- package/src/theme/syntax/defineSyntaxTheme.ts +1 -1
- package/src/theme/tokens.ts +4 -4
- package/src/theme/useTheme.ts +2 -2
- package/src/utils/dateParser.test.ts +26 -0
- package/src/utils/dateParser.ts +16 -2
- package/dist/DropdownMenu/renderXDSDropdownItems.d.ts.map +0 -1
|
@@ -33,10 +33,30 @@ describe('Timestamp', () => {
|
|
|
33
33
|
expect(screen.getByText('2 hours ago')).toBeInTheDocument();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it('renders "
|
|
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('
|
|
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('
|
|
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", "
|
|
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: '
|
|
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, {
|
|
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?: (
|
|
94
|
+
onPressedChange?: (
|
|
95
|
+
isPressed: boolean,
|
|
96
|
+
event: React.MouseEvent<HTMLButtonElement>,
|
|
97
|
+
) => void;
|
|
92
98
|
|
|
93
99
|
/**
|
|
94
|
-
*
|
|
95
|
-
* The button shows a loading spinner while the
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
//
|
|
263
|
+
// Group mode delegates selection to the group; no async-action path.
|
|
241
264
|
group.toggle(value);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const newState = !isPressed;
|
|
245
|
-
onPressedChangeProp(newState);
|
|
246
|
-
if (pressedChangeAction) {
|
|
247
|
-
void pressedChangeAction(newState);
|
|
248
|
-
}
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
return;
|
|
249
267
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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>
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.' },
|