@astryxdesign/core 0.5.2-canary.c9c8564 → 0.5.2-canary.e4f8e4e
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/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +2 -2
- 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 +19 -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.doc.mjs +56 -0
- package/src/Button/Button.test.tsx +10 -0
- package/src/Button/Button.tsx +13 -5
- package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
- package/src/Carousel/Carousel.test.tsx +83 -0
- package/src/Carousel/Carousel.tsx +8 -2
- 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/IconButton/IconButton.doc.mjs +38 -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/ProgressBar/ProgressBar.spec.md +208 -0
- package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
- 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/ToggleButton/ToggleButton.doc.mjs +56 -0
- 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
- package/src/theme/themingTargets.test.ts +91 -25
|
@@ -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
|
);
|
|
@@ -23,6 +23,62 @@ export const docs = {
|
|
|
23
23
|
{guidance: false, description: 'Use the destructive variant without a confirmation step for irreversible actions like deleting data.'},
|
|
24
24
|
{guidance: false, description: 'Use a button for navigation. If it only takes the user to another page, use a link instead. Buttons are for actions like saving, deleting, or submitting.'},
|
|
25
25
|
],
|
|
26
|
+
accessibility: [
|
|
27
|
+
{
|
|
28
|
+
name: 'Text label',
|
|
29
|
+
category: 'Color contrast',
|
|
30
|
+
criterion: '1.4.3 Contrast (Minimum)',
|
|
31
|
+
requirement: '4.5:1',
|
|
32
|
+
states: ['Rest', 'Hover', 'Pointer down'],
|
|
33
|
+
description:
|
|
34
|
+
'Button text must have at least 4.5:1 contrast with the button background in every state. For Hover and Pointer down, measure the final background after the overlay is applied.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Essential icon or spinner arc',
|
|
38
|
+
category: 'Color contrast',
|
|
39
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
40
|
+
requirement: '3:1',
|
|
41
|
+
states: ['Icon only', 'Loading'],
|
|
42
|
+
description:
|
|
43
|
+
'An icon used instead of text must have at least 3:1 contrast with the button background. The moving spinner arc must also meet 3:1. An icon beside a visible label does not need its own check.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'Badge text',
|
|
47
|
+
category: 'Color contrast',
|
|
48
|
+
criterion: '1.4.3 Contrast (Minimum)',
|
|
49
|
+
requirement: '4.5:1',
|
|
50
|
+
states: ['Rest', 'Hover', 'Pointer down'],
|
|
51
|
+
description:
|
|
52
|
+
'Badge text inside a button must have at least 4.5:1 contrast with the Badge background. Check all 14 built-in Badge colors in Rest, Hover, and Pointer down on page and surface backgrounds. This covers 336 pairs per mode. Check custom end content separately.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'Visible control boundary',
|
|
56
|
+
category: 'Color contrast',
|
|
57
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
58
|
+
requirement: '3:1 if needed',
|
|
59
|
+
states: ['Rest'],
|
|
60
|
+
description:
|
|
61
|
+
'The button edge needs 3:1 contrast only when users need it to see the control. A text-only button can rely on its label.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'Keyboard focus indicator',
|
|
65
|
+
category: 'Color contrast',
|
|
66
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
67
|
+
requirement: '3:1',
|
|
68
|
+
states: ['Focus visible'],
|
|
69
|
+
description:
|
|
70
|
+
'The focus outline needs at least 3:1 contrast with the area around the button. Check every style. Destructive buttons use a red outline.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'Disabled appearance',
|
|
74
|
+
category: 'Color contrast',
|
|
75
|
+
criterion: '1.4.3 and 1.4.11 exceptions',
|
|
76
|
+
requirement: 'Not required',
|
|
77
|
+
states: ['Disabled'],
|
|
78
|
+
description:
|
|
79
|
+
'Disabled controls do not need to meet these contrast ratios.',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
26
82
|
anatomy: [
|
|
27
83
|
{name: 'Icon', required: false, description: 'A leading icon that reinforces the label, like a trash icon on a Delete button.'},
|
|
28
84
|
{name: 'Label', required: true, description: 'The visible text describing the action. Also used as the accessible name.'},
|
|
@@ -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,
|
|
@@ -39,6 +39,53 @@ export const docs = {
|
|
|
39
39
|
usage: {
|
|
40
40
|
description:
|
|
41
41
|
'ButtonGroup joins related actions into a single connected control. Use it when multiple buttons represent related choices or operations that belong together visually, like copy/cut/paste, or undo/redo.',
|
|
42
|
+
accessibility: [
|
|
43
|
+
{
|
|
44
|
+
name: 'Text label',
|
|
45
|
+
category: 'Color contrast',
|
|
46
|
+
criterion: '1.4.3 Contrast (Minimum)',
|
|
47
|
+
requirement: '4.5:1',
|
|
48
|
+
states: ['Rest', 'Hover', 'Pointer down'],
|
|
49
|
+
description:
|
|
50
|
+
'Text in each button must have at least 4.5:1 contrast with its background in every state. For Hover and Pointer down, measure the final background after the overlay is applied.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'Essential icon or spinner arc',
|
|
54
|
+
category: 'Color contrast',
|
|
55
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
56
|
+
requirement: '3:1',
|
|
57
|
+
states: ['Icon only', 'Loading'],
|
|
58
|
+
description:
|
|
59
|
+
'An icon used instead of text must have at least 3:1 contrast with the button background. The moving spinner arc must also meet 3:1. An icon beside a visible label does not need its own check.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'Visible control boundary',
|
|
63
|
+
category: 'Color contrast',
|
|
64
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
65
|
+
requirement: '3:1 if needed',
|
|
66
|
+
states: ['Rest'],
|
|
67
|
+
description:
|
|
68
|
+
'Some groups need a divider or edge to show each button. That divider or edge must have at least 3:1 contrast.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'Keyboard focus indicator',
|
|
72
|
+
category: 'Color contrast',
|
|
73
|
+
criterion: '1.4.11 Non-text Contrast',
|
|
74
|
+
requirement: '3:1',
|
|
75
|
+
states: ['Focus visible'],
|
|
76
|
+
description:
|
|
77
|
+
'The focus outline must have at least 3:1 contrast with the area around it.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'Disabled appearance',
|
|
81
|
+
category: 'Color contrast',
|
|
82
|
+
criterion: '1.4.3 and 1.4.11 exceptions',
|
|
83
|
+
requirement: 'Not required',
|
|
84
|
+
states: ['Disabled'],
|
|
85
|
+
description:
|
|
86
|
+
'Disabled controls do not need to meet these contrast ratios.',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
42
89
|
bestPractices: [
|
|
43
90
|
{guidance: true, description: 'Group buttons that perform related actions on the same object, like copy, cut, paste on selected text.'},
|
|
44
91
|
{guidance: true, description: 'Use the same variant for all buttons in a group so they look like a single connected unit.'},
|
|
@@ -850,4 +850,87 @@ describe('Carousel', () => {
|
|
|
850
850
|
expect(scroller).not.toHaveAttribute('data-padding');
|
|
851
851
|
});
|
|
852
852
|
});
|
|
853
|
+
|
|
854
|
+
describe('edge fade', () => {
|
|
855
|
+
function getScroller() {
|
|
856
|
+
const region = screen.getByRole('region');
|
|
857
|
+
return region.firstElementChild as HTMLElement;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function makeOverflowing(el: HTMLElement, scrollLeft: number) {
|
|
861
|
+
Object.defineProperty(el, 'scrollWidth', {
|
|
862
|
+
value: 500,
|
|
863
|
+
configurable: true,
|
|
864
|
+
});
|
|
865
|
+
Object.defineProperty(el, 'clientWidth', {
|
|
866
|
+
value: 200,
|
|
867
|
+
configurable: true,
|
|
868
|
+
});
|
|
869
|
+
Object.defineProperty(el, 'scrollLeft', {
|
|
870
|
+
value: scrollLeft,
|
|
871
|
+
writable: true,
|
|
872
|
+
configurable: true,
|
|
873
|
+
});
|
|
874
|
+
fireEvent.scroll(el);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function injectedCss(): string {
|
|
878
|
+
let out = '';
|
|
879
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
880
|
+
try {
|
|
881
|
+
for (const rule of Array.from(sheet.cssRules)) {
|
|
882
|
+
out += rule.cssText + '\n';
|
|
883
|
+
}
|
|
884
|
+
} catch {
|
|
885
|
+
// Ignore cross-origin sheets.
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
it('fades the physical left edge when only the start overflows (LTR)', () => {
|
|
892
|
+
render(
|
|
893
|
+
<Carousel aria-label="Fade">
|
|
894
|
+
<div>Item 1</div>
|
|
895
|
+
<div>Item 2</div>
|
|
896
|
+
</Carousel>,
|
|
897
|
+
);
|
|
898
|
+
const scroller = getScroller();
|
|
899
|
+
makeOverflowing(scroller, 300);
|
|
900
|
+
expect(getComputedStyle(scroller).maskImage).toContain(
|
|
901
|
+
'linear-gradient(to right',
|
|
902
|
+
);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('fades the physical right edge when only the end overflows (LTR)', () => {
|
|
906
|
+
render(
|
|
907
|
+
<Carousel aria-label="Fade">
|
|
908
|
+
<div>Item 1</div>
|
|
909
|
+
<div>Item 2</div>
|
|
910
|
+
</Carousel>,
|
|
911
|
+
);
|
|
912
|
+
const scroller = getScroller();
|
|
913
|
+
makeOverflowing(scroller, 0);
|
|
914
|
+
expect(getComputedStyle(scroller).maskImage).toContain(
|
|
915
|
+
'linear-gradient(to left',
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it('mirrors both single-edge fade gradients under RTL', () => {
|
|
920
|
+
render(
|
|
921
|
+
<Carousel aria-label="Fade">
|
|
922
|
+
<div>Item 1</div>
|
|
923
|
+
<div>Item 2</div>
|
|
924
|
+
</Carousel>,
|
|
925
|
+
);
|
|
926
|
+
const css = injectedCss();
|
|
927
|
+
expect(css).toMatch(
|
|
928
|
+
/:is\(\[dir="rtl"\][^)]*\)[^{]*\{\s*mask-image:\s*linear-gradient\(to left/,
|
|
929
|
+
);
|
|
930
|
+
expect(css).toMatch(
|
|
931
|
+
/:is\(\[dir="rtl"\][^)]*\)[^{]*\{\s*mask-image:\s*linear-gradient\(to right/,
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
853
936
|
});
|
|
@@ -178,10 +178,16 @@ const styles = stylex.create({
|
|
|
178
178
|
maskImage: 'none',
|
|
179
179
|
},
|
|
180
180
|
fadeStart: {
|
|
181
|
-
maskImage:
|
|
181
|
+
maskImage: {
|
|
182
|
+
default: `linear-gradient(to right, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']})`,
|
|
183
|
+
':is([dir="rtl"] *)': `linear-gradient(to left, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']})`,
|
|
184
|
+
},
|
|
182
185
|
},
|
|
183
186
|
fadeEnd: {
|
|
184
|
-
maskImage:
|
|
187
|
+
maskImage: {
|
|
188
|
+
default: `linear-gradient(to left, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']})`,
|
|
189
|
+
':is([dir="rtl"] *)': `linear-gradient(to right, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']})`,
|
|
190
|
+
},
|
|
185
191
|
},
|
|
186
192
|
fadeBoth: {
|
|
187
193
|
maskImage: `linear-gradient(to right, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']}, black calc(100% - ${spacingVars['--spacing-1']}), rgba(0,0,0,0.3) calc(100% - 2px), transparent 100%)`,
|
|
@@ -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 && (
|