@astryxdesign/core 0.5.2-canary.c9c8564 → 0.5.2-canary.e4f7677
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/dist/BottomSheet/BottomSheet.d.ts +3 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -3
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
- package/dist/Button/Button.d.ts +2 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -4
- package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
- package/dist/Chat/ChatToolCalls.js +34 -17
- package/dist/ContextMenu/ContextMenu.d.ts +14 -3
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +149 -26
- package/dist/ContextMenu/index.d.ts +1 -0
- package/dist/ContextMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +340 -26
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
- package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
- package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
- package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
- package/dist/DropdownMenu/index.d.ts +2 -1
- package/dist/DropdownMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/menuWidth.d.ts +14 -0
- package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
- package/dist/DropdownMenu/menuWidth.js +35 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
- package/dist/DropdownMenu/useMenuOverflow.js +60 -0
- package/dist/MoreMenu/MoreMenu.d.ts +8 -2
- package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
- package/dist/MoreMenu/MoreMenu.js +2 -0
- package/dist/MultiSelector/MultiSelector.d.ts +11 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +97 -68
- package/dist/MultiSelector/index.d.ts +1 -1
- package/dist/MultiSelector/index.d.ts.map +1 -1
- package/dist/Popover/Popover.d.ts +10 -6
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +141 -16
- package/dist/Popover/usePopover.d.ts +4 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +52 -5
- package/dist/Selector/Selector.d.ts +10 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +106 -80
- package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
- package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
- package/dist/Selector/SelectorBottomSheet.js +83 -0
- package/dist/Selector/index.d.ts +1 -1
- package/dist/Selector/index.d.ts.map +1 -1
- package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
- package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
- package/dist/Selector/selectorPresentation.stylex.js +21 -0
- package/dist/Selector/useSelectorPresentation.d.ts +32 -0
- package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
- package/dist/Selector/useSelectorPresentation.js +90 -0
- package/dist/Toast/useToastGesture.d.ts.map +1 -1
- package/dist/Toast/useToastGesture.js +46 -9
- package/dist/astryx.css +17 -1
- package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
- package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
- package/dist/hooks/useAdaptivePresentation.js +19 -0
- package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
- package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
- package/dist/hooks/useFocusReturnVisibility.js +35 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +12 -0
- package/locales/en.json +20 -0
- package/locales/pseudo.json +15 -0
- package/package.json +2 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +7 -4
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
- package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
- package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
- package/src/Button/Button.test.tsx +10 -0
- package/src/Button/Button.tsx +13 -5
- package/src/Chat/ChatToolCalls.test.tsx +44 -0
- package/src/Chat/ChatToolCalls.tsx +36 -15
- package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
- package/src/ContextMenu/ContextMenu.test.tsx +131 -1
- package/src/ContextMenu/ContextMenu.tsx +199 -32
- package/src/ContextMenu/index.ts +1 -0
- package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
- package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
- package/src/DropdownMenu/DropdownMenu.tsx +459 -28
- package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
- package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
- package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
- package/src/DropdownMenu/index.ts +2 -0
- package/src/DropdownMenu/menuWidth.ts +51 -0
- package/src/DropdownMenu/useMenuOverflow.ts +77 -0
- package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
- package/src/MoreMenu/MoreMenu.test.tsx +47 -0
- package/src/MoreMenu/MoreMenu.tsx +13 -1
- package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
- package/src/MultiSelector/MultiSelector.test.tsx +127 -0
- package/src/MultiSelector/MultiSelector.tsx +121 -73
- package/src/MultiSelector/index.ts +1 -0
- package/src/Popover/Popover.doc.mjs +11 -11
- package/src/Popover/Popover.test.tsx +397 -2
- package/src/Popover/Popover.tsx +228 -21
- package/src/Popover/usePopover.tsx +92 -15
- package/src/Selector/Selector.doc.mjs +12 -0
- package/src/Selector/Selector.test.tsx +135 -0
- package/src/Selector/Selector.tsx +141 -82
- package/src/Selector/SelectorBottomSheet.tsx +97 -0
- package/src/Selector/index.ts +1 -0
- package/src/Selector/selectorPresentation.stylex.ts +20 -0
- package/src/Selector/useSelectorPresentation.ts +147 -0
- package/src/Toast/ToastViewport.test.tsx +118 -4
- package/src/Toast/useToastGesture.ts +61 -8
- package/src/hooks/useAdaptivePresentation.ts +35 -0
- package/src/hooks/useFocusReturnVisibility.ts +45 -0
- package/src/hooks/useFocusTrap.test.tsx +40 -0
- package/src/hooks/useFocusTrap.ts +13 -0
|
@@ -2319,6 +2319,35 @@ describe('BottomSheet', () => {
|
|
|
2319
2319
|
|
|
2320
2320
|
expect(document.activeElement).toBe(opener);
|
|
2321
2321
|
});
|
|
2322
|
+
|
|
2323
|
+
it('prefers an explicit final focus target', () => {
|
|
2324
|
+
const finalFocusRef = createRef<HTMLButtonElement>();
|
|
2325
|
+
function ExplicitFocusHarness() {
|
|
2326
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
2327
|
+
return (
|
|
2328
|
+
<>
|
|
2329
|
+
<button ref={finalFocusRef} type="button">
|
|
2330
|
+
Adaptive trigger
|
|
2331
|
+
</button>
|
|
2332
|
+
<BottomSheet
|
|
2333
|
+
isOpen={isOpen}
|
|
2334
|
+
onOpenChange={setIsOpen}
|
|
2335
|
+
finalFocusRef={finalFocusRef}
|
|
2336
|
+
label="Filters">
|
|
2337
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
2338
|
+
Done
|
|
2339
|
+
</button>
|
|
2340
|
+
</BottomSheet>
|
|
2341
|
+
</>
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
render(<ExplicitFocusHarness />);
|
|
2346
|
+
fireEvent.click(screen.getByRole('button', {name: 'Done'}));
|
|
2347
|
+
finishSheetExit();
|
|
2348
|
+
|
|
2349
|
+
expect(document.activeElement).toBe(finalFocusRef.current);
|
|
2350
|
+
});
|
|
2322
2351
|
});
|
|
2323
2352
|
|
|
2324
2353
|
describe('initial focus', () => {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
useLayoutEffect,
|
|
33
33
|
useRef,
|
|
34
34
|
useState,
|
|
35
|
+
type RefObject,
|
|
35
36
|
type ReactNode,
|
|
36
37
|
} from 'react';
|
|
37
38
|
import * as stylex from '@stylexjs/stylex';
|
|
@@ -162,6 +163,8 @@ interface StandaloneBottomSheetProps extends BottomSheetSharedProps {
|
|
|
162
163
|
isOpen: boolean;
|
|
163
164
|
onOpenChange: (isOpen: boolean) => void;
|
|
164
165
|
hasScrim?: boolean;
|
|
166
|
+
/** Element that receives focus after the sheet closes. */
|
|
167
|
+
finalFocusRef?: RefObject<HTMLElement | null>;
|
|
165
168
|
sheetId?: never;
|
|
166
169
|
}
|
|
167
170
|
|
|
@@ -221,6 +224,7 @@ function StandaloneBottomSheet({
|
|
|
221
224
|
height = 'capped',
|
|
222
225
|
snapPoints,
|
|
223
226
|
hasScrim = true,
|
|
227
|
+
finalFocusRef,
|
|
224
228
|
purpose = 'info',
|
|
225
229
|
xstyle,
|
|
226
230
|
...props
|
|
@@ -298,10 +302,10 @@ function StandaloneBottomSheet({
|
|
|
298
302
|
dialog.close();
|
|
299
303
|
}
|
|
300
304
|
setIsPresented(false);
|
|
301
|
-
triggerRef.current?.focus();
|
|
305
|
+
(finalFocusRef?.current ?? triggerRef.current)?.focus();
|
|
302
306
|
triggerRef.current = null;
|
|
303
307
|
},
|
|
304
|
-
[isOpen],
|
|
308
|
+
[finalFocusRef, isOpen],
|
|
305
309
|
);
|
|
306
310
|
|
|
307
311
|
useScrollLock(shouldPresent && hasScrim);
|
|
@@ -385,8 +389,7 @@ function StandaloneBottomSheet({
|
|
|
385
389
|
{children}
|
|
386
390
|
</BottomSheetPanel>
|
|
387
391
|
</div>
|
|
388
|
-
|
|
389
|
-
{hasScrim ? null : <BottomSheetEdgeTint />}
|
|
392
|
+
<BottomSheetEdgeTint />
|
|
390
393
|
</dialog>
|
|
391
394
|
);
|
|
392
395
|
}
|
|
@@ -101,15 +101,13 @@ describe('BottomSheetEdgeTint', () => {
|
|
|
101
101
|
expect(tints()).toHaveLength(1);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
// so a second sampling target there would only fight it.
|
|
106
|
-
it('leaves a modal sheet to its ::backdrop', () => {
|
|
104
|
+
it('gives a modal sheet the same surface-coloured edge tint', () => {
|
|
107
105
|
render(
|
|
108
106
|
<BottomSheet isOpen onOpenChange={() => {}} label="Place details">
|
|
109
107
|
Content
|
|
110
108
|
</BottomSheet>,
|
|
111
109
|
);
|
|
112
|
-
expect(tints()).toHaveLength(
|
|
110
|
+
expect(tints()).toHaveLength(1);
|
|
113
111
|
});
|
|
114
112
|
|
|
115
113
|
it('renders the tint inside the dialog, so it unmounts with the sheet', () => {
|
|
@@ -148,7 +146,7 @@ describe('BottomSheetEdgeTint', () => {
|
|
|
148
146
|
expect(tints()).toHaveLength(1);
|
|
149
147
|
});
|
|
150
148
|
|
|
151
|
-
it('
|
|
149
|
+
it('gives a modal switcher flow exactly one tint', () => {
|
|
152
150
|
render(
|
|
153
151
|
<BottomSheetSwitcher activeSheet="comment" onActiveSheetChange={() => {}}>
|
|
154
152
|
<BottomSheet sheetId="comment" label="Add a comment">
|
|
@@ -156,7 +154,7 @@ describe('BottomSheetEdgeTint', () => {
|
|
|
156
154
|
</BottomSheet>
|
|
157
155
|
</BottomSheetSwitcher>,
|
|
158
156
|
);
|
|
159
|
-
expect(tints()).toHaveLength(
|
|
157
|
+
expect(tints()).toHaveLength(1);
|
|
160
158
|
});
|
|
161
159
|
|
|
162
160
|
it('keeps the tint out of the accessibility tree and out of hit testing', async () => {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @file BottomSheetEdgeTint.tsx
|
|
7
7
|
* @input Uses StyleX and core color tokens
|
|
8
8
|
* @output Exports BottomSheetEdgeTint, an internal decorative element
|
|
9
|
-
* @position Private helper rendered inside
|
|
9
|
+
* @position Private helper rendered inside every BottomSheet dialog
|
|
10
10
|
*
|
|
11
11
|
* iOS 26 Safari dropped `<meta name="theme-color">` and instead derives the
|
|
12
12
|
* colour it paints behind its translucent toolbars by sampling the page: it
|
|
@@ -14,13 +14,10 @@
|
|
|
14
14
|
* `fixed`/`sticky` ancestor, and extends that element's declared
|
|
15
15
|
* `background-color` into the browser chrome.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* fixed
|
|
20
|
-
*
|
|
21
|
-
* candidate by *keeping the colour it already had* — the host page's. The page
|
|
22
|
-
* then shows through behind the address bar while the sheet covers the screen
|
|
23
|
-
* above it.
|
|
17
|
+
* WebKit may otherwise extend either the dialog backdrop or the host page into
|
|
18
|
+
* the browser chrome. Which one wins varies with dialog mode and the page's
|
|
19
|
+
* own fixed or sticky edge content, so relying on the backdrop makes otherwise
|
|
20
|
+
* identical BottomSheets produce different toolbar colours.
|
|
24
21
|
*
|
|
25
22
|
* This element gives the heuristic something unambiguous to sample: fixed,
|
|
26
23
|
* full width, flush with the bottom edge, taller than WebKit's 10px minimum
|
|
@@ -68,8 +65,8 @@ const styles = stylex.create({
|
|
|
68
65
|
});
|
|
69
66
|
|
|
70
67
|
/**
|
|
71
|
-
* Colours the iOS Safari toolbar strip below a
|
|
72
|
-
*
|
|
68
|
+
* Colours the iOS Safari toolbar strip below a sheet. Renders nothing visible;
|
|
69
|
+
* see the file header for why it exists.
|
|
73
70
|
*/
|
|
74
71
|
export function BottomSheetEdgeTint() {
|
|
75
72
|
return (
|
|
@@ -632,8 +632,7 @@ export function BottomSheetSwitcher({
|
|
|
632
632
|
? {role: 'alertdialog'}
|
|
633
633
|
: undefined)}>
|
|
634
634
|
{children}
|
|
635
|
-
|
|
636
|
-
{hasScrim ? null : <BottomSheetEdgeTint />}
|
|
635
|
+
<BottomSheetEdgeTint />
|
|
637
636
|
</dialog>
|
|
638
637
|
</BottomSheetSwitcherContext>
|
|
639
638
|
);
|
|
@@ -70,6 +70,16 @@ describe('Button', () => {
|
|
|
70
70
|
const button = screen.getByRole('button');
|
|
71
71
|
// Button should be disabled when loading
|
|
72
72
|
expect(button).toBeDisabled();
|
|
73
|
+
expect(button.className).toContain('styles.inactive');
|
|
74
|
+
expect(button.className).not.toContain('styles.disabled');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps the dimmed treatment for explicitly disabled buttons', () => {
|
|
78
|
+
render(<Button label="Submit" isDisabled />);
|
|
79
|
+
const button = screen.getByRole('button');
|
|
80
|
+
expect(button).toBeDisabled();
|
|
81
|
+
expect(button.className).toContain('styles.inactive');
|
|
82
|
+
expect(button.className).toContain('styles.disabled');
|
|
73
83
|
});
|
|
74
84
|
|
|
75
85
|
it('sets aria-busy synchronously while clickAction is pending', async () => {
|
package/src/Button/Button.tsx
CHANGED
|
@@ -99,18 +99,20 @@ const styles = stylex.create({
|
|
|
99
99
|
pressable: {
|
|
100
100
|
transform: {
|
|
101
101
|
default: 'scale(1)',
|
|
102
|
-
':active': 'scale(0.98)',
|
|
102
|
+
':active:where(:not(:disabled,[aria-disabled="true"]))': 'scale(0.98)',
|
|
103
103
|
},
|
|
104
104
|
},
|
|
105
|
-
|
|
105
|
+
inactive: {
|
|
106
106
|
cursor: 'default',
|
|
107
|
-
opacity: 0.5,
|
|
108
107
|
backgroundImage: 'none',
|
|
109
108
|
transform: {
|
|
110
109
|
default: 'none',
|
|
111
110
|
':active': 'none',
|
|
112
111
|
},
|
|
113
112
|
},
|
|
113
|
+
disabled: {
|
|
114
|
+
opacity: 0.5,
|
|
115
|
+
},
|
|
114
116
|
ariaDisabled: {
|
|
115
117
|
// The variants' hover treatment already steps aside for
|
|
116
118
|
// `[aria-disabled]`; `:active` still matches a press on an aria-disabled
|
|
@@ -270,7 +272,8 @@ export interface ButtonProps extends BaseProps<HTMLButtonElement> {
|
|
|
270
272
|
*/
|
|
271
273
|
isDisabled?: boolean;
|
|
272
274
|
/**
|
|
273
|
-
* Whether the button is in a loading state.
|
|
275
|
+
* Whether the button is in a loading state. Loading prevents interaction
|
|
276
|
+
* without dimming the spinner; explicit disabled states remain dimmed.
|
|
274
277
|
* @default false
|
|
275
278
|
*/
|
|
276
279
|
isLoading?: boolean;
|
|
@@ -573,6 +576,10 @@ export function Button({
|
|
|
573
576
|
// not disabled, so clicks keep landing and can interrupt the in-flight action.
|
|
574
577
|
const buttonDisabled =
|
|
575
578
|
isDisabled || groupDisabled || (isLoadingState && !isInterruptible);
|
|
579
|
+
// A loading button remains non-interactive, but its spinner communicates an
|
|
580
|
+
// active state and must retain contrast. Only explicitly disabled controls
|
|
581
|
+
// receive the visually dimmed treatment.
|
|
582
|
+
const visuallyDisabled = isDisabled || groupDisabled;
|
|
576
583
|
// isIconOnly prop is the source of truth for icon-only rendering.
|
|
577
584
|
// When false (default), label is always rendered as visible text.
|
|
578
585
|
|
|
@@ -639,7 +646,8 @@ export function Button({
|
|
|
639
646
|
sizeStyles[size],
|
|
640
647
|
isIconOnly && styles.iconOnly,
|
|
641
648
|
interactionOverlayStyles.backgroundImage,
|
|
642
|
-
buttonDisabled && styles.
|
|
649
|
+
buttonDisabled && styles.inactive,
|
|
650
|
+
visuallyDisabled && styles.disabled,
|
|
643
651
|
useAriaDisabled && styles.ariaDisabled,
|
|
644
652
|
renderAsLink && styles.link,
|
|
645
653
|
!buttonGroup && styles.pressable,
|
|
@@ -46,6 +46,50 @@ describe('ChatToolCalls', () => {
|
|
|
46
46
|
expect(screen.queryByText('1.2s')).not.toBeInTheDocument();
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it.each([
|
|
50
|
+
['pending', 'Pending'],
|
|
51
|
+
['running', 'Running'],
|
|
52
|
+
['complete', 'Complete'],
|
|
53
|
+
['error', 'Failed'],
|
|
54
|
+
] as const)(
|
|
55
|
+
'names the %s status for assistive technology',
|
|
56
|
+
(status, label) => {
|
|
57
|
+
render(<ChatToolCalls calls={[{name: 'bash', status}]} />);
|
|
58
|
+
expect(screen.getByText(label)).toBeInTheDocument();
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it('includes the status in an expandable row accessible name', () => {
|
|
63
|
+
render(
|
|
64
|
+
<ChatToolCalls
|
|
65
|
+
calls={[
|
|
66
|
+
{
|
|
67
|
+
name: 'readFile',
|
|
68
|
+
status: 'running',
|
|
69
|
+
resultDetail: <div>file contents</div>,
|
|
70
|
+
},
|
|
71
|
+
]}
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
expect(
|
|
75
|
+
screen.getByRole('button', {name: /Running.*readFile/}),
|
|
76
|
+
).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('includes the latest status in a collapsed group accessible name', () => {
|
|
80
|
+
render(
|
|
81
|
+
<ChatToolCalls
|
|
82
|
+
calls={[
|
|
83
|
+
{name: 'searchCode', status: 'complete'},
|
|
84
|
+
{name: 'editFile', status: 'running'},
|
|
85
|
+
]}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
expect(
|
|
89
|
+
screen.getByRole('button', {name: /Running.*editFile/}),
|
|
90
|
+
).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
49
93
|
it('defaults to collapsed', () => {
|
|
50
94
|
render(
|
|
51
95
|
<ChatToolCalls
|
|
@@ -365,6 +365,26 @@ const STATUS_STYLES: Record<
|
|
|
365
365
|
error: styles.colorError,
|
|
366
366
|
};
|
|
367
367
|
|
|
368
|
+
function getStatusAnnouncement(
|
|
369
|
+
t: ReturnType<typeof useTranslator>,
|
|
370
|
+
status: ChatToolCallStatus,
|
|
371
|
+
errorMessage?: string,
|
|
372
|
+
): string {
|
|
373
|
+
if (status === 'error' && errorMessage != null) {
|
|
374
|
+
return t('@astryx.chatToolCalls.error', {message: errorMessage});
|
|
375
|
+
}
|
|
376
|
+
switch (status) {
|
|
377
|
+
case 'pending':
|
|
378
|
+
return t('@astryx.chatToolCalls.status.pending');
|
|
379
|
+
case 'running':
|
|
380
|
+
return t('@astryx.chatToolCalls.status.running');
|
|
381
|
+
case 'complete':
|
|
382
|
+
return t('@astryx.chatToolCalls.status.complete');
|
|
383
|
+
case 'error':
|
|
384
|
+
return t('@astryx.chatToolCalls.status.error');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
368
388
|
function getToolCallKey(call: ChatToolCallItem): string {
|
|
369
389
|
return getKey(call.key, () =>
|
|
370
390
|
[
|
|
@@ -390,6 +410,11 @@ function CallRow({call}: {call: ChatToolCallItem}) {
|
|
|
390
410
|
const hasDetail = call.resultDetail != null;
|
|
391
411
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
|
392
412
|
const detailId = useId();
|
|
413
|
+
const statusAnnouncement = getStatusAnnouncement(
|
|
414
|
+
t,
|
|
415
|
+
status,
|
|
416
|
+
call.errorMessage,
|
|
417
|
+
);
|
|
393
418
|
|
|
394
419
|
const toggleDetail = hasDetail
|
|
395
420
|
? () => setIsDetailOpen(prev => !prev)
|
|
@@ -417,9 +442,9 @@ function CallRow({call}: {call: ChatToolCallItem}) {
|
|
|
417
442
|
title={status === 'error' ? call.errorMessage : undefined}
|
|
418
443
|
{...stylex.props(styles.statusIcon, STATUS_STYLES[status])}>
|
|
419
444
|
{status === 'running' ? (
|
|
420
|
-
<Spinner size="sm" shade="subtle" />
|
|
445
|
+
<Spinner size="sm" shade="subtle" aria-hidden="true" />
|
|
421
446
|
) : status === 'pending' ? (
|
|
422
|
-
<Spinner size="sm" shade="subtle" />
|
|
447
|
+
<Spinner size="sm" shade="subtle" aria-hidden="true" />
|
|
423
448
|
) : (
|
|
424
449
|
<>
|
|
425
450
|
<span {...stylex.props(styles.statusIconCircle)} />
|
|
@@ -432,17 +457,7 @@ function CallRow({call}: {call: ChatToolCallItem}) {
|
|
|
432
457
|
</span>
|
|
433
458
|
</>
|
|
434
459
|
)}
|
|
435
|
-
{
|
|
436
|
-
// The title attribute above is hover-only; expose the error detail
|
|
437
|
-
// as real text so it reaches screen readers, keyboard, and touch
|
|
438
|
-
// users. Rendering it inside the row also folds it into the
|
|
439
|
-
// accessible name of expandable (role="button") rows.
|
|
440
|
-
<VisuallyHidden>
|
|
441
|
-
{t('@astryx.chatToolCalls.error', {
|
|
442
|
-
message: call.errorMessage ?? '',
|
|
443
|
-
})}
|
|
444
|
-
</VisuallyHidden>
|
|
445
|
-
)}
|
|
460
|
+
<VisuallyHidden>{statusAnnouncement}</VisuallyHidden>
|
|
446
461
|
</span>
|
|
447
462
|
<span {...stylex.props(styles.callName)}>{call.name}</span>
|
|
448
463
|
{call.node != null && (
|
|
@@ -581,6 +596,11 @@ export function ChatToolCalls(props: ChatToolCallsProps) {
|
|
|
581
596
|
// Multiple calls: latest call at surface with chevron to expand all
|
|
582
597
|
const latestCall = calls[calls.length - 1];
|
|
583
598
|
const latestStatus = latestCall.status ?? 'complete';
|
|
599
|
+
const latestStatusAnnouncement = getStatusAnnouncement(
|
|
600
|
+
t,
|
|
601
|
+
latestStatus,
|
|
602
|
+
latestCall.errorMessage,
|
|
603
|
+
);
|
|
584
604
|
|
|
585
605
|
return (
|
|
586
606
|
<div
|
|
@@ -620,9 +640,9 @@ export function ChatToolCalls(props: ChatToolCallsProps) {
|
|
|
620
640
|
<span
|
|
621
641
|
{...stylex.props(styles.statusIcon, STATUS_STYLES[latestStatus])}>
|
|
622
642
|
{latestStatus === 'running' ? (
|
|
623
|
-
<Spinner size="sm" shade="subtle" />
|
|
643
|
+
<Spinner size="sm" shade="subtle" aria-hidden="true" />
|
|
624
644
|
) : latestStatus === 'pending' ? (
|
|
625
|
-
<Spinner size="sm" shade="subtle" />
|
|
645
|
+
<Spinner size="sm" shade="subtle" aria-hidden="true" />
|
|
626
646
|
) : (
|
|
627
647
|
<>
|
|
628
648
|
<span {...stylex.props(styles.statusIconCircle)} />
|
|
@@ -635,6 +655,7 @@ export function ChatToolCalls(props: ChatToolCallsProps) {
|
|
|
635
655
|
</span>
|
|
636
656
|
</>
|
|
637
657
|
)}
|
|
658
|
+
<VisuallyHidden>{latestStatusAnnouncement}</VisuallyHidden>
|
|
638
659
|
</span>
|
|
639
660
|
<span {...stylex.props(styles.callName)}>{latestCall.name}</span>
|
|
640
661
|
{latestCall.target != null && (
|
|
@@ -32,7 +32,7 @@ export const docs = {
|
|
|
32
32
|
{
|
|
33
33
|
name: 'items',
|
|
34
34
|
type: 'ContextMenuOption[]',
|
|
35
|
-
description: 'Array of menu entries. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?}` (variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
|
|
35
|
+
description: 'Array of menu entries. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?, items?}` (nested `items` open a flyout in popover presentation and drill into a new view in bottom-sheet presentation; variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
|
|
36
36
|
required: true,
|
|
37
37
|
},
|
|
38
38
|
{
|
|
@@ -63,6 +63,12 @@ export const docs = {
|
|
|
63
63
|
type: '(isOpen: boolean) => void',
|
|
64
64
|
description: 'Callback fired when the menu opens or closes.',
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: 'presentation',
|
|
68
|
+
type: "'popover' | 'bottom-sheet' | 'adaptive'",
|
|
69
|
+
description: 'Presentation policy. `popover` opens at the pointer position, `bottom-sheet` always uses an action sheet, and `adaptive` uses the BottomSheet at 768px and below when the primary pointer is coarse.',
|
|
70
|
+
default: "'popover'",
|
|
71
|
+
},
|
|
66
72
|
{
|
|
67
73
|
name: 'isDisabled',
|
|
68
74
|
type: 'boolean',
|
|
@@ -78,7 +84,9 @@ export const docs = {
|
|
|
78
84
|
bestPractices: [
|
|
79
85
|
{ guidance: true, description: 'Keep menu items concise and action-oriented; users expect quick access to contextual actions.' },
|
|
80
86
|
{ guidance: true, description: 'Use sections and dividers to group related actions when the menu has many items.' },
|
|
87
|
+
{ guidance: true, description: 'Use `presentation="adaptive"` when right-click should remain cursor-positioned on desktop while long-press opens a reachable BottomSheet on compact touch devices.' },
|
|
81
88
|
{ guidance: true, description: 'Ensure all context menu actions are also accessible via other UI elements for keyboard-only users.' },
|
|
89
|
+
{ guidance: true, description: 'Keep a visible MoreMenu or equivalent trigger for important mobile actions; long-press must not be the only route.' },
|
|
82
90
|
{ guidance: false, description: 'Use a ContextMenu as the only way to access important actions; not all users know to right-click.' },
|
|
83
91
|
{ guidance: false, description: 'Place more than 10–12 items in a single menu without grouping them into sections.' },
|
|
84
92
|
],
|
|
@@ -107,7 +115,9 @@ export const docsZh = {
|
|
|
107
115
|
bestPractices: [
|
|
108
116
|
{ guidance: true, description: '保持菜单项简洁和面向操作。' },
|
|
109
117
|
{ guidance: true, description: '有很多项时使用分组和分隔线。' },
|
|
118
|
+
{ guidance: true, description: '在桌面端需要光标定位、紧凑触控设备上需要 BottomSheet 时,使用 `presentation="adaptive"`。' },
|
|
110
119
|
{ guidance: true, description: '确保所有上下文菜单操作也可通过其他 UI 元素访问。' },
|
|
120
|
+
{ guidance: true, description: '重要的移动端操作还应提供可见的 MoreMenu 或同等入口,不要只依赖长按。' },
|
|
111
121
|
{ guidance: false, description: '将上下文菜单作为访问重要操作的唯一方式。' },
|
|
112
122
|
{ guidance: false, description: '在单个菜单中放置超过 10-12 个项而不分组。' },
|
|
113
123
|
],
|
|
@@ -122,7 +132,9 @@ export const docsDense = {
|
|
|
122
132
|
bestPractices: [
|
|
123
133
|
{ guidance: true, description: 'Keep items concise and action-oriented.' },
|
|
124
134
|
{ guidance: true, description: 'Group related actions with sections and dividers.' },
|
|
135
|
+
{ guidance: true, description: 'Use adaptive presentation for cursor popover on desktop and BottomSheet on compact touch.' },
|
|
125
136
|
{ guidance: true, description: 'Ensure actions are also accessible via other UI elements.' },
|
|
137
|
+
{ guidance: true, description: 'Keep a visible mobile entry point for important actions.' },
|
|
126
138
|
{ guidance: false, description: 'Use as the only way to access important actions.' },
|
|
127
139
|
{ guidance: false, description: 'Place more than 10–12 items without grouping.' },
|
|
128
140
|
],
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
12
|
-
import {render, screen, fireEvent, act} from '@testing-library/react';
|
|
12
|
+
import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
|
|
13
13
|
import userEvent from '@testing-library/user-event';
|
|
14
|
+
import {readFileSync} from 'node:fs';
|
|
14
15
|
import {ContextMenu} from './ContextMenu';
|
|
15
16
|
import {
|
|
16
17
|
ContextMenuItem,
|
|
@@ -23,6 +24,30 @@ import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
|
|
|
23
24
|
import {Divider} from '../Divider';
|
|
24
25
|
|
|
25
26
|
beforeEach(() => {
|
|
27
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
28
|
+
this: HTMLDialogElement,
|
|
29
|
+
) {
|
|
30
|
+
this.setAttribute('open', '');
|
|
31
|
+
});
|
|
32
|
+
HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
|
|
33
|
+
this.setAttribute('open', '');
|
|
34
|
+
});
|
|
35
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
36
|
+
this.removeAttribute('open');
|
|
37
|
+
});
|
|
38
|
+
vi.stubGlobal(
|
|
39
|
+
'matchMedia',
|
|
40
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
41
|
+
matches: false,
|
|
42
|
+
media: query,
|
|
43
|
+
onchange: null,
|
|
44
|
+
addEventListener: vi.fn(),
|
|
45
|
+
removeEventListener: vi.fn(),
|
|
46
|
+
addListener: vi.fn(),
|
|
47
|
+
removeListener: vi.fn(),
|
|
48
|
+
dispatchEvent: vi.fn(),
|
|
49
|
+
})),
|
|
50
|
+
);
|
|
26
51
|
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
27
52
|
this.setAttribute('popover-open', '');
|
|
28
53
|
const event = new Event('toggle', {bubbles: false});
|
|
@@ -123,6 +148,100 @@ describe('ContextMenu', () => {
|
|
|
123
148
|
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
124
149
|
});
|
|
125
150
|
|
|
151
|
+
it('opens the real menu content in a BottomSheet when requested', async () => {
|
|
152
|
+
render(
|
|
153
|
+
<ContextMenu
|
|
154
|
+
presentation="bottom-sheet"
|
|
155
|
+
label="Row actions"
|
|
156
|
+
items={[{label: 'Edit'}]}>
|
|
157
|
+
<div>Right-click me</div>
|
|
158
|
+
</ContextMenu>,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
162
|
+
await waitFor(() =>
|
|
163
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(),
|
|
164
|
+
);
|
|
165
|
+
const dialog = screen.getByRole('dialog', {name: 'Row actions'});
|
|
166
|
+
const actionList = dialog.querySelector('[data-autofocus]');
|
|
167
|
+
expect(actionList).not.toBeNull();
|
|
168
|
+
await waitFor(() => expect(actionList).toHaveFocus());
|
|
169
|
+
expect(screen.getByRole('button', {name: 'Edit'})).not.toHaveFocus();
|
|
170
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('drills into nested data items inside the BottomSheet', async () => {
|
|
174
|
+
const user = userEvent.setup();
|
|
175
|
+
const onMove = vi.fn();
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<ContextMenu
|
|
179
|
+
presentation="bottom-sheet"
|
|
180
|
+
label="Row actions"
|
|
181
|
+
items={[
|
|
182
|
+
{
|
|
183
|
+
label: 'Move to project',
|
|
184
|
+
items: [{label: 'Apollo launch', onClick: onMove}],
|
|
185
|
+
},
|
|
186
|
+
]}>
|
|
187
|
+
<div>Right-click me</div>
|
|
188
|
+
</ContextMenu>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
192
|
+
await user.click(screen.getByRole('button', {name: 'Move to project'}));
|
|
193
|
+
|
|
194
|
+
const submenuHeading = screen.getByRole('heading', {
|
|
195
|
+
name: 'Move to project',
|
|
196
|
+
});
|
|
197
|
+
expect(submenuHeading).toBeInTheDocument();
|
|
198
|
+
await waitFor(() => expect(submenuHeading).toHaveFocus());
|
|
199
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
200
|
+
|
|
201
|
+
await user.click(screen.getByRole('button', {name: 'Apollo launch'}));
|
|
202
|
+
expect(onMove).toHaveBeenCalledOnce();
|
|
203
|
+
await waitFor(() =>
|
|
204
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('keeps only the top content padding in a menu BottomSheet', () => {
|
|
209
|
+
const source = readFileSync(
|
|
210
|
+
'packages/core/src/DropdownMenu/MenuBottomSheet.tsx',
|
|
211
|
+
'utf8',
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(source).toContain('paddingBlockStart={4}');
|
|
215
|
+
expect(source).toContain('paddingBlockEnd={0}');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('uses the BottomSheet for adaptive presentation on compact touch', async () => {
|
|
219
|
+
vi.stubGlobal(
|
|
220
|
+
'matchMedia',
|
|
221
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
222
|
+
matches: query === '(max-width: 768px) and (pointer: coarse)',
|
|
223
|
+
media: query,
|
|
224
|
+
onchange: null,
|
|
225
|
+
addEventListener: vi.fn(),
|
|
226
|
+
removeEventListener: vi.fn(),
|
|
227
|
+
addListener: vi.fn(),
|
|
228
|
+
removeListener: vi.fn(),
|
|
229
|
+
dispatchEvent: vi.fn(),
|
|
230
|
+
})),
|
|
231
|
+
);
|
|
232
|
+
render(
|
|
233
|
+
<ContextMenu presentation="adaptive" items={[{label: 'Edit'}]}>
|
|
234
|
+
<div>Right-click me</div>
|
|
235
|
+
</ContextMenu>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
239
|
+
await waitFor(() =>
|
|
240
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(),
|
|
241
|
+
);
|
|
242
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
126
245
|
it('closes on Escape even when opened without auto-focus', () => {
|
|
127
246
|
render(
|
|
128
247
|
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
@@ -273,6 +392,17 @@ describe('ContextMenu', () => {
|
|
|
273
392
|
}
|
|
274
393
|
});
|
|
275
394
|
|
|
395
|
+
it('suppresses native text selection on the long-press trigger', () => {
|
|
396
|
+
const source = readFileSync(
|
|
397
|
+
'packages/core/src/ContextMenu/ContextMenu.tsx',
|
|
398
|
+
'utf8',
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
expect(source).toMatch(
|
|
402
|
+
/trigger:[\s\S]*?WebkitTouchCallout: 'none',[\s\S]*?WebkitUserSelect: 'none',[\s\S]*?userSelect: 'none'/,
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
276
406
|
it('cancels the long-press when the finger moves past the threshold', () => {
|
|
277
407
|
vi.useFakeTimers();
|
|
278
408
|
try {
|