@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
|
@@ -10,16 +10,43 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
13
|
+
import {readFileSync} from 'node:fs';
|
|
13
14
|
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
14
15
|
import userEvent from '@testing-library/user-event';
|
|
15
16
|
import {useState} from 'react';
|
|
17
|
+
import * as stylex from '@stylexjs/stylex';
|
|
16
18
|
import {DropdownMenu} from './DropdownMenu';
|
|
17
19
|
import {DropdownMenuItem} from './DropdownMenuItem';
|
|
18
20
|
import {DropdownMenuDivider} from './DropdownMenuDivider';
|
|
19
21
|
import {Divider} from '../Divider';
|
|
22
|
+
import {rtlStyles} from '../utils';
|
|
23
|
+
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
24
|
+
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
20
25
|
|
|
21
26
|
// Mock showPopover and hidePopover methods since they're not implemented in jsdom
|
|
22
27
|
beforeEach(() => {
|
|
28
|
+
__resetInteractionModalityForTest();
|
|
29
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
30
|
+
this: HTMLDialogElement,
|
|
31
|
+
) {
|
|
32
|
+
this.setAttribute('open', '');
|
|
33
|
+
});
|
|
34
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
35
|
+
this.removeAttribute('open');
|
|
36
|
+
});
|
|
37
|
+
vi.stubGlobal(
|
|
38
|
+
'matchMedia',
|
|
39
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
40
|
+
matches: false,
|
|
41
|
+
media: query,
|
|
42
|
+
onchange: null,
|
|
43
|
+
addEventListener: vi.fn(),
|
|
44
|
+
removeEventListener: vi.fn(),
|
|
45
|
+
addListener: vi.fn(),
|
|
46
|
+
removeListener: vi.fn(),
|
|
47
|
+
dispatchEvent: vi.fn(),
|
|
48
|
+
})),
|
|
49
|
+
);
|
|
23
50
|
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
24
51
|
this.setAttribute('popover-open', '');
|
|
25
52
|
const event = new Event('toggle', {bubbles: false});
|
|
@@ -83,6 +110,334 @@ describe('DropdownMenu', () => {
|
|
|
83
110
|
).not.toBeInTheDocument();
|
|
84
111
|
});
|
|
85
112
|
|
|
113
|
+
it('renders data-driven actions in a bottom sheet when requested', async () => {
|
|
114
|
+
const user = userEvent.setup();
|
|
115
|
+
const onEdit = vi.fn();
|
|
116
|
+
|
|
117
|
+
render(
|
|
118
|
+
<DropdownMenu
|
|
119
|
+
button={{label: 'Project actions'}}
|
|
120
|
+
presentation="bottom-sheet"
|
|
121
|
+
items={[{label: 'Edit project', onClick: onEdit}]}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const trigger = screen.getByRole('button', {name: /Project actions/});
|
|
126
|
+
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
|
|
127
|
+
expect(screen.queryByRole('menu', {hidden: true})).not.toBeInTheDocument();
|
|
128
|
+
|
|
129
|
+
await user.click(trigger);
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
screen.getByRole('dialog', {name: 'Project actions'}),
|
|
133
|
+
).toBeInTheDocument();
|
|
134
|
+
const editAction = screen.getByRole('button', {name: 'Edit project'});
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(
|
|
137
|
+
screen.getByRole('dialog', {name: 'Project actions'}),
|
|
138
|
+
).toContainElement(document.activeElement as HTMLElement),
|
|
139
|
+
);
|
|
140
|
+
expect(editAction).not.toHaveFocus();
|
|
141
|
+
await user.click(editAction);
|
|
142
|
+
|
|
143
|
+
expect(onEdit).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('aligns the bottom-sheet heading with the item content edge and keeps item padding', async () => {
|
|
148
|
+
const user = userEvent.setup();
|
|
149
|
+
|
|
150
|
+
render(
|
|
151
|
+
<DropdownMenu
|
|
152
|
+
button={{label: 'Project actions'}}
|
|
153
|
+
presentation="bottom-sheet"
|
|
154
|
+
items={[{label: 'Edit project', icon: <span aria-hidden="true" />}]}
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await user.click(screen.getByRole('button', {name: /Project actions/}));
|
|
159
|
+
|
|
160
|
+
const heading = screen.getByRole('heading', {name: 'Project actions'});
|
|
161
|
+
expect(heading).toHaveStyle({marginInlineStart: 'var(--spacing-3)'});
|
|
162
|
+
expect(heading.closest('.astryx-section')).toHaveStyle({
|
|
163
|
+
paddingInlineStart: 'var(--spacing-1)',
|
|
164
|
+
paddingInlineEnd: 'var(--spacing-1)',
|
|
165
|
+
});
|
|
166
|
+
expect(
|
|
167
|
+
screen.getByRole('button', {name: 'Edit project'}).closest('li'),
|
|
168
|
+
).toHaveStyle({paddingInline: 'var(--spacing-3)'});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('drills into nested data items in bottom-sheet presentation', async () => {
|
|
172
|
+
const user = userEvent.setup();
|
|
173
|
+
|
|
174
|
+
render(
|
|
175
|
+
<DropdownMenu
|
|
176
|
+
button={{label: 'Project actions'}}
|
|
177
|
+
presentation="bottom-sheet"
|
|
178
|
+
items={[
|
|
179
|
+
{
|
|
180
|
+
label: 'Move to project',
|
|
181
|
+
items: [{label: 'Apollo launch'}],
|
|
182
|
+
},
|
|
183
|
+
]}
|
|
184
|
+
/>,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await user.click(screen.getByRole('button', {name: /Project actions/}));
|
|
188
|
+
await user.click(screen.getByRole('button', {name: 'Move to project'}));
|
|
189
|
+
|
|
190
|
+
const submenuHeading = screen.getByRole('heading', {
|
|
191
|
+
name: 'Move to project',
|
|
192
|
+
});
|
|
193
|
+
expect(submenuHeading).toBeInTheDocument();
|
|
194
|
+
await waitFor(() => expect(submenuHeading).toHaveFocus());
|
|
195
|
+
expect(
|
|
196
|
+
screen.getByRole('button', {name: 'Apollo launch'}),
|
|
197
|
+
).toBeInTheDocument();
|
|
198
|
+
|
|
199
|
+
const backButton = screen.getByRole('button', {name: 'Back'});
|
|
200
|
+
expect(backButton).toHaveAttribute('aria-label', 'Back');
|
|
201
|
+
expect(backButton).not.toHaveTextContent('Back');
|
|
202
|
+
await user.click(backButton);
|
|
203
|
+
const rootHeading = screen.getByRole('heading', {
|
|
204
|
+
name: 'Project actions',
|
|
205
|
+
});
|
|
206
|
+
expect(rootHeading).toBeInTheDocument();
|
|
207
|
+
await waitFor(() => expect(rootHeading).toHaveFocus());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns to the root after a controlled bottom sheet closes externally', async () => {
|
|
211
|
+
const user = userEvent.setup();
|
|
212
|
+
|
|
213
|
+
function ControlledDropdownMenu() {
|
|
214
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
219
|
+
Close externally
|
|
220
|
+
</button>
|
|
221
|
+
<DropdownMenu
|
|
222
|
+
button={{label: 'Project actions'}}
|
|
223
|
+
presentation="bottom-sheet"
|
|
224
|
+
isMenuOpen={isOpen}
|
|
225
|
+
onOpenChange={setIsOpen}
|
|
226
|
+
items={[
|
|
227
|
+
{
|
|
228
|
+
label: 'Move to project',
|
|
229
|
+
items: [{label: 'Apollo launch'}],
|
|
230
|
+
},
|
|
231
|
+
]}
|
|
232
|
+
/>
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
render(<ControlledDropdownMenu />);
|
|
238
|
+
|
|
239
|
+
await user.click(screen.getByRole('button', {name: /Project actions/}));
|
|
240
|
+
await user.click(screen.getByRole('button', {name: 'Move to project'}));
|
|
241
|
+
expect(
|
|
242
|
+
screen.getByRole('heading', {name: 'Move to project'}),
|
|
243
|
+
).toBeInTheDocument();
|
|
244
|
+
|
|
245
|
+
await user.click(screen.getByRole('button', {name: 'Close externally'}));
|
|
246
|
+
await user.click(screen.getByRole('button', {name: /Project actions/}));
|
|
247
|
+
|
|
248
|
+
expect(
|
|
249
|
+
screen.getByRole('heading', {name: 'Project actions'}),
|
|
250
|
+
).toBeInTheDocument();
|
|
251
|
+
expect(
|
|
252
|
+
screen.queryByRole('button', {name: 'Apollo launch'}),
|
|
253
|
+
).not.toBeInTheDocument();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('mirrors bottom-sheet drill-in affordances under RTL', async () => {
|
|
257
|
+
const user = userEvent.setup();
|
|
258
|
+
const {className: mirrorClassName} = stylex.props(rtlStyles.mirror);
|
|
259
|
+
const mirrorClasses = mirrorClassName?.split(' ') ?? [];
|
|
260
|
+
expect(mirrorClasses.length).toBeGreaterThan(0);
|
|
261
|
+
|
|
262
|
+
render(
|
|
263
|
+
<div dir="rtl">
|
|
264
|
+
<DropdownMenu
|
|
265
|
+
button={{label: 'Project actions'}}
|
|
266
|
+
presentation="bottom-sheet"
|
|
267
|
+
items={[
|
|
268
|
+
{
|
|
269
|
+
label: 'Move to project',
|
|
270
|
+
items: [{label: 'Apollo launch'}],
|
|
271
|
+
},
|
|
272
|
+
]}
|
|
273
|
+
/>
|
|
274
|
+
</div>,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
await user.click(screen.getByRole('button', {name: /Project actions/}));
|
|
278
|
+
const submenuButton = screen.getByRole('button', {
|
|
279
|
+
name: 'Move to project',
|
|
280
|
+
});
|
|
281
|
+
const forwardIcon = submenuButton
|
|
282
|
+
.closest('li')
|
|
283
|
+
?.querySelector('.astryx-icon');
|
|
284
|
+
expect(forwardIcon).not.toBeNull();
|
|
285
|
+
for (const className of mirrorClasses) {
|
|
286
|
+
expect(forwardIcon).toHaveClass(className);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await user.click(submenuButton);
|
|
290
|
+
const backButton = screen.getByRole('button', {name: 'Back'});
|
|
291
|
+
const backIcon = backButton.querySelector('.astryx-icon');
|
|
292
|
+
expect(backIcon).not.toBeNull();
|
|
293
|
+
for (const className of mirrorClasses) {
|
|
294
|
+
expect(backIcon).toHaveClass(className);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('uses the BottomSheet for adaptive presentation on compact touch', async () => {
|
|
299
|
+
vi.stubGlobal(
|
|
300
|
+
'matchMedia',
|
|
301
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
302
|
+
matches: query === '(max-width: 768px) and (pointer: coarse)',
|
|
303
|
+
media: query,
|
|
304
|
+
onchange: null,
|
|
305
|
+
addEventListener: vi.fn(),
|
|
306
|
+
removeEventListener: vi.fn(),
|
|
307
|
+
addListener: vi.fn(),
|
|
308
|
+
removeListener: vi.fn(),
|
|
309
|
+
dispatchEvent: vi.fn(),
|
|
310
|
+
})),
|
|
311
|
+
);
|
|
312
|
+
const user = userEvent.setup();
|
|
313
|
+
render(
|
|
314
|
+
<DropdownMenu
|
|
315
|
+
button={{label: 'Actions'}}
|
|
316
|
+
presentation="adaptive"
|
|
317
|
+
items={[{label: 'Edit'}]}
|
|
318
|
+
/>,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
322
|
+
expect(screen.getByRole('dialog', {name: 'Actions'})).toBeInTheDocument();
|
|
323
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('uses the shared menu BottomSheet frame', () => {
|
|
327
|
+
const source = readFileSync(
|
|
328
|
+
'packages/core/src/DropdownMenu/DropdownMenu.tsx',
|
|
329
|
+
'utf8',
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(source).toContain('<MenuBottomSheet');
|
|
333
|
+
expect(source).not.toContain("import {BottomSheet} from '../BottomSheet'");
|
|
334
|
+
expect(source).not.toContain("import {Section} from '../Section'");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('keeps adaptive presentation anchored without compact touch', async () => {
|
|
338
|
+
const user = userEvent.setup();
|
|
339
|
+
render(
|
|
340
|
+
<DropdownMenu
|
|
341
|
+
button={{label: 'Actions'}}
|
|
342
|
+
presentation="adaptive"
|
|
343
|
+
items={[{label: 'Edit'}]}
|
|
344
|
+
/>,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
348
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalledOnce();
|
|
349
|
+
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('preserves uncontrolled open state when adaptive presentation changes', async () => {
|
|
353
|
+
let matches = false;
|
|
354
|
+
const listeners = new Set<() => void>();
|
|
355
|
+
vi.stubGlobal(
|
|
356
|
+
'matchMedia',
|
|
357
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
358
|
+
get matches() {
|
|
359
|
+
return matches;
|
|
360
|
+
},
|
|
361
|
+
media: query,
|
|
362
|
+
onchange: null,
|
|
363
|
+
addEventListener: (_type: string, listener: () => void) =>
|
|
364
|
+
listeners.add(listener),
|
|
365
|
+
removeEventListener: (_type: string, listener: () => void) =>
|
|
366
|
+
listeners.delete(listener),
|
|
367
|
+
addListener: vi.fn(),
|
|
368
|
+
removeListener: vi.fn(),
|
|
369
|
+
dispatchEvent: vi.fn(),
|
|
370
|
+
})),
|
|
371
|
+
);
|
|
372
|
+
const user = userEvent.setup();
|
|
373
|
+
render(
|
|
374
|
+
<DropdownMenu
|
|
375
|
+
button={{label: 'Actions'}}
|
|
376
|
+
presentation="adaptive"
|
|
377
|
+
items={[{label: 'Edit'}]}
|
|
378
|
+
/>,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
382
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalledOnce();
|
|
383
|
+
|
|
384
|
+
matches = true;
|
|
385
|
+
act(() => listeners.forEach(listener => listener()));
|
|
386
|
+
|
|
387
|
+
expect(screen.getByRole('dialog', {name: 'Actions'})).toBeInTheDocument();
|
|
388
|
+
expect(screen.getByRole('button', {name: /Actions/})).toHaveAttribute(
|
|
389
|
+
'aria-expanded',
|
|
390
|
+
'true',
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('focuses the first action when a bottom sheet opens from the keyboard', async () => {
|
|
395
|
+
const user = userEvent.setup();
|
|
396
|
+
render(
|
|
397
|
+
<DropdownMenu
|
|
398
|
+
button={{label: 'Actions'}}
|
|
399
|
+
presentation="bottom-sheet"
|
|
400
|
+
items={[{label: 'Edit'}, {label: 'Delete'}]}
|
|
401
|
+
/>,
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const trigger = screen.getByRole('button', {name: /Actions/});
|
|
405
|
+
trigger.focus();
|
|
406
|
+
await user.keyboard('{Enter}');
|
|
407
|
+
|
|
408
|
+
await waitFor(() =>
|
|
409
|
+
expect(screen.getByRole('button', {name: 'Edit'})).toHaveFocus(),
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('restores touch focus without painting a trigger focus ring', async () => {
|
|
414
|
+
render(
|
|
415
|
+
<DropdownMenu
|
|
416
|
+
button={{label: 'Actions'}}
|
|
417
|
+
presentation="bottom-sheet"
|
|
418
|
+
items={[{label: 'Edit'}]}
|
|
419
|
+
/>,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const trigger = screen.getByRole('button', {name: /Actions/});
|
|
423
|
+
fireEvent.pointerDown(trigger, {pointerType: 'touch'});
|
|
424
|
+
fireEvent.click(trigger, {detail: 1});
|
|
425
|
+
|
|
426
|
+
const action = screen.getByRole('button', {name: 'Edit'});
|
|
427
|
+
action.focus();
|
|
428
|
+
fireEvent.pointerDown(action, {pointerType: 'touch'});
|
|
429
|
+
fireEvent.click(action, {detail: 1});
|
|
430
|
+
|
|
431
|
+
expect(action).not.toHaveFocus();
|
|
432
|
+
trigger.focus();
|
|
433
|
+
expect(trigger).toHaveFocus();
|
|
434
|
+
await waitFor(() =>
|
|
435
|
+
expect(trigger).toHaveClass(
|
|
436
|
+
stylex.props(focusOutlineStyles.suppressed).className!,
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
86
441
|
it('defaults menu placement below', () => {
|
|
87
442
|
render(
|
|
88
443
|
<DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
|
|
@@ -127,6 +482,26 @@ describe('DropdownMenu', () => {
|
|
|
127
482
|
);
|
|
128
483
|
});
|
|
129
484
|
|
|
485
|
+
it('uses block-axis viewport gutters for side placement', () => {
|
|
486
|
+
render(
|
|
487
|
+
<DropdownMenu
|
|
488
|
+
button={{label: 'Actions'}}
|
|
489
|
+
placement="end"
|
|
490
|
+
alignment="start"
|
|
491
|
+
items={[{label: 'Item 1'}]}
|
|
492
|
+
/>,
|
|
493
|
+
);
|
|
494
|
+
const popover = screen
|
|
495
|
+
.getByRole('menu', {hidden: true})
|
|
496
|
+
.closest('[popover]');
|
|
497
|
+
expect(popover?.className).toContain(
|
|
498
|
+
'DropdownMenu__styles.popoverViewportBlockStart',
|
|
499
|
+
);
|
|
500
|
+
expect(popover?.className).not.toContain(
|
|
501
|
+
'DropdownMenu__styles.popoverViewportStart',
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
130
505
|
it('emits the direction-independent logical mapping under an RTL ancestor (#3389)', async () => {
|
|
131
506
|
// The self-* position-area keywords resolve against the popover's own
|
|
132
507
|
// inherited direction in the browser, so RTL emits the same string as
|
|
@@ -147,6 +522,104 @@ describe('DropdownMenu', () => {
|
|
|
147
522
|
);
|
|
148
523
|
});
|
|
149
524
|
|
|
525
|
+
it('caps default and custom widths to the available viewport', () => {
|
|
526
|
+
const {unmount} = render(
|
|
527
|
+
<DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
|
|
528
|
+
);
|
|
529
|
+
let menu = screen.getByRole('menu', {hidden: true});
|
|
530
|
+
let popover = menu.closest('[popover]');
|
|
531
|
+
expect(popover?.className).toContain(
|
|
532
|
+
'DropdownMenu__styles.popoverViewport',
|
|
533
|
+
);
|
|
534
|
+
expect(popover?.className).toContain('DropdownMenu__styles.popoverAligned');
|
|
535
|
+
expect(popover).toHaveStyle(
|
|
536
|
+
'min-width: min(anchor-size(width),calc(100% - max(var(--spacing-4),env(safe-area-inset-left,0px),env(safe-area-inset-right,0px))))',
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
unmount();
|
|
540
|
+
render(
|
|
541
|
+
<DropdownMenu
|
|
542
|
+
button={{label: 'Wide actions'}}
|
|
543
|
+
menuWidth={640}
|
|
544
|
+
items={[{label: 'Item 1'}]}
|
|
545
|
+
/>,
|
|
546
|
+
);
|
|
547
|
+
menu = screen.getByRole('menu', {hidden: true});
|
|
548
|
+
popover = menu.closest('[popover]');
|
|
549
|
+
expect(popover?.className).toContain(
|
|
550
|
+
'DropdownMenu__styles.popoverViewport',
|
|
551
|
+
);
|
|
552
|
+
expect(popover).toHaveStyle({minWidth: 'var(--x-minWidth)'});
|
|
553
|
+
expect(popover?.getAttribute('style')).toContain('min(640px, calc(100%');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it.each(['max-content', 'fit-content', 'auto'])(
|
|
557
|
+
'preserves the valid %s menuWidth while retaining the viewport cap',
|
|
558
|
+
menuWidth => {
|
|
559
|
+
render(
|
|
560
|
+
<DropdownMenu
|
|
561
|
+
button={{label: 'Actions'}}
|
|
562
|
+
menuWidth={menuWidth}
|
|
563
|
+
items={[{label: 'Item 1'}]}
|
|
564
|
+
/>,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
const popover = screen
|
|
568
|
+
.getByRole('menu', {hidden: true})
|
|
569
|
+
.closest('[popover]');
|
|
570
|
+
expect(popover?.className).toContain(
|
|
571
|
+
'DropdownMenu__styles.popoverCustomIntrinsicWidth',
|
|
572
|
+
);
|
|
573
|
+
expect(popover?.getAttribute('style')).toContain(menuWidth);
|
|
574
|
+
expect(popover?.className).toContain(
|
|
575
|
+
'DropdownMenu__styles.popoverViewportAligned',
|
|
576
|
+
);
|
|
577
|
+
expect(popover?.getAttribute('style')).not.toContain(`min(${menuWidth},`);
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
it('caps menu height and only scrolls when content overflows', async () => {
|
|
582
|
+
const clientHeightSpy = vi
|
|
583
|
+
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
|
|
584
|
+
.mockReturnValue(300);
|
|
585
|
+
const scrollHeightSpy = vi
|
|
586
|
+
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
|
|
587
|
+
.mockReturnValue(300);
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
render(
|
|
591
|
+
<DropdownMenu
|
|
592
|
+
button={{label: 'Actions'}}
|
|
593
|
+
items={[{label: 'Item 1'}]}
|
|
594
|
+
/>,
|
|
595
|
+
);
|
|
596
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
597
|
+
fireEvent.click(screen.getByRole('button', {name: /Actions/}));
|
|
598
|
+
await waitFor(() => {
|
|
599
|
+
expect(screen.getByRole('button', {name: /Actions/})).toHaveAttribute(
|
|
600
|
+
'aria-expanded',
|
|
601
|
+
'true',
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
expect(menu).toHaveStyle(
|
|
605
|
+
'max-height: min(300px,calc(100dvb - max(var(--spacing-4),env(safe-area-inset-top,0px)) - max(var(--spacing-4),env(safe-area-inset-bottom,0px))))',
|
|
606
|
+
);
|
|
607
|
+
expect(menu).not.toHaveStyle({overflowY: 'auto'});
|
|
608
|
+
expect(menu).toHaveAttribute('tabindex', '-1');
|
|
609
|
+
|
|
610
|
+
scrollHeightSpy.mockReturnValue(480);
|
|
611
|
+
fireEvent(window, new Event('resize'));
|
|
612
|
+
|
|
613
|
+
await waitFor(() => {
|
|
614
|
+
expect(menu).toHaveStyle({overflowY: 'auto'});
|
|
615
|
+
expect(menu).toHaveStyle({overflowX: 'hidden'});
|
|
616
|
+
});
|
|
617
|
+
} finally {
|
|
618
|
+
clientHeightSpy.mockRestore();
|
|
619
|
+
scrollHeightSpy.mockRestore();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
150
623
|
it('has aria-haspopup and aria-expanded attributes', () => {
|
|
151
624
|
render(
|
|
152
625
|
<DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
|
|
@@ -196,7 +669,7 @@ describe('DropdownMenu', () => {
|
|
|
196
669
|
);
|
|
197
670
|
});
|
|
198
671
|
|
|
199
|
-
it('restores focus to the trigger after
|
|
672
|
+
it('restores focus to the trigger after keyboard dismissal', async () => {
|
|
200
673
|
const raf = vi
|
|
201
674
|
.spyOn(window, 'requestAnimationFrame')
|
|
202
675
|
.mockImplementation(callback => {
|
|
@@ -215,17 +688,16 @@ describe('DropdownMenu', () => {
|
|
|
215
688
|
|
|
216
689
|
const trigger = screen.getByRole('button', {name: /Actions/});
|
|
217
690
|
trigger.focus();
|
|
218
|
-
await user.
|
|
219
|
-
|
|
220
|
-
|
|
691
|
+
await user.keyboard('{Enter}');
|
|
692
|
+
expect(
|
|
693
|
+
screen.getByRole('menuitem', {name: 'Edit', hidden: true}),
|
|
694
|
+
).toHaveFocus();
|
|
221
695
|
|
|
222
696
|
const popoverEl = screen
|
|
223
697
|
.getByRole('menu', {hidden: true})
|
|
224
698
|
.closest('[popover]');
|
|
225
699
|
expect(popoverEl).not.toBeNull();
|
|
226
|
-
|
|
227
|
-
trigger.blur();
|
|
228
|
-
});
|
|
700
|
+
trigger.blur();
|
|
229
701
|
const toggleEvent = new Event('toggle');
|
|
230
702
|
Object.defineProperty(toggleEvent, 'newState', {value: 'closed'});
|
|
231
703
|
fireEvent(popoverEl as HTMLElement, toggleEvent);
|
|
@@ -236,6 +708,44 @@ describe('DropdownMenu', () => {
|
|
|
236
708
|
}
|
|
237
709
|
});
|
|
238
710
|
|
|
711
|
+
it('does not leave focus on the trigger after pointer dismissal', async () => {
|
|
712
|
+
const raf = vi
|
|
713
|
+
.spyOn(window, 'requestAnimationFrame')
|
|
714
|
+
.mockImplementation(callback => {
|
|
715
|
+
callback(0);
|
|
716
|
+
return 0;
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
render(
|
|
721
|
+
<DropdownMenu
|
|
722
|
+
button={{label: 'Actions'}}
|
|
723
|
+
items={[{label: 'Edit'}, {label: 'Delete'}]}
|
|
724
|
+
/>,
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const trigger = screen.getByRole('button', {name: /Actions/});
|
|
728
|
+
fireEvent.pointerDown(trigger, {pointerType: 'touch'});
|
|
729
|
+
fireEvent.click(trigger, {detail: 1});
|
|
730
|
+
expect(screen.getByRole('menu', {hidden: true})).toHaveFocus();
|
|
731
|
+
|
|
732
|
+
const popoverEl = screen
|
|
733
|
+
.getByRole('menu', {hidden: true})
|
|
734
|
+
.closest('[popover]');
|
|
735
|
+
expect(popoverEl).not.toBeNull();
|
|
736
|
+
// Simulate native popover focus restoration occurring before React's
|
|
737
|
+
// toggle handler; pointer dismissal should remove that focus again.
|
|
738
|
+
trigger.focus();
|
|
739
|
+
const toggleEvent = new Event('toggle');
|
|
740
|
+
Object.defineProperty(toggleEvent, 'newState', {value: 'closed'});
|
|
741
|
+
fireEvent(popoverEl as HTMLElement, toggleEvent);
|
|
742
|
+
|
|
743
|
+
expect(trigger).not.toHaveFocus();
|
|
744
|
+
} finally {
|
|
745
|
+
raf.mockRestore();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
239
749
|
it('closes the menu when Tab is pressed inside it (APG menu-button)', async () => {
|
|
240
750
|
const user = userEvent.setup();
|
|
241
751
|
render(
|
|
@@ -1267,6 +1777,7 @@ describe('DropdownMenu open focus follows input modality (#4477)', () => {
|
|
|
1267
1777
|
|
|
1268
1778
|
const menu = screen.getByRole('menu', {hidden: true});
|
|
1269
1779
|
await waitFor(() => expect(menu).toHaveFocus());
|
|
1780
|
+
expect(menu).toHaveStyle({outline: 'none'});
|
|
1270
1781
|
expect(
|
|
1271
1782
|
screen.getByRole('menuitem', {name: 'Edit', hidden: true}),
|
|
1272
1783
|
).not.toHaveFocus();
|