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) => (
);
+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);