@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.
Files changed (135) 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/Chat/ChatToolCalls.d.ts.map +1 -1
  13. package/dist/Chat/ChatToolCalls.js +34 -17
  14. package/dist/ContextMenu/ContextMenu.d.ts +14 -3
  15. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  16. package/dist/ContextMenu/ContextMenu.js +149 -26
  17. package/dist/ContextMenu/index.d.ts +1 -0
  18. package/dist/ContextMenu/index.d.ts.map +1 -1
  19. package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
  20. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenu.js +340 -26
  22. package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
  23. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  25. package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
  26. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
  27. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
  29. package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
  30. package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
  31. package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
  32. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
  33. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
  34. package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
  35. package/dist/DropdownMenu/index.d.ts +2 -1
  36. package/dist/DropdownMenu/index.d.ts.map +1 -1
  37. package/dist/DropdownMenu/menuWidth.d.ts +14 -0
  38. package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
  39. package/dist/DropdownMenu/menuWidth.js +35 -0
  40. package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
  41. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
  42. package/dist/DropdownMenu/useMenuOverflow.js +60 -0
  43. package/dist/MoreMenu/MoreMenu.d.ts +8 -2
  44. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  45. package/dist/MoreMenu/MoreMenu.js +2 -0
  46. package/dist/MultiSelector/MultiSelector.d.ts +11 -1
  47. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  48. package/dist/MultiSelector/MultiSelector.js +97 -68
  49. package/dist/MultiSelector/index.d.ts +1 -1
  50. package/dist/MultiSelector/index.d.ts.map +1 -1
  51. package/dist/Popover/Popover.d.ts +10 -6
  52. package/dist/Popover/Popover.d.ts.map +1 -1
  53. package/dist/Popover/Popover.js +141 -16
  54. package/dist/Popover/usePopover.d.ts +4 -0
  55. package/dist/Popover/usePopover.d.ts.map +1 -1
  56. package/dist/Popover/usePopover.js +52 -5
  57. package/dist/Selector/Selector.d.ts +10 -0
  58. package/dist/Selector/Selector.d.ts.map +1 -1
  59. package/dist/Selector/Selector.js +106 -80
  60. package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
  61. package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
  62. package/dist/Selector/SelectorBottomSheet.js +83 -0
  63. package/dist/Selector/index.d.ts +1 -1
  64. package/dist/Selector/index.d.ts.map +1 -1
  65. package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
  66. package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
  67. package/dist/Selector/selectorPresentation.stylex.js +21 -0
  68. package/dist/Selector/useSelectorPresentation.d.ts +32 -0
  69. package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
  70. package/dist/Selector/useSelectorPresentation.js +90 -0
  71. package/dist/Toast/useToastGesture.d.ts.map +1 -1
  72. package/dist/Toast/useToastGesture.js +46 -9
  73. package/dist/astryx.css +17 -1
  74. package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
  75. package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
  76. package/dist/hooks/useAdaptivePresentation.js +19 -0
  77. package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
  78. package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
  79. package/dist/hooks/useFocusReturnVisibility.js +35 -0
  80. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  81. package/dist/hooks/useFocusTrap.js +12 -0
  82. package/locales/en.json +20 -0
  83. package/locales/pseudo.json +15 -0
  84. package/package.json +2 -2
  85. package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
  86. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  87. package/src/BottomSheet/BottomSheet.tsx +7 -4
  88. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
  89. package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
  90. package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
  91. package/src/Button/Button.test.tsx +10 -0
  92. package/src/Button/Button.tsx +13 -5
  93. package/src/Chat/ChatToolCalls.test.tsx +44 -0
  94. package/src/Chat/ChatToolCalls.tsx +36 -15
  95. package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
  96. package/src/ContextMenu/ContextMenu.test.tsx +131 -1
  97. package/src/ContextMenu/ContextMenu.tsx +199 -32
  98. package/src/ContextMenu/index.ts +1 -0
  99. package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
  100. package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
  101. package/src/DropdownMenu/DropdownMenu.tsx +459 -28
  102. package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
  103. package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
  104. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
  105. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
  106. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
  107. package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
  108. package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
  109. package/src/DropdownMenu/index.ts +2 -0
  110. package/src/DropdownMenu/menuWidth.ts +51 -0
  111. package/src/DropdownMenu/useMenuOverflow.ts +77 -0
  112. package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
  113. package/src/MoreMenu/MoreMenu.test.tsx +47 -0
  114. package/src/MoreMenu/MoreMenu.tsx +13 -1
  115. package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
  116. package/src/MultiSelector/MultiSelector.test.tsx +127 -0
  117. package/src/MultiSelector/MultiSelector.tsx +121 -73
  118. package/src/MultiSelector/index.ts +1 -0
  119. package/src/Popover/Popover.doc.mjs +11 -11
  120. package/src/Popover/Popover.test.tsx +397 -2
  121. package/src/Popover/Popover.tsx +228 -21
  122. package/src/Popover/usePopover.tsx +92 -15
  123. package/src/Selector/Selector.doc.mjs +12 -0
  124. package/src/Selector/Selector.test.tsx +135 -0
  125. package/src/Selector/Selector.tsx +141 -82
  126. package/src/Selector/SelectorBottomSheet.tsx +97 -0
  127. package/src/Selector/index.ts +1 -0
  128. package/src/Selector/selectorPresentation.stylex.ts +20 -0
  129. package/src/Selector/useSelectorPresentation.ts +147 -0
  130. package/src/Toast/ToastViewport.test.tsx +118 -4
  131. package/src/Toast/useToastGesture.ts +61 -8
  132. package/src/hooks/useAdaptivePresentation.ts +35 -0
  133. package/src/hooks/useFocusReturnVisibility.ts +45 -0
  134. package/src/hooks/useFocusTrap.test.tsx +40 -0
  135. 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
- {/* 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
  );
@@ -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,
@@ -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 && (
@@ -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 {