@sproutsocial/seeds-react-menu 1.2.2 → 1.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": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -74,18 +74,20 @@ export const MultiAutocomplete = <
74
74
  [selectedItemIds, showSelectedItemsInDropdown, getIsItemVisible]
75
75
  );
76
76
 
77
- const { setHiddenItemsMap, defaultDownshiftProps, ...restDefaults } =
78
- useDownshiftDefaults({
79
- isCombobox: true,
80
- isMulti: true,
81
- // handles the prop name mapping for multi select
82
- multiSelectProps: {
83
- getA11yMultiStatusMessage,
84
- onMultiSelectStateChange,
85
- multiSelectStateReducer,
86
- ...useComboboxProps,
87
- },
88
- });
77
+ const downshiftDefaults = useDownshiftDefaults({
78
+ isCombobox: true,
79
+ isMulti: true,
80
+ // handles the prop name mapping for multi select
81
+ multiSelectProps: {
82
+ getA11yMultiStatusMessage,
83
+ onMultiSelectStateChange,
84
+ multiSelectStateReducer,
85
+ ...useComboboxProps,
86
+ },
87
+ });
88
+
89
+ const { setHiddenItemsMap, defaultDownshiftProps, hiddenItemsCount } =
90
+ downshiftDefaults;
89
91
 
90
92
  // used to handle filtering of items
91
93
  const { updateFilteredItems } = useItemFiltering({
@@ -118,9 +120,8 @@ export const MultiAutocomplete = <
118
120
  /**
119
121
  * Destructure Downshift props and call the core useCombobox with items and state handler
120
122
  */
121
- const { isOpen, ...useComboboxReturnProps } = useCombobox<
122
- (typeof allMenuItems)[number]
123
- >({
123
+
124
+ const useComboboxReturnProps = useCombobox<(typeof allMenuItems)[number]>({
124
125
  ...defaultDownshiftProps.combobox,
125
126
  items: allMenuItems,
126
127
  itemToString,
@@ -160,28 +161,38 @@ export const MultiAutocomplete = <
160
161
  ...useComboboxProps,
161
162
  });
162
163
 
164
+ const { isOpen, closeMenu, inputValue } = useComboboxReturnProps;
165
+ const { popoutProps: userPopoutProps } = useComboboxProps;
166
+
167
+ React.useEffect(() => {
168
+ if (!inputValue) return;
169
+ if (allMenuItems && hiddenItemsCount >= allMenuItems.length && isOpen) {
170
+ closeMenu?.();
171
+ }
172
+ }, [hiddenItemsCount, allMenuItems?.length, isOpen, inputValue]);
173
+
163
174
  return (
164
175
  <MenuRootComponent
165
176
  {...{
166
177
  isListbox: true,
167
178
  items: allMenuItems,
168
179
  itemToString,
169
- isOpen,
170
180
  initialSelectedItems,
171
181
  onStateChange,
172
182
  onSelectedItemsChange,
173
183
  getA11yMultiStatusMessage,
174
184
  onMultiSelectStateChange,
175
185
  multiSelectStateReducer,
176
- setHiddenItemsMap,
177
186
  ...useComboboxProps,
178
187
  ...useComboboxReturnProps,
179
188
  ...useMultipleSelectionReturnProps,
180
- ...restDefaults,
189
+ ...downshiftDefaults,
181
190
  }}
182
191
  popoutProps={{
183
192
  focusOnContent: false,
193
+ ...userPopoutProps,
184
194
  }}
195
+ autoCloseOnEmpty
185
196
  >
186
197
  {children}
187
198
  </MenuRootComponent>
@@ -1,3 +1,4 @@
1
+ import React from "react";
1
2
  import { useCombobox } from "downshift";
2
3
  import { useMenuChildren } from "../hooks/useMenuChildren";
3
4
  import { itemToString as defaultItemToString } from "../utils/downshiftDefaults";
@@ -27,8 +28,12 @@ export const SingleAutocomplete = <
27
28
  menuItems,
28
29
  MenuItemComponent,
29
30
  });
30
- const { setHiddenItemsMap, defaultDownshiftProps, ...restDefaults } =
31
- useDownshiftDefaults({ isCombobox: true });
31
+
32
+ const downshiftDefaults = useDownshiftDefaults({ isCombobox: true });
33
+
34
+ const { setHiddenItemsMap, defaultDownshiftProps, hiddenItemsCount } =
35
+ downshiftDefaults;
36
+
32
37
  const { updateFilteredItems } = useItemFiltering({
33
38
  allMenuItems,
34
39
  itemToString,
@@ -39,7 +44,7 @@ export const SingleAutocomplete = <
39
44
  /**
40
45
  * Destructure Downshift props and call the core useCombobox with items and state handler
41
46
  */
42
- const { isOpen, ...useComboboxReturnProps } = useCombobox({
47
+ const useComboboxReturnProps = useCombobox({
43
48
  ...defaultDownshiftProps.combobox,
44
49
  items: allMenuItems,
45
50
  itemToString,
@@ -51,21 +56,24 @@ export const SingleAutocomplete = <
51
56
  ...useComboboxProps,
52
57
  });
53
58
 
59
+ const { isOpen, closeMenu, inputValue } = useComboboxReturnProps;
60
+ const { popoutProps: userPopoutProps } = useComboboxProps;
61
+
54
62
  return (
55
63
  <MenuRootComponent
56
64
  {...{
57
65
  isListbox: true,
58
66
  items: allMenuItems,
59
67
  itemToString,
60
- isOpen,
61
- setHiddenItemsMap,
62
68
  ...useComboboxProps,
63
69
  ...useComboboxReturnProps,
64
- ...restDefaults,
70
+ ...downshiftDefaults,
65
71
  }}
66
72
  popoutProps={{
67
73
  focusOnContent: false,
74
+ ...userPopoutProps,
68
75
  }}
76
+ autoCloseOnEmpty
69
77
  >
70
78
  {children}
71
79
  </MenuRootComponent>
@@ -189,6 +189,27 @@ describe("Autocomplete Component", () => {
189
189
  });
190
190
  });
191
191
 
192
+ it("should apply custom popoutProps.width to the popout container", async () => {
193
+ const { user } = render(
194
+ <Autocomplete
195
+ {...defaultAutocompleteProps}
196
+ popoutProps={{ width: 300 }}
197
+ />
198
+ );
199
+
200
+ const inputElement = screen.getByRole("combobox");
201
+
202
+ await act(async () => {
203
+ await user.click(inputElement);
204
+ await user.type(inputElement, "item");
205
+ });
206
+
207
+ const listbox = screen.getByRole("listbox");
208
+ const popoutContainer = listbox.parentElement;
209
+
210
+ expect(popoutContainer).toHaveStyle({ width: "300px" });
211
+ });
212
+
192
213
  describe("in multiSelect mode", () => {
193
214
  it("should render multiple selected items", () => {
194
215
  render(
@@ -0,0 +1,17 @@
1
+ import styled from "styled-components";
2
+
3
+ const StyledEmptyState = styled.div`
4
+ padding: ${({ theme }) => theme.space[350]};
5
+ text-align: center;
6
+ font-size: ${({ theme }) => theme.typography[200].fontSize};
7
+ `;
8
+
9
+ const DefaultMenuEmptyState = ({
10
+ children,
11
+ }: {
12
+ children?: React.ReactNode;
13
+ }) => {
14
+ return <StyledEmptyState>{children || "No results found."}</StyledEmptyState>;
15
+ };
16
+
17
+ export { DefaultMenuEmptyState };
@@ -4,6 +4,7 @@ import { useMenuContentContext } from "../MenuContext";
4
4
  import { mapMenuItems } from "../hooks/useMenuChildren";
5
5
  import { useCallback } from "react";
6
6
  import type { TypeMenuItemProps } from "../MenuItem";
7
+ import { DefaultMenuEmptyState } from "./DefaultMenuEmptyState";
7
8
 
8
9
  /**
9
10
  * @link https://seeds.sproutsocial.com/components/seeds-react-menu/#menucontent
@@ -35,10 +36,16 @@ const MenuContent = <I extends TypeMenuItemProps = TypeMenuItemProps>({
35
36
  MenuItemComponent,
36
37
  onKeyDown,
37
38
  innerRef = null,
39
+ emptyStateElement,
38
40
  ...rest
39
41
  }: TypeMenuContentProps<I>) => {
40
- const { getToggleButtonProps, getMenuProps, isListbox } =
41
- useMenuContentContext();
42
+ const {
43
+ getToggleButtonProps,
44
+ getMenuProps,
45
+ isListbox,
46
+ hasVisibleItems,
47
+ autoCloseOnEmpty,
48
+ } = useMenuContentContext();
42
49
  const toggleButtonProps = getToggleButtonProps?.();
43
50
 
44
51
  const children =
@@ -46,6 +53,11 @@ const MenuContent = <I extends TypeMenuItemProps = TypeMenuItemProps>({
46
53
  ? childrenProp
47
54
  : mapMenuItems({ menuItems, MenuItemComponent });
48
55
 
56
+ if (!hasVisibleItems && !autoCloseOnEmpty) {
57
+ return emptyStateElement ?? <DefaultMenuEmptyState />;
58
+ }
59
+
60
+ // Hides the content if there are no children and it shouldn't show the empty state
49
61
  if (!children) {
50
62
  return null;
51
63
  }
@@ -5,4 +5,6 @@ export type TypeMenuContentProps<
5
5
  I extends TypeMenuItemProps = TypeMenuItemProps
6
6
  > = Omit<React.ComponentPropsWithoutRef<"ul">, "children"> & {
7
7
  innerRef?: React.Ref<HTMLUListElement>;
8
+ /* Custom component to display when no menu items are visible (e.g. "No results found"). */
9
+ emptyStateElement?: React.ReactNode;
8
10
  } & TypeChildrenOrItems<I>;
@@ -1,14 +1,28 @@
1
1
  import { render, screen } from "@sproutsocial/seeds-react-testing-library";
2
2
  import { MenuFooter, MenuHeader, MenuContent } from "../../index";
3
3
  import Button from "@sproutsocial/seeds-react-button";
4
+ import {
5
+ testMenuToggleElement,
6
+ TestWithMenuRoot,
7
+ } from "../../menuTestingUtils";
8
+
9
+ const mockMenuContext = {
10
+ isOpen: true,
11
+ };
4
12
 
5
13
  describe("MenuContent API", () => {
6
14
  it("should render correctly with default props", () => {
7
15
  render(
8
- <MenuContent data-testid="menu-content">
9
- <div>Test Child</div>
10
- </MenuContent>
16
+ <TestWithMenuRoot
17
+ menuToggleElement={testMenuToggleElement}
18
+ {...mockMenuContext}
19
+ >
20
+ <MenuContent data-testid="menu-content">
21
+ <div>Test Child</div>
22
+ </MenuContent>
23
+ </TestWithMenuRoot>
11
24
  );
25
+
12
26
  const menuContent = screen.getByTestId("menu-content");
13
27
  expect(menuContent).toBeInTheDocument();
14
28
  expect(menuContent).toHaveAttribute("tabIndex", "0");
@@ -18,21 +32,28 @@ describe("MenuContent API", () => {
18
32
  describe("MenuContent Behavior", () => {
19
33
  it("focus should move properly through menu", async () => {
20
34
  const { user } = render(
21
- <div>
22
- <MenuHeader>
23
- <Button data-testid="menu-header-button" onClick={() => {}}>
24
- Button
25
- </Button>
26
- </MenuHeader>
27
- <MenuContent data-testid="menu-content">
28
- <div>Test Child</div>
35
+ <TestWithMenuRoot
36
+ menuToggleElement={testMenuToggleElement}
37
+ {...mockMenuContext}
38
+ >
39
+ <MenuContent>
40
+ <div>
41
+ <MenuHeader>
42
+ <Button data-testid="menu-header-button" onClick={() => {}}>
43
+ Button
44
+ </Button>
45
+ </MenuHeader>
46
+ <MenuContent data-testid="menu-content">
47
+ <div>Test Child</div>
48
+ </MenuContent>
49
+ <MenuFooter>
50
+ <Button data-testid="menu-footer-button" onClick={() => {}}>
51
+ Button 2
52
+ </Button>
53
+ </MenuFooter>
54
+ </div>
29
55
  </MenuContent>
30
- <MenuFooter>
31
- <Button data-testid="menu-footer-button" onClick={() => {}}>
32
- Button 2
33
- </Button>
34
- </MenuFooter>
35
- </div>
56
+ </TestWithMenuRoot>
36
57
  );
37
58
 
38
59
  const menuHeaderButton = screen.getByTestId("menu-header-button");
@@ -111,6 +111,8 @@ export interface TypeMenuContextProps
111
111
  itemsMap?: { [key: string]: TypeInternalItemProps };
112
112
  selectedItemIds?: { [key: string]: boolean };
113
113
  isListbox?: boolean;
114
+ hasVisibleItems?: boolean;
115
+ autoCloseOnEmpty?: boolean;
114
116
  }
115
117
 
116
118
  export interface TypeMenuContext extends TypeMenuContextProps {
@@ -149,6 +151,7 @@ export const defaultMenuContext: TypeMenuContext = {
149
151
  itemsMap: {},
150
152
  isItemSelected: () => false,
151
153
  getInputComponentProps: () => ({}),
154
+ hasVisibleItems: false,
152
155
  };
153
156
 
154
157
  export const getSelectedItemIds = <
@@ -185,6 +188,8 @@ export const useMenu = ({
185
188
  selectedItems,
186
189
  getInputProps,
187
190
  contentRef: contentRefFromProps,
191
+ autoCloseOnEmpty,
192
+ hiddenItemsCount,
188
193
  ...rest
189
194
  }: Partial<TypeMenuContextProps> = {}): TypeMenuContext => {
190
195
  const contentRef = contentRefFromProps || React.createRef<HTMLUListElement>();
@@ -247,6 +252,12 @@ export const useMenu = ({
247
252
  [getInputProps]
248
253
  );
249
254
 
255
+ const hasVisibleItems = useMemo(() => {
256
+ // if (!items || items.length === 0) return false;
257
+ // if (!rest.hiddenItemsMap) return true;
258
+ return (hiddenItemsCount ?? 0) < items.length;
259
+ }, [items, hiddenItemsCount]);
260
+
250
261
  return {
251
262
  isOpen,
252
263
  isListbox,
@@ -259,6 +270,8 @@ export const useMenu = ({
259
270
  getInputProps,
260
271
  getInputComponentProps,
261
272
  contentRef,
273
+ hasVisibleItems,
274
+ autoCloseOnEmpty,
262
275
  ...menuProps,
263
276
  };
264
277
  };
@@ -18,7 +18,11 @@ import {
18
18
  MultiSelectMenu,
19
19
  INPUT_TYPES,
20
20
  } from "../../index";
21
- import { TestWithMenuRoot, TestWithMockedMenu } from "../../menuTestingUtils";
21
+ import {
22
+ testMenuToggleElement,
23
+ TestWithMenuRoot,
24
+ TestWithMockedMenu,
25
+ } from "../../menuTestingUtils";
22
26
 
23
27
  const books = [
24
28
  {
@@ -35,19 +39,6 @@ const menuItemProps = {
35
39
  id: "book-id",
36
40
  children: "Book Title",
37
41
  };
38
- const testMenuContext = {
39
- isOpen: true,
40
- closeMenu: jest.fn(),
41
- openMenu: jest.fn(),
42
- getMenuProps: jest.fn((props) => props),
43
- getToggleButtonProps: jest.fn((props) => ({
44
- onKeyDown: jest.fn(),
45
- ...props,
46
- })),
47
- getItemProps: jest.fn((props) => ({ ...props })),
48
- isListbox: false,
49
- };
50
- const testMenuToggleElement = <MenuToggleButton>Open</MenuToggleButton>;
51
42
 
52
43
  const INTERACTION_TYPES = {
53
44
  click: {
@@ -108,6 +99,12 @@ describe("MenuItem when not isListbox", () => {
108
99
  describe("is clickable", () => {
109
100
  it("fires onClick event when clicked and not disabled", async () => {
110
101
  const handleClick = jest.fn();
102
+
103
+ const testMenuContext = {
104
+ isOpen: true,
105
+ isListbox: false,
106
+ };
107
+
111
108
  const { user } = render(
112
109
  <TestWithMenuRoot
113
110
  menuToggleElement={testMenuToggleElement}
@@ -129,9 +129,8 @@ export const MenuRoot = ({
129
129
  openMenu,
130
130
  closeMenu,
131
131
  inputValue,
132
- hiddenItemsCount = 0,
133
- items,
134
132
  getMenuProps,
133
+ autoCloseOnEmpty = false,
135
134
  } = contextProps;
136
135
 
137
136
  const toggleElementRef = React.useRef<HTMLElement>(null);
@@ -150,18 +149,19 @@ export const MenuRoot = ({
150
149
  }
151
150
  }, [width, toggleElementRef.current?.offsetWidth]);
152
151
 
153
- useEffect(() => {
154
- if (!inputValue) return;
155
- if (items && hiddenItemsCount >= items.length && isOpen) {
156
- closeMenu?.();
157
- }
158
- }, [hiddenItemsCount, items?.length, isOpen, inputValue]);
159
-
160
152
  const menuContext = useMenu(contextProps);
161
153
 
162
154
  // Hack to suppress error: "downshift: You forgot to call the getMenuProps getter function on your component / element."
163
155
  getMenuProps?.({}, { suppressRefError: true });
164
156
 
157
+ useEffect(() => {
158
+ if (!inputValue || !autoCloseOnEmpty) return;
159
+
160
+ if (!menuContext.hasVisibleItems && isOpen) {
161
+ closeMenu?.();
162
+ }
163
+ }, [menuContext.hasVisibleItems, isOpen, inputValue, autoCloseOnEmpty]);
164
+
165
165
  return (
166
166
  <MenuPopout
167
167
  placement={placement}
@@ -230,3 +230,47 @@ export const SingleSelectMenuWithSearch: Story = {
230
230
  );
231
231
  },
232
232
  };
233
+
234
+ export const SingleSelectMenuWithCustomEmpty: Story = {
235
+ name: "Single Select Menu With Custom Empty State",
236
+ render: ({ inputType, ...args }) => {
237
+ return (
238
+ <SingleSelectMenu
239
+ itemToString={(item) => (item ? TEST_BOOKS[item.index].title : "")}
240
+ menuToggleElement={<StorybookMenuToggleButton />}
241
+ onStateChange={(changes) => action("onStateChange")(changes)}
242
+ {...(args as object)}
243
+ >
244
+ <MenuHeader title="Select Book">
245
+ <MenuSearchInput
246
+ id="single-select-search"
247
+ name="search"
248
+ type="search"
249
+ aria-label="Search Books"
250
+ />
251
+ </MenuHeader>
252
+ <MenuContent
253
+ emptyStateElement={
254
+ <div
255
+ style={{
256
+ color: "red",
257
+ fontStyle: "italic",
258
+ padding: "12px",
259
+ fontSize: "14px",
260
+ textAlign: "center",
261
+ }}
262
+ >
263
+ No results found. Try something else...
264
+ </div>
265
+ }
266
+ >
267
+ {TEST_BOOKS.map(({ id, title }) => (
268
+ <MenuItem key={id} id={id} inputType={inputType}>
269
+ {title}
270
+ </MenuItem>
271
+ ))}
272
+ </MenuContent>
273
+ </SingleSelectMenu>
274
+ );
275
+ },
276
+ };
@@ -2,14 +2,15 @@ import React from "react";
2
2
  import {
3
3
  MenuContentContext,
4
4
  MenuRoot,
5
- checkIfItemSelected,
6
- getSelectedItemIds,
7
5
  useMenu,
8
6
  type TypeMenuContextProps,
9
7
  type TypeMenuContext,
10
8
  type TypeMenuRootProps,
9
+ MenuToggleButton,
11
10
  } from "./index";
12
11
 
12
+ export const testMenuToggleElement = <MenuToggleButton>Open</MenuToggleButton>;
13
+
13
14
  export const testDefaultContext = ({
14
15
  getToggleButtonProps,
15
16
  ...context
@@ -24,14 +25,18 @@ export const testDefaultContext = ({
24
25
  // TODO: Make type on MenuContentContext more agnostic for other use cases
25
26
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
26
27
  // @ts-expect-error - mocked function does not match real type
27
- getItemProps: (props) => ({ role: "option", ...props }),
28
+ getItemProps: jest.fn((props) => ({ role: "option", ...props })),
28
29
  // @ts-expect-error - mocked function does not match real type
29
- getToggleButtonProps: ({ ref, refKey, ...props } = {}) => ({
30
+ getToggleButtonProps: jest.fn(({ ref, refKey = "ref", ...props } = {}) => ({
31
+ onKeyDown: jest.fn(),
30
32
  ...getToggleButtonProps?.({ ref, refKey, ...props }),
31
33
  ...(ref ? { [refKey]: ref } : {}),
32
34
  ...props,
33
- }),
35
+ })),
36
+ // @ts-expect-error - mocked function does not match real type
37
+ getMenuProps: jest.fn((props) => props),
34
38
  isListbox: true,
39
+ hasVisibleItems: true,
35
40
  ...context,
36
41
  });
37
42
 
@@ -45,11 +50,11 @@ export const testDefaultContext = ({
45
50
  */
46
51
  export const TestWithMenuRoot = ({
47
52
  children,
48
- menuToggleElement,
53
+ menuToggleElement = testMenuToggleElement,
49
54
  ...props
50
55
  }: {
51
56
  children: TypeMenuRootProps["children"];
52
- menuToggleElement: TypeMenuRootProps["menuToggleElement"];
57
+ menuToggleElement?: TypeMenuRootProps["menuToggleElement"];
53
58
  } & Partial<Omit<TypeMenuRootProps, "children" | "menuToggleElement">>) => {
54
59
  return (
55
60
  <MenuRoot