@astryxdesign/core 0.1.0 → 0.1.1
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/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/Layout/Layout.d.ts +10 -1
- package/dist/Layout/Layout.d.ts.map +1 -1
- package/dist/Layout/Layout.js +5 -1
- package/dist/Outline/Outline.d.ts +3 -2
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +23 -4
- package/dist/Outline/useScrollSpy.d.ts +14 -1
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +161 -50
- package/dist/Resizable/useResizable.d.ts.map +1 -1
- package/dist/Resizable/useResizable.js +1 -5
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +1 -1
- package/dist/ToggleButton/ToggleButton.d.ts +10 -3
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +64 -18
- package/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 +2 -2
- 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/FormLayout/FormLayout.doc.mjs +3 -3
- package/src/Icon/Icon.doc.mjs +4 -4
- package/src/Item/Item.doc.mjs +2 -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/Link/Link.doc.mjs +3 -3
- package/src/Link/LinkProvider.doc.mjs +3 -3
- package/src/Markdown/Markdown.doc.mjs +4 -4
- package/src/Outline/Outline.doc.mjs +1 -1
- package/src/Outline/Outline.test.tsx +76 -38
- package/src/Outline/Outline.tsx +23 -4
- package/src/Outline/useScrollSpy.ts +196 -63
- package/src/Resizable/Resizable.doc.mjs +2 -2
- package/src/Resizable/useResizable.ts +1 -7
- package/src/Selector/Selector.tsx +5 -6
- package/src/Table/Table.doc.mjs +3 -3
- package/src/ToggleButton/ToggleButton.doc.mjs +2 -2
- package/src/ToggleButton/ToggleButton.test.tsx +148 -6
- package/src/ToggleButton/ToggleButton.tsx +83 -20
- 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
|
@@ -4,17 +4,39 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useScrollSpy.ts
|
|
7
|
-
* @input Uses React,
|
|
7
|
+
* @input Uses React, scroll position of heading elements, OutlineItem type
|
|
8
8
|
* @output Exports internal useScrollSpy hook
|
|
9
9
|
* @position Internal behavior hook; consumed by Outline.tsx
|
|
10
10
|
*
|
|
11
|
+
* Drives the active outline item from scroll position. On each scroll
|
|
12
|
+
* (rAF-throttled) it reads live heading positions and marks the last heading
|
|
13
|
+
* whose top has passed its activation line (its own scroll-margin-top, i.e.
|
|
14
|
+
* where it lands when navigated to). This is stable — it never compares stale
|
|
15
|
+
* cached positions — so the indicator moves monotonically instead of jumping.
|
|
16
|
+
* Defaults to the first item at the top and the last item at the bottom so
|
|
17
|
+
* short final sections still activate.
|
|
18
|
+
*
|
|
11
19
|
* SYNC: When modified, update /packages/core/src/Outline/Outline.tsx
|
|
12
20
|
*/
|
|
13
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
|
-
|
|
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):
|
|
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
|
|
159
|
+
if (isControlled || typeof window === 'undefined') {
|
|
64
160
|
return;
|
|
65
161
|
}
|
|
66
162
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
.filter((element): element is HTMLElement => element != null);
|
|
163
|
+
const scrollRoot = getScrollableAncestor(rootRef.current);
|
|
164
|
+
const scrollTarget: HTMLElement | Window = scrollRoot ?? window;
|
|
70
165
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
129
|
-
visibleHeadingIds.clear();
|
|
130
|
-
headingTop.clear();
|
|
203
|
+
releaseSuppressionRef.current?.();
|
|
131
204
|
};
|
|
132
|
-
}, [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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'],
|
package/src/Table/Table.doc.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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: '
|
|
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
|
// =============================================================================
|