@sproutsocial/seeds-react-menu 1.7.5 → 1.7.7

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.7.5",
3
+ "version": "1.7.7",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -64,7 +64,6 @@ export const menuPlacementOrigin: {
64
64
  };
65
65
 
66
66
  export const menuAnimationConfig = {
67
- layout: true,
68
67
  variants: {
69
68
  initial: (
70
69
  custom: { placement: EnumPlacements } = { placement: "bottom" }
@@ -3,6 +3,7 @@ import React from "react";
3
3
  import type { Meta, StoryObj } from "@storybook/react";
4
4
 
5
5
  import { Box } from "@sproutsocial/seeds-react-box";
6
+ import { Text } from "@sproutsocial/seeds-react-text";
6
7
  // eslint-disable-next-line no-restricted-imports
7
8
  import { TEST_BOOKS } from "../__fixtures__/menu-test-data";
8
9
 
@@ -191,3 +192,96 @@ export const NestedMenuMultiLevelStory: Story = {
191
192
  );
192
193
  },
193
194
  };
195
+
196
+ export const FocusManagementDemo: Story = {
197
+ name: "Focus Management (DS-3901)",
198
+ render: (args) => {
199
+ const focusDemoMenus = {
200
+ root: {
201
+ MenuComponent: ActionMenu,
202
+ menuProps: {
203
+ menuItems: [
204
+ { id: "child1", children: "Open Child Menu", childMenuId: "child" },
205
+ { id: "action1", children: "Close Menu Action" },
206
+ ],
207
+ },
208
+ MenuHeaderComponent: null,
209
+ },
210
+ child: {
211
+ MenuComponent: SingleSelectMenu,
212
+ menuProps: {
213
+ menuItems: [
214
+ { id: "item1", children: "Item 1" },
215
+ { id: "item2", children: "Item 2" },
216
+ { id: "item3", children: "Item 3" },
217
+ ],
218
+ },
219
+ menuHeaderProps: {
220
+ title: "Child Menu",
221
+ },
222
+ },
223
+ };
224
+
225
+ return (
226
+ <Box
227
+ display="flex"
228
+ flexDirection="column"
229
+ alignItems="center"
230
+ justifyContent="center"
231
+ gap="24px"
232
+ height="100vh"
233
+ width="100vw"
234
+ >
235
+ <Box
236
+ maxWidth="500px"
237
+ padding="16px"
238
+ backgroundColor="background.secondary"
239
+ borderRadius="8px"
240
+ >
241
+ <Text>
242
+ <strong>Testing Focus Management (DS-3901)</strong>
243
+ </Text>
244
+ <Text size="small" marginTop="8px">
245
+ This story demonstrates that focus returns to the toggle button when
246
+ the menu closes:
247
+ </Text>
248
+ <ul style={{ marginTop: "8px", paddingLeft: "20px" }}>
249
+ <li>
250
+ <Text size="small">
251
+ Open the menu and press <kbd>Escape</kbd> → Focus returns to
252
+ button
253
+ </Text>
254
+ </li>
255
+ <li>
256
+ <Text size="small">
257
+ Open the menu and click &quot;Close Menu Action&quot; → Focus
258
+ returns to button
259
+ </Text>
260
+ </li>
261
+ <li>
262
+ <Text size="small">
263
+ Navigate to child menu and press <kbd>Escape</kbd> → Focus
264
+ returns to button
265
+ </Text>
266
+ </li>
267
+ <li>
268
+ <Text size="small">
269
+ Navigate to child menu and select an item → Focus returns to
270
+ button
271
+ </Text>
272
+ </li>
273
+ </ul>
274
+ </Box>
275
+
276
+ <NestedMenu
277
+ initialMenuId="root"
278
+ menus={focusDemoMenus}
279
+ backArrowLabel="Back"
280
+ menuToggleElement={
281
+ <MenuToggleButton width="200px">Test Focus Return</MenuToggleButton>
282
+ }
283
+ />
284
+ </Box>
285
+ );
286
+ },
287
+ };
@@ -111,6 +111,7 @@ export const NestedMenu = <
111
111
  const [currentMenuId, setCurrentMenuId] = useState(initialMenuId);
112
112
  const [isAdding, setIsAdding] = useState(false);
113
113
  const popoutUpdateRef = useRef<() => void | undefined>();
114
+ const toggleElementRef = useRef<HTMLElement>(null);
114
115
 
115
116
  const setSelectedMenuId = (id: string) => {
116
117
  setIsAdding(true);
@@ -292,6 +293,7 @@ export const NestedMenu = <
292
293
  }
293
294
  menuToggleElement={menuToggleElement}
294
295
  width={width}
296
+ toggleElementRef={toggleElementRef}
295
297
  scheduleUpdateRef={(callback) => {
296
298
  popoutUpdateRef.current = callback;
297
299
  externalPopoutUpdateRef?.(callback);
@@ -1,3 +1,4 @@
1
+ import { type RefObject } from "react";
1
2
  import { useNestedMenuContext } from "./NestedMenuContext";
2
3
  import {
3
4
  MenuPopout,
@@ -7,6 +8,7 @@ import {
7
8
  import { MenuToggleProvider } from "../MenuContext";
8
9
  import { motion } from "motion/react";
9
10
  import styled from "styled-components";
11
+ import { mergeRefs } from "@sproutsocial/seeds-react-utilities";
10
12
 
11
13
  const StyledDiv = styled(motion.div)`
12
14
  overflow: hidden;
@@ -17,9 +19,12 @@ export const NestedMenuPopout = ({
17
19
  menuToggleElement,
18
20
  width,
19
21
  onClose,
22
+ toggleElementRef,
23
+ focusLockProps,
20
24
  ...popoutProps
21
25
  }: Omit<TypeMenuPopoutProps, "children"> & {
22
26
  menuToggleElement: TypeMenuRootProps["menuToggleElement"];
27
+ toggleElementRef: RefObject<HTMLElement>;
23
28
  }) => {
24
29
  const { activeMenuContext, rootMenuContextProps, resetSelectedMenuPath } =
25
30
  useNestedMenuContext();
@@ -30,7 +35,11 @@ export const NestedMenuPopout = ({
30
35
  // Reset the selected menu path after the close animation completes
31
36
  onClose={() => {
32
37
  onClose?.();
33
- setTimeout(() => resetSelectedMenuPath?.(), 100);
38
+ // Manually focus the toggle element and then reset state
39
+ setTimeout(() => {
40
+ toggleElementRef.current?.focus();
41
+ resetSelectedMenuPath?.();
42
+ }, 200);
34
43
  }}
35
44
  isOpen={!!rootMenuContextProps.isOpen}
36
45
  openMenu={rootMenuContextProps.openMenu}
@@ -41,6 +50,10 @@ export const NestedMenuPopout = ({
41
50
  {typeof content === "function" ? content(props) : content}
42
51
  </StyledDiv>
43
52
  )}
53
+ focusLockProps={{
54
+ crossFrame: false,
55
+ ...focusLockProps,
56
+ }}
44
57
  {...popoutProps}
45
58
  >
46
59
  {(toggleProps) => (
@@ -50,6 +63,10 @@ export const NestedMenuPopout = ({
50
63
  ...activeMenuContext,
51
64
  ...rootMenuContextProps,
52
65
  ...toggleProps,
66
+ ref: mergeRefs<HTMLElement>(
67
+ toggleProps.ref,
68
+ toggleElementRef
69
+ ) as RefObject<HTMLElement>,
53
70
  }}
54
71
  >
55
72
  {menuToggleElement}
@@ -219,4 +219,87 @@ describe("NestedMenu", () => {
219
219
 
220
220
  expect(mockOnBackButtonClick).toHaveBeenCalled();
221
221
  });
222
+
223
+ describe("Focus Management", () => {
224
+ it("returns focus to the toggle button when menu closes via Escape key", async () => {
225
+ const utils = render(<NestedMenu {...testProps} />);
226
+ const toggleButton = screen.getByRole("button", { name: "Open Menu" });
227
+
228
+ // Focus the button first (FocusLock needs to know what had focus before)
229
+ toggleButton.focus();
230
+ expect(toggleButton).toHaveFocus();
231
+
232
+ // Now open the menu with keyboard (preserves focus better)
233
+ await act(async () => {
234
+ await utils.user.keyboard("{Enter}");
235
+ });
236
+
237
+ // Menu should be open
238
+ expect(await screen.findByText("Go to Child Menu 1")).toBeInTheDocument();
239
+
240
+ // Close menu with Escape
241
+ await act(async () => {
242
+ await utils.user.keyboard("{Escape}");
243
+ });
244
+
245
+ // Wait for menu to close
246
+ await waitFor(() =>
247
+ expect(screen.queryByText("Go to Child Menu 1")).not.toBeInTheDocument()
248
+ );
249
+
250
+ // Wait for animation and focus to complete
251
+ await new Promise((resolve) => setTimeout(resolve, 250));
252
+
253
+ // Focus should return to toggle button
254
+ await waitFor(() => {
255
+ expect(toggleButton).toHaveFocus();
256
+ });
257
+ });
258
+
259
+ it("returns focus to the toggle button when menu closes via clicking an action item", async () => {
260
+ const utils = render(<NestedMenu {...testProps} />);
261
+ const toggleButton = screen.getByRole("button", { name: "Open Menu" });
262
+
263
+ // Focus the button first (FocusLock needs to know what had focus before)
264
+ toggleButton.focus();
265
+ expect(toggleButton).toHaveFocus();
266
+
267
+ // Now open the menu with keyboard (preserves focus better)
268
+ await act(async () => {
269
+ await utils.user.keyboard("{Enter}");
270
+ });
271
+
272
+ // Wait for menu to open
273
+ expect(await screen.findByText("Go to Child Menu 1")).toBeInTheDocument();
274
+
275
+ // Click an action item to close the menu
276
+ const item = screen.getByText("No Child Menu");
277
+ await act(async () => {
278
+ await utils.user.click(item);
279
+ });
280
+
281
+ // Wait for menu to close
282
+ await waitFor(() =>
283
+ expect(screen.queryByText("No Child Menu")).not.toBeInTheDocument()
284
+ );
285
+
286
+ // Wait for animation and focus to complete
287
+ await new Promise((resolve) => setTimeout(resolve, 250));
288
+
289
+ // Focus should return to toggle button
290
+ await waitFor(() => {
291
+ expect(toggleButton).toHaveFocus();
292
+ });
293
+ });
294
+
295
+ it("assigns ref to the toggle button element", async () => {
296
+ render(<NestedMenu {...testProps} />);
297
+ const toggleButton = screen.getByRole("button", { name: "Open Menu" });
298
+
299
+ // The toggle button should have a ref assigned
300
+ // This can be verified by checking if the element is properly connected to the DOM
301
+ expect(toggleButton).toBeInTheDocument();
302
+ expect(toggleButton).toBeInstanceOf(HTMLElement);
303
+ });
304
+ });
222
305
  });