@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.
Files changed (146) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +3 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +4 -3
  4. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
  7. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  8. package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
  9. package/dist/Button/Button.d.ts +2 -1
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +11 -4
  12. package/dist/Carousel/Carousel.d.ts.map +1 -1
  13. package/dist/Carousel/Carousel.js +2 -2
  14. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  15. package/dist/Chat/ChatToolCalls.js +34 -17
  16. package/dist/ContextMenu/ContextMenu.d.ts +14 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  18. package/dist/ContextMenu/ContextMenu.js +149 -26
  19. package/dist/ContextMenu/index.d.ts +1 -0
  20. package/dist/ContextMenu/index.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
  22. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenu.js +340 -26
  24. package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  26. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
  31. package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
  32. package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
  33. package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
  34. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
  35. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
  36. package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
  37. package/dist/DropdownMenu/index.d.ts +2 -1
  38. package/dist/DropdownMenu/index.d.ts.map +1 -1
  39. package/dist/DropdownMenu/menuWidth.d.ts +14 -0
  40. package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
  41. package/dist/DropdownMenu/menuWidth.js +35 -0
  42. package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
  43. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
  44. package/dist/DropdownMenu/useMenuOverflow.js +60 -0
  45. package/dist/MoreMenu/MoreMenu.d.ts +8 -2
  46. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  47. package/dist/MoreMenu/MoreMenu.js +2 -0
  48. package/dist/MultiSelector/MultiSelector.d.ts +11 -1
  49. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  50. package/dist/MultiSelector/MultiSelector.js +97 -68
  51. package/dist/MultiSelector/index.d.ts +1 -1
  52. package/dist/MultiSelector/index.d.ts.map +1 -1
  53. package/dist/Popover/Popover.d.ts +10 -6
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +141 -16
  56. package/dist/Popover/usePopover.d.ts +4 -0
  57. package/dist/Popover/usePopover.d.ts.map +1 -1
  58. package/dist/Popover/usePopover.js +52 -5
  59. package/dist/Selector/Selector.d.ts +10 -0
  60. package/dist/Selector/Selector.d.ts.map +1 -1
  61. package/dist/Selector/Selector.js +106 -80
  62. package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
  63. package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
  64. package/dist/Selector/SelectorBottomSheet.js +83 -0
  65. package/dist/Selector/index.d.ts +1 -1
  66. package/dist/Selector/index.d.ts.map +1 -1
  67. package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
  68. package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
  69. package/dist/Selector/selectorPresentation.stylex.js +21 -0
  70. package/dist/Selector/useSelectorPresentation.d.ts +32 -0
  71. package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
  72. package/dist/Selector/useSelectorPresentation.js +90 -0
  73. package/dist/Toast/useToastGesture.d.ts.map +1 -1
  74. package/dist/Toast/useToastGesture.js +46 -9
  75. package/dist/astryx.css +19 -1
  76. package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
  77. package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
  78. package/dist/hooks/useAdaptivePresentation.js +19 -0
  79. package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
  80. package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
  81. package/dist/hooks/useFocusReturnVisibility.js +35 -0
  82. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  83. package/dist/hooks/useFocusTrap.js +12 -0
  84. package/locales/en.json +20 -0
  85. package/locales/pseudo.json +15 -0
  86. package/package.json +2 -2
  87. package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +7 -4
  90. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
  91. package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
  93. package/src/Button/Button.doc.mjs +56 -0
  94. package/src/Button/Button.test.tsx +10 -0
  95. package/src/Button/Button.tsx +13 -5
  96. package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
  97. package/src/Carousel/Carousel.test.tsx +83 -0
  98. package/src/Carousel/Carousel.tsx +8 -2
  99. package/src/Chat/ChatToolCalls.test.tsx +44 -0
  100. package/src/Chat/ChatToolCalls.tsx +36 -15
  101. package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
  102. package/src/ContextMenu/ContextMenu.test.tsx +131 -1
  103. package/src/ContextMenu/ContextMenu.tsx +199 -32
  104. package/src/ContextMenu/index.ts +1 -0
  105. package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
  106. package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
  107. package/src/DropdownMenu/DropdownMenu.tsx +459 -28
  108. package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
  109. package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
  110. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
  112. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
  113. package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
  114. package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
  115. package/src/DropdownMenu/index.ts +2 -0
  116. package/src/DropdownMenu/menuWidth.ts +51 -0
  117. package/src/DropdownMenu/useMenuOverflow.ts +77 -0
  118. package/src/IconButton/IconButton.doc.mjs +38 -0
  119. package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
  120. package/src/MoreMenu/MoreMenu.test.tsx +47 -0
  121. package/src/MoreMenu/MoreMenu.tsx +13 -1
  122. package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
  123. package/src/MultiSelector/MultiSelector.test.tsx +127 -0
  124. package/src/MultiSelector/MultiSelector.tsx +121 -73
  125. package/src/MultiSelector/index.ts +1 -0
  126. package/src/Popover/Popover.doc.mjs +11 -11
  127. package/src/Popover/Popover.test.tsx +397 -2
  128. package/src/Popover/Popover.tsx +228 -21
  129. package/src/Popover/usePopover.tsx +92 -15
  130. package/src/ProgressBar/ProgressBar.spec.md +208 -0
  131. package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
  132. package/src/Selector/Selector.doc.mjs +12 -0
  133. package/src/Selector/Selector.test.tsx +135 -0
  134. package/src/Selector/Selector.tsx +141 -82
  135. package/src/Selector/SelectorBottomSheet.tsx +97 -0
  136. package/src/Selector/index.ts +1 -0
  137. package/src/Selector/selectorPresentation.stylex.ts +20 -0
  138. package/src/Selector/useSelectorPresentation.ts +147 -0
  139. package/src/Toast/ToastViewport.test.tsx +118 -4
  140. package/src/Toast/useToastGesture.ts +61 -8
  141. package/src/ToggleButton/ToggleButton.doc.mjs +56 -0
  142. package/src/hooks/useAdaptivePresentation.ts +35 -0
  143. package/src/hooks/useFocusReturnVisibility.ts +45 -0
  144. package/src/hooks/useFocusTrap.test.tsx +40 -0
  145. package/src/hooks/useFocusTrap.ts +13 -0
  146. 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
- {/* A modal sheet's ::backdrop already answers Safari's edge sampler. */}
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
- // A modal sheet's ::backdrop is a case WebKit's sampler handles on its own,
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(0);
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('leaves a modal switcher flow to its ::backdrop', () => {
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(0);
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 a non-modal BottomSheet dialog
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
- * A modal sheet is served by that heuristic already WebKit has a dedicated
18
- * branch for a dialog's `::backdrop`. A non-modal sheet is not: the nearest
19
- * fixed ancestor of the panel is the sheet's own full-viewport `<dialog>`,
20
- * which is transparent and viewport-sized, and WebKit answers a viewport-sized
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 non-modal sheet. Renders
72
- * nothing visible; see the file header for why it exists.
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
- {/* A modal flow's ::backdrop already answers Safari's edge sampler. */}
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 () => {
@@ -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
- disabled: {
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.disabled,
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: `linear-gradient(to right, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']})`,
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: `linear-gradient(to left, transparent 0%, rgba(0,0,0,0.3) 2px, black ${spacingVars['--spacing-1']})`,
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
- {status === 'error' && call.errorMessage != null && (
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 && (