Skip to content

Commit 0cf3de5

Browse files
authored
feat: add navigation menu for mobile (#436)
close #40 If horizontal width of a navigation list exceeds a window size, it will store them in a small menu. Preview ![image](https://github.com/jsr-io/jsr/assets/114303361/1c58458e-452d-4903-a548-7114363360f2)
1 parent adc1f97 commit 0cf3de5

File tree

3 files changed

+89
-2
lines changed

3 files changed

+89
-2
lines changed

frontend/components/Nav.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright 2024 the JSR authors. All rights reserved. MIT license.
22
import { ComponentChildren } from "preact";
3+
import { NavMenu } from "../islands/NavMenu.tsx";
34

45
export interface NavProps {
56
children?: ComponentChildren;
@@ -11,9 +12,12 @@ export function Nav(props: NavProps) {
1112
<nav
1213
class={`${
1314
props.noTopMargin ? "" : "mt-3"
14-
} md:border-b border-jsr-cyan-300/30 flex flex-wrap md:flex-nowrap flex-row max-w-full overflow-auto items-end`}
15+
} md:border-b border-jsr-cyan-300/30 max-w-full flex justify-between overflow-hidden items-end`}
1516
>
16-
{props.children}
17+
<ul id="nav-items" class="flex flex-row">
18+
{props.children}
19+
</ul>
20+
<NavMenu />
1721
</nav>
1822
);
1923
}

frontend/fresh.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import * as $GithubUserLink from "./islands/GithubUserLink.tsx";
5555
import * as $GlobalSearch from "./islands/GlobalSearch.tsx";
5656
import * as $HeaderLogo from "./islands/HeaderLogo.tsx";
5757
import * as $HomepageHeroParticles from "./islands/HomepageHeroParticles.tsx";
58+
import * as $NavMenu from "./islands/NavMenu.tsx";
5859
import * as $PublishingTaskRequeue from "./islands/PublishingTaskRequeue.tsx";
5960
import * as $UserManageScopeInvite from "./islands/UserManageScopeInvite.tsx";
6061
import * as $UserMenu from "./islands/UserMenu.tsx";
@@ -127,6 +128,7 @@ const manifest = {
127128
"./islands/GlobalSearch.tsx": $GlobalSearch,
128129
"./islands/HeaderLogo.tsx": $HeaderLogo,
129130
"./islands/HomepageHeroParticles.tsx": $HomepageHeroParticles,
131+
"./islands/NavMenu.tsx": $NavMenu,
130132
"./islands/PublishingTaskRequeue.tsx": $PublishingTaskRequeue,
131133
"./islands/UserManageScopeInvite.tsx": $UserManageScopeInvite,
132134
"./islands/UserMenu.tsx": $UserMenu,

frontend/islands/NavMenu.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright 2024 the JSR authors. All rights reserved. MIT license.
2+
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
3+
import IconDots from "$tabler_icons/dots.tsx";
4+
5+
const useWindowWidth = () => {
6+
const [windowWidth, setWindowWidth] = useState(0);
7+
const handleWidth = () => {
8+
setWindowWidth(globalThis.innerWidth);
9+
};
10+
useLayoutEffect(() => {
11+
handleWidth();
12+
globalThis.addEventListener("resize", handleWidth);
13+
14+
return () => globalThis.removeEventListener("resize", handleWidth);
15+
}, []);
16+
return windowWidth;
17+
};
18+
19+
export function NavMenu() {
20+
const [open, setOpen] = useState(false);
21+
const ref = useRef<HTMLButtonElement>(null);
22+
23+
useWindowWidth();
24+
useEffect(() => {
25+
function outsideClick(e: Event) {
26+
if (ref.current && !ref.current.contains(e.target as Element)) {
27+
setOpen(false);
28+
}
29+
}
30+
document.addEventListener("click", outsideClick);
31+
return () => document.removeEventListener("click", outsideClick);
32+
}, []);
33+
34+
if (typeof document === "undefined") return null;
35+
36+
const navItems = document.getElementById("nav-items");
37+
const nav = navItems?.parentElement;
38+
let navWidth = 0;
39+
if (nav) {
40+
navWidth = nav.offsetWidth;
41+
}
42+
43+
let sumWidth = 50;
44+
let displayMenu = false;
45+
const navMenuList = [];
46+
if (navItems) {
47+
for (let i = 0; i < navItems.children.length; i++) {
48+
const child = navItems.children[i];
49+
child.classList.remove("invisible");
50+
sumWidth += child.clientWidth;
51+
52+
if (sumWidth > navWidth) {
53+
displayMenu = true;
54+
navMenuList.push(child.outerHTML);
55+
child.classList.add("invisible");
56+
}
57+
}
58+
}
59+
60+
return (
61+
<button
62+
id="nav-menu"
63+
class={`group absolute right-4 md:right-10 rounded md:rounded-b-none border-1 md:border-b-0 border-jsr-cyan-100 hover:bg-jsr-cyan-50 hover:cursor-pointer ${
64+
displayMenu ? "" : "hidden"
65+
}`}
66+
aria-expanded={open ? "true" : "false"}
67+
onClick={() => setOpen((v) => !v)}
68+
ref={ref}
69+
>
70+
<span class="flex p-1">
71+
<IconDots />
72+
</span>
73+
{open && (
74+
<div
75+
class="absolute top-[120%] -right-4 z-[70] px-1 py-2 rounded border-1.5 border-current bg-white w-56 shadow overflow-hidden opacity-100 translate-y-0 transition [&>a]:rounded"
76+
dangerouslySetInnerHTML={{ __html: navMenuList.join("") }}
77+
/>
78+
)}
79+
</button>
80+
);
81+
}

0 commit comments

Comments
 (0)