From a7a86015b12f6483d2edc5e156518ea6d9e8457e Mon Sep 17 00:00:00 2001 From: Gaya KACI <163902005+kacigaya@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:29:31 +0000 Subject: [PATCH] feat(web): add collapsible sidebar, raise UI contrast - Collapse the sidebar to an icon rail with a toggle or Ctrl/Cmd+B - Keep the state in a cookie so the first paint has the right width - Show nav labels in tooltips in rail mode - Raise border, input, hover, and muted text contrast - Mark the active nav item with a brand tint and bar --- web/app/(dashboard)/layout.tsx | 39 +++--- web/app/globals.css | 44 ++++--- web/components/accessibility.test.tsx | 16 +++ web/components/app-nav.tsx | 183 ++++++++++++++++++-------- web/components/brand.tsx | 5 +- web/components/dashboard-sidebar.tsx | 110 ++++++++++++++++ web/components/sidebar-toggle.tsx | 47 +++++++ web/components/ui/tooltip.tsx | 67 ++++++++++ web/lib/sidebar.ts | 7 + 9 files changed, 420 insertions(+), 98 deletions(-) create mode 100644 web/components/dashboard-sidebar.tsx create mode 100644 web/components/sidebar-toggle.tsx create mode 100644 web/components/ui/tooltip.tsx create mode 100644 web/lib/sidebar.ts diff --git a/web/app/(dashboard)/layout.tsx b/web/app/(dashboard)/layout.tsx index 225b10f..c2eb9d7 100644 --- a/web/app/(dashboard)/layout.tsx +++ b/web/app/(dashboard)/layout.tsx @@ -1,15 +1,11 @@ -import { - Account, - AppNav, - MobileNav, - type NavInstallation, -} from "@/components/app-nav"; -import { Brand } from "@/components/brand"; -import { ThemeToggle } from "@/components/theme-toggle"; +import { cookies } from "next/headers"; +import { MobileNav, type NavInstallation } from "@/components/app-nav"; +import { DashboardSidebar } from "@/components/dashboard-sidebar"; import { api } from "@/lib/api"; import { accountAvatar } from "@/lib/avatar"; import { readSession } from "@/lib/auth"; import { getEnv } from "@/lib/env"; +import { SIDEBAR_COLLAPSED, SIDEBAR_COOKIE } from "@/lib/sidebar"; import type { Installation, User } from "@/lib/types"; export default async function DashboardLayout({ @@ -17,10 +13,11 @@ export default async function DashboardLayout({ }: { children: React.ReactNode; }) { - const [user, installations, session] = await Promise.all([ + const [user, installations, session, cookieStore] = await Promise.all([ api("/api/v1/me"), api("/api/v1/installations"), readSession(), + cookies(), ]); // GitHub sign-in stores the display name and avatar in the session cookie. const profile = { @@ -40,24 +37,18 @@ export default async function DashboardLayout({ })); const installUrl = `https://github.com/apps/${getEnv().appSlug}/installations/new`; return ( -
+
Skip to content -
- -
+
{ const html = renderToStaticMarkup(); expect(html).toContain('aria-label="1 Nov 2026, 10:00 UTC"'); }); + +test("sidebar toggle names its action and exposes the expanded state", () => { + const expanded = renderToStaticMarkup( + {}} />, + ); + expect(expanded).toContain('aria-label="Collapse sidebar"'); + expect(expanded).toContain('aria-expanded="true"'); + expect(expanded).toContain('aria-controls="sidebar"'); + expect(expanded).toContain('aria-keyshortcuts="Control+B Meta+B"'); + const collapsed = renderToStaticMarkup( + {}} />, + ); + expect(collapsed).toContain('aria-label="Expand sidebar"'); + expect(collapsed).toContain('aria-expanded="false"'); +}); diff --git a/web/components/app-nav.tsx b/web/components/app-nav.tsx index 09028de..4365b06 100644 --- a/web/components/app-nav.tsx +++ b/web/components/app-nav.tsx @@ -9,6 +9,11 @@ import { signOut } from "@/app/auth-actions"; import { Avatar, Brand, Monogram } from "@/components/brand"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { AVATAR_PREFIX } from "@/lib/avatar"; import { cn } from "@/lib/utils"; import { DOCS_URL } from "@/lib/docs-site"; @@ -24,10 +29,12 @@ type NavProps = { installations: NavInstallation[]; installUrl: string; onNavigate?: () => void; + /** Icon rail mode: labels move to tooltips and stay readable by screen readers. */ + collapsed?: boolean; }; const itemClass = - "flex h-8 items-center gap-2.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"; + "relative flex h-8 items-center gap-2.5 rounded-md px-2 text-sm text-muted-foreground transition-colors before:absolute before:inset-y-1.5 before:-left-2 before:w-0.75 before:rounded-r-full hover:bg-accent hover:text-foreground aria-[current=page]:bg-sidebar-active aria-[current=page]:font-medium aria-[current=page]:text-sidebar-active-foreground aria-[current=page]:before:bg-brand"; const stateLabel = { enabled: "Reviews on", @@ -35,78 +42,131 @@ const stateLabel = { suspended: "Suspended", }; -export function AppNav({ installations, installUrl, onNavigate }: NavProps) { +const stateDot = { + enabled: "bg-success", + disabled: "bg-neutral-400 dark:bg-neutral-500", + suspended: "bg-warning", +}; + +/** Shows the item label in a tooltip when the sidebar is an icon rail. */ +function NavTooltip({ + label, + collapsed, + children, +}: { + label: string; + collapsed: boolean; + children: React.ReactElement; +}) { + if (!collapsed) return children; + return ( + + + {label} + + ); +} + +export function AppNav({ + installations, + installUrl, + onNavigate, + collapsed = false, +}: NavProps) { const pathname = usePathname().replace(/\/$/, ""); + const item = cn(itemClass, collapsed && "justify-center px-0"); + const label = collapsed ? "sr-only" : "min-w-0 flex-1 truncate"; return ( @@ -120,10 +180,20 @@ export type Profile = { image?: string | null; }; -export function Account({ login, name, image }: Profile) { +export function Account({ + login, + name, + image, + compact, +}: Profile & { compact?: boolean }) { const displayName = name?.trim() || login; return ( -
+
{image?.startsWith(AVATAR_PREFIX) ? ( )} -
+

{displayName}

@@ -146,9 +216,16 @@ export function Account({ login, name, image }: Profile) { )}
- + + +
); diff --git a/web/components/brand.tsx b/web/components/brand.tsx index 6a78451..66488e7 100644 --- a/web/components/brand.tsx +++ b/web/components/brand.tsx @@ -3,14 +3,15 @@ import Link from "next/link"; import { AVATAR_PREFIX } from "@/lib/avatar"; import { asset } from "@/lib/docs-site"; -export function Brand({ href }: { href: string }) { +/** Logo and product name. `compact` keeps the name for screen readers only. */ +export function Brand({ href, compact }: { href: string; compact?: boolean }) { return ( - CroCode + CroCode ); } diff --git a/web/components/dashboard-sidebar.tsx b/web/components/dashboard-sidebar.tsx new file mode 100644 index 0000000..965c35f --- /dev/null +++ b/web/components/dashboard-sidebar.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; +import { + Account, + AppNav, + type NavInstallation, + type Profile, +} from "@/components/app-nav"; +import { Brand } from "@/components/brand"; +import { SidebarToggle } from "@/components/sidebar-toggle"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { SIDEBAR_COLLAPSED, SIDEBAR_COOKIE } from "@/lib/sidebar"; +import { cn } from "@/lib/utils"; + +const YEAR_SECONDS = 60 * 60 * 24 * 365; + +function isEditable(target: EventTarget | null) { + return ( + target instanceof HTMLElement && + (target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) + ); +} + +/** Desktop sidebar. Collapses to an icon rail with the toggle or Ctrl/⌘+B. */ +export function DashboardSidebar({ + defaultCollapsed, + installations, + installUrl, + profile, +}: { + defaultCollapsed: boolean; + installations: NavInstallation[]; + installUrl: string; + profile: Profile; +}) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const id = useId(); + + useEffect(() => { + document.cookie = `${SIDEBAR_COOKIE}=${collapsed ? SIDEBAR_COLLAPSED : "expanded"}; path=/; max-age=${YEAR_SECONDS}; samesite=lax`; + }, [collapsed]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.key.toLowerCase() !== "b" || + !(event.ctrlKey || event.metaKey) || + event.altKey || + event.shiftKey || + isEditable(event.target) + ) { + return; + } + event.preventDefault(); + setCollapsed((value) => !value); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + return ( +
+ + + +
+ ); +} diff --git a/web/components/sidebar-toggle.tsx b/web/components/sidebar-toggle.tsx new file mode 100644 index 0000000..4fff037 --- /dev/null +++ b/web/components/sidebar-toggle.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +/** Button that collapses the sidebar to an icon rail or expands it again. */ +export function SidebarToggle({ + collapsed, + controls, + onToggle, +}: { + collapsed: boolean; + controls: string; + onToggle: () => void; +}) { + const label = collapsed ? "Expand sidebar" : "Collapse sidebar"; + const Icon = collapsed ? PanelLeftOpen : PanelLeftClose; + return ( + + + } + > + + + {label} + Ctrl/⌘ B + + + ); +} diff --git a/web/components/ui/tooltip.tsx b/web/components/ui/tooltip.tsx new file mode 100644 index 0000000..5bb7362 --- /dev/null +++ b/web/components/ui/tooltip.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import type React from "react"; +import { cn } from "@/lib/utils"; + +export const TooltipCreateHandle: typeof TooltipPrimitive.createHandle = + TooltipPrimitive.createHandle; + +export const TooltipProvider: typeof TooltipPrimitive.Provider = + TooltipPrimitive.Provider; + +export const Tooltip: typeof TooltipPrimitive.Root = TooltipPrimitive.Root; + +export function TooltipTrigger( + props: TooltipPrimitive.Trigger.Props, +): React.ReactElement { + return ; +} + +export function TooltipPopup({ + className, + align = "center", + sideOffset = 4, + side = "top", + anchor, + children, + portalProps, + ...props +}: TooltipPrimitive.Popup.Props & { + align?: TooltipPrimitive.Positioner.Props["align"]; + side?: TooltipPrimitive.Positioner.Props["side"]; + sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; + anchor?: TooltipPrimitive.Positioner.Props["anchor"]; + portalProps?: TooltipPrimitive.Portal.Props; +}): React.ReactElement { + return ( + + + + + {children} + + + + + ); +} + +export { TooltipPrimitive, TooltipPopup as TooltipContent }; diff --git a/web/lib/sidebar.ts b/web/lib/sidebar.ts new file mode 100644 index 0000000..96093eb --- /dev/null +++ b/web/lib/sidebar.ts @@ -0,0 +1,7 @@ +/** + * Cookie that keeps the desktop sidebar state across page loads. The + * dashboard layout reads it on the server, so the first paint has the + * right width. + */ +export const SIDEBAR_COOKIE = "crocode-sidebar"; +export const SIDEBAR_COLLAPSED = "collapsed";