@sproutsocial/seeds-react-menu 0.2.3 → 0.3.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": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -11,10 +11,10 @@ import { useMenuContext } from "../MenuContext";
11
11
  * <ActionMenu>
12
12
  * <MenuHeader>Header</MenuHeader>
13
13
  * <MenuContent>
14
- * <MenuGroup>
14
+ * <MenuGroup id="1">
15
15
  * <MenuItem>Item 1</MenuItem>
16
16
  * </MenuGroup>
17
- * <MenuGroup>
17
+ * <MenuGroup id="2">
18
18
  * <MenuItem>Item 2</MenuItem>
19
19
  * </MenuGroup>
20
20
  * </MenuContent>
@@ -6,20 +6,19 @@ Feature: Menu Group
6
6
  Background:
7
7
  Given an end user is using the component
8
8
 
9
- Scenario: MenuGroup has isSingleSelect true
10
- When the MenuGroup isSingleSelect is true
11
- And MenuContext is set to multiple selection
12
- Then the group behaves like a single select
9
+ Scenario Outline: MenuGroup can be single select
10
+ When the MenuGroup has isSingleSelect set to <value>
11
+ And MenuContext is set to <menu type>
12
+ Then <selection behavior>
13
13
 
14
- Scenario: MenuGroup has isSingleSelect undefined
15
- When the MenuGroup isSingleSelect is undefined
16
- And MenuContext is set to multiple selection
17
- Then the group behaves like a multi select
18
-
19
- Scenario: MenuGroup has isSingleSelect false
20
- When the MenuGroup isSingleSelect is false
21
- And MenuContext is set to multiple selection
22
- Then the group behaves like a multi select
14
+ Examples:
15
+ | value | menu type | selection behavior |
16
+ | true | single | the group behaves like a single select |
17
+ | false | single | the group behaves like a single select |
18
+ | undefined | single | the group behaves like a single select |
19
+ | true | multiple | the group behaves like a single select |
20
+ | false | multiple | the group behaves like a multi select |
21
+ | undefined | multiple | the group behaves like a multi select |
23
22
 
24
23
  Rule: API
25
24
  Background:
@@ -59,14 +58,14 @@ Feature: Menu Group
59
58
  Given a list of items is not empty
60
59
  And the items are a valid object shape
61
60
  When that list of items is provided to the items prop on a MenuGroup
62
- And a valid component is provided to the customMenuItem prop
61
+ And a valid component is provided to the customMenuItem prop
63
62
  Then the MenuGroup will render the items using the customMenuItem component
64
63
 
65
64
  Scenario: MenuGroup ignores the menuItemType without items prop
66
65
  When a MenuGroup has menuItemType set
67
66
  And is not provided with a valid items prop
68
67
  Then the menuItemType will be ignored
69
-
68
+
70
69
  Scenario Outline: MenuGroup can set the type for all MenuItems
71
70
  When menuItemType is set to <menu item type>
72
71
  And MenuGroup is provided with a valid items prop
@@ -98,5 +97,6 @@ Feature: Menu Group
98
97
  | children | ReactNode |
99
98
  | title | string |
100
99
  | items | TypeItem[] |
100
+ | isSingleSelect | boolean |
101
101
  | menuItemType | TypeMenuItemProps['type'] |
102
102
  | customMenuItem | tyepof MenuItem |
@@ -10,10 +10,10 @@ import { StyledMenuGroup, StyledTitle, StyledMenuGroupUl } from "./styles";
10
10
  * @example
11
11
  * <ActionMenu>
12
12
  * <MenuContent>
13
- * <MenuGroup title="Group 1">
13
+ * <MenuGroup title="Group 1" id="1">
14
14
  * <MenuItem>Item 1</MenuItem>
15
15
  * </MenuGroup>
16
- * <MenuGroup title="Group 1">
16
+ * <MenuGroup title="Group 2" id="2">
17
17
  * <MenuItem>Item 2</MenuItem>
18
18
  * </MenuGroup>
19
19
  * </MenuContent>
@@ -27,6 +27,8 @@ export const MenuGroup = ({
27
27
  children,
28
28
  title,
29
29
  color,
30
+ isSingleSelect,
31
+ id,
30
32
  ...rest
31
33
  }: TypeMenuGroupProps) => {
32
34
  // Generate a unique ID for the title
@@ -35,6 +37,8 @@ export const MenuGroup = ({
35
37
  return (
36
38
  <StyledMenuGroup
37
39
  role="group"
40
+ id={id}
41
+ isSingleSelect={isSingleSelect}
38
42
  aria-labelledby={title ? titleId : undefined}
39
43
  // TODO: fix this type since `color` should be valid here. TS can't resolve the correct type.
40
44
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -23,6 +23,8 @@ export interface TypeMenuGroupStyledProps
23
23
  export interface TypeMenuGroupProps
24
24
  extends TypeMenuGroupStyledProps,
25
25
  Omit<React.ComponentPropsWithoutRef<"ul">, "color"> {
26
+ id: string;
27
+ isSingleSelect?: boolean;
26
28
  title?: string;
27
29
  children: ReactNode;
28
30
  titleAs?: string | React.ComponentType<any>;
@@ -1,6 +1,13 @@
1
- import { render, screen } from "@sproutsocial/seeds-react-testing-library";
1
+ import {
2
+ render,
3
+ screen,
4
+ waitFor,
5
+ fireEvent,
6
+ act,
7
+ } from "@sproutsocial/seeds-react-testing-library";
2
8
  import {
3
9
  SingleSelectMenu,
10
+ MultiSelectMenu,
4
11
  MenuContent,
5
12
  MenuGroup,
6
13
  MenuToggleButton,
@@ -14,11 +21,139 @@ const menuItems = [
14
21
  ];
15
22
 
16
23
  describe("MenuGroup", () => {
17
- xdescribe("User Behavior", () => {
18
- // To be enabled when isSingleSelect prop added
19
- xit("behaves as it normally would when isSingleSelect is undefined", () => {});
20
- xit("behaves as it normally would when isSingleSelect is false", () => {});
21
- xit("behaves like a single select when isSingleSelect is true", () => {});
24
+ describe("User Behavior", () => {
25
+ it("allows multiple selections per group when isSingleSelect is undefined", () => {
26
+ render(
27
+ <MultiSelectMenu
28
+ defaultIsOpen
29
+ menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
30
+ >
31
+ <MenuContent>
32
+ <MenuGroup id="group">
33
+ {menuItems.map(({ id, label }) => (
34
+ <MenuItem key={id} id={id}>
35
+ {label}
36
+ </MenuItem>
37
+ ))}
38
+ </MenuGroup>
39
+ </MenuContent>
40
+ </MultiSelectMenu>
41
+ );
42
+
43
+ const options = screen.getAllByRole("option");
44
+
45
+ // Click on each option
46
+ for (const option of screen.getAllByRole("option")) {
47
+ fireEvent.click(option);
48
+ }
49
+
50
+ // Expect all options to be selected.
51
+ for (const option of options) {
52
+ expect(option).toHaveAttribute("aria-selected", "true");
53
+ }
54
+ });
55
+ it("allows multiple selections per group when isSingleSelect is false", () => {
56
+ render(
57
+ <MultiSelectMenu
58
+ defaultIsOpen
59
+ menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
60
+ >
61
+ <MenuContent>
62
+ <MenuGroup id="group" isSingleSelect={false}>
63
+ {menuItems.map(({ id, label }) => (
64
+ <MenuItem key={id} id={id}>
65
+ {label}
66
+ </MenuItem>
67
+ ))}
68
+ </MenuGroup>
69
+ </MenuContent>
70
+ </MultiSelectMenu>
71
+ );
72
+
73
+ const options = screen.getAllByRole("option");
74
+
75
+ // Click on each option
76
+ for (const option of screen.getAllByRole("option")) {
77
+ fireEvent.click(option);
78
+ }
79
+
80
+ // Expect all options to be selected.
81
+ for (const option of options) {
82
+ expect(option).toHaveAttribute("aria-selected", "true");
83
+ }
84
+ });
85
+ it("only allows one selection per group when isSingleSelect is true", async () => {
86
+ render(
87
+ <MultiSelectMenu
88
+ defaultIsOpen
89
+ menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
90
+ >
91
+ <MenuContent>
92
+ <MenuGroup id="group" isSingleSelect={true}>
93
+ {menuItems.map(({ id, label }) => (
94
+ <MenuItem key={id} id={id}>
95
+ {label}
96
+ </MenuItem>
97
+ ))}
98
+ </MenuGroup>
99
+ </MenuContent>
100
+ </MultiSelectMenu>
101
+ );
102
+
103
+ const options = screen.getAllByRole("option");
104
+
105
+ // Click on each option
106
+ for (const option of screen.getAllByRole("option")) {
107
+ fireEvent.click(option);
108
+ }
109
+
110
+ // Expect only the last option to be selected.
111
+ await waitFor(() =>
112
+ expect(options[0]).toHaveAttribute("aria-selected", "false")
113
+ );
114
+ await waitFor(() =>
115
+ expect(options[1]).toHaveAttribute("aria-selected", "false")
116
+ );
117
+ await waitFor(() =>
118
+ expect(options[2]).toHaveAttribute("aria-selected", "true")
119
+ );
120
+ });
121
+ it("only allows one selection per group in SingleSelectMenu, even if isSingleSelect is false", async () => {
122
+ render(
123
+ <SingleSelectMenu
124
+ defaultIsOpen
125
+ menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
126
+ >
127
+ <MenuContent>
128
+ <MenuGroup id="group" isSingleSelect={false}>
129
+ {menuItems.map(({ id, label }) => (
130
+ <MenuItem key={id} id={id}>
131
+ {label}
132
+ </MenuItem>
133
+ ))}
134
+ </MenuGroup>
135
+ </MenuContent>
136
+ </SingleSelectMenu>
137
+ );
138
+
139
+ const options = screen.getAllByRole("option");
140
+
141
+ // Click on each option
142
+ for (const option of screen.getAllByRole("option")) {
143
+ fireEvent.click(option);
144
+ }
145
+
146
+ // Expect only the last option to be selected.
147
+ await waitFor(() =>
148
+ expect(options[0]).toHaveAttribute("aria-selected", "false")
149
+ );
150
+ await waitFor(() =>
151
+ expect(options[1]).toHaveAttribute("aria-selected", "false")
152
+ );
153
+ await waitFor(() =>
154
+ expect(options[2]).toHaveAttribute("aria-selected", "true")
155
+ );
156
+ });
22
157
  });
23
158
  describe("API", () => {
24
159
  it("renders a group with a title", () => {
@@ -29,7 +164,7 @@ describe("MenuGroup", () => {
29
164
  menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
30
165
  >
31
166
  <MenuContent>
32
- <MenuGroup title={menuTitle}>
167
+ <MenuGroup title={menuTitle} id={menuTitle}>
33
168
  {menuItems.map(({ id, label }) => (
34
169
  <MenuItem key={id} id={id}>
35
170
  {label}
@@ -55,7 +190,7 @@ describe("MenuGroup", () => {
55
190
  menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
56
191
  >
57
192
  <MenuContent>
58
- <MenuGroup>
193
+ <MenuGroup id="group">
59
194
  {menuItems.map(({ id, label }) => (
60
195
  <MenuItem key={id} id={id}>
61
196
  {label}
@@ -78,7 +213,7 @@ describe("MenuGroup", () => {
78
213
  menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
79
214
  >
80
215
  <MenuContent>
81
- <MenuGroup>
216
+ <MenuGroup id="group">
82
217
  {menuItems.map(({ id, label }) => (
83
218
  <MenuItem key={id} id={id}>
84
219
  {label}
@@ -4,41 +4,53 @@ import { Text } from "@sproutsocial/seeds-react-text";
4
4
  export const MenuGroupTypeTest = () => (
5
5
  <>
6
6
  {/* MenuGroup allows children */}
7
- <MenuGroup>Test</MenuGroup>
7
+ <MenuGroup id="group">Test</MenuGroup>
8
8
  {/* MenuGroup allows children with MenuItems */}
9
- <MenuGroup>
9
+ <MenuGroup id="group">
10
10
  <MenuItem id="1">Test 1</MenuItem>
11
11
  <MenuItem id="2">Test 2</MenuItem>
12
12
  </MenuGroup>
13
+ {/* MenuGroup allows isSingleSelect prop */}
14
+ <MenuGroup id="group-1" isSingleSelect={true}>
15
+ <MenuItem id="1">Test 1</MenuItem>
16
+ </MenuGroup>
13
17
  {/* MenuGroup allows title as a string */}
14
- <MenuGroup title="title">Test</MenuGroup>
18
+ <MenuGroup id="group" title="title">
19
+ Test
20
+ </MenuGroup>
15
21
  {/* MenuGroup allows titleAs as a string */}
16
- <MenuGroup title="title" titleAs="h3">
22
+ <MenuGroup id="group" title="title" titleAs="h3">
17
23
  Test
18
24
  </MenuGroup>
19
25
  {/* MenuGroup allows titleAs as a component */}
20
- <MenuGroup title="title" titleAs={Text}>
26
+ <MenuGroup id="group" title="title" titleAs={Text}>
21
27
  Test
22
28
  </MenuGroup>
23
29
  {/* MenuGroup accepts ul props */}
24
- <MenuGroup aria-label="test label">Test</MenuGroup>
30
+ <MenuGroup id="group" aria-label="test label">
31
+ Test
32
+ </MenuGroup>
25
33
  {/* MenuGroup accepts style props */}
26
- <MenuGroup border="1px solid black" bg="white" width="150px">
34
+ <MenuGroup id="group" border="1px solid black" bg="white" width="150px">
27
35
  Test
28
36
  </MenuGroup>
29
37
 
30
38
  {/* @ts-expect-error - MenuGroup requires children */}
31
- <MenuGroup />
39
+ <MenuGroup id="group" />
32
40
  {/* @ts-expect-error - MenuGroup title is invalid as number */}
33
- <MenuGroup title={123}>Test</MenuGroup>
41
+ <MenuGroup id="group" title={123}>
42
+ Test
43
+ </MenuGroup>
34
44
  {/* @ts-expect-error - MenuGroup title is invalid as boolean */}
35
- <MenuGroup title={false}>Test</MenuGroup>
45
+ <MenuGroup id="group" title={false}>
46
+ Test
47
+ </MenuGroup>
36
48
  {/* @ts-expect-error - MenuGroup titleAs is invalid as number */}
37
- <MenuGroup title="title" titleAs={123}>
49
+ <MenuGroup id="group" title="title" titleAs={123}>
38
50
  Test
39
51
  </MenuGroup>
40
52
  {/* @ts-expect-error - MenuGroup titleAs is invalid as boolean */}
41
- <MenuGroup title="title" titleAs={false}>
53
+ <MenuGroup id="group" title="title" titleAs={false}>
42
54
  Test
43
55
  </MenuGroup>
44
56
  </>
@@ -25,12 +25,12 @@ export const MenuItem = ({
25
25
  }: TypeMenuItemProps) => {
26
26
  const { getItemProps, itemsMap, highlightedIndex, isListbox } =
27
27
  useContext(MenuContext);
28
+ const item = (itemsMap[id] || { index: 0, id }) as TypeDefaultItemProps;
28
29
  const { ...optionProps } = useOptionProps({
29
30
  id,
30
31
  children,
31
32
  ...props,
32
33
  });
33
- const item = (itemsMap[id] || { index: 0, id }) as TypeDefaultItemProps;
34
34
  const highlighted = highlightedIndex === item?.index;
35
35
 
36
36
  return (
@@ -219,7 +219,7 @@ describe("MenuRoot", () => {
219
219
  >
220
220
  <MenuHeader title="Menu Header" />
221
221
  <div>Custom Banner</div>
222
- <MenuGroup title="Menu Group">
222
+ <MenuGroup title="Menu Group" id="group">
223
223
  <MenuItem id="1">Item 1</MenuItem>
224
224
  </MenuGroup>
225
225
  <MenuFooter>Menu Footer</MenuFooter>
@@ -105,6 +105,14 @@ export const MultiSelectMenu = ({
105
105
  } else {
106
106
  addSelectedItem(newSelectedItem);
107
107
  }
108
+ // If the newly selected item's group is single select, remove any other selected items in that group.
109
+ if (newSelectedItem.menuGroupProps?.isSingleSelect) {
110
+ const groupItems = selectedItems.filter(
111
+ (item) =>
112
+ item.menuGroupProps?.id === newSelectedItem.menuGroupProps?.id
113
+ );
114
+ groupItems.forEach((item) => removeSelectedItem(item));
115
+ }
108
116
  break;
109
117
  default:
110
118
  break;
@@ -35,7 +35,7 @@ describe("MultiSelectMenu", () => {
35
35
  }
36
36
  >
37
37
  <MenuContent>
38
- <MenuGroup>
38
+ <MenuGroup id="group">
39
39
  {menuItems.map((item) => (
40
40
  <MenuItem key={item.id} id={item.id} label={item.label}>
41
41
  {item.label}
@@ -78,7 +78,7 @@ describe("MultiSelectMenu", () => {
78
78
  }
79
79
  >
80
80
  <MenuContent>
81
- <MenuGroup>
81
+ <MenuGroup id="group">
82
82
  {menuItems.map((item) => (
83
83
  <MenuItem
84
84
  key={item.id}
@@ -136,7 +136,7 @@ describe("MultiSelectMenu", () => {
136
136
  }
137
137
  >
138
138
  <MenuContent>
139
- <MenuGroup>
139
+ <MenuGroup id="group">
140
140
  {menuItems.map((item) => (
141
141
  <MenuItem
142
142
  key={item.id}
@@ -199,7 +199,7 @@ describe("MultiSelectMenu", () => {
199
199
  }
200
200
  >
201
201
  <MenuContent>
202
- <MenuGroup>
202
+ <MenuGroup id="group">
203
203
  {menuItems.map((item) => (
204
204
  <MenuItem
205
205
  key={item.id}
@@ -257,7 +257,7 @@ describe("MultiSelectMenu", () => {
257
257
  selectedItems={[menuItems[1], menuItems[2]]}
258
258
  >
259
259
  <MenuContent>
260
- <MenuGroup>
260
+ <MenuGroup id="group">
261
261
  {menuItems.map((item) => (
262
262
  <MenuItem
263
263
  key={item.id}
@@ -310,7 +310,7 @@ describe("MultiSelectMenu", () => {
310
310
  onSelectedItemsChange={mockSelectedItemsChange}
311
311
  >
312
312
  <MenuContent>
313
- <MenuGroup>
313
+ <MenuGroup id="group">
314
314
  {menuItems.map((item) => (
315
315
  <MenuItem
316
316
  key={item.id}
@@ -34,7 +34,7 @@ describe("User Behavior", () => {
34
34
  }
35
35
  >
36
36
  <MenuContent>
37
- <MenuGroup title="Menu Title">
37
+ <MenuGroup title="Menu Title" id="menu-title">
38
38
  {menuItems.map(({ id, label }) => (
39
39
  <MenuItem key={id} id={id}>
40
40
  {label}
@@ -100,7 +100,7 @@ describe("User Behavior", () => {
100
100
  }
101
101
  >
102
102
  <MenuContent>
103
- <MenuGroup title="Menu Title">
103
+ <MenuGroup title="Menu Title" id="menu-title">
104
104
  {menuItems.map(({ id, label }) => (
105
105
  <MenuItem key={id} id={id}>
106
106
  {label}
@@ -135,7 +135,7 @@ describe("User Behavior", () => {
135
135
  menuToggleElement={<MenuToggleButton>Click me</MenuToggleButton>}
136
136
  >
137
137
  <MenuContent>
138
- <MenuGroup title="Menu Title">
138
+ <MenuGroup title="Menu Title" id="menu-title">
139
139
  {menuItems.map(({ id, label }) => (
140
140
  <MenuItem key={id} id={id}>
141
141
  {label}
@@ -1,14 +1,18 @@
1
1
  import React, { type ReactNode, useMemo } from "react";
2
2
  import type { TypeMenuItemProps } from "../MenuItem";
3
3
  import type { TypeMenuContext } from "../MenuContext";
4
+ import type { TypeMenuGroupProps } from "../MenuGroup/index";
4
5
 
5
6
  export interface TypeUseItemsFromChildrenProps {
6
7
  children: ReactNode;
7
8
  }
8
- export interface TypeItemElements {
9
+ export interface TypeItemElement {
9
10
  element: React.ReactElement<TypeMenuItemProps>;
10
11
  parents: readonly React.ReactNode[];
11
12
  }
13
+ export interface TypeMenuGroupElement {
14
+ element: React.ReactElement<TypeMenuGroupProps>;
15
+ }
12
16
 
13
17
  // https://stackoverflow.com/questions/69119016/find-specific-children-components-regardless-of-depth-in-react
14
18
  const walkAllChildren = (
@@ -43,12 +47,11 @@ const checkIfIsElement = (element: ReactNode, name: string): boolean => {
43
47
  };
44
48
 
45
49
  const getAllMenuItems = (children: ReactNode) => {
46
- const allMenuItems: TypeItemElements[] = [];
47
- // console.log("children", children);
50
+ const allMenuItems: TypeItemElement[] = [];
48
51
  walkAllChildren(children, (element, parents) => {
49
52
  if (checkIfIsElement(element, "MenuItem")) {
50
53
  allMenuItems.push({
51
- element: element as TypeItemElements["element"],
54
+ element: element as TypeItemElement["element"],
52
55
  parents,
53
56
  });
54
57
  }
@@ -63,12 +66,16 @@ export const useItemsFromChildren = ({
63
66
  const allMenuItemElements = getAllMenuItems(children);
64
67
  const menuItemsMap: TypeMenuContext["itemsMap"] = {};
65
68
  const allMenuItems: TypeMenuContext["items"] = allMenuItemElements.map(
66
- ({ element }, index) => {
69
+ ({ element, parents }, index) => {
70
+ const parentMenuGroup = parents.find((parent) =>
71
+ checkIfIsElement(parent, "MenuGroup")
72
+ ) as TypeMenuGroupElement["element"];
67
73
  const item = {
68
74
  id: element.props.id,
69
75
  disabled: element.props.disabled,
70
76
  index,
71
77
  menuItemProps: element.props,
78
+ menuGroupProps: parentMenuGroup ? parentMenuGroup.props : undefined,
72
79
  };
73
80
  menuItemsMap[`${item.id}`] = item;
74
81
  return item;
package/src/types.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { TypeMenuItemProps } from "./MenuItem";
2
+ import type { TypeMenuGroupProps } from "./MenuGroup";
2
3
 
3
4
  export interface TypeDefaultItemProps {
4
5
  id: string;
5
6
  index: number;
6
7
  disabled?: boolean;
7
8
  menuItemProps?: TypeMenuItemProps;
9
+ menuGroupProps?: TypeMenuGroupProps;
8
10
  }
9
11
 
10
12
  export type TypeNotEmptyChildren =