diff --git a/cypress/integration/navigation.spec.js b/cypress/integration/navigation.spec.js new file mode 100644 index 000000000..a7b084277 --- /dev/null +++ b/cypress/integration/navigation.spec.js @@ -0,0 +1,38 @@ +context("Navigation", () => { + beforeEach(() => { + cy.visitPage("navigation"); + }); + + it("displays the navigation items at large sizes", () => { + cy.findByRole("link", { name: "Products" }).should("be.visible"); + cy.findByRole("button", { name: "Menu" }).should("not.exist"); + }); + + it("displays the mobile menu at small sizes", () => { + cy.viewport('iphone-6') + cy.findByRole("link", { name: "Products" }).should("not.exist"); + cy.findByRole("button", { name: "Menu" }).should("be.visible"); + }); + + it("can open the mobile menu", () => { + cy.viewport('iphone-6') + cy.findByRole("link", { name: "Products" }).should("not.exist"); + cy.findByRole("button", { name: "Menu" }).click(); + cy.findByRole("link", { name: "Products" }).should("be.visible"); + }); + + it("can open the search at large sizes", () => { + cy.visitPage("Navigation", "search"); + cy.findByRole("searchbox", { name: "Search" }).should("not.exist"); + cy.findByRole("button", { name: "Search" }).click(); + cy.findByRole("searchbox", { name: "Search" }).should("be.visible"); + }); + + it("can open the search at small sizes", () => { + cy.viewport('iphone-6') + cy.visitPage("Navigation", "search"); + cy.findByRole("searchbox", { name: "Search" }).should("not.exist"); + cy.findByRole("button", { name: "Search" }).click(); + cy.findByRole("searchbox", { name: "Search" }).should("be.visible"); + }); +}); diff --git a/src/components/Navigation/Navigation.stories.mdx b/src/components/Navigation/Navigation.stories.mdx new file mode 100644 index 000000000..cb4b412f1 --- /dev/null +++ b/src/components/Navigation/Navigation.stories.mdx @@ -0,0 +1,282 @@ +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; +import Navigation from "./Navigation"; +import { Theme } from "../../types"; + + + +export const Template = (args) => ; + +### Navigation + +This is the [React](https://reactjs.org/) component for the Vanilla +[Navigation](https://vanillaframework.io/docs/patterns/navigation) pattern. It +is a simple navigation bar that you can add to the top of your site or app. + +The navigation items are collapsed behind a "Menu" link in small screens and +displayed horizontally on larger screens. + +### Props + + + +### Default + +The default navigation is constrained to the max width of the Vanilla grid and +uses the light theme. + + + + {Template.bind({})} + + + +### Dark + +You can switch to a dark themed Navigation by using the `dark` prop. This will +automatically update the Navigation items to use lighter text and hover state +colours. + + + + {Template.bind({})} + + + +### Dropdown + +Sub-navigation dropdown menus can be added to Navigation by adding an `items` +array instead of a URL. By default, the dropdown items will align to the left of the +parent item. This can be changed by adding `alignRight` to the subnav +object. + + + + {Template.bind({})} + + + +### Search + +Expanding search can be enabled by providing props to the underlying [`SearchBox`](/?path=/docs/searchbox--default-story) +component. Elements to toggle the Searchbox will be included automatically if +the SearchBox props are provided. + + + null, + }, + }} + > + {Template.bind({})} + + + +### Overriding the logo + +Logos can be displayed using the new tag design. In cases where another logo +style is required then an element can be provided to the `logo` prop. + + + + ), + }} + > + {Template.bind({})} + + + +### Overriding the link component + +In some cases such as when using [React Router](https://reactrouter.com/) it is +necessary to use custom components for links. When this is required then a +function can be passed to `generateLink` which should return your component. +Bear in mind that some props like classes and on-click events might be passed to +this function so take care in overriding any link props. + + + ( + + ), + items: [ + { + label: "Products", + url: "#", + }, + { + label: "Services", + url: "#", + }, + { + label: "Partners", + url: "#", + }, + ], + logo: { + src: "https://assets.ubuntu.com/v1/82818827-CoF_white.svg", + title: "Canonical", + url: "#", + }, + }} + > + {Template.bind({})} + + diff --git a/src/components/Navigation/Navigation.test.tsx b/src/components/Navigation/Navigation.test.tsx new file mode 100644 index 000000000..5a3f051d9 --- /dev/null +++ b/src/components/Navigation/Navigation.test.tsx @@ -0,0 +1,348 @@ +import React from "react"; + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import Navigation from "./Navigation"; +import { Theme } from "../../types"; + +/* eslint-disable testing-library/no-node-access */ +it("displays light theme", () => { + render(} theme={Theme.LIGHT} />); + expect(screen.getByRole("banner").className.includes("is-light")).toBe(true); +}); + +it("displays dark theme", () => { + render(} theme={Theme.DARK} />); + expect(screen.getByRole("banner").className.includes("is-dark")).toBe(true); +}); + +it("displays full width", () => { + render(} />); + + expect( + document.querySelector(".p-navigation__row--full-width") + ).toBeInTheDocument(); + expect(document.querySelector(".p-navigation__row")).not.toBeInTheDocument(); +}); + +it("passes additional classes to the header element", () => { + render( + } + /> + ); + expect(screen.getByRole("banner")).toHaveClass( + "p-navigation not-a-footer-thats-for-sure" + ); +}); + +it("passes additional props to the header element", () => { + render( + } + /> + ); + expect(screen.getByRole("banner")).toHaveAttribute( + "aria-label", + "This is definitely the header" + ); +}); + +it("can display a standard logo", () => { + render( + + ); + const homePageLink = screen.getByRole("link", { name: "Homepage" }); + expect(homePageLink).toBeInTheDocument(); + expect(homePageLink).toHaveAttribute("href", "/this/is/the/logo/link"); + expect(homePageLink).toHaveTextContent("This is the site name"); + + const homepageLogo = within(homePageLink).getByRole("img"); + expect(homepageLogo).toHaveAttribute("src", "http://this.is.the.logo.svg"); + expect(homepageLogo).toHaveAttribute("class", "p-navigation__logo-icon"); +}); + +it("can display a standard logo with a generated link", () => { + render( + ( + + {label} + + )} + logo={{ + "aria-label": "Homepage", + src: "http://this.is.the.logo.svg", + title: "This is the site name", + url: "/this/is/the/logo/link", + }} + /> + ); + const homePageLink = screen.getByRole("link", { name: "Homepage" }); + expect(homePageLink).toBeInTheDocument(); + expect(homePageLink).toHaveTextContent("This is the site name"); + + const homepageLogo = within(homePageLink).getByRole("img"); + expect(homepageLogo).toHaveAttribute("src", "http://this.is.the.logo.svg"); + expect(homepageLogo).toHaveAttribute("class", "p-navigation__logo-icon"); +}); + +it("can provide a custom logo", () => { + render( + + + This logo is totally not anything like the normal one. + + } + /> + ); + expect( + screen.getByRole("link", { + name: "This logo is totally not anything like the normal one.", + }) + ).toBeInTheDocument(); +}); + +it("can display menus", () => { + render( + } + /> + ); + expect( + screen.getByRole("button", { name: "THIS IS A MENU" }) + ).toBeInTheDocument(); +}); + +it("can display links", () => { + render( + } + /> + ); + expect( + screen.getByRole("link", { name: "THIS IS A LINK" }) + ).toBeInTheDocument(); +}); + +it("can pass additional classes to links", () => { + render( + } + /> + ); + expect(screen.getByRole("link", { name: "THIS IS A LINK" })).toHaveClass( + "p-navigation__link this-link-has-a-very-nice-custom-class" + ); +}); + +it("can mark a nav item as selected", () => { + render( + } + /> + ); + expect( + within(screen.getByLabelText("Left nav")) + .getByRole("listitem") + .className.includes("is-selected") + ).toBe(true); +}); + +it("displays without search", () => { + render(} />); + expect( + screen.queryByRole("button", { + name: "Search", + }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("listitem", { + name: "Search", + }) + ).not.toBeInTheDocument(); + expect( + document.querySelector(".p-navigation__search") + ).not.toBeInTheDocument(); + expect( + document.querySelector(".p-navigation__search-overlay") + ).not.toBeInTheDocument(); +}); + +it("displays with search", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + rightNavProps={{ "aria-label": "Right nav" }} + /> + ); + expect( + within(screen.getByLabelText("Right nav")).getByRole("button", { + name: "Search", + }) + ).toBeInTheDocument(); + expect( + document.querySelector(".p-navigation__link--search-toggle") + ).toBeInTheDocument(); + expect(document.querySelector(".p-navigation__search")).toBeInTheDocument(); + expect( + document.querySelector(".p-navigation__search-overlay") + ).toBeInTheDocument(); +}); + +it("can open the search form", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + /> + ); + userEvent.click(screen.getAllByRole("button", { name: "Search" })[0]); + expect(screen.getByRole("banner").className.includes("has-search-open")).toBe( + true + ); + expect(screen.getByRole("searchbox")).toBeInTheDocument(); +}); + +it("focuses on the searchbox when it appears", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + /> + ); + userEvent.click(screen.getAllByRole("button", { name: "Search" })[0]); + expect(screen.getByRole("searchbox")).toHaveFocus(); +}); + +it("closes the search form when the escape key is pressed", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + /> + ); + userEvent.click(screen.getAllByRole("button", { name: "Search" })[0]); + expect(screen.getByRole("banner").className.includes("has-search-open")).toBe( + true + ); + expect(screen.getByRole("searchbox")).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); +}); + +it("closes the search form when opening the mobile menu", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + /> + ); + const banner = screen.getByRole("banner"); + // Open the search form. + userEvent.click(screen.getAllByRole("button", { name: "Search" })[0]); + expect(banner.className.includes("has-search-open")).toBe(true); + userEvent.click(screen.getByRole("button", { name: "Menu" })); + expect(banner.className.includes("has-menu-open")).toBe(true); + expect(banner.className.includes("has-search-open")).toBe(false); +}); + +it("closes the search form when clicking on the overlay", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + /> + ); + const banner = screen.getByRole("banner"); + // Open the search form. + userEvent.click(screen.getAllByRole("button", { name: "Search" })[0]); + expect(banner.className.includes("has-search-open")).toBe(true); + expect(screen.getByRole("searchbox")).toBeInTheDocument(); + userEvent.click(document.querySelector(".p-navigation__search-overlay")); + expect(banner.className.includes("has-search-open")).toBe(false); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); +}); + +it("closes the mobile menu when opening the search form", () => { + render( + } + searchProps={{ onSearch: jest.fn() }} + /> + ); + const banner = screen.getByRole("banner"); + // Open the mobile menu. + userEvent.click(screen.getByRole("button", { name: "Menu" })); + expect(banner.className.includes("has-menu-open")).toBe(true); + userEvent.click(screen.getAllByRole("button", { name: "Search" })[0]); + expect(banner.className.includes("has-search-open")).toBe(true); + expect(banner.className.includes("has-menu-open")).toBe(false); +}); + +it("can open the mobile menu", () => { + render(} />); + const banner = screen.getByRole("banner"); + expect(banner.className.includes("has-menu-open")).toBe(false); + userEvent.click(screen.getByRole("button", { name: "Menu" })); + expect(banner.className.includes("has-menu-open")).toBe(true); +}); + +it("closes the mobile menu when clicking on a nav link", () => { + render( + } + /> + ); + const banner = screen.getByRole("banner"); + userEvent.click(screen.getByRole("button", { name: "Menu" })); + expect(banner.className.includes("has-menu-open")).toBe(true); + userEvent.click(screen.getByRole("link", { name: "THIS IS A LINK" })); + expect(banner.className.includes("has-menu-open")).toBe(false); +}); + +it("does not close the mobile menu when clicking on a nav menu", () => { + render( + } + /> + ); + const banner = screen.getByRole("banner"); + userEvent.click(screen.getByRole("button", { name: "Menu" })); + expect(banner.className.includes("has-menu-open")).toBe(true); + userEvent.click(screen.getByRole("button", { name: "THIS IS A MENU" })); + expect(banner.className.includes("has-menu-open")).toBe(true); +}); diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx new file mode 100644 index 000000000..9472112a2 --- /dev/null +++ b/src/components/Navigation/Navigation.tsx @@ -0,0 +1,298 @@ +import { ReactNode, HTMLProps, useRef, useEffect } from "react"; +import React, { isValidElement, useState } from "react"; + +import classNames from "classnames"; + +import NavigationLink from "./NavigationLink"; +import NavigationMenu from "./NavigationMenu"; +import type { GenerateLink, NavItem, NavMenu, LogoProps } from "./types"; +import { PropsWithSpread, SubComponentProps, Theme } from "types"; +import SearchBox, { SearchBoxProps } from "components/SearchBox"; +import { useOnEscapePressed } from "hooks"; + +export type Props = PropsWithSpread< + { + /** + * By default the header is constrained to the width of the grid. Use this + * option to make the header take the full width of the page. + */ + fullWidth?: boolean; + /** + * This function can be used to generate link elements when you don't want to + * use a standard HTML anchor. + */ + generateLink?: GenerateLink | null; + /** + * The main navigation items that appear on the left hand side next to the logo. + */ + items?: NavItem[] | null; + /** + * Additional navigation items that appear on the right hand side of the + * navigation banner. + */ + itemsRight?: NavItem[] | null; + /** + * Additional props to be applied to the nav element that wraps the main + * navigation items on the left hand side. + */ + leftNavProps?: HTMLProps | null; + /** + * The logo can be defined either by providing props for the standard logo + * or the full logo markup when a custom logo is needed. + */ + logo: LogoProps | ReactNode; + /** + * Additional props to be applied to the nav element that wraps the + * left and right nav items. + */ + navProps?: HTMLProps | null; + /** + * Additional props to be applied to the nav element that wraps the + * navigation items on the right hand side. + */ + rightNavProps?: HTMLProps | null; + /** + * Props to pass to the SearchBox component. When these props are provided the + * search box will appear. + */ + searchProps?: SubComponentProps | null; + /** + * The header theme. When this is not provided the header will use the default + * theme defined in the Vanilla settings. + */ + theme?: Theme | null; + }, + HTMLProps +>; + +/** + * Narrow the type of the nav item to a NavMenu. + */ +const isMenu = (item: NavItem): item is NavMenu => "items" in item; + +/** + * Narrow the type of the logo prop to LogoProps. + */ +const isLogoProps = (logo: Props["logo"]): logo is LogoProps => + !isValidElement(logo); + +/** + * Display the standard logo if the props were provided otherwise display the + * full element provided. + */ +const generateLogo = (logo: Props["logo"], generateLink: GenerateLink) => { + if (isLogoProps(logo)) { + const { + url, + src, + title, + icon, + "aria-current": ariaCurrent, + "aria-label": ariaLabel, + ...logoProps + } = logo; + const content = ( + <> +
+ {icon ?? } +
+ {title} + + ); + return ( +
+ +
+ ); + } + return
{logo}
; +}; + +/** + * Generate the JSX for a set of nav items. This will map the items to menus, + * links or generated components. + * @param items The nav items to map to elements. + * @param closeMobileMenu A function to close the mobile menu. + * @param generateLink The optional function used to generate link components. + * @returns A list of navigation item elements. + */ +const generateItems = ( + items: NavItem[], + closeMobileMenu: () => void, + generateLink?: GenerateLink | null +) => + items.map((item, i) => + isMenu(item) ? ( + + ) : ( +
  • + { + item.onClick?.(evt); + closeMobileMenu(); + }} + generateLink={generateLink} + className={classNames("p-navigation__link", item.className)} + /> +
  • + ) + ); + +const Navigation = ({ + fullWidth, + generateLink, + items, + itemsRight, + leftNavProps, + logo, + navProps, + rightNavProps, + searchProps, + theme, + ...headerProps +}: Props): JSX.Element => { + const searchRef = useRef(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + // Display the search box if the props have been provided. + const hasSearch = !!searchProps; + // Close the mobile menu when the search box is opened. + const toggleSearch = (open?: boolean) => { + setSearchOpen(open ?? !searchOpen); + setMobileMenuOpen(false); + }; + // Close the search box when the mobile menu is opened. + const toggleMobileMenu = () => { + setMobileMenuOpen(!mobileMenuOpen); + setSearchOpen(false); + }; + const closeMobileMenu = () => { + if (mobileMenuOpen) { + setMobileMenuOpen(false); + } + }; + // Hide the searchbox when the escape key is pressed. + useOnEscapePressed(() => toggleSearch(false)); + + useEffect(() => { + if (searchOpen) { + // Focus on the searchbox when it appears. This done in a useEffect so + // that the state change to display the searchbox has already occured and + // the input has been made visible. + searchRef.current?.focus(); + } + }, [searchOpen]); + + return ( +
    +
    +
    + {generateLogo(logo, generateLink)} +
      + { + // When the header has a search box then this button is used to + // toggle the search box at mobile size. + hasSearch ? ( +
    • + +
    • + ) : null + } +
    • + +
    • +
    +
    + +
    + { + // When the header has a search box and the user has opened the search + // form then this element is overlayed over the whole page. + hasSearch ? ( +
    setSearchOpen(false)} + >
    + ) : null + } +
    + ); +}; + +export default Navigation; diff --git a/src/components/Navigation/NavigationLink/NavigationLink.test.tsx b/src/components/Navigation/NavigationLink/NavigationLink.test.tsx new file mode 100644 index 000000000..9d3ed037e --- /dev/null +++ b/src/components/Navigation/NavigationLink/NavigationLink.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; + +import { render, screen } from "@testing-library/react"; + +import NavigationLink from "./NavigationLink"; + +it("generates a standard anchor", () => { + render(); + expect( + screen.getByRole("link", { + name: "Go here", + }) + ).toBeInTheDocument(); +}); + +it("can select an anchor", () => { + render(); + expect( + screen.getByRole("link", { + name: "Go here", + }) + ).toHaveAttribute("aria-current", "page"); +}); + +it("generates a custom link", () => { + render( + } + label="Go here" + url="/to/here" + /> + ); + expect( + screen.getByRole("button", { + name: "Go here", + }) + ).toBeInTheDocument(); +}); + +it("can select a custom link", () => { + render( + ( + + )} + isSelected + label="Go here" + url="/to/here" + /> + ); + expect( + screen.getByRole("button", { + name: "Go here", + }) + ).toHaveAttribute("aria-current", "page"); +}); diff --git a/src/components/Navigation/NavigationLink/NavigationLink.tsx b/src/components/Navigation/NavigationLink/NavigationLink.tsx new file mode 100644 index 000000000..ee89da13c --- /dev/null +++ b/src/components/Navigation/NavigationLink/NavigationLink.tsx @@ -0,0 +1,48 @@ +import type { HTMLProps } from "react"; +import React from "react"; +import { PropsWithSpread } from "types"; + +import type { GenerateLink, NavLink } from "../types"; + +type Props = PropsWithSpread< + NavLink & { + generateLink?: GenerateLink; + }, + HTMLProps +>; + +/** + * This component is used internally to display links inside the Navigation component. + */ +const NavigationLink = ({ + generateLink, + isSelected, + label, + url, + ...props +}: Props): JSX.Element => { + const ariaCurrent = isSelected ? "page" : undefined; + if (generateLink) { + // If a function has been provided then use it to generate the link element. + return ( + <> + {generateLink({ + isSelected, + label, + url, + "aria-current": ariaCurrent, + ...props, + })} + + ); + } else { + // If a function has not been provided then use a standard anchor element. + return ( + + {label} + + ); + } +}; + +export default NavigationLink; diff --git a/src/components/Navigation/NavigationLink/index.ts b/src/components/Navigation/NavigationLink/index.ts new file mode 100644 index 000000000..575b6b33e --- /dev/null +++ b/src/components/Navigation/NavigationLink/index.ts @@ -0,0 +1 @@ +export { default } from "./NavigationLink"; diff --git a/src/components/Navigation/NavigationMenu/NavigationMenu.test.tsx b/src/components/Navigation/NavigationMenu/NavigationMenu.test.tsx new file mode 100644 index 000000000..62b1e2ff5 --- /dev/null +++ b/src/components/Navigation/NavigationMenu/NavigationMenu.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import NavigationMenu from "./NavigationMenu"; + +it("can display when closed", () => { + render(); + expect(screen.getByRole("listitem")).not.toHaveClass("is-active"); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Today's menu", + }) + ).toBeInTheDocument(); +}); + +it("can open the menu", () => { + render(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + userEvent.click(screen.getByRole("button", { name: "Today's menu" })); + expect(screen.getByRole("listitem").className.includes("is-active")).toBe( + true + ); + expect(screen.getByRole("list")).toHaveAttribute("aria-hidden", "false"); + expect( + screen.getByRole("button", { + name: "Today's menu", + }) + ).toBeInTheDocument(); +}); + +it("can align menu items to the right", () => { + render(); + userEvent.click(screen.getByRole("button", { name: "Today's menu" })); + expect( + screen.getByRole("list").className.includes("p-navigation__dropdown--right") + ).toBe(true); +}); + +it("can display links using a standard anchor", () => { + render( + + ); + // Open the menu so the links are displayed. + userEvent.click(screen.getByRole("button", { name: "Today's menu" })); + expect( + screen.getByRole("link", { + name: "Eggs florentine", + }) + ).toBeInTheDocument(); +}); + +it("can display links using a custom link", () => { + render( + } + items={[{ label: "Eggs benedict", url: "/eggs/benedict" }]} + label="Today's menu" + /> + ); + // Open the menu so the links are displayed. + userEvent.click(screen.getByRole("button", { name: "Today's menu" })); + expect( + screen.getByRole("button", { + name: "Eggs benedict", + }) + ).toBeInTheDocument(); +}); + +it("can pass additional classes to the links", () => { + render( + + ); + // Open the menu so the links are displayed. + userEvent.click(screen.getByRole("button", { name: "Today's menu" })); + expect( + screen.getByRole("link", { + name: "Smashed avo", + }) + ).toHaveClass("p-navigation__dropdown-item on-24-hour-rye"); +}); diff --git a/src/components/Navigation/NavigationMenu/NavigationMenu.tsx b/src/components/Navigation/NavigationMenu/NavigationMenu.tsx new file mode 100644 index 000000000..2f2c52be2 --- /dev/null +++ b/src/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -0,0 +1,77 @@ +import type { HTMLProps } from "react"; +import React, { useCallback, useState } from "react"; + +import classNames from "classnames"; + +import NavigationLink from "../NavigationLink"; +import type { GenerateLink, NavMenu } from "../types"; +import { PropsWithSpread } from "types"; +import { useClickOutside } from "hooks"; + +type Props = PropsWithSpread< + NavMenu & { + generateLink?: GenerateLink; + }, + HTMLProps +>; + +/** + * This component is used internally to display menus inside the Navigation component. + */ +const NavigationMenu = ({ + alignRight, + generateLink, + items, + label, + ...props +}: Props): JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const closeMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const [menuRef, menuId] = useClickOutside(closeMenu); + return ( +
  • + +
      + {items.map((item, i) => ( +
    • + +
    • + ))} +
    +
  • + ); +}; + +export default NavigationMenu; diff --git a/src/components/Navigation/NavigationMenu/index.ts b/src/components/Navigation/NavigationMenu/index.ts new file mode 100644 index 000000000..620e770d1 --- /dev/null +++ b/src/components/Navigation/NavigationMenu/index.ts @@ -0,0 +1 @@ +export { default } from "./NavigationMenu"; diff --git a/src/components/Navigation/index.ts b/src/components/Navigation/index.ts new file mode 100644 index 000000000..d0f964e25 --- /dev/null +++ b/src/components/Navigation/index.ts @@ -0,0 +1,9 @@ +export { default } from "./Navigation"; +export type { Props as NavigationProps } from "./Navigation"; +export type { + GenerateLink, + LogoProps, + NavLink, + NavMenu, + NavItem, +} from "./types"; diff --git a/src/components/Navigation/types.ts b/src/components/Navigation/types.ts new file mode 100644 index 000000000..a6c95b335 --- /dev/null +++ b/src/components/Navigation/types.ts @@ -0,0 +1,68 @@ +import type { HTMLProps, ReactNode } from "react"; +import { PropsWithSpread } from "types"; + +export type NavLink = PropsWithSpread< + { + /** + * Whether this nav item is currently selected. + */ + isSelected?: boolean; + /** + * The label of the link. + */ + label: ReactNode; + /** + * The URL of the link. + */ + url?: string; + }, + HTMLProps +>; + +export type NavMenu = { + /** + * Whether to align the dropdown to the right edge of the navigation item. + */ + alignRight?: boolean; + /** + * The links to appear in the dropdown. + */ + items: NavLink[]; + /** + * The label of the navigation item that opens the menu. + */ + label: string; +}; + +/** + * Navigation items which can be either a link or a menu containing links. + */ +export type NavItem = NavLink | NavMenu; + +/** + * A function that can be used to generate link elements when you don't want to + * use a standard HTML anchor. + */ +export type GenerateLink = (item: NavLink) => ReactNode; + +export type LogoProps = PropsWithSpread< + { + /** + * An icon to display in the tag. + */ + icon?: ReactNode; + /** + * The logo image source URL. + */ + src?: string; + /** + * The site's title. + */ + title: string; + /** + * The URL to navigate to when the logo is clicked. + */ + url: string; + }, + HTMLProps +>; diff --git a/src/components/SearchAndFilter/SearchAndFilter.tsx b/src/components/SearchAndFilter/SearchAndFilter.tsx index 1f472840c..c234e2b82 100644 --- a/src/components/SearchAndFilter/SearchAndFilter.tsx +++ b/src/components/SearchAndFilter/SearchAndFilter.tsx @@ -4,6 +4,7 @@ import FilterPanelSection from "./FilterPanelSection"; import Chip from "../Chip"; import { overflowingChipsCount, isChipInArray } from "./utils"; import type { SearchAndFilterChip, SearchAndFilterData } from "./types"; +import { useOnEscapePressed } from "hooks"; export type Props = { /** @@ -64,13 +65,14 @@ const SearchAndFilter = ({ }; }, [searchContainerActive]); + const closePanel = () => { + setFilterPanelHidden(true); + }; + useOnEscapePressed(() => closePanel()); + // This useEffect sets up listeners so the panel will close if user clicks // anywhere else on the page or hits the escape key useEffect(() => { - const closePanel = () => { - setFilterPanelHidden(true); - }; - const mouseDown = (e) => { // Check if click is outside of filter panel if (!searchAndFilterRef?.current?.contains(e.target)) { @@ -79,21 +81,11 @@ const SearchAndFilter = ({ } }; - const keyDown = (e) => { - if (e.code === "Escape") { - // Close panel if Esc keydown detected - closePanel(); - } - }; - // Add listener on document to capture click events document.addEventListener("mousedown", mouseDown); - // Add listener on document to capture keydown events - document.addEventListener("keydown", keyDown); // return function to be called when unmounted return () => { document.removeEventListener("mousedown", mouseDown); - document.removeEventListener("keydown", keyDown); }; }, []); diff --git a/src/components/SearchBox/SearchBox.test.tsx b/src/components/SearchBox/SearchBox.test.tsx index 67214d22a..ce6901331 100644 --- a/src/components/SearchBox/SearchBox.test.tsx +++ b/src/components/SearchBox/SearchBox.test.tsx @@ -61,4 +61,16 @@ describe("SearchBox ", () => { "testID" ); }); + + it("accepts a ref for the input", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const ref = React.createRef(); + const wrapper = mount(, { + attachTo: container, + }); + ref.current.focus(); + expect(wrapper.find("input").getDOMNode()).toHaveFocus(); + document.body.removeChild(container); + }); }); diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 327dadaac..62469dfbb 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import React, { HTMLProps } from "react"; +import React, { HTMLProps, useRef } from "react"; import Icon from "../Icon"; @@ -35,6 +35,10 @@ export type Props = PropsWithSpread< * A search input placeholder message. */ placeholder?: string; + /** + * A ref that is passed to the input element. + */ + ref?: string; /** * The value of the search input when the state is externally controlled. */ @@ -43,68 +47,81 @@ export type Props = PropsWithSpread< HTMLProps >; -const SearchBox = ({ - autocomplete = "on", - className, - disabled, - externallyControlled, - onChange, - onSearch, - placeholder = "Search", - value, - ...props -}: Props): JSX.Element => { - const input = React.createRef(); - const resetInput = () => { - onChange && onChange(""); - if (input.current) { - input.current.value = ""; - } - }; +const SearchBox = React.forwardRef( + ( + { + autocomplete = "on", + className, + disabled, + externallyControlled, + onChange, + onSearch, + placeholder = "Search", + value, + ...props + }: Props, + forwardedRef + ): JSX.Element => { + const internalRef = useRef(); + const resetInput = () => { + onChange && onChange(""); + if (internalRef.current) { + internalRef.current.value = ""; + } + }; - const triggerSearch = () => { - onSearch && onSearch(); - }; + const triggerSearch = () => { + onSearch && onSearch(); + }; - return ( -
    - - onChange(evt.target.value)} - placeholder={placeholder} - ref={input} - type="search" - defaultValue={externallyControlled ? undefined : value} - value={externallyControlled ? value : undefined} - {...props} - /> - {value && ( + return ( +
    + + onChange(evt.target.value)} + placeholder={placeholder} + ref={(input) => { + internalRef.current = input; + // Handle both function and object refs. + if (typeof forwardedRef === "function") { + forwardedRef(input); + } else if (forwardedRef) { + forwardedRef.current = input; + } + }} + type="search" + defaultValue={externallyControlled ? undefined : value} + value={externallyControlled ? value : undefined} + {...props} + /> + {value && ( + + )} - )} - -
    - ); -}; +
    + ); + } +); SearchBox.displayName = "SearchBox"; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 89ce52213..a4db764dc 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,7 +1,9 @@ -export { useWindowFitment } from "./useWindowFitment"; +export { useClickOutside } from "./useClickOutside"; +export { useId } from "./useId"; export { useListener } from "./useListener"; +export { useOnEscapePressed } from "./useOnEscapePressed"; export { usePrevious } from "./usePrevious"; export { useThrottle } from "./useThrottle"; -export { useId } from "./useId"; export { usePagination } from "./usePagination"; +export { useWindowFitment } from "./useWindowFitment"; export type { WindowFitment } from "./useWindowFitment"; diff --git a/src/hooks/useClickOutside.test.tsx b/src/hooks/useClickOutside.test.tsx new file mode 100644 index 000000000..9527212a9 --- /dev/null +++ b/src/hooks/useClickOutside.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React, { PropsWithChildren } from "react"; +import { useClickOutside } from "./useClickOutside"; + +describe("useClickOutside", () => { + const TestComponent = ({ + children, + onClickOutside, + }: PropsWithChildren<{ + onClickOutside: () => void; + }>) => { + const [wrapperRef, id] = useClickOutside(onClickOutside); + return ( +
    +
    + Menu + +
    + + {children} +
    + ); + }; + + it("handles clicks outside the target", () => { + const onClickOutside = jest.fn(); + render(); + userEvent.click(screen.getByRole("button", { name: "Outside" })); + expect(onClickOutside).toHaveBeenCalled(); + }); + + it("handles clicks inside the target", () => { + const onClickOutside = jest.fn(); + render(); + userEvent.click(screen.getByRole("button", { name: "Inside" })); + expect(onClickOutside).not.toHaveBeenCalled(); + }); + + it("handles clicking on elements that don't have string classNames", () => { + const onClickOutside = jest.fn(); + render( + + + + ); + userEvent.click(screen.getByTestId("no-classname")); + expect(onClickOutside).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 000000000..024fdd35e --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,41 @@ +import { MutableRefObject, useCallback, useEffect, useRef } from "react"; + +import { useId } from "./useId"; + +/** + * Handle clicks outside an element. + * @returns An id and ref to pass to the element to handle clicks + * outside of. + */ +export const useClickOutside = ( + onClickOutside: () => void +): [MutableRefObject, string] => { + const wrapperRef = useRef(null); + const id = useId(); + + const handleClickOutside = useCallback( + (evt: MouseEvent) => { + const target = evt.target as HTMLElement; + // The target might be something like an SVG node which doesn't provide + // the class name as a string. + const isValidTarget = + typeof (evt?.target as HTMLElement)?.className === "string"; + if ( + !isValidTarget || + (wrapperRef.current && + !wrapperRef.current?.contains(target) && + target.id !== id) + ) { + onClickOutside(); + } + }, + [id, onClickOutside] + ); + + useEffect(() => { + document.addEventListener("click", handleClickOutside, false); + return () => + document.removeEventListener("click", handleClickOutside, false); + }, [handleClickOutside]); + return [wrapperRef, id]; +}; diff --git a/src/hooks/useOnEscapePressed.test.tsx b/src/hooks/useOnEscapePressed.test.tsx new file mode 100644 index 000000000..dedd0729d --- /dev/null +++ b/src/hooks/useOnEscapePressed.test.tsx @@ -0,0 +1,11 @@ +import { renderHook } from "@testing-library/react-hooks"; +import userEvent from "@testing-library/user-event"; + +import { useOnEscapePressed } from "./useOnEscapePressed"; + +it("calls the callback when the escape key is pressed", () => { + const onEscape = jest.fn(); + renderHook(() => useOnEscapePressed(onEscape)); + userEvent.keyboard("{esc}"); + expect(onEscape).toHaveBeenCalled(); +}); diff --git a/src/hooks/useOnEscapePressed.ts b/src/hooks/useOnEscapePressed.ts new file mode 100644 index 000000000..70a9f6408 --- /dev/null +++ b/src/hooks/useOnEscapePressed.ts @@ -0,0 +1,21 @@ +import { useCallback, useEffect } from "react"; + +/** + * Handle the escape key pressed. + */ +export const useOnEscapePressed = (onEscape: () => void) => { + const keyDown = useCallback( + (evt) => { + if (evt.code === "Escape") { + onEscape(); + } + }, + [onEscape] + ); + useEffect(() => { + document.addEventListener("keydown", keyDown); + return () => { + document.removeEventListener("keydown", keyDown); + }; + }, [keyDown]); +}; diff --git a/src/index.ts b/src/index.ts index bab9e12b7..c45da143c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export { default as List } from "./components/List"; export { default as Loader } from "./components/Loader"; export { default as MainTable } from "./components/MainTable"; export { default as ModularTable } from "./components/ModularTable"; +export { default as Navigation } from "./components/Navigation"; export { default as Modal } from "./components/Modal"; export { default as Notification, @@ -82,6 +83,13 @@ export type { ListProps } from "./components/List"; export type { MainTableProps } from "./components/MainTable"; export type { ModularTableProps } from "./components/ModularTable"; export type { ModalProps } from "./components/Modal"; +export type { + GenerateLink, + LogoProps, + NavigationProps, + NavItem, + NavLink, +} from "./components/Navigation"; export type { NotificationProps } from "./components/Notification"; export type { PaginationProps } from "./components/Pagination"; export type { RadioInputProps } from "./components/RadioInput"; @@ -102,11 +110,13 @@ export type { TextareaProps } from "./components/Textarea"; export type { TooltipProps } from "./components/Tooltip"; export { - useWindowFitment, + useClickOutside, + useId, useListener, + useOnEscapePressed, usePrevious, useThrottle, - useId, + useWindowFitment, } from "hooks"; export type { WindowFitment } from "hooks"; @@ -116,6 +126,7 @@ export type { PropsWithSpread, SortDirection, SubComponentProps, + Theme, TSFixMe, ValueOf, } from "./types"; diff --git a/src/types/index.ts b/src/types/index.ts index fc142c0e5..0a870a02a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,3 +39,17 @@ export type TSFixMe = any; // eslint-disable-line @typescript-eslint/no-explicit * defined in EnumLike. */ export type ValueOf = T[keyof T]; + +/** + * The Vanilla theme types. + */ +export enum Theme { + /** + * The dark Vanilla theme. + */ + DARK = "dark", + /** + * The light Vanilla theme. + */ + LIGHT = "light", +}