Skip to content

Commit

Permalink
feat: mobile and touch friendly multi level navigation (#106)
Browse files Browse the repository at this point in the history
  • Loading branch information
niccofyren authored Nov 13, 2024
1 parent c556a1f commit 2a1d496
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 61 deletions.
60 changes: 33 additions & 27 deletions src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import NavItem from "./navigation/NavItem.astro";
---

<script>
const mobileMenu = document.querySelector("#mobile-menu");
const mobileMenu = document.querySelector(
"[data-component='header'] [data-part='mobile-menu']",
);
const mobileMenuButton = document.querySelector(
"button[aria-controls='mobile-menu']",
"[data-component='header'] [data-part='mobile-menu-trigger']",
);

if (mobileMenuButton && mobileMenu) {
Expand All @@ -25,7 +27,7 @@ import NavItem from "./navigation/NavItem.astro";
}
</script>

<header class="flex flex-col-reverse">
<header class="flex flex-col-reverse" data-component="header">
<div
class="flex justify-between items-center py-1 px-5 max-w-7xl mb-8 justify-self-center w-full mx-auto bg-backgroundSecondary sm:rounded-xl sm:mx-5 sm:mt-2 sm:mb-8 sm:w-auto xl:mx-auto xl:w-full"
>
Expand Down Expand Up @@ -77,7 +79,7 @@ import NavItem from "./navigation/NavItem.astro";
<div class="absolute inset-y-0 right-0 flex items-center sm:hidden">
<button
type="button"
id="mobile-menu-button"
data-part="mobile-menu-trigger"
class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
aria-controls="mobile-menu"
aria-expanded="false"
Expand Down Expand Up @@ -116,29 +118,33 @@ import NavItem from "./navigation/NavItem.astro";
</div>
</div>
<!-- Mobile menu, show/hide based on menu state. -->
<div class="hidden" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2">
<a
href="/competitions/creative"
class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-orange-500 hover:text-white"
>Kreative konkurranser</a
>
<a
href="/competitions/esport"
class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-orange-500 hover:text-white"
>E-sport</a
>
<a
href="/contact"
class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-orange-500 hover:text-white"
>Kontakt oss</a
>
<a
href="/tickets"
class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-orange-500 hover:text-white"
>Billetter</a
>
</div>
<div class="hidden md:hidden" id="mobile-menu" data-part="mobile-menu">
<ul class="space-y-3 px-2 pb-3 pt-2 flex flex-col">
<NavItem
title="Konkurranser"
subItems={[
{ title: "Kreative", path: "/competitions/creative" },
{ title: "Esport", path: "/competitions/esport" },
]}
/>
<NavItem title="Kontakt oss" path="/contact" />
<NavItem
title="Billetter"
path="/tickets"
subAlignment="right"
subItems={[
{ title: "Vilkår", path: "/tickets/terms-and-conditions" },
{
title: "Arrangementsregler",
path: "/event/rules",
},
{
title: "Konstruksjonsregler",
path: "/event/construction-rules",
},
]}
/>
</ul>
</div>
</nav>
</div>
Expand Down
168 changes: 134 additions & 34 deletions src/components/navigation/NavItem.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,164 @@ export interface Props {
title: string;
path?: string;
subAlignment?: "left" | "right";
subItems?: Array<{
title: string;
path: string;
}>;
subItems?: SubItem[];
class?: string;
}
interface SubItem {
title: string;
path: string;
}
const { title, path, subItems = [], subAlignment = "left" } = Astro.props;
const {
title,
path,
subItems = [],
subAlignment = "left",
class: className = "",
} = Astro.props;
const key = `${title.toLowerCase().replace(" ", "-")}-${path || ""}`;
const clickable = !!path;
const expandable = !!subItems.length;
const TextContainer = clickable ? "a" : "span";
const isActive =
path === Astro.url.pathname ||
subItems.some((item) => item.path === Astro.url.pathname);
const subItemsWithParent: Array<
SubItem & {
touchOnly?: boolean;
}
> = path ? [{ path, title, touchOnly: true }, ...subItems] : subItems;
---

<li class="relative group bg-backgroundSecondary">
<TextContainer
href={path}
class={`block ${isActive ? "text-orange-500" : "text-white"} ${clickable ? "group-hover:text-orange-500" : "group-hover:text-gray-500"} flex z-10 relative ${clickable ? "cursor-pointer" : "cursor-default"}`}
<script>
interface SubNavComponents {
mainLink: HTMLElement;
trigger: HTMLElement;
subMenu: HTMLElement;
navItem: HTMLElement;
}

const isTouchDevice =
"ontouchstart" in window || navigator.maxTouchPoints > 0;

const navItems: SubNavComponents[] = document
.querySelectorAll("[data-component='nav-item']")
.values()
.map((navItem) => {
const trigger = navItem.querySelector("[data-part='trigger']");
const subMenu = navItem.querySelector("[data-part='sub-menu']");
const mainLink = navItem.querySelector("[data-part='main-link']");
return { mainLink, trigger, subMenu, navItem };
})
.filter(
({ mainLink, navItem, trigger, subMenu }) =>
mainLink && navItem && trigger && subMenu,
)
.toArray() as unknown as SubNavComponents[];

function expand(index: number, expanded: boolean) {
const { navItem, trigger, subMenu } = navItems[index] || {};
if (!trigger || !subMenu) {
return;
}
navItem.classList.toggle("expanded", expanded);
trigger.setAttribute("aria-expanded", String(expanded));
subMenu.classList.toggle("flex", expanded);
subMenu.classList.toggle("hidden", !expanded);
}

function closeAll() {
navItems.forEach((_, index: number) => {
expand(index, false);
});
}

navItems.forEach(({ mainLink, navItem, trigger, subMenu }, index: number) => {
navItem.classList.toggle("touch-device", isTouchDevice);

if (isTouchDevice) {
subMenu.querySelectorAll(".hidden").forEach((subMenu) => {
subMenu.classList.remove("hidden");
});
[mainLink, trigger].forEach((item) =>
item?.addEventListener("click", (event) => {
event?.preventDefault();
const expanded = !(
trigger.getAttribute("aria-expanded") === "true" || false
);
closeAll();
expand(index, expanded);
}),
);
} else {
navItem.addEventListener("mouseenter", () => {
closeAll();
expand(index, true);
});
navItem.addEventListener("mouseleave", () => {
expand(index, false);
});
}
});
</script>

<li
class={`relative group bg-backgroundSecondary ${className}`}
data-component="nav-item"
>
<div
class={`block ${isActive ? "text-orange-500" : "text-white"} ${clickable ? "group-[.expanded]:text-orange-500" : "group-[.expanded]:text-neutral-400"} flex z-10 relative ${clickable ? "cursor-pointer" : "cursor-default"} items-center h-6`}
>
{title}
<TextContainer href={path} data-part="main-link" class="mr-3 w-full">
{title}
</TextContainer>
{
expandable ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-3 w-3 m-1.5 group-hover:scale-y-[-1] transition-transform duration-100 ease-out"
aria-hidden="false"
aria-haspopup="menu"
<button
type="button"
data-part="trigger"
aria-controls={`nav-item-sub-${key}`}
aria-expanded="false"
class="block p-1 ml-auto rounded group-[.touch-device]:bg-neutral-800"
>
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 9l6 6l6 -6" />
</>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-3 w-3 m-1.5 group-[.expanded]:scale-y-[-1] transition-transform duration-100 ease-out"
aria-hidden="false"
aria-haspopup="menu"
>
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 9l6 6l6 -6" />
</>
</svg>
</button>
) : null
}
</TextContainer>
</div>
{
expandable ? (
<ul
class={`hidden absolute top-0 ${subAlignment}-0 min-w-full flex flex-col bg-backgroundSecondary space-y-1 px-2 pb-3 pt-10 rounded-b-md group-hover:flex z-0`}
id="comp-sub-nav"
class={`hidden min-w-full flex-col bg-neutral-800 md:bg-backgroundSecondary space-y-1 px-2 py-3 mt-3 z-0 md:mt-0 md:pt-10 md:rounded-b-md md:absolute md:top-0 ${subAlignment === "left" ? "md:left-0" : "md:right-0"}`}
id={`nav-item-sub-${key}`}
aria-role="menu"
data-part="sub-menu"
>
{subItems.map((subItem) => (
<li>
{subItemsWithParent.map((subItem) => (
<li class={`${subItem.touchOnly ? "hidden" : ""}`}>
<a
href={subItem.path}
class={`block rounded-md px-2 py-1 text-sm font-medium ${subItem.path === Astro.url.pathname ? "text-orange-500" : "text-gray-300"} hover:bg-orange-500 hover:text-white`}
class={`block rounded-md px-2 py-1 text-sm font-medium ${subItem.path === Astro.url.pathname ? "text-orange-500" : "text-neutral-300"} hover:bg-orange-500 hover:text-white`}
>
{subItem.title}
</a>
Expand Down

0 comments on commit 2a1d496

Please sign in to comment.