Navigation Bar
A navigation bar allows users to quickly find links to the most important parts of the website.
Tests
E2E Test
Before implementing the navigation bar we will create an E2E test in Qase. Add a new test case to the "Web Exclusive" suite named "Navigation Bar":
Tip
There is no need to explicitly mention testing the navigation bar on mobile browsers in addition to desktop browsers. During release testing we naturally change the web browser and device the browser is running on for each release.
For example, when release testing for the web app release in May 2025, we might use Google Chrome on Windows 11. Then when release testing for the web app release in June 2025, we might use Mozilla Firefox on Android.
For more details about E2E testing, see the E2E Tests section on the Testing page of this guide.
Integration Tests
There is nothing for us to test at the integration level. The only thing that could potentially be tested is navigating to another page when any of the links in the navigation bar are clicked. However this is tricky to test outside of E2E tests. Therefore the navigation is only covered in a manual E2E test above but not at the integration test level.
Unit Tests
At the unit test level we will test that:
- The mobile menu opens when the open mobile menu icon is tapped
- The mobile menu closes when the close mobile menu icon is tapped
- The mobile menu closes when outside the mobile menu is tapped
- The mobile menu icon displayed is toggled based on whether the menu is open or closed
To add TypeScript unit tests, we need a TypeScript configuration file. Start by creating a tsconfig.json
file in assets/
and populate it with the below content:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["jest"],
"baseUrl": "./",
"paths": {
"*": ["node_modules/*"]
}
},
"include": ["ts/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules"]
}
Next we will need a Babel configuration file. Babel is a transcompiler to convert modern JavaScript into older JavaScript compatible with older browsers and interpreters. We need Babel as Jest requires it to run TypeScript unit tests. Create a file named babel.config.js
in assets/
and populate it with the below content:
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};
We will need to add some dependencies to run the TypeScript tests. In the assets
directory, run the below command:
npm install --save-dev @testing-library/dom @testing-library/jest-dom @types/jest babel-jest @babel/core @babel/preset-env @babel/preset-typescript
Now we can add the actual tests. Create a new directory named test
in the assets
directory. Inside this new directory, create a file named navigation-bar.test.ts
and populate it with the below content:
import "@testing-library/jest-dom";
import { fireEvent } from "@testing-library/dom";
import { initializeNavigationBar } from "../ts/navigation-bar";
let mobileMenu: HTMLDivElement;
let mobileMenuButton: HTMLButtonElement;
let openMenuIcon: SVGElement;
let closeMenuIcon: SVGElement;
const hidden = "hidden";
beforeEach(() => {
document.body.innerHTML = `
<div id="mobile-menu" class="hidden">
<a href="/download">Download</a>
<a href="/app">App</a>
</div>
<button id="mobile-menu-button">
<svg id="open-menu-icon"></svg>
<svg id="close-menu-icon" class="hidden"></svg>
</button>
<div id="outside-mobile-menu">Outside Mobile Menu</div>
`;
mobileMenu = document.getElementById("mobile-menu") as HTMLDivElement;
mobileMenuButton =
document.getElementById("mobile-menu-button") as HTMLButtonElement;
openMenuIcon =
document.getElementById("open-menu-icon") as unknown as SVGElement;
closeMenuIcon =
document.getElementById("close-menu-icon") as unknown as SVGElement;
initializeNavigationBar();
});
describe("Navigation Bar", () => {
test("open mobile menu when user taps hamburger", () => {
fireEvent.click(mobileMenuButton);
expect(mobileMenu).not.toHaveClass(hidden);
expect(openMenuIcon).toHaveClass(hidden);
expect(closeMenuIcon).not.toHaveClass(hidden);
});
test("close mobile menu when user taps close button", () => {
fireEvent.click(mobileMenuButton);
fireEvent.click(mobileMenuButton);
expect(mobileMenu).toHaveClass(hidden);
expect(openMenuIcon).not.toHaveClass(hidden);
expect(closeMenuIcon).toHaveClass(hidden);
});
test("close mobile menu when user taps outside mobile menu", () => {
const outsideMobileMenu = document.getElementById("outside-mobile-menu")!;
fireEvent.click(mobileMenuButton);
fireEvent.click(outsideMobileMenu);
expect(mobileMenu).toHaveClass(hidden);
expect(openMenuIcon).not.toHaveClass(hidden);
expect(closeMenuIcon).toHaveClass(hidden);
});
});
Run the tests using the below command:
npm run test
Naturally at this point the tests should not work yet since the production code to make them pass is still missing. When running the tests a coverage report will be generated. We need to add the directory that contains the coverage report files to .gitignore
to prevent them from accidentally being added to the repository. Add the below line to your .gitignore
file located in the root of the Phoenix project:
/assets/coverage/
Production Code
Our navigation bar will link to pages that don't exist yet, e.g. the download page. For the time being we need to create empty pages in our Phoenix project so that these links work. In later sections of this guide we will fill these empty pages with content.
Layouts
We will start by creating a website layout. Create a file named website.html.heex
in the directory lib/epic_fantasy_forge_web/components/layouts/website.html.heex
and populate it with the below content:
<main>
{@inner_content}
</main>
We will have three layout files in total:
Layout | Purpose |
---|---|
root.html.heex | Common to both the website and app |
app.html.heex | Layout for the web app |
website.html.heex | Layout for the website |
Our Phoenix project will serve both the general website and our web app. The general website includes the landing page, contact page, etc. The app is the worldbuilding part of this Phoenix project. The website and app will each get their own unique layouts. Other parts of the layout will be common to both, e.g. Featurebase widgets (feedback, changelog, survey) located in root.html.heex.
Only the website.html.heex layout will contain the navigation bar. The web app layout (app.html.heex) will not have a navigation bar.
The app layout, app.html.heex
already exists, so no need to create it.
Templates
We will now create the HTML templates for our new pages. Create a file named download.html.heex
in the directory lib/epic_fantasy_forge_web/controllers/page_html/
. The new file can remain empty for now.
Create a directory named app_html
in the directory sh lib/epic_fantasy_forge_web/controllers/
. Now create a file named app.html.heex
in the new directory and also leave it empty for now.
Create a new file named app_html.ex
in the directory lib/epic_fantasy_forge_web/controllers/
and populate it with the below content:
defmodule EpicFantasyForgeWeb.AppHTML do
@moduledoc """
This module contains pages rendered by AppController
"""
use EpicFantasyForgeWeb, :html
embed_templates "app_html/*"
end
Controllers
Create a file named app_controller.ex
in the directory lib/epic_fantasy_forge_web/controllers/
and populate it with the below content:
defmodule EpicFantasyForgeWeb.AppController do
use EpicFantasyForgeWeb, :controller
def app(conn, _params) do
render(conn, :app, layout: {EpicFantasyForgeWeb.Layouts, :app})
end
end
Update lib/epic_fantasy_forge_web/controllers/page_controller.ex
to include the new download page. Additionally we update the home page to use the "website" layout.
def home(conn, _params) do
render(conn, :home, layout: {EpicFantasyForgeWeb.Layouts, :website})
end
def download(conn, _params) do
render(conn, :download, layout: {EpicFantasyForgeWeb.Layouts, :website})
end
Router
Now open lib/epic_fantasy_forge_web/router.ex
and add routes for the new pages:
scope "/", EpicFantasyForgeWeb do
pipe_through :browser
get "/", PageController, :home
get "/download", PageController, :download
get "/app", AppController, :app
end
TypeScript
We will now add some TypeScript code to control the opening and closing of the mobile menu when the mobile menu icon is tapped. In the assets/ts/
directory, create a new file named navigation-bar.ts
and populate it with the below content:
export function initializeNavigationBar() {
try {
const mobileMenu = new MobileMenu();
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
if (!mobileMenu.contains(target)) {
mobileMenu.close();
}
});
} catch (error) {
console.error("Error initializing navigation bar:", error);
}
}
class MobileMenu {
private static readonly hidden = "hidden";
private menu: HTMLDivElement;
private button: HTMLButtonElement;
private openIcon: SVGElement;
private closeIcon: SVGElement;
constructor() {
this.menu = document.getElementById("mobile-menu") as HTMLDivElement;
this.button =
document.getElementById("mobile-menu-button") as HTMLButtonElement;
this.openIcon =
document.getElementById("open-menu-icon") as unknown as SVGElement;
this.closeIcon =
document.getElementById("close-menu-icon") as unknown as SVGElement;
this.button.addEventListener("click", () => {
this.toggle();
});
}
toggle() {
this.menu.classList.toggle(MobileMenu.hidden);
this.openIcon.classList.toggle(MobileMenu.hidden);
this.closeIcon.classList.toggle(MobileMenu.hidden);
}
close() {
this.menu.classList.add(MobileMenu.hidden);
this.openIcon.classList.remove(MobileMenu.hidden);
this.closeIcon.classList.add(MobileMenu.hidden);
}
contains(target: HTMLElement): boolean {
return this.menu.contains(target) || this.button.contains(target);
}
}
Now update app.ts
located in assets/ts
to call the initializeNavigationBar()
function:
import { initializeNavigationBar } from "./navigation-bar";
initializeNavigationBar();
One more change is needed to take this TypeScript code into use. Add the below to the end of app.js
located in assets/js
:
document.addEventListener('DOMContentLoaded', function() {
import("../ts/app.ts");
});
The TypeScript unit tests that we wrote above should now pass when running the below command in the assets
directory:
npm run test
Font
To display the brand name, in this case "Epic Fantasy Forge", we will use a custom font. You can get custom fonts from Google Fonts.
Warning
Different fonts have different licensing requirements. Make sure your usage of the font is permitted by its license. Often you will need to acknowledge your use of the font. This can be done on a dedicated "Third-party Licenses" page on your website. The creation of such a third-party acknowledgements page is covered on the Third-party Licenses of this guide.
To add a custom font to your website, select a font you like in Google Fonts and click on "Get font". In the case of Epic Fantasy Forge the custom font "Orbitron" is used:
Click on "<> Get embed code":
Select the "Web" tab, ensure the "" checkbox is selected and click on "Copy code" in the "Embed code in the
of your html" section:Now paste the copied code into the <head>
section of root.html.heex
located in the directory lib/epic_fantasy_forge_web/components/layouts/
:
<!-- Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Orbitron:[email protected]&display=swap"
rel="stylesheet"
/>
Next we need to update tailwind.config.js
located in the directory assets/
to support using the new font. Add a fontFamily
block after the colors
block under the theme/extend
block:
fontFamily: {
orbitron: ['"Orbitron"', 'sans-serif']
},
sans-serif
is the fallback font family in case the custom font Orbitron
is not available. This may be the case when the end user is blocking Google Fonts. Most web browsers should be able to render a sans-serif
font, which is why it is commonly used as a fallback font.
HTML
First create a new directory named web
in the lib/epic_fantasy_forge_web/components
directory. Inside this new directory we will place all components that are website exclusive. Later we will add two additional directories, one to hold web app exclusive components and another one to hold components that are common to both the website and web app.
Inside the directory you just created, create another directory named templates
. Inside the templates
directory create a file named navigation_bar.html.heex
and populate it with the below content:
<nav class="bg-gray-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="mr-2 -ml-2 flex items-center lg:hidden">
<button
id="mobile-menu-button"
type="button"
class="
relative
inline-flex
items-center
justify-center
rounded-md
p-2
text-gray-400
hover:bg-gray-700
hover:text-white
focus:ring-2
focus:ring-white
focus:outline-hidden
focus:ring-inset"
aria-controls="mobile-menu"
aria-expanded="false"
>
<span class="absolute -inset-0.5"></span>
<span class="sr-only">Open main menu</span>
<svg
id="open-menu-icon"
class="size-10"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
aria-hidden="true"
data-slot="icon"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
<svg
id="close-menu-icon"
class="hidden size-10"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
aria-hidden="true"
data-slot="icon"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex shrink-0 items-center">
<a href="/" data-testid="brand-logo">
<img
class="h-12 w-auto"
src="/images/logo.png"
alt="Epic Fantasy Forge"
/>
</a>
<a
href="/"
class="
max-sm:hidden
ml-3
text-xl
font-orbitron
text-gray-300
hover:text-white"
>
Epic Fantasy Forge
</a>
</div>
<div class="hidden md:ml-6 lg:flex md:items-center md:space-x-4">
<a
href="/download"
class="
rounded-md
px-3
py-2
text-sm
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
App
</a>
<a
href="https://documentation.epicfantasyforge.com"
target="_blank"
rel="noopener noreferrer"
class="
rounded-md
px-3
py-2
text-sm
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Manual
</a>
<a
href="https://feedback.epicfantasyforge.com/roadmap"
target="_blank"
rel="noopener noreferrer"
class="
rounded-md
px-3
py-2
text-sm
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Roadmap
</a>
<a
href="https://development.epicfantasyforge.com"
target="_blank"
rel="noopener noreferrer"
class="
rounded-md
px-3
py-2
text-sm
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Development
</a>
<a
href=""
target="_blank"
rel="noopener noreferrer"
class="
rounded-md
px-3
py-2
text-sm
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Analytics
</a>
</div>
</div>
<div class="flex items-center">
<div class="shrink-0">
<button
type="button"
class="
relative
inline-flex
items-center
gap-x-1.5
rounded-md
bg-indigo-600
px-3
py-2
text-sm
font-semibold
text-white
shadow-xs
hover:bg-indigo-500
focus-visible:outline-2
focus-visible:outline-offset-2
focus-visible:outline-indigo-500"
>
Forge World
</button>
</div>
</div>
</div>
</div>
<div class="hidden lg:hidden" id="mobile-menu">
<div class="space-y-1 px-2 pt-2 pb-3 sm:px-3">
<a
href="/download"
class="
block
rounded-md
px-3
py-2
text-base
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
App
</a>
<a
href="https://documentation.epicfantasyforge.com"
target="_blank"
rel="noopener noreferrer"
class="
block
rounded-md
px-3
py-2
text-base
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Manual
</a>
<a
href="https://feedback.epicfantasyforge.com/roadmap"
target="_blank"
rel="noopener noreferrer"
class="
block
rounded-md
px-3
py-2
text-base
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Roadmap
</a>
<a
href="https://development.epicfantasyforge.com"
target="_blank"
rel="noopener noreferrer"
class="
block
rounded-md
px-3
py-2
text-base
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Development
</a>
<a
href=""
target="_blank"
rel="noopener noreferrer"
class="
block
rounded-md
px-3
py-2
text-base
font-medium
text-gray-300
hover:bg-gray-700
hover:text-white"
>
Analytics
</a>
</div>
</div>
</nav>
Now add a new file named web_components.ex
in the directory lib/epic_fantasy_forge_web/components/web/
and populate it with the below content:
defmodule EpicFantasyForgeWeb.WebComponents do
@moduledoc """
Web components for the Epic Fantasy Forge website.
"""
use EpicFantasyForgeWeb, :html
embed_templates "templates/*"
end
Now let's take this new navigation bar component into use in our website.html.heex
layout. Add the below at the beginning of the file:
<EpicFantasyForgeWeb.WebComponents.navigation_bar />
Now when you run your Phoenix project you should have a navigation bar:
On mobile sized screens the navigation bar should look like this: