Skip to content

Commit 495cf0a

Browse files
feat(web): add mobile drawer for docs navigation (#1985)
* feat(web): add mobile drawer for docs navigation - Add DocsDrawerContext to manage drawer state - Add MobileDrawer component that shows docs navigation on mobile - Add drawer icon (PanelLeft) to header that only appears on docs pages in mobile view - Extract DocsNavigation component for reuse between sidebar and drawer Co-Authored-By: [email protected] <[email protected]> * fix(web): move DocsDrawerContext.Provider to _view/route.tsx - Move the context provider to the parent layout so Header can access it - Update docs/route.tsx to consume context instead of providing it - This fixes the drawer icon not appearing in the header on docs pages Co-Authored-By: [email protected] <[email protected]> * Move header icon to prefix on small screens Place the logo (and optional docs drawer button) in the header prefix for small screens so the icon appears on the left side instead of the suffix. This rearranges the mobile-only markup: the docs drawer button and homepage Link are wrapped together in the prefix sm:hidden container, and the duplicate docs-button in the suffix is removed to avoid rendering the icon on the right. * Add padding between logo and drawer icon Increase spacing between the logo and the drawer icon on small screens to improve visual balance and touch target separation. This adjusts the gap utility from 1 to 3 in the header component so the drawer button isn't crowded against the logo when viewing documentation pages. * Show docs drawer toggle immediately when docs sidebar hides Make the docs navigation toggle button available as soon as the documentation sidebar is not visible so users on smaller viewports can open the docs drawer without waiting. This change removes the sm-only wrapper around the left header block, adds a docs-drawer button (hidden on md+), and moves several nav links to be hidden on small screens (sm:block) to keep header layout consistent. This ensures the drawer toggle appears immediately when the left sidebar disappears, improving navigation for documentation pages on narrow screens. * Align docs drawer under header and unify hierarchy Place the mobile documentation drawer inside the main _view route so it shares the same layout/hierarchy as the header and shows below it (header on top, drawer then body). This removes the separate drawer implementation from the docs route and reuses a MobileDocsDrawer in the top-level _view route. The change moves drawer rendering into the main layout, wires up docs data and routing for navigation links, and adjusts styles/animations to display the drawer beneath the header on mobile. Summary of changes: - Remove MobileDrawer component and related hook usage from apps/web/src/routes/_view/docs/route.tsx. - Add MobileDocsDrawer, DocsNavigation, and required imports (allDocs, X icon, docsStructure, useMatchRoute, useMemo) to apps/web/src/routes/_view/route.tsx. - Render MobileDocsDrawer conditionally under Header in the top-level _view route and manage isOpen state there. - Build docs sections and links for the drawer using content-collections and docsStructure, preserving currentSlug highlighting and onLink close behavior. * Make docs drawer interactive and dynamic Refine the documentation drawer to behave like a collapsible drawer with a dynamic icon and toggle behavior. Clicking the drawer icon now toggles open/closed state (and updates aria-label accordingly), and the drawer markup/CSS was adjusted for smoother transitions, larger max height, and simplified structure to remove the separate close button and header. Changes: - Toggle docsDrawer.isOpen when icon button is clicked and swap PanelLeft/PanelLeftClose icons. - Update aria-label to reflect open/close state. - Adjust drawer container classes (max-height, borders, shadow, transition) and simplify internal structure; remove explicit close button and header, increase scrollable area. * Slide docs drawer in from left, match header z-index Make the mobile docs drawer slide in from the left instead of dropping down from the top, set its height to calc(100vh - 69px) so it fits beneath the header, and ensure its z-index matches/overlaps the header by using z-50 with an overlay at z-40. This improves the mobile docs UX and keeps the footer/main layout stable by moving Outlet and Footer positions. Changes: - Move Outlet and Footer placement inside main layout to ensure proper structure. - Replace previous top-down collapsible block with a left sliding fixed drawer and backdrop overlay. - Add positioning, height, width, borders, shadow, and transition classes for smooth slide animation. - Keep DocsNavigation inside a scrollable container within the drawer. * Remove backdrop overlay and add right-side shadow to drawer Simplify mobile drawer UI by removing the fullscreen backdrop and embedding the drawer directly. Replaced the overlay with a drawer container that always renders and uses translate-x for show/hide, added larger shadow (shadow-2xl with neutral tint) and minor structure adjustments so navigation stays scrollable and closes on link click. This reduces DOM elements and implements the requested right-side shadow instead of a backdrop. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 662da6b commit 495cf0a

File tree

4 files changed

+221
-49
lines changed

4 files changed

+221
-49
lines changed

apps/web/src/components/header.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { Link, useRouterState } from "@tanstack/react-router";
2-
import { ChevronDown, ChevronUp, Menu } from "lucide-react";
2+
import {
3+
ChevronDown,
4+
ChevronUp,
5+
Menu,
6+
PanelLeft,
7+
PanelLeftClose,
8+
} from "lucide-react";
39
import { useState } from "react";
410

11+
import { useDocsDrawer } from "@/hooks/use-docs-drawer";
512
import { getPlatformCTA, usePlatform } from "@/hooks/use-platform";
613

714
function scrollToHero() {
@@ -39,6 +46,8 @@ export function Header() {
3946
const platformCTA = getPlatformCTA(platform);
4047
const router = useRouterState();
4148
const maxWidthClass = getMaxWidthClass(router.location.pathname);
49+
const isDocsPage = router.location.pathname.startsWith("/docs");
50+
const docsDrawer = useDocsDrawer();
4251

4352
return (
4453
<>
@@ -47,7 +56,24 @@ export function Header() {
4756
className={`${maxWidthClass} mx-auto px-4 laptop:px-0 border-x border-neutral-100 h-full`}
4857
>
4958
<div className="flex items-center justify-between h-full">
50-
<div className="hidden sm:flex items-center gap-5">
59+
<div className="flex items-center gap-5">
60+
{isDocsPage && docsDrawer && (
61+
<button
62+
onClick={() => docsDrawer.setIsOpen(!docsDrawer.isOpen)}
63+
className="md:hidden px-3 h-8 flex items-center text-sm border border-neutral-200 rounded-full hover:bg-neutral-50 active:scale-[98%] transition-all"
64+
aria-label={
65+
docsDrawer.isOpen
66+
? "Close docs navigation"
67+
: "Open docs navigation"
68+
}
69+
>
70+
{docsDrawer.isOpen ? (
71+
<PanelLeftClose className="text-neutral-600" size={16} />
72+
) : (
73+
<PanelLeft className="text-neutral-600" size={16} />
74+
)}
75+
</button>
76+
)}
5177
<Link
5278
to="/"
5379
className="font-semibold text-2xl font-serif hover:scale-105 transition-transform mr-4"
@@ -59,7 +85,7 @@ export function Header() {
5985
/>
6086
</Link>
6187
<div
62-
className="relative"
88+
className="relative hidden sm:block"
6389
onMouseEnter={() => setIsProductOpen(true)}
6490
onMouseLeave={() => setIsProductOpen(false)}
6591
>
@@ -130,13 +156,13 @@ export function Header() {
130156
</Link>
131157
<Link
132158
to="/blog"
133-
className="text-sm text-neutral-600 hover:text-neutral-800 transition-all hover:underline decoration-dotted"
159+
className="hidden sm:block text-sm text-neutral-600 hover:text-neutral-800 transition-all hover:underline decoration-dotted"
134160
>
135161
Blog
136162
</Link>
137163
<Link
138164
to="/pricing"
139-
className="text-sm text-neutral-600 hover:text-neutral-800 transition-all hover:underline decoration-dotted"
165+
className="hidden sm:block text-sm text-neutral-600 hover:text-neutral-800 transition-all hover:underline decoration-dotted"
140166
>
141167
Pricing
142168
</Link>
@@ -148,17 +174,6 @@ export function Header() {
148174
</Link>
149175
</div>
150176

151-
<Link
152-
to="/"
153-
className="sm:hidden font-semibold text-2xl font-serif hover:scale-105 transition-transform"
154-
>
155-
<img
156-
src="/api/images/hyprnote/logo.svg"
157-
alt="Hyprnote"
158-
className="h-6"
159-
/>
160-
</Link>
161-
162177
<nav className="hidden sm:flex items-center gap-2">
163178
<Link
164179
to="/join-waitlist"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createContext, useContext } from "react";
2+
3+
interface DocsDrawerContextType {
4+
isOpen: boolean;
5+
setIsOpen: (open: boolean) => void;
6+
}
7+
8+
export const DocsDrawerContext = createContext<DocsDrawerContextType | null>(
9+
null,
10+
);
11+
12+
export function useDocsDrawer() {
13+
return useContext(DocsDrawerContext);
14+
}

apps/web/src/routes/_view/docs/route.tsx

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,32 +75,50 @@ function LeftSidebar() {
7575
return (
7676
<aside className="hidden md:block w-64 shrink-0">
7777
<div className="sticky top-[69px] max-h-[calc(100vh-69px)] overflow-y-auto scrollbar-hide space-y-6 px-4 py-6">
78-
<nav className="space-y-4">
79-
{docsBySection.sections.map((section) => (
80-
<div key={section.title}>
81-
<h3 className="px-3 text-sm font-semibold text-neutral-700 mb-2">
82-
{section.title}
83-
</h3>
84-
<div className="space-y-0.5">
85-
{section.docs.map((doc) => (
86-
<Link
87-
key={doc.slug}
88-
to="/docs/$"
89-
params={{ _splat: doc.slug }}
90-
className={`block px-3 py-1.5 text-sm rounded-sm transition-colors ${
91-
currentSlug === doc.slug
92-
? "bg-neutral-100 text-stone-600 font-medium"
93-
: "text-neutral-600 hover:text-stone-600 hover:bg-neutral-50"
94-
}`}
95-
>
96-
{doc.title}
97-
</Link>
98-
))}
99-
</div>
100-
</div>
101-
))}
102-
</nav>
78+
<DocsNavigation
79+
sections={docsBySection.sections}
80+
currentSlug={currentSlug}
81+
/>
10382
</div>
10483
</aside>
10584
);
10685
}
86+
87+
function DocsNavigation({
88+
sections,
89+
currentSlug,
90+
onLinkClick,
91+
}: {
92+
sections: { title: string; docs: (typeof allDocs)[0][] }[];
93+
currentSlug: string | undefined;
94+
onLinkClick?: () => void;
95+
}) {
96+
return (
97+
<nav className="space-y-4">
98+
{sections.map((section) => (
99+
<div key={section.title}>
100+
<h3 className="px-3 text-sm font-semibold text-neutral-700 mb-2">
101+
{section.title}
102+
</h3>
103+
<div className="space-y-0.5">
104+
{section.docs.map((doc) => (
105+
<Link
106+
key={doc.slug}
107+
to="/docs/$"
108+
params={{ _splat: doc.slug }}
109+
onClick={onLinkClick}
110+
className={`block px-3 py-1.5 text-sm rounded-sm transition-colors ${
111+
currentSlug === doc.slug
112+
? "bg-neutral-100 text-stone-600 font-medium"
113+
: "text-neutral-600 hover:text-stone-600 hover:bg-neutral-50"
114+
}`}
115+
>
116+
{doc.title}
117+
</Link>
118+
))}
119+
</div>
120+
</div>
121+
))}
122+
</nav>
123+
);
124+
}

apps/web/src/routes/_view/route.tsx

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import {
22
createFileRoute,
3+
Link,
34
Outlet,
5+
useMatchRoute,
46
useRouterState,
57
} from "@tanstack/react-router";
6-
import { createContext, useContext, useState } from "react";
8+
import { allDocs } from "content-collections";
9+
import { createContext, useContext, useMemo, useState } from "react";
710

811
import { Footer } from "@/components/footer";
912
import { Header } from "@/components/header";
13+
import { DocsDrawerContext } from "@/hooks/use-docs-drawer";
14+
15+
import { docsStructure } from "./docs/structure";
1016

1117
export const Route = createFileRoute("/_view")({
1218
component: Component,
@@ -27,6 +33,7 @@ function Component() {
2733
const router = useRouterState();
2834
const isDocsPage = router.location.pathname.startsWith("/docs");
2935
const [onTrigger, setOnTrigger] = useState<(() => void) | null>(null);
36+
const [isDocsDrawerOpen, setIsDocsDrawerOpen] = useState(false);
3037

3138
return (
3239
<HeroContext.Provider
@@ -35,13 +42,131 @@ function Component() {
3542
setOnTrigger: (callback) => setOnTrigger(() => callback),
3643
}}
3744
>
38-
<div className="min-h-screen flex flex-col">
39-
<Header />
40-
<main className="flex-1">
41-
<Outlet />
42-
</main>
43-
{!isDocsPage && <Footer />}
44-
</div>
45+
<DocsDrawerContext.Provider
46+
value={{ isOpen: isDocsDrawerOpen, setIsOpen: setIsDocsDrawerOpen }}
47+
>
48+
<div className="min-h-screen flex flex-col">
49+
<Header />
50+
<main className="flex-1">
51+
<Outlet />
52+
</main>
53+
{!isDocsPage && <Footer />}
54+
{isDocsPage && (
55+
<MobileDocsDrawer
56+
isOpen={isDocsDrawerOpen}
57+
onClose={() => setIsDocsDrawerOpen(false)}
58+
/>
59+
)}
60+
</div>
61+
</DocsDrawerContext.Provider>
4562
</HeroContext.Provider>
4663
);
4764
}
65+
66+
function MobileDocsDrawer({
67+
isOpen,
68+
onClose,
69+
}: {
70+
isOpen: boolean;
71+
onClose: () => void;
72+
}) {
73+
const matchRoute = useMatchRoute();
74+
const match = matchRoute({ to: "/docs/$", fuzzy: true });
75+
76+
const currentSlug = (
77+
match && typeof match !== "boolean" ? match._splat : undefined
78+
) as string | undefined;
79+
80+
const docsBySection = useMemo(() => {
81+
const sectionGroups: Record<
82+
string,
83+
{ title: string; docs: (typeof allDocs)[0][] }
84+
> = {};
85+
86+
allDocs.forEach((doc) => {
87+
if (doc.slug === "index" || doc.isIndex) {
88+
return;
89+
}
90+
91+
const sectionName = doc.section;
92+
93+
if (!sectionGroups[sectionName]) {
94+
sectionGroups[sectionName] = {
95+
title: sectionName,
96+
docs: [],
97+
};
98+
}
99+
100+
sectionGroups[sectionName].docs.push(doc);
101+
});
102+
103+
Object.keys(sectionGroups).forEach((sectionName) => {
104+
sectionGroups[sectionName].docs.sort((a, b) => a.order - b.order);
105+
});
106+
107+
const sections = docsStructure.sections
108+
.map((sectionId) => {
109+
const sectionName =
110+
sectionId.charAt(0).toUpperCase() + sectionId.slice(1);
111+
return sectionGroups[sectionName];
112+
})
113+
.filter(Boolean);
114+
115+
return { sections };
116+
}, []);
117+
118+
return (
119+
<div
120+
className={`fixed top-[69px] left-0 h-[calc(100vh-69px)] w-72 bg-white border-r border-neutral-100 shadow-2xl shadow-neutral-900/20 z-50 md:hidden transition-transform duration-300 ease-in-out ${
121+
isOpen ? "translate-x-0" : "-translate-x-full"
122+
}`}
123+
>
124+
<div className="h-full overflow-y-auto p-4">
125+
<DocsNavigation
126+
sections={docsBySection.sections}
127+
currentSlug={currentSlug}
128+
onLinkClick={onClose}
129+
/>
130+
</div>
131+
</div>
132+
);
133+
}
134+
135+
function DocsNavigation({
136+
sections,
137+
currentSlug,
138+
onLinkClick,
139+
}: {
140+
sections: { title: string; docs: (typeof allDocs)[0][] }[];
141+
currentSlug: string | undefined;
142+
onLinkClick?: () => void;
143+
}) {
144+
return (
145+
<nav className="space-y-4">
146+
{sections.map((section) => (
147+
<div key={section.title}>
148+
<h3 className="px-3 text-sm font-semibold text-neutral-700 mb-2">
149+
{section.title}
150+
</h3>
151+
<div className="space-y-0.5">
152+
{section.docs.map((doc) => (
153+
<Link
154+
key={doc.slug}
155+
to="/docs/$"
156+
params={{ _splat: doc.slug }}
157+
onClick={onLinkClick}
158+
className={`block px-3 py-1.5 text-sm rounded-sm transition-colors ${
159+
currentSlug === doc.slug
160+
? "bg-neutral-100 text-stone-600 font-medium"
161+
: "text-neutral-600 hover:text-stone-600 hover:bg-neutral-50"
162+
}`}
163+
>
164+
{doc.title}
165+
</Link>
166+
))}
167+
</div>
168+
</div>
169+
))}
170+
</nav>
171+
);
172+
}

0 commit comments

Comments
 (0)