From b3e196339b948190c6ac3430583cb6687f14450b Mon Sep 17 00:00:00 2001 From: Mark Janssen <20283+praseodym@users.noreply.github.com> Date: Wed, 12 Feb 2025 08:24:36 +0100 Subject: [PATCH] Add hamburger menu to navigation bar (#994) --- documentatie/style-guide/icons.md | 31 ++++++++++ .../app/component/navbar/NavBar.module.css | 60 ++++++++++++++++++- .../app/component/navbar/NavBar.stories.tsx | 16 +++++ frontend/app/component/navbar/NavBar.test.tsx | 49 +++++++++++---- frontend/app/component/navbar/NavBarLinks.tsx | 23 ++++--- frontend/app/component/navbar/NavBarMenu.tsx | 60 +++++++++++++++++++ frontend/lib/i18n/locales/nl/generic.json | 1 + frontend/lib/icon/README.md | 28 --------- frontend/lib/icon/generated.tsx | 53 ++++++++++++++++ frontend/lib/icon/svg/compass.svg | 4 ++ frontend/lib/icon/svg/file.svg | 3 + frontend/lib/icon/svg/hamburger.svg | 4 ++ frontend/lib/icon/svg/laptop.svg | 3 + frontend/lib/icon/svg/users.svg | 3 + frontend/lib/ui/style/variables.css | 1 + 15 files changed, 284 insertions(+), 55 deletions(-) create mode 100644 documentatie/style-guide/icons.md create mode 100644 frontend/app/component/navbar/NavBarMenu.tsx delete mode 100644 frontend/lib/icon/README.md create mode 100644 frontend/lib/icon/svg/compass.svg create mode 100644 frontend/lib/icon/svg/file.svg create mode 100644 frontend/lib/icon/svg/hamburger.svg create mode 100644 frontend/lib/icon/svg/laptop.svg create mode 100644 frontend/lib/icon/svg/users.svg diff --git a/documentatie/style-guide/icons.md b/documentatie/style-guide/icons.md new file mode 100644 index 000000000..68796da70 --- /dev/null +++ b/documentatie/style-guide/icons.md @@ -0,0 +1,31 @@ +# Iconenbibliotheek + +De iconenbibliotheek is te vinden in `frontend/lib/icon/`. Het bevat alle iconen die in de applicatie worden gebruikt. + +## Richtlijnen + +Sommige iconen kunnen zich anders gedragen, maar hier zijn enkele richtlijnen voor het toevoegen/maken van iconen + +### Verplicht + +- Geen inline stijlen +- Geen ID-attribuut +- Geen class-attribuut +- Voeg `role="img"` toe + +### Richtlijnen + +- Monokleur (zwart) +- Alleen gevulde paden + - Converteer lijnpaden met Adobe Illustrator (Object > Path > Outline Stroke) of Inkscape (Path > Stroke to Path) +- Grenzen van `0 0 24 24` + +## Bouwen + +Voer vanuit `frontend/` uit: + +```sh +npm run gen:icons +``` + +Dit zal `lib/icon/generated.tsx` maken met alle iconen. diff --git a/frontend/app/component/navbar/NavBar.module.css b/frontend/app/component/navbar/NavBar.module.css index 2284e76b0..31d9aed06 100644 --- a/frontend/app/component/navbar/NavBar.module.css +++ b/frontend/app/component/navbar/NavBar.module.css @@ -35,7 +35,7 @@ gap: 0.25rem; } - :first-child:not(:global(.active)) { + > :first-child:not(:global(.active)) { padding-left: 0; } @@ -58,3 +58,61 @@ fill: var(--base-white); } } + +.nav-bar-menu { + position: absolute; + top: 0.75rem; + left: 0; + z-index: 1; + + display: flex; + width: 13rem; + flex-direction: column; + align-items: flex-start; + background: var(--base-white); + overflow: hidden; + + border-radius: 0.5rem; + border: 1px solid var(--gray-300); + box-shadow: + 0px 4px 8px -2px rgba(16, 24, 40, 0.1), + 0px 2px 4px -2px rgba(16, 24, 40, 0.06); + + > a { + display: flex; + padding: 0.75rem; + gap: 1.25rem; + align-self: stretch; + + font-weight: 500; + line-height: 1.5rem; + color: var(--gray-700); + text-decoration: none; + + > svg { + width: 1.5rem; + fill: var(--gray-700); + } + + &:hover { + background: var(--gray-50); + + > svg { + fill: var(--link-default); + } + } + } +} + +.nav-bar-menu-container { + position: relative; + height: 3rem; + padding: 0.75rem; + cursor: pointer; + + /* undo default button styling */ + background: none; + color: inherit; + border: none; + font: inherit; +} diff --git a/frontend/app/component/navbar/NavBar.stories.tsx b/frontend/app/component/navbar/NavBar.stories.tsx index 67f39049f..0fb12c7a2 100644 --- a/frontend/app/component/navbar/NavBar.stories.tsx +++ b/frontend/app/component/navbar/NavBar.stories.tsx @@ -7,6 +7,8 @@ import { ElectionProviderContext } from "lib/api/election/ElectionProviderContex import { Election } from "@kiesraad/api"; import { NavBar } from "./NavBar"; +import styles from "./NavBar.module.css"; +import { NavBarMenu, NavBarMenuButton } from "./NavBarMenu"; export default { title: "App / Navigation bar", @@ -55,3 +57,17 @@ export const AllRoutes: Story = () => ( ))} ); + +export const Menu: Story = () => ( +
+ +
+); + +export const MenuButton: Story = () => ( + +); diff --git a/frontend/app/component/navbar/NavBar.test.tsx b/frontend/app/component/navbar/NavBar.test.tsx index 4ea1f6d0d..7c0dc0bdf 100644 --- a/frontend/app/component/navbar/NavBar.test.tsx +++ b/frontend/app/component/navbar/NavBar.test.tsx @@ -1,3 +1,4 @@ +import { userEvent } from "@testing-library/user-event"; import { beforeEach, describe, expect, test } from "vitest"; import { ElectionProvider } from "@kiesraad/api"; @@ -26,6 +27,7 @@ describe("NavBar", () => { { pathname: "/account/login", hash: "" }, { pathname: "/account/setup", hash: "" }, { pathname: "/elections", hash: "" }, + { pathname: "/elections/1", hash: "" }, { pathname: "/invalid-notfound", hash: "" }, ])("no links for $pathname", async (location) => { await renderNavBar(location); @@ -58,24 +60,14 @@ describe("NavBar", () => { expect(screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" })).toBeVisible(); }); - test("current election name for '/elections/1'", async () => { - await renderNavBar({ pathname: "/elections/1", hash: "" }); - - expect( - screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" }), - ).not.toBeInTheDocument(); - - expect(screen.queryByText("Heemdamseburg")).toBeVisible(); - expect(screen.queryByText("Gemeenteraadsverkiezingen 2026")).toBeVisible(); - }); - test.each([ { pathname: "/elections", hash: "#administratorcoordinator" }, { pathname: "/users", hash: "#administratorcoordinator" }, { pathname: "/workstations", hash: "#administratorcoordinator" }, { pathname: "/logs", hash: "#administratorcoordinator" }, - ])("top level management links for $pathname", async () => { - await renderNavBar({ pathname: "/elections", hash: "#administratorcoordinator" }); + { pathname: "/elections/1", hash: "#administratorcoordinator" }, + ])("top level management links for $pathname", async (location) => { + await renderNavBar(location); expect(screen.queryByRole("link", { name: "Verkiezingen" })).toBeVisible(); expect(screen.queryByRole("link", { name: "Gebruikers" })).toBeVisible(); @@ -104,4 +96,35 @@ describe("NavBar", () => { expect(screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" })).toBeVisible(); expect(screen.queryByRole("link", { name: "Stembureaus" })).toBeVisible(); }); + + test.each([ + { pathname: "/elections/1/report", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/status", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/polling-stations", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/polling-stations/create", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/polling-stations/1/update", hash: "#administratorcoordinator" }, + ])("menu works for $pathname", async (location) => { + const user = userEvent.setup(); + await renderNavBar(location); + + const menuButton = screen.getByRole("button", { name: "Menu" }); + expect(menuButton).toBeVisible(); + + // menu should be invisible + expect(screen.queryByRole("link", { name: "Verkiezingen" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Gebruikers" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Werkplekken" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Logs" })).not.toBeInTheDocument(); + + // menu should be visible after clicking button + await user.click(menuButton); + expect(screen.queryByRole("link", { name: "Verkiezingen" })).toBeVisible(); + expect(screen.queryByRole("link", { name: "Gebruikers" })).toBeVisible(); + expect(screen.queryByRole("link", { name: "Werkplekken" })).toBeVisible(); + expect(screen.queryByRole("link", { name: "Logs" })).toBeVisible(); + + // menu should hide after clicking outside it + await user.click(document.body); + expect(screen.queryByRole("link", { name: "Verkiezingen" })).not.toBeInTheDocument(); + }); }); diff --git a/frontend/app/component/navbar/NavBarLinks.tsx b/frontend/app/component/navbar/NavBarLinks.tsx index 92c5946f8..fa0498182 100644 --- a/frontend/app/component/navbar/NavBarLinks.tsx +++ b/frontend/app/component/navbar/NavBarLinks.tsx @@ -4,6 +4,8 @@ import { Election, useElection } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { IconChevronRight } from "@kiesraad/icon"; +import { NavBarMenuButton } from "./NavBarMenu"; + type NavBarLinksProps = { location: { pathname: string; hash: string } }; function ElectionBreadcrumb({ election }: { election: Election }) { @@ -41,17 +43,12 @@ function DataEntryLinks({ location }: NavBarLinksProps) { function ElectionManagementLinks({ location }: NavBarLinksProps) { const { election } = useElection(); - // TODO: Add left side menu, #920 - if (location.pathname.match(/^\/elections\/\d+\/?$/)) { - return ( - - - - ); + return <>; } else { return ( <> + @@ -81,17 +78,17 @@ export function NavBarLinks({ location }: NavBarLinksProps) { const isAdministrator = location.hash.includes("administrator"); const isCoordinator = location.hash.includes("coordinator"); - if (location.pathname.match(/^\/elections\/\d+\/data-entry/)) { - return ; - } else if (location.pathname.match(/^\/elections\/\d+/)) { - return ; - } else if ( - (location.pathname === "/elections" && (isAdministrator || isCoordinator)) || + if ( + (location.pathname.match(/^\/elections(\/\d+)?$/) && (isAdministrator || isCoordinator)) || location.pathname === "/users" || location.pathname === "/workstations" || location.pathname === "/logs" ) { return ; + } else if (location.pathname.match(/^\/elections\/\d+\/data-entry/)) { + return ; + } else if (location.pathname.match(/^\/elections\/\d+/)) { + return ; } else { return <>; } diff --git a/frontend/app/component/navbar/NavBarMenu.tsx b/frontend/app/component/navbar/NavBarMenu.tsx new file mode 100644 index 000000000..1b6daad03 --- /dev/null +++ b/frontend/app/component/navbar/NavBarMenu.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { NavLink } from "react-router"; + +import { t } from "@kiesraad/i18n"; +import { IconCompass, IconFile, IconHamburger, IconLaptop, IconUsers } from "@kiesraad/icon"; + +import styles from "./NavBar.module.css"; + +export function NavBarMenu() { + return ( +
+ + + {t("election.title.plural")} + + + + {t("users.users")} + + + + {t("workstations.workstations")} + + + + {t("logs")} + +
+ ); +} + +export function NavBarMenuButton() { + const [isMenuVisible, setMenuVisible] = React.useState(false); + + React.useEffect(() => { + if (isMenuVisible) { + const handleClickOutside = (event: MouseEvent) => { + if (!document.querySelector(`.${styles.navBarMenu}`)?.contains(event.target as Node)) { + setMenuVisible(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [isMenuVisible]); + + const toggleMenu = () => { + setMenuVisible(!isMenuVisible); + }; + + return ( + + ); +} diff --git a/frontend/lib/i18n/locales/nl/generic.json b/frontend/lib/i18n/locales/nl/generic.json index 9c791efa8..784eca0f5 100644 --- a/frontend/lib/i18n/locales/nl/generic.json +++ b/frontend/lib/i18n/locales/nl/generic.json @@ -30,6 +30,7 @@ "logs": "Logs", "manage_elections": "Beheer verkiezingen", "manual_input": "Handmatig invullen", + "menu": "Menu", "name": "Naam", "next": "Volgende", "not_yet_finished": "nog niet afgerond", diff --git a/frontend/lib/icon/README.md b/frontend/lib/icon/README.md deleted file mode 100644 index e630437c1..000000000 --- a/frontend/lib/icon/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Icon Library - -## Guidelines - -Some icons might behave differently but here are some guidelines for adding/making icons - -### Mandatory - -- No inline styles -- No ID attribute -- No class attribute -- Add role="img" - -### Guidelines - -- Mono color (black) -- Only fill, no outline -- bounds of 0 0 24 24 - -## Building - -From the frontend project root run - -```sh -npm run gen:icons -``` - -This wil create a "generated.tsx" file with all the icons inline. diff --git a/frontend/lib/icon/generated.tsx b/frontend/lib/icon/generated.tsx index afd2258a3..0b2ffd65d 100644 --- a/frontend/lib/icon/generated.tsx +++ b/frontend/lib/icon/generated.tsx @@ -154,6 +154,20 @@ export const IconClock = (props: React.SVGAttributes) => ( ); +export const IconCompass = (props: React.SVGAttributes) => ( + + + + +); + export const IconCornerDownLeft = (props: React.SVGAttributes) => ( ) => ( ); +export const IconFile = (props: React.SVGAttributes) => ( + + + +); + +export const IconHamburger = (props: React.SVGAttributes) => ( + + + + +); + export const IconHourglass = (props: React.SVGAttributes) => ( ) => ( ); +export const IconLaptop = (props: React.SVGAttributes) => ( + + + +); + export const IconLock = (props: React.SVGAttributes) => ( @@ -284,6 +331,12 @@ export const IconUser = (props: React.SVGAttributes) => ( ); +export const IconUsers = (props: React.SVGAttributes) => ( + + + +); + export const IconWarning = (props: React.SVGAttributes) => ( + + + diff --git a/frontend/lib/icon/svg/file.svg b/frontend/lib/icon/svg/file.svg new file mode 100644 index 000000000..e95799e22 --- /dev/null +++ b/frontend/lib/icon/svg/file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/lib/icon/svg/hamburger.svg b/frontend/lib/icon/svg/hamburger.svg new file mode 100644 index 000000000..a14891cab --- /dev/null +++ b/frontend/lib/icon/svg/hamburger.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/lib/icon/svg/laptop.svg b/frontend/lib/icon/svg/laptop.svg new file mode 100644 index 000000000..fed46cda5 --- /dev/null +++ b/frontend/lib/icon/svg/laptop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/lib/icon/svg/users.svg b/frontend/lib/icon/svg/users.svg new file mode 100644 index 000000000..7822b80aa --- /dev/null +++ b/frontend/lib/icon/svg/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/lib/ui/style/variables.css b/frontend/lib/ui/style/variables.css index 13421aa49..f1b07d93a 100644 --- a/frontend/lib/ui/style/variables.css +++ b/frontend/lib/ui/style/variables.css @@ -109,6 +109,7 @@ --gray-300: #d0d5dd; --gray-200: #eaecf0; --gray-100: #f2f4f7; + --gray-50: #f9fafb; --gray-25: #fcfcfd; --text-color-header: var(--gray-900);