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! +
+
); diff --git a/app/(root)/layout.tsx b/app/(root)/layout.tsx index 4892922..b4916a3 100644 --- a/app/(root)/layout.tsx +++ b/app/(root)/layout.tsx @@ -1,11 +1,18 @@ +import { LeftSidebar } from "@/components/navigation/left-sidebar"; import { Navbar } from "@/components/navigation/navbar"; +import { SidebarProvider } from "@/components/sidebar-provider"; 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..8d34e5c 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -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..a596b72 --- /dev/null +++ b/components/navigation/left-sidebar.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { SignedIn, UserButton } from "@clerk/nextjs"; +import { NavLink } from "@/components/navigation/nav-link"; +import { NAV_LINKS } from "@/components/navigation/nav-links.constants"; +import { SidebarToggle } from "@/components/navigation/sidebar-toggle"; +import { useSidebar } from "@/components/sidebar-provider"; +import { cn } from "@/lib/utils"; + +// Navbar height minus overlap to hide shadow-sm +const SIDEBAR_TOP_OFFSET = 72; // 73px navbar - 1px overlap + +export function LeftSidebar() { + const { isCollapsed } = useSidebar(); + + return ( + + ); +} diff --git a/components/navigation/mobile-nav.tsx b/components/navigation/mobile-nav.tsx index 947fd6e..736608e 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, @@ -22,91 +22,107 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +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..b9cb35f 100644 --- a/components/navigation/nav-link.tsx +++ b/components/navigation/nav-link.tsx @@ -1,41 +1,63 @@ "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 { useSidebar } from "@/components/sidebar-provider"; 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": Sidebar nav — icon-only when collapsed, icon + label when expanded + * - "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 { isCollapsed } = useSidebar(); + const isActive = pathname === route || pathname.startsWith(`${route}/`); + + const isRail = variant === "rail"; + // For rail variant, show icon-only if collapsed, icon+label if expanded + const showIconOnly = isRail && isCollapsed; 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 = () => (