From 8358947351dc2705b214253e46fdfd8ca73362be Mon Sep 17 00:00:00 2001 From: Michelle Date: Sun, 21 Dec 2025 09:59:00 +0400 Subject: [PATCH 1/7] chore: add dummy content to test future sidebar/rail scroll Adds 4 question card components to the home page to create vertical scroll content for testing sticky behaviour of the future left sidebar and rail. --- app/(root)/(with-right-sidebar)/page.tsx | 68 +++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/app/(root)/(with-right-sidebar)/page.tsx b/app/(root)/(with-right-sidebar)/page.tsx index 4aa7c66..6bd8036 100644 --- a/app/(root)/(with-right-sidebar)/page.tsx +++ b/app/(root)/(with-right-sidebar)/page.tsx @@ -3,7 +3,7 @@ const Home = () => (

Hello Root page with heading H1

{/* Header boundary marker */} -
HEADER: {"words ".repeat(50)}
+
{"words ".repeat(50)}
{/* Flexbox demo: grow vs flex-none */}
@@ -24,7 +24,71 @@ const Home = () => (
{/* Footer boundary marker */} -
FOOTER: {"words ".repeat(50)}
+
{"words ".repeat(50)}
+ + {/* Question boxes for testing sticky sidebar scroll */} +
+ {[1, 2, 3, 4].map((num) => ( +
+
+ + Question #{num} + + + Asked {num} hours ago + +
+ +

+ How to implement a sticky sidebar in Next.js with Tailwind CSS? +

+ +

+ I'm building a dashboard layout and need the sidebar to remain + visible while scrolling the main content. The sidebar should stick + below the navbar and scroll independently if its content overflows. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+ + {/* Tags */} +
+ {["next.js", "tailwindcss", "react", "css"].map((tag) => ( + + {tag} + + ))} +
+ + {/* Stats */} +
+ + {num * 7}{" "} + votes + + + {num * 3}{" "} + answers + + + {num * 47}{" "} + views + +
+
+ ))} + + {/* End marker */} +
+ You've reached the end β€” sidebar should still be sticky! +
+
); From 4aeef3e0654ff4f2a4b5149ff69c52a2b68fcdb5 Mon Sep 17 00:00:00 2001 From: Michelle Date: Mon, 22 Dec 2025 22:30:19 +0400 Subject: [PATCH 2/7] feat: add responsive left sidebar navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation: - Add LeftSidebar: sticky sidebar with 500ms width transition - NavLink gains `variant` prop β€” "rail" (icon-only) vs "mobile" (full) - Avatar relocated from navbar to sidebar bottom (signed-in users) - MobileNav: `modal={false}` + custom overlay so Clerk popups work Responsive Behaviour: - πŸ“± < 640px: hamburger β†’ slide-out drawer - πŸ“² 640–1023px: sticky icon rail (4rem) - πŸ–₯️ 1024px+: full sidebar with labels (14rem) Layout: - Root layout now flex container: Navbar + LeftSidebar + Main - Sidebar uses `sticky` (not fixed) β€” scrolls with content, no z-index wars Tests: - Split by context: authenticated.desktop, authenticated.mobile, navigation - Add 2FA/OTP handling (email verification + TOTP) - Cover "Manage account" modal open/close, overlay tap-to-dismiss Housekeeping: - Rename constants.ts β†’ nav-links.constants.ts - Route type safety via `Route` literal in nav-links - Add --overlay CSS var, group non-shadcn variables - Bump @clerk/nextjs 6.36.5, next 16.1.0 Desktop users get persistent navigation without hamburger menus. Mobile keeps its touch-friendly drawer. Sidebar width animates smoothly between rail and full modes. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/(root)/layout.tsx | 10 +- app/globals.css | 28 +- biome.json | 8 + components/clerk-provider.tsx | 1 + components/navigation/full-logo.tsx | 4 +- components/navigation/left-sidebar.tsx | 40 ++ components/navigation/mobile-nav.tsx | 150 ++++--- components/navigation/nav-link.tsx | 40 +- .../{constants.ts => nav-links.constants.ts} | 5 +- components/navigation/navbar.tsx | 31 +- e2e/auth.spec.ts | 53 --- e2e/authenticated.desktop.spec.ts | 70 +++ e2e/authenticated.mobile.spec.ts | 101 +++++ e2e/mobile-nav-user-flow.spec.ts | 44 -- e2e/navigation.spec.ts | 74 ++++ e2e/{homepage.spec.ts => smoke.spec.ts} | 0 package-lock.json | 142 +++--- package.json | 4 +- sidebar.md | 110 +++++ x_docs/mine/nav_mobile_sidebar-and-rail.md | 413 +++++------------- 20 files changed, 745 insertions(+), 583 deletions(-) create mode 100644 components/navigation/left-sidebar.tsx rename components/navigation/{constants.ts => nav-links.constants.ts} (86%) delete mode 100644 e2e/auth.spec.ts create mode 100644 e2e/authenticated.desktop.spec.ts create mode 100644 e2e/authenticated.mobile.spec.ts delete mode 100644 e2e/mobile-nav-user-flow.spec.ts create mode 100644 e2e/navigation.spec.ts rename e2e/{homepage.spec.ts => smoke.spec.ts} (100%) create mode 100644 sidebar.md diff --git a/app/(root)/layout.tsx b/app/(root)/layout.tsx index 4892922..505178e 100644 --- a/app/(root)/layout.tsx +++ b/app/(root)/layout.tsx @@ -1,11 +1,15 @@ +import { LeftSidebar } from "@/components/navigation/left-sidebar"; import { Navbar } from "@/components/navigation/navbar"; const RootLayout = ({ children }: { children: React.ReactNode }) => { return ( - <> +
- {children} - +
+ +
{children}
+
+
); }; diff --git a/app/globals.css b/app/globals.css index 80cc9ff..47a5b4a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -29,7 +29,6 @@ --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); - --color-mobile-nav: var(--mobile-nav); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); @@ -51,18 +50,15 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + + /* NOT SHADCN VARIABLES */ + --color-mobile-nav: var(--mobile-nav); + --color-overlay: var(--overlay); } :root { /* Creates CSS variables available to all elements (no utility generation) */ - /* Only defined in root, not .dark */ --radius: 0.625rem; - --primary-gradient-to: oklch(0.7434 0.115 58.23); /* #E2995F */ - --gradient-primary: linear-gradient( - 93.22deg, - var(--primary) -13.95%, - var(--primary-gradient-to) 99.54% - ); --background: oklch(0.994 0 0); /* #FDFDFD */ --foreground: oklch(0.129 0.042 264.695); @@ -95,9 +91,16 @@ --sidebar-accent-foreground: oklch(0.208 0.042 265.755); --sidebar-border: oklch(0.929 0.013 255.508); --sidebar-ring: oklch(0.704 0.04 256.788); - --mobile-nav: oklch(1 0 0); /* #FFFFFF */ - /* Centralised logo theming */ + /* NOT SHADCN VARIABLES */ + --primary-gradient-to: oklch(0.7434 0.115 58.23); /* #E2995F */ + --gradient-primary: linear-gradient( + 93.22deg, + var(--primary) -13.95%, + var(--primary-gradient-to) 99.54% + ); + --mobile-nav: oklch(1 0 0); /* #FFFFFF */ + --overlay: oklch(0 0 0); /* black, both modes */ --logo-full-themed: url("/images/logo-light.svg"); --auth-bg: url("/images/auth-bg-light.webp"); } @@ -135,9 +138,10 @@ --sidebar-accent-foreground: oklch(0.984 0.003 247.858); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.551 0.027 264.364); - --mobile-nav: oklch(0.1288 0.0406 264.7); /* #07080b */ - /* Centralised logo theming (dark) */ + /* NOT SHADCN VARIABLES */ + --mobile-nav: oklch(0.1288 0.0406 264.7); /* #07080b */ + --overlay: oklch(0 0 0); /* black, both modes */ --logo-full-themed: url("/images/logo-dark.svg"); --auth-bg: url("/images/auth-bg-dark.webp"); } diff --git a/biome.json b/biome.json index cf56d75..713ec33 100644 --- a/biome.json +++ b/biome.json @@ -31,6 +31,14 @@ "enabled": true, "rules": { "recommended": true, + "style": { + "useConsistentTypeDefinitions": { + "level": "error", + "options": { + "style": "type" + } + } + }, "suspicious": { "noUnknownAtRules": "off" } diff --git a/components/clerk-provider.tsx b/components/clerk-provider.tsx index cb37d25..3765916 100644 --- a/components/clerk-provider.tsx +++ b/components/clerk-provider.tsx @@ -21,6 +21,7 @@ export function ClerkProvider({ formButtonPrimary: "bg-[image:var(--gradient-primary)] text-white hover:opacity-90", footer: "bg-card", + avatarBox: "size-8", }, ...appearance, }} diff --git a/components/navigation/full-logo.tsx b/components/navigation/full-logo.tsx index 0c663a0..41c5735 100644 --- a/components/navigation/full-logo.tsx +++ b/components/navigation/full-logo.tsx @@ -4,9 +4,9 @@ import { cn } from "@/lib/utils"; const LOGO_WIDTH = 137; const LOGO_HEIGHT = 23; -interface ThemeLogoProps { +type ThemeLogoProps = { className?: string; -} +}; /** * Theme-aware logo that switches between light/dark variants via CSS variable. diff --git a/components/navigation/left-sidebar.tsx b/components/navigation/left-sidebar.tsx new file mode 100644 index 0000000..1c6954a --- /dev/null +++ b/components/navigation/left-sidebar.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { SignedIn, UserButton } from "@clerk/nextjs"; +import { NavLink } from "@/components/navigation/nav-link"; +import { NAV_LINKS } from "@/components/navigation/nav-links.constants"; + +// Navbar height minus overlap to hide shadow-sm +const SIDEBAR_TOP_OFFSET = 72; // 73px navbar - 1px overlap + +export function LeftSidebar() { + return ( + + ); +} diff --git a/components/navigation/mobile-nav.tsx b/components/navigation/mobile-nav.tsx index 947fd6e..17d04ef 100644 --- a/components/navigation/mobile-nav.tsx +++ b/components/navigation/mobile-nav.tsx @@ -4,15 +4,15 @@ import { SignedIn, SignedOut, SignInButton, - SignOutButton, SignUpButton, + UserButton, } from "@clerk/nextjs"; -import { LogOut, Menu, X } from "lucide-react"; +import { Menu, X } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; -import { NAV_LINKS } from "@/components/navigation/constants"; import { ThemeLogo } from "@/components/navigation/full-logo"; import { NavLink } from "@/components/navigation/nav-link"; +import { NAV_LINKS } from "@/components/navigation/nav-links.constants"; import { Button } from "@/components/ui/button"; import { Sheet, @@ -23,90 +23,102 @@ import { SheetTrigger, } from "@/components/ui/sheet"; +const MOBILE_NAV_MAX_WIDTH = "max-w-[320px]"; + export function MobileNav() { const [open, setOpen] = useState(false); return ( - - - + + - {open ? : } - - - - {/* Visually hidden title and description for accessibility */} - Navigation menu - - Browse site pages and manage your account - + {/* Visually hidden title and description for accessibility */} + Navigation menu + + Browse site pages and manage your account + - {/* Logo */} - - - - - + {/* Logo */} + + + + + - {/* Navigation Links */} - + {/* Navigation Links */} + - {/* Sign out - Only when signed in */} - -
- - - - - -
-
+ {/* Avatar - Only when signed in */} + +
+ +
+
- {/* Auth Buttons - Only when signed out */} - -
- + {/* Auth Buttons - Only when signed out */} + +
- - - - - -
-
- - +
+
+
+ + ); } diff --git a/components/navigation/nav-link.tsx b/components/navigation/nav-link.tsx index 6cc5698..b1ad602 100644 --- a/components/navigation/nav-link.tsx +++ b/components/navigation/nav-link.tsx @@ -1,41 +1,57 @@ "use client"; -import type { Route } from "next"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import type { NavLink as NavLinkType } from "@/components/navigation/constants"; +import type { NavLink as NavLinkType } from "@/components/navigation/nav-links.constants"; import { cn } from "@/lib/utils"; type NavLinkProps = NavLinkType & { - // Passed by SheetClose asChild to close the sheet on click + /** Passed by SheetClose asChild to close the sheet on click */ onClick?: () => void; + /** + * - "rail": Responsive sidebar β€” icon-only (sm-lg), full with tight padding (lg+) + * - "mobile": Touch-optimised β€” always full with generous padding (px-4 py-3) + */ + variant?: "rail" | "mobile"; }; -export function NavLink({ imgURL, route, label, onClick }: NavLinkProps) { +export function NavLink({ + imgURL, + route, + label, + onClick, + variant = "mobile", +}: NavLinkProps) { const pathname = usePathname(); - const isActive = - (pathname.startsWith(route) && route.length > 1) || pathname === route; + const isActive = pathname === route || pathname.startsWith(`${route}/`); + + const isRail = variant === "rail"; return ( {label} - {label} + {label} ); } diff --git a/components/navigation/constants.ts b/components/navigation/nav-links.constants.ts similarity index 86% rename from components/navigation/constants.ts rename to components/navigation/nav-links.constants.ts index 1a66ff9..026a389 100644 --- a/components/navigation/constants.ts +++ b/components/navigation/nav-links.constants.ts @@ -1,6 +1,8 @@ +import type { Route } from "next"; + export type NavLink = { imgURL: string; - route: string; + route: Route; label: string; }; @@ -15,5 +17,4 @@ export const NAV_LINKS = [ route: "/ask-question", label: "Ask a question", }, - { imgURL: "/icons/user.svg", route: "/profile", label: "Profile" }, ] as const satisfies readonly NavLink[]; diff --git a/components/navigation/navbar.tsx b/components/navigation/navbar.tsx index 1bc00a3..ad27520 100644 --- a/components/navigation/navbar.tsx +++ b/components/navigation/navbar.tsx @@ -1,11 +1,4 @@ -import { - SignedIn, - SignedOut, - SignInButton, - SignUpButton, - UserButton, -} from "@clerk/nextjs"; -import Image from "next/image"; +import { SignedOut, SignInButton, SignUpButton } from "@clerk/nextjs"; import Link from "next/link"; import { ThemeLogo } from "@/components/navigation/full-logo"; import { MobileNav } from "@/components/navigation/mobile-nav"; @@ -15,17 +8,16 @@ import { Button } from "@/components/ui/button"; export const Navbar = () => (