Skip to content

Commit 252c4b2

Browse files
JamesDoingStuffMatthew Wilcoxson
andauthored
Multi-level Navbar Menu (#123)
* Add NavMenu component * Modify NavMenuLink to support router links * Demo multilevel menu in storybook. --------- Co-authored-by: Matthew Wilcoxson <github+anon@akademy.co.uk>
1 parent 9b56dec commit 252c4b2

File tree

9 files changed

+439
-10
lines changed

9 files changed

+439
-10
lines changed

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ SciReactUI Changelog
77
### Added
88
- New *Progress* component based on Diamond Light added.
99
- New *ProgressDelayed* component so that the progress isn't shown at all when it's a small wait.
10+
- *NavMenu* component added for creating dropdown menus in the Navbar
11+
- *NavMenuLink* component extends NavLink to work in the NavMenu
1012

1113
### Fixed
1214
- Hovering over a slot caused a popup with the slot title in. This has been removed.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@storybook/test": "^8.4.4",
6868
"@testing-library/jest-dom": "^6.9.1",
6969
"@testing-library/react": "^16.1.0",
70+
"@testing-library/user-event": "^14.6.1",
7071
"@types/node": "^20.19.21",
7172
"@types/react": "^18.3.12",
7273
"@types/react-dom": "^18.3.1",

pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { NavMenu, NavMenuLink } from "./NavMenu";
3+
import { Button, Divider, Typography } from "@mui/material";
4+
import { Autorenew } from "@mui/icons-material";
5+
import { MockLink } from "../../utils/MockLink";
6+
7+
const meta: Meta<typeof NavMenu> = {
8+
title: "Components/Navigation/NavMenu",
9+
component: NavMenu,
10+
tags: ["autodocs"],
11+
parameters: {
12+
docs: {
13+
description: {
14+
component:
15+
"A dropdown menu for the Navbar. Can contain multiple `NavMenuLink`s that can be navigated between using the mouse or the keyboard.",
16+
},
17+
},
18+
},
19+
};
20+
21+
export default meta;
22+
type Story = StoryObj<typeof meta>;
23+
24+
export const BasicMenu: Story = {
25+
args: {
26+
label: "NavMenu",
27+
children: (
28+
<>
29+
<NavMenuLink href="#Link1">First Link</NavMenuLink>
30+
<NavMenuLink href="#Link2">Second Link</NavMenuLink>
31+
<NavMenuLink href="#Link3">Third Link</NavMenuLink>
32+
</>
33+
),
34+
},
35+
parameters: {
36+
docs: {
37+
description: {
38+
story:
39+
'A `NavMenu` populated with `NavMenuLink`s. The menu text is set using `label: "NavMenu"`.',
40+
},
41+
},
42+
},
43+
};
44+
45+
export const RouterMenu: Story = {
46+
args: {
47+
label: "NavMenu",
48+
children: (
49+
<>
50+
<NavMenuLink to="/home/first" linkComponent={MockLink}>
51+
First Route
52+
</NavMenuLink>
53+
<NavMenuLink to="/home/second" linkComponent={MockLink}>
54+
Second Route
55+
</NavMenuLink>
56+
</>
57+
),
58+
},
59+
parameters: {
60+
docs: {
61+
description: {
62+
story: "Like `NavLink`s, `NavMenuLink`s can use routing links too.",
63+
},
64+
},
65+
},
66+
};
67+
68+
export const CustomChildren: Story = {
69+
args: {
70+
label: "NavMenu",
71+
children: (
72+
<>
73+
<Typography
74+
sx={{
75+
padding: "4px 4px 4px",
76+
color: "white",
77+
}}
78+
>
79+
Section Header
80+
</Typography>
81+
<Divider />
82+
<Button sx={{ color: "white" }} startIcon={<Autorenew />}>
83+
Button
84+
</Button>
85+
</>
86+
),
87+
},
88+
parameters: {
89+
docs: {
90+
description: {
91+
story:
92+
"A `NavMenu` may contain components other than `NavMenuLink`s. This one has a section header (made using a `Typography` and a `Divider`) and a button.",
93+
},
94+
},
95+
},
96+
};
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { screen, act } from "@testing-library/react";
2+
import { userEvent } from "@testing-library/user-event";
3+
import { renderWithProviders } from "../../__test-utils__/helpers";
4+
import { NavMenu, NavMenuLink } from "./NavMenu";
5+
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
6+
const user = userEvent.setup();
7+
8+
describe("NavMenu", () => {
9+
it("should render with a label", () => {
10+
renderWithProviders(<NavMenu label="Navmenu" />);
11+
expect(screen.getByText("Navmenu")).toBeInTheDocument();
12+
});
13+
14+
it("should open when clicked", async () => {
15+
renderWithProviders(
16+
<NavMenu label="Navmenu">
17+
<NavMenuLink href="#test">Link 1</NavMenuLink>
18+
<NavMenuLink href="#test2">Link 2</NavMenuLink>
19+
</NavMenu>,
20+
);
21+
const menuButton = screen.getByRole("button");
22+
expect(screen.queryByText("Link 1")).not.toBeInTheDocument();
23+
expect(menuButton).toHaveAttribute("aria-expanded", "false");
24+
await user.click(menuButton);
25+
expect(screen.getByText("Link 1")).toBeVisible();
26+
expect(screen.getByText("Link 2")).toBeVisible();
27+
expect(menuButton).toHaveAttribute("aria-expanded", "true");
28+
});
29+
30+
it("should open when selected using keyboard", async () => {
31+
renderWithProviders(
32+
<NavMenu label="Navmenu">
33+
<NavMenuLink href="">Link 1</NavMenuLink>
34+
</NavMenu>,
35+
);
36+
37+
expect(screen.queryByText("Link 1")).not.toBeInTheDocument();
38+
await user.keyboard("[Tab][Enter]");
39+
expect(screen.getByText("Link 1")).toBeVisible();
40+
});
41+
42+
it("should be possible to access the contents using the keyboard", async () => {
43+
renderWithProviders(
44+
<NavMenu label="Navmenu">
45+
<NavMenuLink href="">Link 1</NavMenuLink>
46+
<NavMenuLink href="">Link 2</NavMenuLink>
47+
</NavMenu>,
48+
);
49+
50+
await user.keyboard("[Tab][Enter][ArrowDown]");
51+
const link1 = screen.getByRole("menuitem", { name: "Link 1" });
52+
expect(document.activeElement).toBe(link1);
53+
await user.keyboard("[ArrowDown]");
54+
const link2 = screen.getByRole("menuitem", { name: "Link 2" });
55+
expect(document.activeElement).toBe(link2);
56+
});
57+
58+
it("should render with accessibility props", async () => {
59+
renderWithProviders(<NavMenu label="Navmenu" />);
60+
61+
const menuButton = screen.getByRole("button");
62+
const buttonControlsId = menuButton.getAttribute("aria-controls");
63+
expect(menuButton).toHaveAttribute("aria-haspopup", "menu");
64+
await user.click(menuButton);
65+
const menuId = screen.getByRole("presentation").getAttribute("id");
66+
expect(buttonControlsId).toEqual(menuId);
67+
});
68+
});
69+
70+
describe("NavMenuLink", () => {
71+
it("should function as a link", () => {
72+
renderWithProviders(<NavMenuLink href="/test">Link</NavMenuLink>);
73+
expect(screen.getByRole("menuitem")).toHaveAttribute("href", "/test");
74+
});
75+
76+
it("should accept router link props", () => {
77+
renderWithProviders(
78+
<MemoryRouter>
79+
<NavMenuLink to="/test" linkComponent={Link}>
80+
Link
81+
</NavMenuLink>
82+
</MemoryRouter>,
83+
);
84+
expect(screen.getByRole("menuitem")).toHaveAttribute("href", "/test");
85+
});
86+
87+
it("should use routing when clicked", async () => {
88+
renderWithProviders(
89+
<MemoryRouter>
90+
<Routes>
91+
<Route
92+
path="/"
93+
element={
94+
<NavMenuLink to="/test" linkComponent={Link}>
95+
Link
96+
</NavMenuLink>
97+
}
98+
/>
99+
<Route path="/test" element={<p>Second page</p>} />
100+
</Routes>
101+
</MemoryRouter>,
102+
);
103+
await user.click(screen.getByRole("menuitem"));
104+
expect(screen.getByText("Second page")).toBeInTheDocument();
105+
});
106+
107+
it("should use routing on enter key press", async () => {
108+
renderWithProviders(
109+
<MemoryRouter>
110+
<Routes>
111+
<Route
112+
path="/"
113+
element={
114+
<NavMenuLink to="/test" linkComponent={Link}>
115+
Link
116+
</NavMenuLink>
117+
}
118+
/>
119+
<Route path="/test" element={<p>Second page</p>} />
120+
</Routes>
121+
</MemoryRouter>,
122+
);
123+
const link = screen.getByRole("menuitem");
124+
act(() => link.focus());
125+
await user.keyboard("[enter]");
126+
expect(screen.getByText("Second page")).toBeInTheDocument();
127+
});
128+
});

0 commit comments

Comments
 (0)