@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/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +23 -0
- package/dist/esm/index.js +19 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +38 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/MenuRoot/MenuRoot.tsx +0 -1
- package/src/NestedMenu/NestedMenu.stories.tsx +94 -0
- package/src/NestedMenu/NestedMenu.tsx +2 -0
- package/src/NestedMenu/NestedMenuPopout.tsx +18 -1
- package/src/NestedMenu/__tests__/NestedMenu.test.tsx +83 -0
package/package.json
CHANGED
|
@@ -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 "Close Menu Action" → 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
|
-
|
|
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
|
});
|