@sproutsocial/seeds-react-menu 1.6.16 → 1.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-menu",
3
- "version": "1.6.16",
3
+ "version": "1.7.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -17,25 +17,25 @@
17
17
  "test:watch": "jest --watch --coverage=false"
18
18
  },
19
19
  "dependencies": {
20
- "@sproutsocial/seeds-react-box": "^1.1.6",
21
- "@sproutsocial/seeds-react-button": "^1.3.5",
22
- "@sproutsocial/seeds-react-checkbox": "^1.3.8",
23
- "@sproutsocial/seeds-react-icon": "^2.0.0",
24
- "@sproutsocial/seeds-react-input": "^1.4.11",
25
- "@sproutsocial/seeds-react-label": "^1.0.5",
26
- "@sproutsocial/seeds-react-popout": "^2.4.11",
27
- "@sproutsocial/seeds-react-radio": "^1.3.4",
28
- "@sproutsocial/seeds-react-switch": "^1.2.8",
20
+ "@sproutsocial/seeds-react-box": "^1.1.7",
21
+ "@sproutsocial/seeds-react-button": "^1.3.6",
22
+ "@sproutsocial/seeds-react-checkbox": "^1.3.9",
23
+ "@sproutsocial/seeds-react-icon": "^2.0.1",
24
+ "@sproutsocial/seeds-react-input": "^1.4.12",
25
+ "@sproutsocial/seeds-react-label": "^1.0.6",
26
+ "@sproutsocial/seeds-react-popout": "^2.4.12",
27
+ "@sproutsocial/seeds-react-radio": "^1.3.5",
28
+ "@sproutsocial/seeds-react-switch": "^1.2.9",
29
29
  "@sproutsocial/seeds-react-system-props": "^3.0.2",
30
- "@sproutsocial/seeds-react-theme": "^3.2.0",
31
- "@sproutsocial/seeds-react-token-input": "^1.4.11",
30
+ "@sproutsocial/seeds-react-theme": "^3.2.1",
31
+ "@sproutsocial/seeds-react-token-input": "^1.4.12",
32
32
  "@sproutsocial/seeds-react-utilities": "^4.3.0",
33
33
  "downshift": "9.0.4",
34
34
  "lodash.uniqueid": "^4.0.1",
35
35
  "motion": "^12.6.3"
36
36
  },
37
37
  "devDependencies": {
38
- "@sproutsocial/seeds-react-tooltip": "^1.0.10",
38
+ "@sproutsocial/seeds-react-tooltip": "^1.0.11",
39
39
  "@sproutsocial/eslint-config-seeds": "*",
40
40
  "@sproutsocial/seeds-react-testing-library": "*",
41
41
  "@sproutsocial/seeds-testing": "*",
@@ -42,6 +42,21 @@ export const MenuToggleButton = (props: TypeMenuToggleButtonProps) => {
42
42
  ...(isListbox ? {} : { role: "button", "aria-haspopup": "menu" }),
43
43
  });
44
44
 
45
+ // Clean up default downshift aria attributes
46
+ // If there is not an active descendant, remove the attribute
47
+ if (toggleButtonProps && toggleButtonProps["aria-activedescendant"] === "") {
48
+ toggleButtonProps["aria-activedescendant"] = undefined;
49
+ }
50
+ // If the menu is closed, remove the aria-controls attribute
51
+ if (toggleButtonProps && toggleButtonProps["aria-expanded"] === false) {
52
+ // @ts-expect-error downshift types require aria-controls even though it is not required
53
+ toggleButtonProps["aria-controls"] = undefined;
54
+ }
55
+ // Remove aria-labelledby, we add our own Label component
56
+ if (toggleButtonProps) {
57
+ toggleButtonProps["aria-labelledby"] = undefined;
58
+ }
59
+
45
60
  return (
46
61
  <Button appearance="secondary" {...toggleButtonProps} {...restProps} />
47
62
  );
@@ -0,0 +1,495 @@
1
+ import React from "react";
2
+ import {
3
+ render,
4
+ screen,
5
+ fireEvent,
6
+ } from "@sproutsocial/seeds-react-testing-library";
7
+ import { MenuToggleButton } from "../MenuToggleButton";
8
+ import { MenuToggleContext } from "../../MenuContext";
9
+ import type { TypeMenuToggleContext } from "../../MenuContext";
10
+
11
+ describe("MenuToggleButton", () => {
12
+ describe("aria attribute logic", () => {
13
+ it("should add role='button' and aria-haspopup='menu' when isListbox is false", () => {
14
+ const mockContext: TypeMenuToggleContext = {
15
+ items: [],
16
+ itemsMap: {},
17
+ isItemSelected: jest.fn(),
18
+ getInputComponentProps: jest.fn(),
19
+ isListbox: false,
20
+ // @ts-expect-error partial mock for testing
21
+ getToggleButtonProps: jest.fn((props) => ({
22
+ "aria-expanded": true,
23
+ ...props,
24
+ })),
25
+ };
26
+
27
+ render(
28
+ <MenuToggleContext.Provider value={mockContext}>
29
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
30
+ </MenuToggleContext.Provider>
31
+ );
32
+
33
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
34
+ expect(button).toHaveAttribute("role", "button");
35
+ expect(button).toHaveAttribute("aria-haspopup", "menu");
36
+ });
37
+
38
+ it("should not add role='button' or aria-haspopup when isListbox is true", () => {
39
+ const mockContext: TypeMenuToggleContext = {
40
+ items: [],
41
+ itemsMap: {},
42
+ isItemSelected: jest.fn(),
43
+ getInputComponentProps: jest.fn(),
44
+ isListbox: true,
45
+ // @ts-expect-error partial mock for testing
46
+ getToggleButtonProps: jest.fn((props) => ({
47
+ "aria-expanded": true,
48
+ ...props,
49
+ })),
50
+ };
51
+
52
+ render(
53
+ <MenuToggleContext.Provider value={mockContext}>
54
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
55
+ </MenuToggleContext.Provider>
56
+ );
57
+
58
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
59
+ // Button role comes from the Button component itself, not from our override
60
+ expect(button).not.toHaveAttribute("aria-haspopup", "menu");
61
+ });
62
+
63
+ it("should remove aria-activedescendant when it is an empty string", () => {
64
+ const mockContext: TypeMenuToggleContext = {
65
+ items: [],
66
+ itemsMap: {},
67
+ isItemSelected: jest.fn(),
68
+ getInputComponentProps: jest.fn(),
69
+ isListbox: true,
70
+ // @ts-expect-error partial mock for testing
71
+ getToggleButtonProps: jest.fn((props) => ({
72
+ "aria-expanded": true,
73
+ "aria-activedescendant": "",
74
+ ...props,
75
+ })),
76
+ };
77
+
78
+ render(
79
+ <MenuToggleContext.Provider value={mockContext}>
80
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
81
+ </MenuToggleContext.Provider>
82
+ );
83
+
84
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
85
+ expect(button).not.toHaveAttribute("aria-activedescendant");
86
+ });
87
+
88
+ it("should keep aria-activedescendant when it has a value", () => {
89
+ const mockContext: TypeMenuToggleContext = {
90
+ items: [],
91
+ itemsMap: {},
92
+ isItemSelected: jest.fn(),
93
+ getInputComponentProps: jest.fn(),
94
+ isListbox: true,
95
+ // @ts-expect-error partial mock for testing
96
+ getToggleButtonProps: jest.fn((props) => ({
97
+ "aria-expanded": true,
98
+ "aria-activedescendant": "menu-item-1",
99
+ ...props,
100
+ })),
101
+ };
102
+
103
+ render(
104
+ <MenuToggleContext.Provider value={mockContext}>
105
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
106
+ </MenuToggleContext.Provider>
107
+ );
108
+
109
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
110
+ expect(button).toHaveAttribute("aria-activedescendant", "menu-item-1");
111
+ });
112
+
113
+ it("should remove aria-controls when aria-expanded is false", () => {
114
+ const mockContext: TypeMenuToggleContext = {
115
+ items: [],
116
+ itemsMap: {},
117
+ isItemSelected: jest.fn(),
118
+ getInputComponentProps: jest.fn(),
119
+ isListbox: true,
120
+ // @ts-expect-error partial mock for testing
121
+ getToggleButtonProps: jest.fn((props) => ({
122
+ "aria-expanded": false,
123
+ "aria-controls": "menu-content",
124
+ ...props,
125
+ })),
126
+ };
127
+
128
+ render(
129
+ <MenuToggleContext.Provider value={mockContext}>
130
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
131
+ </MenuToggleContext.Provider>
132
+ );
133
+
134
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
135
+ expect(button).toHaveAttribute("aria-expanded", "false");
136
+ expect(button).not.toHaveAttribute("aria-controls");
137
+ });
138
+
139
+ it("should keep aria-controls when aria-expanded is true", () => {
140
+ const mockContext: TypeMenuToggleContext = {
141
+ items: [],
142
+ itemsMap: {},
143
+ isItemSelected: jest.fn(),
144
+ getInputComponentProps: jest.fn(),
145
+ isListbox: true,
146
+ // @ts-expect-error partial mock for testing
147
+ getToggleButtonProps: jest.fn((props) => ({
148
+ "aria-expanded": true,
149
+ "aria-controls": "menu-content",
150
+ ...props,
151
+ })),
152
+ };
153
+
154
+ render(
155
+ <MenuToggleContext.Provider value={mockContext}>
156
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
157
+ </MenuToggleContext.Provider>
158
+ );
159
+
160
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
161
+ expect(button).toHaveAttribute("aria-expanded", "true");
162
+ expect(button).toHaveAttribute("aria-controls", "menu-content");
163
+ });
164
+
165
+ it("should always remove aria-labelledby", () => {
166
+ const mockContext: TypeMenuToggleContext = {
167
+ items: [],
168
+ itemsMap: {},
169
+ isItemSelected: jest.fn(),
170
+ getInputComponentProps: jest.fn(),
171
+ isListbox: true,
172
+ // @ts-expect-error partial mock for testing
173
+ getToggleButtonProps: jest.fn((props) => ({
174
+ "aria-expanded": true,
175
+ "aria-labelledby": "some-label-id",
176
+ ...props,
177
+ })),
178
+ };
179
+
180
+ render(
181
+ <MenuToggleContext.Provider value={mockContext}>
182
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
183
+ </MenuToggleContext.Provider>
184
+ );
185
+
186
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
187
+ expect(button).not.toHaveAttribute("aria-labelledby");
188
+ });
189
+
190
+ it("should handle all aria attribute cleanups together", () => {
191
+ const mockContext: TypeMenuToggleContext = {
192
+ items: [],
193
+ itemsMap: {},
194
+ isItemSelected: jest.fn(),
195
+ getInputComponentProps: jest.fn(),
196
+ isListbox: false,
197
+ // @ts-expect-error partial mock for testing
198
+ getToggleButtonProps: jest.fn((props) => ({
199
+ "aria-expanded": false,
200
+ "aria-controls": "menu-content",
201
+ "aria-activedescendant": "",
202
+ "aria-labelledby": "some-label-id",
203
+ ...props,
204
+ })),
205
+ };
206
+
207
+ render(
208
+ <MenuToggleContext.Provider value={mockContext}>
209
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
210
+ </MenuToggleContext.Provider>
211
+ );
212
+
213
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
214
+
215
+ // Should have role and haspopup because isListbox is false
216
+ expect(button).toHaveAttribute("role", "button");
217
+ expect(button).toHaveAttribute("aria-haspopup", "menu");
218
+
219
+ // Should clean up invalid aria attributes
220
+ expect(button).not.toHaveAttribute("aria-controls");
221
+ expect(button).not.toHaveAttribute("aria-activedescendant");
222
+ expect(button).not.toHaveAttribute("aria-labelledby");
223
+
224
+ // Should keep aria-expanded
225
+ expect(button).toHaveAttribute("aria-expanded", "false");
226
+ });
227
+ });
228
+
229
+ describe("user event handlers", () => {
230
+ it("should call user-supplied onClick handler", () => {
231
+ const userOnClick = jest.fn();
232
+ const mockContext: TypeMenuToggleContext = {
233
+ items: [],
234
+ itemsMap: {},
235
+ isItemSelected: jest.fn(),
236
+ getInputComponentProps: jest.fn(),
237
+ isListbox: true,
238
+ // @ts-expect-error partial mock for testing
239
+ getToggleButtonProps: jest.fn((props) => ({
240
+ "aria-expanded": false,
241
+ onClick: (e) => {
242
+ // @ts-expect-error partial mock for testing
243
+ props.onClick?.(e);
244
+ },
245
+ ...props,
246
+ })),
247
+ };
248
+
249
+ render(
250
+ <MenuToggleContext.Provider value={mockContext}>
251
+ <MenuToggleButton onClick={userOnClick}>Toggle Menu</MenuToggleButton>
252
+ </MenuToggleContext.Provider>
253
+ );
254
+
255
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
256
+ button.click();
257
+
258
+ expect(userOnClick).toHaveBeenCalledTimes(1);
259
+ });
260
+
261
+ it("should call user-supplied onKeyDown handler", () => {
262
+ const userOnKeyDown = jest.fn();
263
+ const mockContext: TypeMenuToggleContext = {
264
+ items: [],
265
+ itemsMap: {},
266
+ isItemSelected: jest.fn(),
267
+ getInputComponentProps: jest.fn(),
268
+ isListbox: true,
269
+ // @ts-expect-error partial mock for testing
270
+ getToggleButtonProps: jest.fn((props) => ({
271
+ "aria-expanded": false,
272
+ onKeyDown: (e) => {
273
+ // @ts-expect-error partial mock for testing
274
+ props.onKeyDown?.(e);
275
+ },
276
+ ...props,
277
+ })),
278
+ };
279
+
280
+ render(
281
+ <MenuToggleContext.Provider value={mockContext}>
282
+ <MenuToggleButton onKeyDown={userOnKeyDown}>
283
+ Toggle Menu
284
+ </MenuToggleButton>
285
+ </MenuToggleContext.Provider>
286
+ );
287
+
288
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
289
+
290
+ // Simulate keydown using fireEvent
291
+ fireEvent.keyDown(button, { key: "Enter" });
292
+
293
+ expect(userOnKeyDown).toHaveBeenCalled();
294
+ });
295
+
296
+ it("should call user-supplied onBlur handler", () => {
297
+ const userOnBlur = jest.fn();
298
+ const mockContext: TypeMenuToggleContext = {
299
+ items: [],
300
+ itemsMap: {},
301
+ isItemSelected: jest.fn(),
302
+ getInputComponentProps: jest.fn(),
303
+ isListbox: true,
304
+ // @ts-expect-error partial mock for testing
305
+ getToggleButtonProps: jest.fn((props) => ({
306
+ "aria-expanded": false,
307
+ onBlur: (e) => {
308
+ // @ts-expect-error partial mock for testing
309
+ props.onBlur?.(e);
310
+ },
311
+ ...props,
312
+ })),
313
+ };
314
+
315
+ render(
316
+ <MenuToggleContext.Provider value={mockContext}>
317
+ <MenuToggleButton onBlur={userOnBlur}>Toggle Menu</MenuToggleButton>
318
+ </MenuToggleContext.Provider>
319
+ );
320
+
321
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
322
+ button.focus();
323
+ button.blur();
324
+
325
+ expect(userOnBlur).toHaveBeenCalled();
326
+ });
327
+ });
328
+
329
+ describe("getDropdownProps integration", () => {
330
+ it("should use getDropdownProps when provided", () => {
331
+ const mockGetDropdownProps = jest.fn((props) => ({
332
+ ...props,
333
+ "data-testid": "dropdown-test",
334
+ }));
335
+
336
+ const mockContext: TypeMenuToggleContext = {
337
+ items: [],
338
+ itemsMap: {},
339
+ isItemSelected: jest.fn(),
340
+ getInputComponentProps: jest.fn(),
341
+ isListbox: true,
342
+ // @ts-expect-error partial mock for testing
343
+ getToggleButtonProps: jest.fn((props) => ({
344
+ "aria-expanded": false,
345
+ ...props,
346
+ })),
347
+ getDropdownProps: mockGetDropdownProps,
348
+ };
349
+
350
+ render(
351
+ <MenuToggleContext.Provider value={mockContext}>
352
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
353
+ </MenuToggleContext.Provider>
354
+ );
355
+
356
+ expect(mockGetDropdownProps).toHaveBeenCalledWith(
357
+ expect.objectContaining({
358
+ preventKeyAction: true,
359
+ })
360
+ );
361
+ });
362
+
363
+ it("should fallback to defaultDropdownProps when getDropdownProps is not provided", () => {
364
+ const mockGetToggleButtonProps = jest.fn((props) => ({
365
+ "aria-expanded": false,
366
+ ...props,
367
+ }));
368
+
369
+ const mockContext: TypeMenuToggleContext = {
370
+ items: [],
371
+ itemsMap: {},
372
+ isItemSelected: jest.fn(),
373
+ getInputComponentProps: jest.fn(),
374
+ isListbox: true,
375
+ getToggleButtonProps: mockGetToggleButtonProps,
376
+ getDropdownProps: undefined,
377
+ };
378
+
379
+ render(
380
+ <MenuToggleContext.Provider value={mockContext}>
381
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
382
+ </MenuToggleContext.Provider>
383
+ );
384
+
385
+ // Should still render successfully
386
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
387
+ expect(button).toBeInTheDocument();
388
+ });
389
+ });
390
+
391
+ describe("ariaProps integration", () => {
392
+ it("should merge ariaProps from context", () => {
393
+ const mockContext: TypeMenuToggleContext = {
394
+ items: [],
395
+ itemsMap: {},
396
+ isItemSelected: jest.fn(),
397
+ getInputComponentProps: jest.fn(),
398
+ isListbox: true,
399
+ // @ts-expect-error partial mock for testing
400
+ getToggleButtonProps: jest.fn((props) => ({
401
+ "aria-expanded": true,
402
+ ...props,
403
+ })),
404
+ ariaProps: {
405
+ "aria-describedby": "menu-description",
406
+ },
407
+ };
408
+
409
+ render(
410
+ <MenuToggleContext.Provider value={mockContext}>
411
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
412
+ </MenuToggleContext.Provider>
413
+ );
414
+
415
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
416
+ expect(button).toHaveAttribute("aria-describedby", "menu-description");
417
+ });
418
+ });
419
+
420
+ describe("Button props forwarding", () => {
421
+ it("should forward appearance prop to Button and render successfully", () => {
422
+ const mockContext: TypeMenuToggleContext = {
423
+ items: [],
424
+ itemsMap: {},
425
+ isItemSelected: jest.fn(),
426
+ getInputComponentProps: jest.fn(),
427
+ isListbox: true,
428
+ // @ts-expect-error partial mock for testing
429
+ getToggleButtonProps: jest.fn((props) => ({
430
+ "aria-expanded": false,
431
+ ...props,
432
+ })),
433
+ };
434
+
435
+ render(
436
+ <MenuToggleContext.Provider value={mockContext}>
437
+ <MenuToggleButton appearance="primary">Toggle Menu</MenuToggleButton>
438
+ </MenuToggleContext.Provider>
439
+ );
440
+
441
+ // Should render successfully with appearance prop
442
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
443
+ expect(button).toBeInTheDocument();
444
+ });
445
+
446
+ it("should render with secondary appearance by default", () => {
447
+ const mockContext: TypeMenuToggleContext = {
448
+ items: [],
449
+ itemsMap: {},
450
+ isItemSelected: jest.fn(),
451
+ getInputComponentProps: jest.fn(),
452
+ isListbox: true,
453
+ // @ts-expect-error partial mock for testing
454
+ getToggleButtonProps: jest.fn((props) => ({
455
+ "aria-expanded": false,
456
+ ...props,
457
+ })),
458
+ };
459
+
460
+ render(
461
+ <MenuToggleContext.Provider value={mockContext}>
462
+ <MenuToggleButton>Toggle Menu</MenuToggleButton>
463
+ </MenuToggleContext.Provider>
464
+ );
465
+
466
+ // Should render successfully with default appearance
467
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
468
+ expect(button).toBeInTheDocument();
469
+ });
470
+
471
+ it("should forward other Button props like disabled", () => {
472
+ const mockContext: TypeMenuToggleContext = {
473
+ items: [],
474
+ itemsMap: {},
475
+ isItemSelected: jest.fn(),
476
+ getInputComponentProps: jest.fn(),
477
+ isListbox: true,
478
+ // @ts-expect-error partial mock for testing
479
+ getToggleButtonProps: jest.fn((props) => ({
480
+ "aria-expanded": false,
481
+ ...props,
482
+ })),
483
+ };
484
+
485
+ render(
486
+ <MenuToggleContext.Provider value={mockContext}>
487
+ <MenuToggleButton disabled>Toggle Menu</MenuToggleButton>
488
+ </MenuToggleContext.Provider>
489
+ );
490
+
491
+ const button = screen.getByRole("button", { name: "Toggle Menu" });
492
+ expect(button).toBeDisabled();
493
+ });
494
+ });
495
+ });