Skip to content

Commit

Permalink
Add hamburger menu to navigation bar (#994)
Browse files Browse the repository at this point in the history
  • Loading branch information
praseodym authored and lkleuver committed Feb 12, 2025
1 parent 8ea1ef5 commit afdfe06
Show file tree
Hide file tree
Showing 15 changed files with 284 additions and 55 deletions.
31 changes: 31 additions & 0 deletions documentatie/style-guide/icons.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 59 additions & 1 deletion frontend/app/component/navbar/NavBar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
gap: 0.25rem;
}

:first-child:not(:global(.active)) {
> :first-child:not(:global(.active)) {
padding-left: 0;
}

Expand All @@ -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;
}
16 changes: 16 additions & 0 deletions frontend/app/component/navbar/NavBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -55,3 +57,17 @@ export const AllRoutes: Story = () => (
))}
</ElectionProviderContext.Provider>
);

export const Menu: Story = () => (
<div className={styles.navBarMenuContainer}>
<NavBarMenu />
</div>
);

export const MenuButton: Story = () => (
<nav aria-label="primary-navigation" className={styles.navBar}>
<div className={styles.links}>
<NavBarMenuButton />
</div>
</nav>
);
49 changes: 36 additions & 13 deletions frontend/app/component/navbar/NavBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { userEvent } from "@testing-library/user-event";
import { beforeEach, describe, expect, test } from "vitest";

import { ElectionProvider } from "@kiesraad/api";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});
23 changes: 10 additions & 13 deletions frontend/app/component/navbar/NavBarLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 (
<span>
<ElectionBreadcrumb election={election} />
</span>
);
return <></>;
} else {
return (
<>
<NavBarMenuButton />
<Link to={`/elections/${election.id}#administratorcoordinator`}>
<ElectionBreadcrumb election={election} />
</Link>
Expand Down Expand Up @@ -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 <DataEntryLinks location={location} />;
} else if (location.pathname.match(/^\/elections\/\d+/)) {
return <ElectionManagementLinks location={location} />;
} 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 <TopLevelManagementLinks />;
} else if (location.pathname.match(/^\/elections\/\d+\/data-entry/)) {
return <DataEntryLinks location={location} />;
} else if (location.pathname.match(/^\/elections\/\d+/)) {
return <ElectionManagementLinks location={location} />;
} else {
return <></>;
}
Expand Down
60 changes: 60 additions & 0 deletions frontend/app/component/navbar/NavBarMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.navBarMenu}>
<NavLink to={"/elections#administrator"}>
<IconCompass />
{t("election.title.plural")}
</NavLink>
<NavLink to={"/users#administratorcoordinator"}>
<IconUsers />
{t("users.users")}
</NavLink>
<NavLink to={"/workstations#administrator"}>
<IconLaptop />
{t("workstations.workstations")}
</NavLink>
<NavLink to={"/logs#administratorcoordinator"}>
<IconFile />
{t("logs")}
</NavLink>
</div>
);
}

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 (
<button className={styles.navBarMenuContainer} onClick={toggleMenu} title={t("menu")}>
<IconHamburger />
{isMenuVisible && <NavBarMenu />}
</button>
);
}
1 change: 1 addition & 0 deletions frontend/lib/i18n/locales/nl/generic.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 0 additions & 28 deletions frontend/lib/icon/README.md

This file was deleted.

Loading

0 comments on commit afdfe06

Please sign in to comment.