@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
@@ -74,6 +74,44 @@ export const docs = {
74
74
 
75
75
  usage: {
76
76
  description: 'A button that shows only an icon with no visible text. Use IconButton in toolbars, table rows, and compact UI where space is tight and the icon is universally understood.',
77
+ accessibility: [
78
+ {
79
+ name: 'Essential icon or spinner arc',
80
+ category: 'Color contrast',
81
+ criterion: '1.4.11 Non-text Contrast',
82
+ requirement: '3:1',
83
+ states: ['Rest', 'Hover', 'Pointer down', 'Loading'],
84
+ description:
85
+ 'IconButton has no visible label. Its icon must have at least 3:1 contrast with the button background in Rest, Hover, and Pointer down. The moving spinner arc must also meet 3:1 while loading.',
86
+ },
87
+ {
88
+ name: 'Visible control boundary',
89
+ category: 'Color contrast',
90
+ criterion: '1.4.11 Non-text Contrast',
91
+ requirement: '3:1 if needed',
92
+ states: ['Rest'],
93
+ description:
94
+ 'The button edge needs 3:1 contrast only when users need it to see the control.',
95
+ },
96
+ {
97
+ name: 'Keyboard focus indicator',
98
+ category: 'Color contrast',
99
+ criterion: '1.4.11 Non-text Contrast',
100
+ requirement: '3:1',
101
+ states: ['Focus visible'],
102
+ description:
103
+ 'The focus outline must have at least 3:1 contrast with the area around the button. Check the red outline on destructive buttons too.',
104
+ },
105
+ {
106
+ name: 'Disabled appearance',
107
+ category: 'Color contrast',
108
+ criterion: '1.4.3 and 1.4.11 exceptions',
109
+ requirement: 'Not required',
110
+ states: ['Disabled'],
111
+ description:
112
+ 'Disabled controls do not need to meet these contrast ratios.',
113
+ },
114
+ ],
77
115
  bestPractices: [
78
116
  { guidance: true, description: 'Make the aria-label specific: a trash icon labeled "Delete conversation" is clearer than just "Delete" for screen readers.' },
79
117
  { guidance: true, description: 'Add a tooltip: even a gear icon can mean Settings, Preferences, or Configure.' },
@@ -61,6 +61,12 @@ export const docs = {
61
61
  "Alignment along the placement axis. Use 'end' to align the menu with the trigger's trailing edge, which is usually what an overflow menu wants.",
62
62
  default: "'start'",
63
63
  },
64
+ {
65
+ name: 'presentation',
66
+ type: "'popover' | 'bottom-sheet' | 'adaptive'",
67
+ description: 'Presentation policy forwarded to DropdownMenu. `adaptive` keeps the anchored popover on pointer-based layouts and uses a BottomSheet at 768px and below when the primary pointer is coarse.',
68
+ default: "'popover'",
69
+ },
64
70
  {
65
71
  name: 'onOpenChange',
66
72
  type: '(isOpen: boolean) => void',
@@ -93,6 +99,7 @@ export const docs = {
93
99
  bestPractices: [
94
100
  { guidance: true, description: 'Use for overflow or secondary actions; keep primary actions visible outside the menu.' },
95
101
  { guidance: true, description: 'Use dividers or sections to group related actions when the menu has many items.' },
102
+ { guidance: true, description: 'Use `presentation="adaptive"` when the visible overflow trigger should open a thumb-reachable BottomSheet on compact touch devices.' },
96
103
  { guidance: false, description: 'Hide primary actions inside a MoreMenu; they should be directly visible.' },
97
104
  ],
98
105
  },
@@ -153,6 +160,12 @@ export const docsZh = {
153
160
  "沿放置轴的对齐方式。使用 'end' 让菜单与触发器的尾部边缘对齐,这通常是溢出菜单所需的效果。",
154
161
  default: "'start'",
155
162
  },
163
+ {
164
+ name: 'presentation',
165
+ type: "'popover' | 'bottom-sheet' | 'adaptive'",
166
+ description: '菜单呈现策略。`adaptive` 在指针布局中使用锚定浮层,在主指针为粗略指针且宽度不超过 768px 时使用 BottomSheet。',
167
+ default: "'popover'",
168
+ },
156
169
  {
157
170
  name: 'xstyle',
158
171
  type: 'StyleXStyles',
@@ -171,6 +184,7 @@ export const docsZh = {
171
184
  bestPractices: [
172
185
  { guidance: true, description: 'Use for overflow or secondary actions; keep primary actions visible outside the menu.' },
173
186
  { guidance: true, description: 'Use dividers or sections to group related actions when the menu has many items.' },
187
+ { guidance: true, description: 'Use `presentation="adaptive"` when the visible overflow trigger should open a thumb-reachable BottomSheet on compact touch devices.' },
174
188
  { guidance: false, description: 'Hide primary actions inside a MoreMenu; they should be directly visible.' },
175
189
  ],
176
190
  },
@@ -186,6 +200,7 @@ export const docsDense = {
186
200
  bestPractices: [
187
201
  { guidance: true, description: 'Use for overflow or secondary actions; keep primary actions visible outside the menu.' },
188
202
  { guidance: true, description: 'Use dividers or sections to group related actions when the menu has many items.' },
203
+ { guidance: true, description: 'Use `presentation="adaptive"` when the visible overflow trigger should open a thumb-reachable BottomSheet on compact touch devices.' },
189
204
  { guidance: false, description: 'Hide primary actions inside a MoreMenu; they should be directly visible.' },
190
205
  ],
191
206
  },
@@ -199,6 +214,8 @@ export const docsDense = {
199
214
  placement: 'Menu position relative to trigger. Logical (RTL mirrors).',
200
215
  alignment:
201
216
  "Alignment along the placement axis. 'end' aligns with the trigger's trailing edge.",
217
+ presentation:
218
+ 'Popover, BottomSheet, or adaptive compact-touch presentation.',
202
219
  xstyle:
203
220
  'StyleX styles for layout customization (margins, positioning, sizing). Must be stylex.create() value.',
204
221
  },
@@ -17,6 +17,30 @@ import {DropdownMenu} from '../DropdownMenu/DropdownMenu';
17
17
 
18
18
  // Mock showPopover and hidePopover methods since they're not implemented in jsdom
19
19
  beforeEach(() => {
20
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
21
+ this: HTMLDialogElement,
22
+ ) {
23
+ this.setAttribute('open', '');
24
+ });
25
+ HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
26
+ this.setAttribute('open', '');
27
+ });
28
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
29
+ this.removeAttribute('open');
30
+ });
31
+ vi.stubGlobal(
32
+ 'matchMedia',
33
+ vi.fn().mockImplementation((query: string) => ({
34
+ matches: false,
35
+ media: query,
36
+ onchange: null,
37
+ addEventListener: vi.fn(),
38
+ removeEventListener: vi.fn(),
39
+ addListener: vi.fn(),
40
+ removeListener: vi.fn(),
41
+ dispatchEvent: vi.fn(),
42
+ })),
43
+ );
20
44
  HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
21
45
  this.setAttribute('popover-open', '');
22
46
  const event = new Event('toggle', {bubbles: false});
@@ -85,6 +109,29 @@ describe('MoreMenu', () => {
85
109
  expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
86
110
  });
87
111
 
112
+ it('keeps the trigger visually pressed while the menu is open', async () => {
113
+ const user = userEvent.setup();
114
+ render(<MoreMenu items={defaultItems} />);
115
+
116
+ const trigger = screen.getByRole('button', {name: 'More options'});
117
+ expect(trigger.className).not.toContain('DropdownMenu__styles.triggerOpen');
118
+
119
+ await user.click(trigger);
120
+
121
+ expect(trigger.className).toContain('DropdownMenu__styles.triggerOpen');
122
+ });
123
+
124
+ it('forwards BottomSheet presentation to DropdownMenu', async () => {
125
+ const user = userEvent.setup();
126
+ render(<MoreMenu items={defaultItems} presentation="bottom-sheet" />);
127
+
128
+ await user.click(screen.getByRole('button', {name: 'More options'}));
129
+ await waitFor(() =>
130
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(),
131
+ );
132
+ expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
133
+ });
134
+
88
135
  it('calls onClick when item is clicked', async () => {
89
136
  const handleEdit = vi.fn();
90
137
  const items = [
@@ -22,7 +22,10 @@ import type {ReactNode} from 'react';
22
22
  import {useIcon} from '../Icon';
23
23
  import {DropdownMenu} from '../DropdownMenu/DropdownMenu';
24
24
  import {useSize} from '../SizeContext/SizeContext';
25
- import type {DropdownMenuOption} from '../DropdownMenu';
25
+ import type {
26
+ DropdownMenuOption,
27
+ DropdownMenuPresentation,
28
+ } from '../DropdownMenu';
26
29
  import type {LayerAlignment, LayerPlacement} from '../Layer';
27
30
  import type {ButtonVariant, ButtonSize} from '../Button';
28
31
  import type {BaseProps} from '../BaseProps';
@@ -87,6 +90,13 @@ export interface MoreMenuProps extends Pick<
87
90
  */
88
91
  alignment?: LayerAlignment;
89
92
 
93
+ /**
94
+ * Menu presentation policy. `adaptive` uses a BottomSheet on compact
95
+ * coarse-pointer viewports and an anchored popover elsewhere.
96
+ * @default 'popover'
97
+ */
98
+ presentation?: DropdownMenuPresentation;
99
+
90
100
  /**
91
101
  * Controlled open state for the menu.
92
102
  */
@@ -125,6 +135,7 @@ export function MoreMenu({
125
135
  isDisabled = false,
126
136
  placement,
127
137
  alignment,
138
+ presentation,
128
139
  isMenuOpen,
129
140
  onOpenChange,
130
141
  xstyle,
@@ -151,6 +162,7 @@ export function MoreMenu({
151
162
  onOpenChange={onOpenChange}
152
163
  placement={placement}
153
164
  alignment={alignment}
165
+ presentation={presentation}
154
166
  button={{
155
167
  label,
156
168
  icon: icon ?? moreIcon,
@@ -224,6 +224,13 @@ export const docs = {
224
224
  'Which edge of the option row carries the checkbox. end pushes it to the far edge of the row, including on the select-all row.',
225
225
  default: "'start'",
226
226
  },
227
+ {
228
+ name: 'presentation',
229
+ type: "'popover' | 'bottom-sheet' | 'adaptive'",
230
+ description:
231
+ 'How the option list is presented. adaptive uses a bottom sheet on compact touch screens and an anchored popover otherwise.',
232
+ default: "'popover'",
233
+ },
227
234
  {
228
235
  name: 'width',
229
236
  type: 'SizeValue',
@@ -290,6 +297,11 @@ export const docs = {
290
297
  description:
291
298
  'Use variant="ghost" when a multi-selector sits in a toolbar with ghost buttons. If validation status is needed there, prefer statusVariant="tooltip" so the toolbar height stays compact.',
292
299
  },
300
+ {
301
+ guidance: true,
302
+ description:
303
+ 'Use presentation="adaptive" when the multi-selector should become a bottom sheet on compact touch screens.',
304
+ },
293
305
  {
294
306
  guidance: false,
295
307
  description: 'Use for single-value selection; use Selector instead.',
@@ -349,6 +361,8 @@ export const docsZh = {
349
361
  '状态消息的放置方式:attached 直接叠加在输入框下方;detached 作为独立元素浮于下方并留有间距。',
350
362
  renderOption:
351
363
  '每个可选选项的自定义渲染函数。不会用于分隔线、分组或全选行。',
364
+ presentation:
365
+ '选项列表的呈现方式:锚定弹出框、底部抽屉,或根据紧凑触控屏自适应。',
352
366
  xstyle: '布局自定义的 StyleX 样式,必须是 stylex.create() 值。',
353
367
  },
354
368
  },
@@ -487,6 +501,8 @@ export const docsDense = {
487
501
  'status message placement; ghost detaches attached by default; use tooltip for compact toolbars.',
488
502
  renderOption:
489
503
  'custom render fn per selectable option; not dividers/sections/select-all',
504
+ presentation:
505
+ 'popover, bottom-sheet, or compact-touch adaptive presentation',
490
506
  xstyle: 'StyleX layout styles; stylex.create() only',
491
507
  },
492
508
  },
@@ -19,12 +19,15 @@ import {
19
19
  within,
20
20
  } from '@testing-library/react';
21
21
  import userEvent from '@testing-library/user-event';
22
+ import * as stylex from '@stylexjs/stylex';
22
23
  import {MultiSelector} from './MultiSelector';
23
24
  import {Icon} from '../Icon';
24
25
  import {InternationalizationProvider} from '../i18n';
25
26
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
27
+ import {__resetInteractionModalityForTest} from '../utils/interactionModality';
26
28
  import {defineTheme} from '../theme/defineTheme';
27
29
  import {generateThemeCSS} from '../theme/generateThemeRules';
30
+ import {selectorPresentationStyles} from '../Selector/selectorPresentation.stylex';
28
31
 
29
32
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
30
33
  const {prose, component} = generateThemeCSS(theme);
@@ -40,6 +43,28 @@ function politeRegion(): HTMLElement | null {
40
43
 
41
44
  // Mock showPopover and hidePopover methods since they're not implemented in jsdom
42
45
  beforeEach(() => {
46
+ __resetInteractionModalityForTest();
47
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
48
+ this: HTMLDialogElement,
49
+ ) {
50
+ this.setAttribute('open', '');
51
+ });
52
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
53
+ this.removeAttribute('open');
54
+ });
55
+ vi.stubGlobal(
56
+ 'matchMedia',
57
+ vi.fn().mockImplementation((query: string) => ({
58
+ matches: false,
59
+ media: query,
60
+ onchange: null,
61
+ addEventListener: vi.fn(),
62
+ removeEventListener: vi.fn(),
63
+ addListener: vi.fn(),
64
+ removeListener: vi.fn(),
65
+ dispatchEvent: vi.fn(),
66
+ })),
67
+ );
43
68
  HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
44
69
  this.setAttribute('popover-open', '');
45
70
  const event = new Event('toggle', {bubbles: false});
@@ -75,6 +100,108 @@ const h = {hidden: true} as const;
75
100
  describe('MultiSelector', () => {
76
101
  const defaultOptions = ['Apple', 'Banana', 'Orange'];
77
102
 
103
+ it('uses a bottom sheet and stays open while toggling options', async () => {
104
+ const user = userEvent.setup();
105
+ const onChange = vi.fn();
106
+ render(
107
+ <MultiSelector
108
+ label="Fruit"
109
+ options={defaultOptions}
110
+ value={[]}
111
+ onChange={onChange}
112
+ presentation="bottom-sheet"
113
+ />,
114
+ );
115
+
116
+ const trigger = screen.getByRole('combobox');
117
+ await user.click(trigger);
118
+
119
+ expect(
120
+ await screen.findByRole('dialog', {name: 'Fruit'}),
121
+ ).toBeInTheDocument();
122
+ expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
123
+
124
+ await user.click(screen.getByRole('option', {name: 'Banana'}));
125
+ expect(onChange).toHaveBeenCalledWith(['Banana']);
126
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
127
+ });
128
+
129
+ it('uses a bottom sheet for adaptive presentation on compact touch', async () => {
130
+ vi.stubGlobal(
131
+ 'matchMedia',
132
+ vi.fn().mockImplementation((query: string) => ({
133
+ matches: query === '(max-width: 768px) and (pointer: coarse)',
134
+ media: query,
135
+ onchange: null,
136
+ addEventListener: vi.fn(),
137
+ removeEventListener: vi.fn(),
138
+ addListener: vi.fn(),
139
+ removeListener: vi.fn(),
140
+ dispatchEvent: vi.fn(),
141
+ })),
142
+ );
143
+ const user = userEvent.setup();
144
+ render(
145
+ <MultiSelector
146
+ label="Fruit"
147
+ options={defaultOptions}
148
+ value={[]}
149
+ onChange={() => {}}
150
+ presentation="adaptive"
151
+ />,
152
+ );
153
+
154
+ await user.click(screen.getByRole('combobox'));
155
+ expect(
156
+ await screen.findByRole('dialog', {name: 'Fruit'}),
157
+ ).toBeInTheDocument();
158
+ expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('moves keyboard focus into a bottom-sheet listbox', async () => {
162
+ const user = userEvent.setup();
163
+ render(
164
+ <MultiSelector
165
+ label="Fruit"
166
+ options={defaultOptions}
167
+ value={[]}
168
+ onChange={() => {}}
169
+ presentation="bottom-sheet"
170
+ />,
171
+ );
172
+
173
+ const trigger = screen.getByRole('combobox');
174
+ trigger.focus();
175
+ await user.keyboard('{Enter}');
176
+
177
+ await waitFor(() => expect(screen.getByRole('listbox')).toHaveFocus());
178
+ });
179
+
180
+ it('restores touch focus without painting a trigger focus ring', async () => {
181
+ render(
182
+ <MultiSelector
183
+ label="Fruit"
184
+ options={defaultOptions}
185
+ value={[]}
186
+ onChange={() => {}}
187
+ presentation="bottom-sheet"
188
+ />,
189
+ );
190
+
191
+ const trigger = screen.getByRole('combobox');
192
+ fireEvent.pointerDown(trigger, {pointerType: 'touch'});
193
+ fireEvent.click(trigger, {detail: 1});
194
+ const dialog = await screen.findByRole('dialog', {name: 'Fruit'});
195
+ fireEvent.pointerDown(dialog, {pointerType: 'touch'});
196
+ fireEvent.click(dialog, {detail: 1});
197
+
198
+ trigger.focus();
199
+ expect(trigger).toHaveFocus();
200
+ expect(trigger.parentElement).toHaveClass(
201
+ stylex.props(selectorPresentationStyles.pointerRestoredFocus).className!,
202
+ );
203
+ });
204
+
78
205
  it('renders with label', () => {
79
206
  render(
80
207
  <MultiSelector