diff --git a/changelog.md b/changelog.md index c4379c6..0a769f1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,35 +1,37 @@ -SciReactUI Changelog -==================== +# SciReactUI Changelog -[v0.3.1alpha] - 2025-?-? ------------------------- +## [v0.3.1alpha] - 2025-?-? ### 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. + +- 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. - Stopped Bar-based components (e.g. Navbar, Footer) from expanding when a parent component has a set height -- The base *Bar* component was not being exported. ### Changed -- Remove first-child css selector as it causes problems with server-side rendering. +- Remove first-child css selector as it causes problems with server-side rendering. -[v0.3.0] - 2025-09-04 ---------------------- +## [v0.3.0] - 2025-09-04 ### Added -- *Logo* component, to easily add the theme logo to anywhere -- *ImageColourSchemeSwitch* takes a parameter *interchange* to swap image based on the opposite -of the colour scheme switch - for use with alternative background colours. -- *BaseBar* component is the base for all the bars used in SciReactUI. Can also be used itself. -- *AppBar* is a bar to show the main title of your App. + +- _Logo_ component, to easily add the theme logo to anywhere +- _ImageColourSchemeSwitch_ takes a parameter _interchange_ to swap image based on the opposite + of the colour scheme switch - for use with alternative background colours. +- _BaseBar_ component is the base for all the bars used in SciReactUI. Can also be used itself. +- _AppBar_ is a bar to show the main title of your App. - JsonForms renderers have been added for use with readonly mode in JsonForms. - Support for TIFFs in ScrollableImages component ### Fixed + - Themes were not inheriting all details from their parents. - Fixed alt text on logos. - Fixed Footer was not adhering to Container width. (Can be turned off with containerWidth setting) @@ -37,39 +39,41 @@ of the colour scheme switch - for use with alternative background colours. - Ordering of StoryBook now more intuitive. ### Changed -- Breaking change: The use of *color* has been replaced with *colour* throughout. - - *ImageColorSchemeSwitch*, *ImageColorSchemeSwitchType* and *ImageColorSchemeSwitchProps* - renamed to *ImageColourSchemeSwitch*, ImageColourSchemeSwitchType and ImageColourSchemeSwitchProps respectively - - *User* component color prop renamed to colour. -- RootProps on *Breadcrumbs* has been removed. There props can be passed in directly. -e.g. `` instead of `` +- Breaking change: The use of _color_ has been replaced with _colour_ throughout. + - _ImageColorSchemeSwitch_, _ImageColorSchemeSwitchType_ and _ImageColorSchemeSwitchProps_ + renamed to _ImageColourSchemeSwitch_, ImageColourSchemeSwitchType and ImageColourSchemeSwitchProps respectively + - _User_ component color prop renamed to colour. +- RootProps on _Breadcrumbs_ has been removed. There props can be passed in directly. + e.g. `` instead of `` -[v0.2.0] - 2025-06-11 ---------------------- +## [v0.2.0] - 2025-06-11 ### Fixed + - Styles added to Navbar and Footer incorrectly remove built in styles. - Logo not appearing when no dark src set in dark mode. ### Changed -- Breadcrumbs component takes optional linkComponent prop for page routing. + +- Breadcrumbs component takes optional linkComponent prop for page routing. - Navbar, NavLink and FooterLink will use routing library for links if provided with linkComponent and to props. - Navbar uses slots for positioning elements. Breaking change: elements must now use rightSlot for positioning to the far right. - User can take additional menu items through the menuItems prop. - Footer uses slots for positioning elements. Breaking change: elements must now use rightSlot for positioning to the far right. ### Added -- ScrollableImages component to scroll through multiple images. +- ScrollableImages component to scroll through multiple images. -[v0.1.0] - 2025-04-10 ---------------------- +## [v0.1.0] - 2025-04-10 ### Added + - Breadcrumbs take object array (CustomLink) for total control over names and links. ### Fixed + - Stopped flicker between colour modes when starting an app in dark mode. - Footer links stopped from moving on hover when only showing links. - Footer links now correctly center horizontally, if needed. @@ -77,29 +81,29 @@ e.g. `` instead of `` instead of `=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 + + + + + ), + }, + 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 ( + + {children} + + ); + }, +); + +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 ( + <> + + + {children} + + + ); +}; + +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";