@sproutsocial/seeds-react-menu 1.2.3 → 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.3",
3
+ "version": "1.3.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -162,6 +162,7 @@ export const MultiAutocomplete = <
162
162
  });
163
163
 
164
164
  const { isOpen, closeMenu, inputValue } = useComboboxReturnProps;
165
+ const { popoutProps: userPopoutProps } = useComboboxProps;
165
166
 
166
167
  React.useEffect(() => {
167
168
  if (!inputValue) return;
@@ -189,7 +190,9 @@ export const MultiAutocomplete = <
189
190
  }}
190
191
  popoutProps={{
191
192
  focusOnContent: false,
193
+ ...userPopoutProps,
192
194
  }}
195
+ autoCloseOnEmpty
193
196
  >
194
197
  {children}
195
198
  </MenuRootComponent>
@@ -57,13 +57,7 @@ export const SingleAutocomplete = <
57
57
  });
58
58
 
59
59
  const { isOpen, closeMenu, inputValue } = useComboboxReturnProps;
60
-
61
- React.useEffect(() => {
62
- if (!inputValue) return;
63
- if (allMenuItems && hiddenItemsCount >= allMenuItems.length && isOpen) {
64
- closeMenu?.();
65
- }
66
- }, [hiddenItemsCount, allMenuItems?.length, isOpen, inputValue]);
60
+ const { popoutProps: userPopoutProps } = useComboboxProps;
67
61
 
68
62
  return (
69
63
  <MenuRootComponent
@@ -77,7 +71,9 @@ export const SingleAutocomplete = <
77
71
  }}
78
72
  popoutProps={{
79
73
  focusOnContent: false,
74
+ ...userPopoutProps,
80
75
  }}
76
+ autoCloseOnEmpty
81
77
  >
82
78
  {children}
83
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);
@@ -155,6 +154,14 @@ export const MenuRoot = ({
155
154
  // Hack to suppress error: "downshift: You forgot to call the getMenuProps getter function on your component / element."
156
155
  getMenuProps?.({}, { suppressRefError: true });
157
156
 
157
+ useEffect(() => {
158
+ if (!inputValue || !autoCloseOnEmpty) return;
159
+
160
+ if (!menuContext.hasVisibleItems && isOpen) {
161
+ closeMenu?.();
162
+ }
163
+ }, [menuContext.hasVisibleItems, isOpen, inputValue, autoCloseOnEmpty]);
164
+
158
165
  return (
159
166
  <MenuPopout
160
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