diff --git a/changelog.md b/changelog.md
index c4379c6..f8f723f 100644
--- a/changelog.md
+++ b/changelog.md
@@ -7,6 +7,8 @@ SciReactUI Changelog
### Added
- New *Progress* component based on Diamond Light added.
- New *ProgressDelayed* component so that the progress isn't shown at all when it's a small wait.
+- *NavMenu* component added for creating dropdown menus in the NavBar
+ - *NavMenuLink* component extends NavLink to work in the NavMenu
### Fixed
- Hovering over a slot caused a popup with the slot title in. This has been removed.
diff --git a/package.json b/package.json
index 9eb2726..db0afd4 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"@storybook/test": "^8.4.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20.19.21",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f3b6b6d..9acfe33 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -117,6 +117,9 @@ importers:
'@testing-library/react':
specifier: ^16.1.0
version: 16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.0)
'@types/node':
specifier: ^20.19.21
version: 20.19.21
@@ -1981,6 +1984,12 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tootallnate/once@2.0.0':
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -6788,6 +6797,10 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.0
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)':
+ dependencies:
+ '@testing-library/dom': 10.4.0
+
'@tootallnate/once@2.0.0':
optional: true
diff --git a/src/components/navigation/NavMenu.stories.tsx b/src/components/navigation/NavMenu.stories.tsx
new file mode 100644
index 0000000..6f2a896
--- /dev/null
+++ b/src/components/navigation/NavMenu.stories.tsx
@@ -0,0 +1,96 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { NavMenu, NavMenuLink } from "./NavMenu";
+import { Button, Divider, Typography } from "@mui/material";
+import { Autorenew } from "@mui/icons-material";
+import { MockLink } from "../../utils/MockLink";
+
+const meta: Meta = {
+ title: "Components/Navigation/NavMenu",
+ component: NavMenu,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A dropdown menu for the Navbar. Can contain multiple `NavMenuLink`s that can be navigated between using the mouse or the keyboard.",
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const BasicMenu: Story = {
+ args: {
+ label: "NavMenu",
+ children: (
+ <>
+ First Link
+ Second Link
+ Third Link
+ >
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A `NavMenu` populated with `NavMenuLink`s. The menu text is set using `label: "NavMenu"`.',
+ },
+ },
+ },
+};
+
+export const RouterMenu: Story = {
+ args: {
+ label: "NavMenu",
+ children: (
+ <>
+
+ First Route
+
+
+ Second Route
+
+ >
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Like `NavLink`s, `NavMenuLink`s can use routing links too.",
+ },
+ },
+ },
+};
+
+export const CustomChildren: Story = {
+ args: {
+ label: "NavMenu",
+ children: (
+ <>
+
+ Section Header
+
+
+ }>
+ Button
+
+ >
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "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.",
+ },
+ },
+ },
+};
diff --git a/src/components/navigation/NavMenu.test.tsx b/src/components/navigation/NavMenu.test.tsx
new file mode 100644
index 0000000..178647e
--- /dev/null
+++ b/src/components/navigation/NavMenu.test.tsx
@@ -0,0 +1,128 @@
+import { screen, act } from "@testing-library/react";
+import { userEvent } from "@testing-library/user-event";
+import { renderWithProviders } from "../../__test-utils__/helpers";
+import { NavMenu, NavMenuLink } from "./NavMenu";
+import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
+const user = userEvent.setup();
+
+describe("NavMenu", () => {
+ it("should render with a label", () => {
+ renderWithProviders();
+ expect(screen.getByText("Navmenu")).toBeInTheDocument();
+ });
+
+ it("should open when clicked", async () => {
+ renderWithProviders(
+
+ Link 1
+ Link 2
+ ,
+ );
+ const menuButton = screen.getByRole("button");
+ expect(screen.queryByText("Link 1")).not.toBeInTheDocument();
+ expect(menuButton).toHaveAttribute("aria-expanded", "false");
+ await user.click(menuButton);
+ expect(screen.getByText("Link 1")).toBeVisible();
+ expect(screen.getByText("Link 2")).toBeVisible();
+ expect(menuButton).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("should open when selected using keyboard", async () => {
+ renderWithProviders(
+
+ Link 1
+ ,
+ );
+
+ expect(screen.queryByText("Link 1")).not.toBeInTheDocument();
+ await user.keyboard("[Tab][Enter]");
+ expect(screen.getByText("Link 1")).toBeVisible();
+ });
+
+ it("should be possible to access the contents using the keyboard", async () => {
+ renderWithProviders(
+
+ Link 1
+ Link 2
+ ,
+ );
+
+ await user.keyboard("[Tab][Enter][ArrowDown]");
+ const link1 = screen.getByRole("menuitem", { name: "Link 1" });
+ expect(document.activeElement).toBe(link1);
+ await user.keyboard("[ArrowDown]");
+ const link2 = screen.getByRole("menuitem", { name: "Link 2" });
+ expect(document.activeElement).toBe(link2);
+ });
+
+ it("should render with accessibility props", async () => {
+ renderWithProviders();
+
+ const menuButton = screen.getByRole("button");
+ const buttonControlsId = menuButton.getAttribute("aria-controls");
+ expect(menuButton).toHaveAttribute("aria-haspopup", "menu");
+ await user.click(menuButton);
+ const menuId = screen.getByRole("presentation").getAttribute("id");
+ expect(buttonControlsId).toEqual(menuId);
+ });
+});
+
+describe("NavMenuLink", () => {
+ it("should function as a link", () => {
+ renderWithProviders(Link);
+ expect(screen.getByRole("menuitem")).toHaveAttribute("href", "/test");
+ });
+
+ it("should accept router link props", () => {
+ renderWithProviders(
+
+
+ Link
+
+ ,
+ );
+ expect(screen.getByRole("menuitem")).toHaveAttribute("href", "/test");
+ });
+
+ it("should use routing when clicked", async () => {
+ renderWithProviders(
+
+
+
+ Link
+
+ }
+ />
+ Second page
} />
+
+ ,
+ );
+ await user.click(screen.getByRole("menuitem"));
+ expect(screen.getByText("Second page")).toBeInTheDocument();
+ });
+
+ it("should use routing on enter key press", async () => {
+ renderWithProviders(
+
+
+
+ Link
+
+ }
+ />
+ Second page} />
+
+ ,
+ );
+ const link = screen.getByRole("menuitem");
+ act(() => link.focus());
+ await user.keyboard("[enter]");
+ expect(screen.getByText("Second page")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/navigation/NavMenu.tsx b/src/components/navigation/NavMenu.tsx
new file mode 100644
index 0000000..667626e
--- /dev/null
+++ b/src/components/navigation/NavMenu.tsx
@@ -0,0 +1,131 @@
+import {
+ Typography,
+ Menu,
+ Button,
+ useTheme,
+ type MenuListProps,
+ MenuItem,
+ type MenuItemProps,
+} from "@mui/material";
+import React, { useState, forwardRef, useId } from "react";
+import { ExpandMore } from "@mui/icons-material";
+import { NavLink, NavLinkProps } from "./Navbar";
+
+type NavMenuLinkProps = MenuItemProps & NavLinkProps;
+
+const NavMenuLink = forwardRef(
+ function NavMenuLink({ children, ...props }: NavMenuLinkProps, ref) {
+ const theme = useTheme();
+
+ return (
+
+ );
+ },
+);
+
+interface NavMenuProps extends MenuListProps {
+ label: string;
+}
+
+const NavMenu = ({ label, children }: NavMenuProps) => {
+ const [anchorElement, setAnchorElement] = useState(null);
+ const open = Boolean(anchorElement);
+ const [menuWidth, setMenuWidth] = useState(0);
+ const menuId = useId();
+
+ const openMenu = (e: React.MouseEvent) => {
+ if (!open) {
+ setAnchorElement(e.currentTarget);
+ setMenuWidth(e.currentTarget.offsetWidth);
+ }
+ };
+
+ const closeMenu = () => {
+ setAnchorElement(null);
+ };
+
+ const theme = useTheme();
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export { NavMenu, NavMenuLink, type NavMenuLinkProps, type NavMenuProps };
diff --git a/src/components/navigation/Navbar.stories.tsx b/src/components/navigation/Navbar.stories.tsx
index 5aec179..67a9a74 100644
--- a/src/components/navigation/Navbar.stories.tsx
+++ b/src/components/navigation/Navbar.stories.tsx
@@ -8,6 +8,7 @@ import { ColourSchemeButton } from "../controls/ColourSchemeButton";
import { User } from "../controls/User";
import { MockLink } from "../../utils/MockLink";
import { Logo } from "../controls/Logo";
+import { NavMenu, NavMenuLink } from "../navigation/NavMenu";
const meta: Meta = {
title: "Components/Navigation/Navbar",
@@ -124,6 +125,25 @@ export const LinksAndUser: Story = {
},
};
+export const WithLinksInMenu: Story = {
+ args: {
+ leftSlot: (
+
+ First Link
+ Second Link
+ Third Link
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "The `NavMenu` component is used to contain multiple links.",
+ },
+ },
+ },
+};
+
export const WithThemeLogo: Story = {
args: {
children: (
diff --git a/src/components/navigation/Navbar.tsx b/src/components/navigation/Navbar.tsx
index f233c67..cb90574 100644
--- a/src/components/navigation/Navbar.tsx
+++ b/src/components/navigation/Navbar.tsx
@@ -10,7 +10,7 @@ import {
styled,
} from "@mui/material";
import { MdMenu, MdClose } from "react-icons/md";
-import React, { useState } from "react";
+import React, { forwardRef, useState } from "react";
import {
ImageColourSchemeSwitch,
@@ -26,13 +26,10 @@ interface NavLinkProps extends LinkProps {
href?: string;
}
-const NavLink = ({
- children,
- linkComponent,
- to,
- href,
- ...props
-}: NavLinkProps) => {
+const NavLink = forwardRef(function NavLink(
+ { children, linkComponent, to, href, ...props }: NavLinkProps,
+ ref,
+) {
const theme = useTheme();
const shouldUseLinkComponent = linkComponent && to;
@@ -44,6 +41,7 @@ const NavLink = ({
return (
);
-};
+});
interface NavLinksProps {
children: React.ReactElement | React.ReactElement[];
@@ -187,4 +185,4 @@ const Navbar = ({
};
export { Navbar, NavLinks, NavLink };
-export type { NavLinksProps, NavbarProps };
+export type { NavLinkProps, NavLinksProps, NavbarProps };
diff --git a/src/index.ts b/src/index.ts
index 8277145..4604c42 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@
export * from "./components/navigation/Breadcrumbs";
export * from "./components/navigation/Footer";
export * from "./components/navigation/Navbar";
+export * from "./components/navigation/NavMenu";
// components/controls
export * from "./components/controls/AppTitlebar";