@seed-design/react-menu 0.0.0-alpha-20260414104312
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/lib/Menu-12s-DcQqm37B.js +189 -0
- package/lib/Menu-12s-KCTCu-et.cjs +202 -0
- package/lib/index.cjs +25 -0
- package/lib/index.d.ts +2711 -0
- package/lib/index.js +16 -0
- package/lib/useMenu-12s-DN0SpRKs.cjs +269 -0
- package/lib/useMenu-12s-WINwYlwH.js +269 -0
- package/package.json +53 -0
- package/src/Menu.namespace.ts +18 -0
- package/src/Menu.tsx +221 -0
- package/src/index.ts +31 -0
- package/src/useMenu.ts +411 -0
- package/src/useMenu.vitest.tsx +895 -0
- package/src/useMenuContext.tsx +21 -0
- package/src/useMenuItemContext.tsx +21 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
// this file is .vitest.tsx, not .test.tsx — so bun test won't pick it up.
|
|
2
|
+
//
|
|
3
|
+
// @floating-ui/react defers focus via requestAnimationFrame (enqueueFocus).
|
|
4
|
+
// bun test preloads happydom, which doesn't fire rAF the way we need.
|
|
5
|
+
// vitest + jsdom (pretendToBeVisual) gives us a real rAF that ticks at ~16ms,
|
|
6
|
+
// letting waitForFocus() actually flush the deferred .focus() calls.
|
|
7
|
+
//
|
|
8
|
+
// see vitest.config.ts for the jsdom environment setup.
|
|
9
|
+
|
|
10
|
+
/// <reference types="@testing-library/jest-dom/vitest" />
|
|
11
|
+
|
|
12
|
+
import { render, fireEvent, act } from "@testing-library/react";
|
|
13
|
+
import userEvent from "@testing-library/user-event";
|
|
14
|
+
import { describe, expect, it, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
import * as React from "react";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
MenuRoot as Menu,
|
|
20
|
+
MenuTrigger,
|
|
21
|
+
MenuPositioner,
|
|
22
|
+
MenuContent,
|
|
23
|
+
MenuItem,
|
|
24
|
+
MenuGroup,
|
|
25
|
+
MenuGroupLabel,
|
|
26
|
+
type MenuRootProps,
|
|
27
|
+
} from "./index";
|
|
28
|
+
|
|
29
|
+
type UseMenuProps = MenuRootProps;
|
|
30
|
+
|
|
31
|
+
// Flush microtasks so Floating UI position state settles.
|
|
32
|
+
// See: https://floating-ui.com/docs/react#testing
|
|
33
|
+
const waitForPositioning = () => act(async () => {});
|
|
34
|
+
|
|
35
|
+
// Flush rAF-deferred focus from FloatingFocusManager / useListNavigation.
|
|
36
|
+
// jsdom (pretendToBeVisual) fires rAF callbacks every ~16 ms via setInterval,
|
|
37
|
+
// so a short timer is needed for enqueueFocus() in @floating-ui/react to land.
|
|
38
|
+
const waitForFocus = () =>
|
|
39
|
+
act(async () => {
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function BasicMenu(props: UseMenuProps) {
|
|
44
|
+
return (
|
|
45
|
+
<Menu {...props}>
|
|
46
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
47
|
+
<MenuPositioner>
|
|
48
|
+
<MenuContent>
|
|
49
|
+
<MenuItem>Item 1</MenuItem>
|
|
50
|
+
<MenuItem>Item 2</MenuItem>
|
|
51
|
+
<MenuItem>Item 3</MenuItem>
|
|
52
|
+
</MenuContent>
|
|
53
|
+
</MenuPositioner>
|
|
54
|
+
</Menu>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function MenuWithDisabledItems(props: UseMenuProps) {
|
|
59
|
+
return (
|
|
60
|
+
<Menu {...props}>
|
|
61
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
62
|
+
<MenuPositioner>
|
|
63
|
+
<MenuContent>
|
|
64
|
+
<MenuItem>Item 1</MenuItem>
|
|
65
|
+
<MenuItem disabled>Item 2 (disabled)</MenuItem>
|
|
66
|
+
<MenuItem>Item 3</MenuItem>
|
|
67
|
+
</MenuContent>
|
|
68
|
+
</MenuPositioner>
|
|
69
|
+
</Menu>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function MenuWithGroups() {
|
|
74
|
+
return (
|
|
75
|
+
<Menu>
|
|
76
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
77
|
+
<MenuPositioner>
|
|
78
|
+
<MenuContent>
|
|
79
|
+
<MenuGroup>
|
|
80
|
+
<MenuGroupLabel>Group 1</MenuGroupLabel>
|
|
81
|
+
<MenuItem>Item A</MenuItem>
|
|
82
|
+
<MenuItem>Item B</MenuItem>
|
|
83
|
+
</MenuGroup>
|
|
84
|
+
<MenuGroup>
|
|
85
|
+
<MenuGroupLabel>Group 2</MenuGroupLabel>
|
|
86
|
+
<MenuItem>Item C</MenuItem>
|
|
87
|
+
</MenuGroup>
|
|
88
|
+
</MenuContent>
|
|
89
|
+
</MenuPositioner>
|
|
90
|
+
</Menu>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function MenuWithLabels(props: UseMenuProps) {
|
|
95
|
+
return (
|
|
96
|
+
<Menu {...props}>
|
|
97
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
98
|
+
<MenuPositioner>
|
|
99
|
+
<MenuContent>
|
|
100
|
+
<MenuItem typeaheadLabel="Apple">Apple</MenuItem>
|
|
101
|
+
<MenuItem typeaheadLabel="Banana">Banana</MenuItem>
|
|
102
|
+
<MenuItem typeaheadLabel="Cherry">Cherry</MenuItem>
|
|
103
|
+
<MenuItem typeaheadLabel="Dragonfruit">Dragonfruit</MenuItem>
|
|
104
|
+
</MenuContent>
|
|
105
|
+
</MenuPositioner>
|
|
106
|
+
</Menu>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ControlledMenu({ onOpenChange }: { onOpenChange: (open: boolean) => void }) {
|
|
111
|
+
const [open, setOpen] = React.useState(false);
|
|
112
|
+
return (
|
|
113
|
+
<Menu
|
|
114
|
+
open={open}
|
|
115
|
+
onOpenChange={(nextOpen) => {
|
|
116
|
+
setOpen(nextOpen);
|
|
117
|
+
onOpenChange(nextOpen);
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
121
|
+
<MenuPositioner>
|
|
122
|
+
<MenuContent>
|
|
123
|
+
<MenuItem>Item 1</MenuItem>
|
|
124
|
+
</MenuContent>
|
|
125
|
+
</MenuPositioner>
|
|
126
|
+
</Menu>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function TwoSiblingMenus({
|
|
131
|
+
onMenuAOpenChange,
|
|
132
|
+
onMenuBOpenChange,
|
|
133
|
+
}: {
|
|
134
|
+
onMenuAOpenChange?: (open: boolean) => void;
|
|
135
|
+
onMenuBOpenChange?: (open: boolean) => void;
|
|
136
|
+
} = {}) {
|
|
137
|
+
return (
|
|
138
|
+
<div>
|
|
139
|
+
<Menu onOpenChange={onMenuAOpenChange}>
|
|
140
|
+
<MenuTrigger>Trigger A</MenuTrigger>
|
|
141
|
+
<MenuPositioner>
|
|
142
|
+
<MenuContent>
|
|
143
|
+
<MenuItem>Item A1</MenuItem>
|
|
144
|
+
<MenuItem>Item A2</MenuItem>
|
|
145
|
+
</MenuContent>
|
|
146
|
+
</MenuPositioner>
|
|
147
|
+
</Menu>
|
|
148
|
+
<Menu onOpenChange={onMenuBOpenChange}>
|
|
149
|
+
<MenuTrigger>Trigger B</MenuTrigger>
|
|
150
|
+
<MenuPositioner>
|
|
151
|
+
<MenuContent>
|
|
152
|
+
<MenuItem>Item B1</MenuItem>
|
|
153
|
+
<MenuItem>Item B2</MenuItem>
|
|
154
|
+
</MenuContent>
|
|
155
|
+
</MenuPositioner>
|
|
156
|
+
</Menu>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ===========================================================================
|
|
162
|
+
// Tests
|
|
163
|
+
// ===========================================================================
|
|
164
|
+
|
|
165
|
+
describe("useMenu", () => {
|
|
166
|
+
describe("rendering & structure", () => {
|
|
167
|
+
it("renders a trigger with aria-haspopup='menu'", async () => {
|
|
168
|
+
const { getByText } = render(<BasicMenu />);
|
|
169
|
+
await waitForPositioning();
|
|
170
|
+
expect(getByText("Open Menu")).toHaveAttribute("aria-haspopup", "menu");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("renders menu content with role='menu' when open", async () => {
|
|
174
|
+
const user = userEvent.setup();
|
|
175
|
+
const { getByText, getByRole } = render(<BasicMenu />);
|
|
176
|
+
await waitForPositioning();
|
|
177
|
+
await user.click(getByText("Open Menu"));
|
|
178
|
+
expect(getByRole("menu")).toHaveAttribute("data-open");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("renders items with role='menuitem'", async () => {
|
|
182
|
+
const user = userEvent.setup();
|
|
183
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
184
|
+
await waitForPositioning();
|
|
185
|
+
await user.click(getByText("Open Menu"));
|
|
186
|
+
const items = getAllByRole("menuitem");
|
|
187
|
+
expect(items).toHaveLength(3);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("renders a group with role='group' and aria-labelledby pointing to group label", async () => {
|
|
191
|
+
const user = userEvent.setup();
|
|
192
|
+
const { getByText, getAllByRole } = render(<MenuWithGroups />);
|
|
193
|
+
await waitForPositioning();
|
|
194
|
+
await user.click(getByText("Open Menu"));
|
|
195
|
+
const groups = getAllByRole("group");
|
|
196
|
+
const labelledBy = groups[0].getAttribute("aria-labelledby");
|
|
197
|
+
expect(labelledBy).toBeTruthy();
|
|
198
|
+
const label = getByText("Group 1");
|
|
199
|
+
expect(label).toHaveAttribute("id", labelledBy);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("open/close state", () => {
|
|
204
|
+
it("opens on trigger click", async () => {
|
|
205
|
+
const user = userEvent.setup();
|
|
206
|
+
const { getByText, queryByRole } = render(<BasicMenu />);
|
|
207
|
+
await waitForPositioning();
|
|
208
|
+
expect(queryByRole("menu")).not.toHaveAttribute("data-open");
|
|
209
|
+
await user.click(getByText("Open Menu"));
|
|
210
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("closes on trigger click when open", async () => {
|
|
214
|
+
const user = userEvent.setup();
|
|
215
|
+
const { getByText, queryByRole } = render(<BasicMenu />);
|
|
216
|
+
await waitForPositioning();
|
|
217
|
+
await user.click(getByText("Open Menu"));
|
|
218
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
219
|
+
await user.click(getByText("Open Menu"));
|
|
220
|
+
expect(queryByRole("menu")).not.toHaveAttribute("data-open");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("supports controlled open state", async () => {
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const onOpenChange = vi.fn();
|
|
226
|
+
const { getByText } = render(<ControlledMenu onOpenChange={onOpenChange} />);
|
|
227
|
+
await waitForPositioning();
|
|
228
|
+
await user.click(getByText("Open Menu"));
|
|
229
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("supports uncontrolled with defaultOpen", async () => {
|
|
233
|
+
const { queryByRole } = render(<BasicMenu defaultOpen />);
|
|
234
|
+
await waitForPositioning();
|
|
235
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("calls onOpenChange when toggled", async () => {
|
|
239
|
+
const user = userEvent.setup();
|
|
240
|
+
const onOpenChange = vi.fn();
|
|
241
|
+
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
242
|
+
await waitForPositioning();
|
|
243
|
+
await user.click(getByText("Open Menu"));
|
|
244
|
+
expect(onOpenChange).toHaveBeenCalledWith(
|
|
245
|
+
true,
|
|
246
|
+
expect.objectContaining({ reason: "trigger" }),
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("reports reason 'escapeKeyDown' when closed by Escape", async () => {
|
|
251
|
+
const user = userEvent.setup();
|
|
252
|
+
const onOpenChange = vi.fn();
|
|
253
|
+
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
254
|
+
await waitForPositioning();
|
|
255
|
+
await user.click(getByText("Open Menu"));
|
|
256
|
+
onOpenChange.mockClear();
|
|
257
|
+
await user.keyboard("{Escape}");
|
|
258
|
+
expect(onOpenChange).toHaveBeenCalledWith(
|
|
259
|
+
false,
|
|
260
|
+
expect.objectContaining({ reason: "escapeKeyDown" }),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("reports reason 'interactOutside' when closed by outside click", async () => {
|
|
265
|
+
const user = userEvent.setup();
|
|
266
|
+
const onOpenChange = vi.fn();
|
|
267
|
+
const { getByText } = render(
|
|
268
|
+
<div>
|
|
269
|
+
<BasicMenu onOpenChange={onOpenChange} />
|
|
270
|
+
<button type="button">Outside</button>
|
|
271
|
+
</div>,
|
|
272
|
+
);
|
|
273
|
+
await waitForPositioning();
|
|
274
|
+
await user.click(getByText("Open Menu"));
|
|
275
|
+
onOpenChange.mockClear();
|
|
276
|
+
await user.click(getByText("Outside"));
|
|
277
|
+
expect(onOpenChange).toHaveBeenCalledWith(
|
|
278
|
+
false,
|
|
279
|
+
expect.objectContaining({ reason: "interactOutside" }),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reports reason 'itemClick' when closed by item selection", async () => {
|
|
284
|
+
const user = userEvent.setup();
|
|
285
|
+
const onOpenChange = vi.fn();
|
|
286
|
+
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
287
|
+
await waitForPositioning();
|
|
288
|
+
await user.click(getByText("Open Menu"));
|
|
289
|
+
onOpenChange.mockClear();
|
|
290
|
+
await user.click(getByText("Item 1"));
|
|
291
|
+
expect(onOpenChange).toHaveBeenCalledWith(
|
|
292
|
+
false,
|
|
293
|
+
expect.objectContaining({ reason: "itemClick" }),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("closes on Escape key", async () => {
|
|
298
|
+
const user = userEvent.setup();
|
|
299
|
+
const { getByText, queryByRole } = render(<BasicMenu />);
|
|
300
|
+
await waitForPositioning();
|
|
301
|
+
await user.click(getByText("Open Menu"));
|
|
302
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
303
|
+
await user.keyboard("{Escape}");
|
|
304
|
+
expect(queryByRole("menu")).not.toHaveAttribute("data-open");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("closes on outside click", async () => {
|
|
308
|
+
const user = userEvent.setup();
|
|
309
|
+
const { getByText, queryByRole } = render(
|
|
310
|
+
<div>
|
|
311
|
+
<BasicMenu />
|
|
312
|
+
<button type="button">Outside</button>
|
|
313
|
+
</div>,
|
|
314
|
+
);
|
|
315
|
+
await waitForPositioning();
|
|
316
|
+
await user.click(getByText("Open Menu"));
|
|
317
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
318
|
+
await user.click(getByText("Outside"));
|
|
319
|
+
expect(queryByRole("menu")).not.toHaveAttribute("data-open");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("does not open when disabled", async () => {
|
|
323
|
+
const user = userEvent.setup();
|
|
324
|
+
const { getByText, queryByRole } = render(<BasicMenu disabled />);
|
|
325
|
+
await waitForPositioning();
|
|
326
|
+
await user.click(getByText("Open Menu"));
|
|
327
|
+
expect(queryByRole("menu")).not.toHaveAttribute("data-open");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("keyboard navigation", () => {
|
|
332
|
+
it("focuses the first item when opened by keyboard (ArrowDown on trigger)", async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
335
|
+
await waitForPositioning();
|
|
336
|
+
getByText("Open Menu").focus();
|
|
337
|
+
await user.keyboard("{ArrowDown}");
|
|
338
|
+
await waitForFocus();
|
|
339
|
+
const items = getAllByRole("menuitem");
|
|
340
|
+
expect(items[0]).toHaveFocus();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("focuses the last item when opened by ArrowUp on trigger", async () => {
|
|
344
|
+
const user = userEvent.setup();
|
|
345
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
346
|
+
await waitForPositioning();
|
|
347
|
+
getByText("Open Menu").focus();
|
|
348
|
+
await user.keyboard("{ArrowUp}");
|
|
349
|
+
await waitForFocus();
|
|
350
|
+
const items = getAllByRole("menuitem");
|
|
351
|
+
expect(items[items.length - 1]).toHaveFocus();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("navigates items with ArrowDown", async () => {
|
|
355
|
+
const user = userEvent.setup();
|
|
356
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
357
|
+
await waitForPositioning();
|
|
358
|
+
await user.click(getByText("Open Menu"));
|
|
359
|
+
const items = getAllByRole("menuitem");
|
|
360
|
+
// No item should be focused after opening
|
|
361
|
+
await user.keyboard("{ArrowDown}");
|
|
362
|
+
expect(items[0]).toHaveFocus();
|
|
363
|
+
await user.keyboard("{ArrowDown}");
|
|
364
|
+
expect(items[1]).toHaveFocus();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("navigates items with ArrowUp", async () => {
|
|
368
|
+
const user = userEvent.setup();
|
|
369
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
370
|
+
await waitForPositioning();
|
|
371
|
+
await user.click(getByText("Open Menu"));
|
|
372
|
+
const items = getAllByRole("menuitem");
|
|
373
|
+
// No item should be focused after opening
|
|
374
|
+
await user.keyboard("{ArrowUp}"); // focus last item
|
|
375
|
+
expect(items[items.length - 1]).toHaveFocus();
|
|
376
|
+
await user.keyboard("{ArrowUp}");
|
|
377
|
+
expect(items[items.length - 2]).toHaveFocus();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("wraps from last to first", async () => {
|
|
381
|
+
const user = userEvent.setup();
|
|
382
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
383
|
+
await waitForPositioning();
|
|
384
|
+
await user.click(getByText("Open Menu"));
|
|
385
|
+
await waitForFocus();
|
|
386
|
+
const items = getAllByRole("menuitem");
|
|
387
|
+
// Navigate to last item
|
|
388
|
+
await user.keyboard("{End}");
|
|
389
|
+
expect(items[items.length - 1]).toHaveFocus();
|
|
390
|
+
// Arrow down should wrap to first
|
|
391
|
+
await user.keyboard("{ArrowDown}");
|
|
392
|
+
expect(items[0]).toHaveFocus();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("wraps from first to last", async () => {
|
|
396
|
+
const user = userEvent.setup();
|
|
397
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
398
|
+
await waitForPositioning();
|
|
399
|
+
await user.click(getByText("Open Menu"));
|
|
400
|
+
const items = getAllByRole("menuitem");
|
|
401
|
+
// First item focused
|
|
402
|
+
await user.keyboard("{ArrowUp}");
|
|
403
|
+
expect(items[items.length - 1]).toHaveFocus();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("navigates to first item with Home", async () => {
|
|
407
|
+
const user = userEvent.setup();
|
|
408
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
409
|
+
await waitForPositioning();
|
|
410
|
+
await user.click(getByText("Open Menu"));
|
|
411
|
+
await waitForFocus();
|
|
412
|
+
const items = getAllByRole("menuitem");
|
|
413
|
+
await user.keyboard("{End}"); // go to last
|
|
414
|
+
await user.keyboard("{Home}");
|
|
415
|
+
expect(items[0]).toHaveFocus();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("navigates to last item with End", async () => {
|
|
419
|
+
const user = userEvent.setup();
|
|
420
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
421
|
+
await waitForPositioning();
|
|
422
|
+
await user.click(getByText("Open Menu"));
|
|
423
|
+
await waitForFocus();
|
|
424
|
+
const items = getAllByRole("menuitem");
|
|
425
|
+
await user.keyboard("{End}");
|
|
426
|
+
expect(items[items.length - 1]).toHaveFocus();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("excludes disabled items during keyboard navigation", async () => {
|
|
430
|
+
const user = userEvent.setup();
|
|
431
|
+
const { getByText, getAllByRole } = render(<MenuWithDisabledItems />);
|
|
432
|
+
await waitForPositioning();
|
|
433
|
+
await user.click(getByText("Open Menu"));
|
|
434
|
+
const items = getAllByRole("menuitem");
|
|
435
|
+
// No item should be focused after opening
|
|
436
|
+
await user.keyboard("{ArrowDown}");
|
|
437
|
+
expect(items[0]).toHaveFocus();
|
|
438
|
+
expect(items[1]).toHaveAttribute("aria-disabled", "true");
|
|
439
|
+
await user.keyboard("{ArrowDown}");
|
|
440
|
+
expect(items[2]).toHaveFocus();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("opens the menu with Enter on trigger", async () => {
|
|
444
|
+
const user = userEvent.setup();
|
|
445
|
+
const { getByText, queryByRole } = render(<BasicMenu />);
|
|
446
|
+
await waitForPositioning();
|
|
447
|
+
getByText("Open Menu").focus();
|
|
448
|
+
await user.keyboard("{Enter}");
|
|
449
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("opens the menu with Space on trigger", async () => {
|
|
453
|
+
const user = userEvent.setup();
|
|
454
|
+
const { getByText, queryByRole } = render(<BasicMenu />);
|
|
455
|
+
await waitForPositioning();
|
|
456
|
+
getByText("Open Menu").focus();
|
|
457
|
+
await user.keyboard(" ");
|
|
458
|
+
expect(queryByRole("menu")).toHaveAttribute("data-open");
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe("typeahead", () => {
|
|
463
|
+
it("focuses item matching typed character", async () => {
|
|
464
|
+
const user = userEvent.setup();
|
|
465
|
+
const { getByText } = render(<MenuWithLabels />);
|
|
466
|
+
await waitForPositioning();
|
|
467
|
+
await user.click(getByText("Open Menu"));
|
|
468
|
+
await waitForFocus();
|
|
469
|
+
await user.keyboard("b");
|
|
470
|
+
expect(getByText("Banana")).toHaveFocus();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("resets typeahead after timeout", async () => {
|
|
474
|
+
const user = userEvent.setup();
|
|
475
|
+
const { getByText } = render(<MenuWithLabels />);
|
|
476
|
+
await waitForPositioning();
|
|
477
|
+
await user.click(getByText("Open Menu"));
|
|
478
|
+
await waitForFocus();
|
|
479
|
+
await user.keyboard("b");
|
|
480
|
+
expect(getByText("Banana")).toHaveFocus();
|
|
481
|
+
// Wait for typeahead reset (750ms default)
|
|
482
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
483
|
+
await user.keyboard("c");
|
|
484
|
+
expect(getByText("Cherry")).toHaveFocus();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("supports diacritic characters", async () => {
|
|
488
|
+
const user = userEvent.setup();
|
|
489
|
+
const { getByText } = render(
|
|
490
|
+
<Menu>
|
|
491
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
492
|
+
<MenuPositioner>
|
|
493
|
+
<MenuContent>
|
|
494
|
+
<MenuItem typeaheadLabel="Café">Café</MenuItem>
|
|
495
|
+
<MenuItem typeaheadLabel="Naïve">Naïve</MenuItem>
|
|
496
|
+
<MenuItem typeaheadLabel="Résumé">Résumé</MenuItem>
|
|
497
|
+
</MenuContent>
|
|
498
|
+
</MenuPositioner>
|
|
499
|
+
</Menu>,
|
|
500
|
+
);
|
|
501
|
+
await waitForPositioning();
|
|
502
|
+
await user.click(getByText("Open Menu"));
|
|
503
|
+
await waitForFocus();
|
|
504
|
+
await user.keyboard("r");
|
|
505
|
+
expect(getByText("Résumé")).toHaveFocus();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("navigates using the typeaheadLabel prop when provided", async () => {
|
|
509
|
+
const user = userEvent.setup();
|
|
510
|
+
const { getByText } = render(
|
|
511
|
+
<Menu>
|
|
512
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
513
|
+
<MenuPositioner>
|
|
514
|
+
<MenuContent>
|
|
515
|
+
<MenuItem typeaheadLabel="Alpha">Item with icon 1</MenuItem>
|
|
516
|
+
<MenuItem typeaheadLabel="Beta">Item with icon 2</MenuItem>
|
|
517
|
+
</MenuContent>
|
|
518
|
+
</MenuPositioner>
|
|
519
|
+
</Menu>,
|
|
520
|
+
);
|
|
521
|
+
await waitForPositioning();
|
|
522
|
+
await user.click(getByText("Open Menu"));
|
|
523
|
+
await waitForFocus();
|
|
524
|
+
await user.keyboard("b");
|
|
525
|
+
expect(getByText("Item with icon 2")).toHaveFocus();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe("item interaction", () => {
|
|
530
|
+
it("calls onClick on item when clicked", async () => {
|
|
531
|
+
const user = userEvent.setup();
|
|
532
|
+
const onClick = vi.fn();
|
|
533
|
+
const { getByText } = render(
|
|
534
|
+
<Menu>
|
|
535
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
536
|
+
<MenuPositioner>
|
|
537
|
+
<MenuContent>
|
|
538
|
+
<MenuItem onClick={onClick}>Clickable</MenuItem>
|
|
539
|
+
</MenuContent>
|
|
540
|
+
</MenuPositioner>
|
|
541
|
+
</Menu>,
|
|
542
|
+
);
|
|
543
|
+
await waitForPositioning();
|
|
544
|
+
await user.click(getByText("Open Menu"));
|
|
545
|
+
await user.click(getByText("Clickable"));
|
|
546
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("closes menu after item click (default closeOnClick=true)", async () => {
|
|
550
|
+
const user = userEvent.setup();
|
|
551
|
+
const { getByText, queryByRole } = render(
|
|
552
|
+
<Menu>
|
|
553
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
554
|
+
<MenuPositioner>
|
|
555
|
+
<MenuContent>
|
|
556
|
+
<MenuItem>Clickable</MenuItem>
|
|
557
|
+
</MenuContent>
|
|
558
|
+
</MenuPositioner>
|
|
559
|
+
</Menu>,
|
|
560
|
+
);
|
|
561
|
+
await waitForPositioning();
|
|
562
|
+
await user.click(getByText("Open Menu"));
|
|
563
|
+
await user.click(getByText("Clickable"));
|
|
564
|
+
expect(queryByRole("menu")).not.toHaveAttribute("data-open");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("activates focused item on Enter key", async () => {
|
|
568
|
+
const user = userEvent.setup();
|
|
569
|
+
const onClick = vi.fn();
|
|
570
|
+
const { getByText } = render(
|
|
571
|
+
<Menu>
|
|
572
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
573
|
+
<MenuPositioner>
|
|
574
|
+
<MenuContent>
|
|
575
|
+
<MenuItem onClick={onClick}>Activatable</MenuItem>
|
|
576
|
+
</MenuContent>
|
|
577
|
+
</MenuPositioner>
|
|
578
|
+
</Menu>,
|
|
579
|
+
);
|
|
580
|
+
await waitForPositioning();
|
|
581
|
+
await user.click(getByText("Open Menu"));
|
|
582
|
+
getByText("Activatable").focus();
|
|
583
|
+
await user.keyboard("{Enter}");
|
|
584
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("activates focused item on Space key", async () => {
|
|
588
|
+
const user = userEvent.setup();
|
|
589
|
+
const onClick = vi.fn();
|
|
590
|
+
const { getByText } = render(
|
|
591
|
+
<Menu>
|
|
592
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
593
|
+
<MenuPositioner>
|
|
594
|
+
<MenuContent>
|
|
595
|
+
<MenuItem onClick={onClick}>Activatable</MenuItem>
|
|
596
|
+
</MenuContent>
|
|
597
|
+
</MenuPositioner>
|
|
598
|
+
</Menu>,
|
|
599
|
+
);
|
|
600
|
+
await waitForPositioning();
|
|
601
|
+
await user.click(getByText("Open Menu"));
|
|
602
|
+
getByText("Activatable").focus();
|
|
603
|
+
await user.keyboard(" ");
|
|
604
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("does not activate disabled item on click or keyboard", async () => {
|
|
608
|
+
const user = userEvent.setup();
|
|
609
|
+
const onClick = vi.fn();
|
|
610
|
+
const { getByText } = render(
|
|
611
|
+
<Menu>
|
|
612
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
613
|
+
<MenuPositioner>
|
|
614
|
+
<MenuContent>
|
|
615
|
+
<MenuItem disabled onClick={onClick}>
|
|
616
|
+
Disabled
|
|
617
|
+
</MenuItem>
|
|
618
|
+
</MenuContent>
|
|
619
|
+
</MenuPositioner>
|
|
620
|
+
</Menu>,
|
|
621
|
+
);
|
|
622
|
+
await waitForPositioning();
|
|
623
|
+
await user.click(getByText("Open Menu"));
|
|
624
|
+
await user.click(getByText("Disabled"));
|
|
625
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
626
|
+
await user.keyboard("{Enter}");
|
|
627
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
describe("focus management", () => {
|
|
632
|
+
it("doesn't focus the first item when menu opens", async () => {
|
|
633
|
+
const user = userEvent.setup();
|
|
634
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
635
|
+
await waitForPositioning();
|
|
636
|
+
await user.click(getByText("Open Menu"));
|
|
637
|
+
const items = getAllByRole("menuitem");
|
|
638
|
+
expect(items[0]).not.toHaveFocus();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("focuses the first item when menu opens using Enter", async () => {
|
|
642
|
+
const user = userEvent.setup();
|
|
643
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
644
|
+
await waitForPositioning();
|
|
645
|
+
getByText("Open Menu").focus();
|
|
646
|
+
await user.keyboard("{Enter}");
|
|
647
|
+
await waitForFocus();
|
|
648
|
+
const items = getAllByRole("menuitem");
|
|
649
|
+
expect(items[0]).toHaveFocus();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("focuses the first item when menu opens using Space", async () => {
|
|
653
|
+
const user = userEvent.setup();
|
|
654
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
655
|
+
await waitForPositioning();
|
|
656
|
+
getByText("Open Menu").focus();
|
|
657
|
+
await user.keyboard(" ");
|
|
658
|
+
await waitForFocus();
|
|
659
|
+
const items = getAllByRole("menuitem");
|
|
660
|
+
expect(items[0]).toHaveFocus();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("focuses the first item when menu opens using ArrowDown", async () => {
|
|
664
|
+
const user = userEvent.setup();
|
|
665
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
666
|
+
await waitForPositioning();
|
|
667
|
+
getByText("Open Menu").focus();
|
|
668
|
+
await user.keyboard("{ArrowDown}");
|
|
669
|
+
await waitForFocus();
|
|
670
|
+
const items = getAllByRole("menuitem");
|
|
671
|
+
expect(items[0]).toHaveFocus();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("focuses the last item when menu opens using ArrowUp", async () => {
|
|
675
|
+
const user = userEvent.setup();
|
|
676
|
+
const { getByText, getAllByRole } = render(<BasicMenu />);
|
|
677
|
+
await waitForPositioning();
|
|
678
|
+
getByText("Open Menu").focus();
|
|
679
|
+
await user.keyboard("{ArrowUp}");
|
|
680
|
+
await waitForFocus();
|
|
681
|
+
const items = getAllByRole("menuitem");
|
|
682
|
+
expect(items[items.length - 1]).toHaveFocus();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("returns focus to trigger when menu closes via Escape", async () => {
|
|
686
|
+
const user = userEvent.setup();
|
|
687
|
+
const { getByText } = render(<BasicMenu />);
|
|
688
|
+
await waitForPositioning();
|
|
689
|
+
const trigger = getByText("Open Menu");
|
|
690
|
+
await user.click(trigger);
|
|
691
|
+
await user.keyboard("{Escape}");
|
|
692
|
+
expect(trigger).toHaveFocus();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("returns focus to trigger after item selection via Enter", async () => {
|
|
696
|
+
const user = userEvent.setup();
|
|
697
|
+
const { getByText } = render(<BasicMenu />);
|
|
698
|
+
await waitForPositioning();
|
|
699
|
+
const trigger = getByText("Open Menu");
|
|
700
|
+
await user.click(trigger);
|
|
701
|
+
|
|
702
|
+
await user.keyboard("{ArrowDown}"); // focus first item
|
|
703
|
+
await user.keyboard("{Enter}"); // select first item
|
|
704
|
+
expect(trigger).toHaveFocus();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("returns focus to trigger after item selection via Space", async () => {
|
|
708
|
+
const user = userEvent.setup();
|
|
709
|
+
const { getByText } = render(<BasicMenu />);
|
|
710
|
+
await waitForPositioning();
|
|
711
|
+
const trigger = getByText("Open Menu");
|
|
712
|
+
await user.click(trigger);
|
|
713
|
+
|
|
714
|
+
await user.keyboard("{ArrowDown}"); // focus first item
|
|
715
|
+
await user.keyboard(" "); // select first item
|
|
716
|
+
expect(trigger).toHaveFocus();
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
describe("sibling menus", () => {
|
|
721
|
+
it("opens Menu B after closing Menu A by clicking Trigger B", async () => {
|
|
722
|
+
const user = userEvent.setup();
|
|
723
|
+
const { getByText } = render(<TwoSiblingMenus />);
|
|
724
|
+
await waitForPositioning();
|
|
725
|
+
|
|
726
|
+
// Open Menu A
|
|
727
|
+
await user.click(getByText("Trigger A"));
|
|
728
|
+
expect(getByText("Trigger A")).toHaveAttribute("aria-expanded", "true");
|
|
729
|
+
|
|
730
|
+
// Click Trigger B — should close Menu A and open Menu B
|
|
731
|
+
await user.click(getByText("Trigger B"));
|
|
732
|
+
|
|
733
|
+
expect(getByText("Trigger A")).toHaveAttribute("aria-expanded", "false");
|
|
734
|
+
expect(getByText("Trigger B")).toHaveAttribute("aria-expanded", "true");
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("does not cascade-dismiss Menu B when Menu A's layer is removed", async () => {
|
|
738
|
+
const user = userEvent.setup();
|
|
739
|
+
const onMenuBOpenChange = vi.fn();
|
|
740
|
+
const { getByText } = render(<TwoSiblingMenus onMenuBOpenChange={onMenuBOpenChange} />);
|
|
741
|
+
await waitForPositioning();
|
|
742
|
+
|
|
743
|
+
// Open Menu A
|
|
744
|
+
await user.click(getByText("Trigger A"));
|
|
745
|
+
await waitForPositioning();
|
|
746
|
+
|
|
747
|
+
// Click Trigger B
|
|
748
|
+
await user.click(getByText("Trigger B"));
|
|
749
|
+
await waitForPositioning();
|
|
750
|
+
|
|
751
|
+
// Menu B should have been opened and NOT immediately closed
|
|
752
|
+
const calls = onMenuBOpenChange.mock.calls.map((args) => args[0] as boolean);
|
|
753
|
+
expect(calls).toEqual([true]);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("opens Menu B after tapping Trigger B while Menu A is open (touch)", async () => {
|
|
757
|
+
const user = userEvent.setup();
|
|
758
|
+
const { getByText } = render(<TwoSiblingMenus />);
|
|
759
|
+
await waitForPositioning();
|
|
760
|
+
|
|
761
|
+
// Open Menu A
|
|
762
|
+
await user.click(getByText("Trigger A"));
|
|
763
|
+
expect(getByText("Trigger A")).toHaveAttribute("aria-expanded", "true");
|
|
764
|
+
|
|
765
|
+
// Wait for Menu A's pointerdown handler (registered via setTimeout(0))
|
|
766
|
+
await act(async () => {
|
|
767
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Simulate touch tap on Trigger B.
|
|
771
|
+
// userEvent.pointer doesn't fire touchstart/touchend which the drag-mode
|
|
772
|
+
// handler needs, so we use fireEvent for the touch events.
|
|
773
|
+
const triggerB = getByText("Trigger B");
|
|
774
|
+
fireEvent.touchStart(triggerB, { touches: [{ clientX: 50, clientY: 50 }] });
|
|
775
|
+
fireEvent.touchEnd(triggerB, { touches: [] });
|
|
776
|
+
// After touchend, Menu A's deferToClick handler is registered.
|
|
777
|
+
// The synthetic click triggers both Menu A's dismiss and Menu B's open.
|
|
778
|
+
fireEvent.click(triggerB);
|
|
779
|
+
await waitForPositioning();
|
|
780
|
+
|
|
781
|
+
// Menu A should be closed, Menu B should be open
|
|
782
|
+
expect(getByText("Trigger A")).toHaveAttribute("aria-expanded", "false");
|
|
783
|
+
expect(getByText("Trigger B")).toHaveAttribute("aria-expanded", "true");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("does not cascade-dismiss Menu B on touch switch (touch)", async () => {
|
|
787
|
+
const user = userEvent.setup();
|
|
788
|
+
const onMenuBOpenChange = vi.fn();
|
|
789
|
+
const { getByText } = render(<TwoSiblingMenus onMenuBOpenChange={onMenuBOpenChange} />);
|
|
790
|
+
await waitForPositioning();
|
|
791
|
+
|
|
792
|
+
// Open Menu A
|
|
793
|
+
await user.click(getByText("Trigger A"));
|
|
794
|
+
await waitForPositioning();
|
|
795
|
+
|
|
796
|
+
// Wait for Menu A's pointerdown handler
|
|
797
|
+
await act(async () => {
|
|
798
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// Touch tap on Trigger B (see above for why fireEvent is needed here)
|
|
802
|
+
const triggerB = getByText("Trigger B");
|
|
803
|
+
fireEvent.touchStart(triggerB, { touches: [{ clientX: 50, clientY: 50 }] });
|
|
804
|
+
fireEvent.touchEnd(triggerB, { touches: [] });
|
|
805
|
+
fireEvent.click(triggerB);
|
|
806
|
+
await waitForPositioning();
|
|
807
|
+
|
|
808
|
+
// Menu B should have opened and NOT been immediately closed by cascade dismiss
|
|
809
|
+
const calls = onMenuBOpenChange.mock.calls.map((args) => args[0] as boolean);
|
|
810
|
+
// If the bug exists, calls would be [true, false] (opened then cascade-dismissed)
|
|
811
|
+
expect(calls).toEqual([true]);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("keeps Menu B open and interactive after switching from Menu A", async () => {
|
|
815
|
+
const user = userEvent.setup();
|
|
816
|
+
const onClick = vi.fn();
|
|
817
|
+
const { getByText } = render(
|
|
818
|
+
<div>
|
|
819
|
+
<Menu>
|
|
820
|
+
<MenuTrigger>Trigger A</MenuTrigger>
|
|
821
|
+
<MenuPositioner>
|
|
822
|
+
<MenuContent>
|
|
823
|
+
<MenuItem>Item A1</MenuItem>
|
|
824
|
+
</MenuContent>
|
|
825
|
+
</MenuPositioner>
|
|
826
|
+
</Menu>
|
|
827
|
+
<Menu>
|
|
828
|
+
<MenuTrigger>Trigger B</MenuTrigger>
|
|
829
|
+
<MenuPositioner>
|
|
830
|
+
<MenuContent>
|
|
831
|
+
<MenuItem onClick={onClick}>Item B1</MenuItem>
|
|
832
|
+
</MenuContent>
|
|
833
|
+
</MenuPositioner>
|
|
834
|
+
</Menu>
|
|
835
|
+
</div>,
|
|
836
|
+
);
|
|
837
|
+
await waitForPositioning();
|
|
838
|
+
|
|
839
|
+
await user.click(getByText("Trigger A"));
|
|
840
|
+
await user.click(getByText("Trigger B"));
|
|
841
|
+
await waitForFocus();
|
|
842
|
+
|
|
843
|
+
// Menu B items should be clickable
|
|
844
|
+
await user.click(getByText("Item B1"));
|
|
845
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
describe("mouse interaction", () => {
|
|
850
|
+
it("activates item on mouse-up after drag from trigger", async () => {
|
|
851
|
+
const onClick = vi.fn();
|
|
852
|
+
const { getByText } = render(
|
|
853
|
+
<Menu>
|
|
854
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
855
|
+
<MenuPositioner>
|
|
856
|
+
<MenuContent>
|
|
857
|
+
<MenuItem onClick={onClick}>Target</MenuItem>
|
|
858
|
+
</MenuContent>
|
|
859
|
+
</MenuPositioner>
|
|
860
|
+
</Menu>,
|
|
861
|
+
);
|
|
862
|
+
await waitForPositioning();
|
|
863
|
+
const trigger = getByText("Open Menu");
|
|
864
|
+
// Simulate: mousedown on trigger → move to item → mouseup on item
|
|
865
|
+
fireEvent.pointerDown(trigger);
|
|
866
|
+
await waitForPositioning();
|
|
867
|
+
// Menu should now be open
|
|
868
|
+
const item = getByText("Target");
|
|
869
|
+
fireEvent.pointerEnter(item);
|
|
870
|
+
fireEvent.pointerUp(item);
|
|
871
|
+
fireEvent.click(item);
|
|
872
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
describe("data attributes", () => {
|
|
877
|
+
it("sets data-open on trigger when menu is open", async () => {
|
|
878
|
+
const user = userEvent.setup();
|
|
879
|
+
const { getByText } = render(<BasicMenu />);
|
|
880
|
+
await waitForPositioning();
|
|
881
|
+
const trigger = getByText("Open Menu");
|
|
882
|
+
expect(trigger).not.toHaveAttribute("data-open");
|
|
883
|
+
await user.click(trigger);
|
|
884
|
+
expect(trigger).toHaveAttribute("data-open");
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("sets data-disabled on disabled items", async () => {
|
|
888
|
+
const user = userEvent.setup();
|
|
889
|
+
const { getByText } = render(<MenuWithDisabledItems />);
|
|
890
|
+
await waitForPositioning();
|
|
891
|
+
await user.click(getByText("Open Menu"));
|
|
892
|
+
expect(getByText("Item 2 (disabled)")).toHaveAttribute("data-disabled");
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
});
|