Merge pull request #38601 from BerriAI/litellm_ui_navbar_papercuts

fix(ui): one-click theme toggle and matching Docs/Blog styling in the top bar

(cherry picked from commit 3beb02e512)
This commit is contained in:
ryan-crabbe-berri 2026-08-27 21:06:41 -07:00 committed by Yuneng Jiang
parent 31321883e7
commit b23632f628
No known key found for this signature in database
10 changed files with 130 additions and 104 deletions

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { DashboardHeader } from "./DashboardHeader";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const state = {
@ -55,6 +56,16 @@ describe("DashboardHeader breadcrumb", () => {
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
});
it("styles Docs with the shared product-link class instead of a muted toolbar button", () => {
render(<DashboardHeader page="logs" />);
const docs = screen.getByRole("link", { name: "Docs" });
for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) {
expect(docs).toHaveClass(cls);
}
expect(docs).not.toHaveClass("text-muted-foreground");
});
it("renders the tools divider centered rather than stretched to the top of the row", () => {
const { container } = render(<DashboardHeader page="logs" />);

View file

@ -1,6 +1,5 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
@ -11,6 +10,7 @@ import {
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
import { getBreadcrumb } from "@/components/leftnav";
import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
import { DocsLink } from "@/components/Navbar/DocsLink/DocsLink";
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
@ -62,15 +62,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
<ToolbarSeparator />
</>
)}
<Button
variant="ghost"
size="sm"
nativeButton={false}
render={<a href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer" />}
className="text-muted-foreground"
>
Docs
</Button>
<DocsLink />
<BlogDropdown />
{!hideCommunityLinks && <CommunityEngagementButtons />}
<ToolbarSeparator />

View file

@ -66,6 +66,14 @@ describe("BlogDropdown", () => {
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
});
it("should keep the shared hover highlight rather than pinning the background transparent", () => {
renderWithProviders(<BlogDropdown />);
const trigger = screen.getByRole("button", { name: /blog/i });
expect(trigger).toHaveClass("hover:bg-accent");
expect(trigger.className).not.toMatch(/\bbg-\S*!/);
});
it("should not render menu content before the trigger is hovered", () => {
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
renderWithProviders(<BlogDropdown />);

View file

@ -85,7 +85,7 @@ export const BlogDropdown: React.FC = () => {
<DropdownMenuTrigger
openOnHover
closeDelay={100}
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`} />}
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0!`} />}
>
Blog
<ChevronDown className="size-2.5 text-muted-foreground" aria-hidden />

View file

@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { DocsLink } from "./DocsLink";
const sharedClasses = NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/);
describe("DocsLink", () => {
it("opens the docs in a new tab without leaking the opener", () => {
render(<DocsLink />);
const link = screen.getByRole("link", { name: "Docs" });
expect(link).toHaveAttribute("href", "https://docs.litellm.ai/docs/");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("carries the same product-link styling as the Blog trigger, so the two never drift apart", () => {
render(<DocsLink />);
const link = screen.getByRole("link", { name: "Docs" });
for (const cls of sharedClasses) {
expect(link).toHaveClass(cls);
}
expect(link).not.toHaveClass("text-muted-foreground");
});
it("carries a focus ring, so tabbing to Docs looks like tabbing to Blog", () => {
render(<DocsLink />);
const link = screen.getByRole("link", { name: "Docs" });
expect(link).toHaveClass("focus-visible:ring-3");
expect(link).toHaveClass("focus-visible:ring-ring/50");
});
it("stays a link rather than being relabelled as a button by the Button primitive", () => {
render(<DocsLink />);
expect(screen.getByRole("link", { name: "Docs" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Docs" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,18 @@
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
import { ChevronDown } from "lucide-react";
import React from "react";
export const DOCS_URL = "https://docs.litellm.ai/docs/";
const ChevronWidthSpacer: React.FC = () => (
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
);
export const DocsLink: React.FC = () => (
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
Docs
<ChevronWidthSpacer />
</a>
);
export default DocsLink;

View file

@ -1,3 +1,3 @@
/** Shared styling for Docs / Blog in the top nav (product navigation zone). */
/** Shared styling for Docs / Blog in the top nav (product navigation zone). Focus ring matches the Button primitive. */
export const NAV_PRODUCT_LINK_CLASS =
"inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm font-medium leading-none text-foreground transition-colors hover:bg-accent ";
"inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm font-medium leading-none text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 ";

View file

@ -11,12 +11,7 @@ const renderToggle = () =>
</ThemeProvider>,
);
const openMenu = async () => {
await userEvent.click(screen.getByRole("button", { name: "Theme" }));
await screen.findByRole("menu");
};
const pick = async (label: string | RegExp) => userEvent.click(screen.getByRole("menuitemradio", { name: label }));
const toggle = () => screen.getByRole("button", { name: /Switch to (light|dark) mode/ });
beforeEach(() => {
localStorage.clear();
@ -28,50 +23,49 @@ afterAll(() => {
});
describe("ThemeToggle", () => {
it("starts on light rather than following the system preference", async () => {
it("switches to dark on a single click, with no menu in between", async () => {
renderToggle();
await openMenu();
expect(screen.getByRole("menuitemradio", { name: "Light" })).toBeChecked();
expect(screen.getByRole("menuitemradio", { name: /^Dark/ })).not.toBeChecked();
expect(screen.getByRole("menuitemradio", { name: "System" })).not.toBeChecked();
});
it("puts the dark class on the document and remembers the choice", async () => {
renderToggle();
await openMenu();
await pick(/^Dark/);
await userEvent.click(toggle());
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(document.documentElement).toHaveClass("dark");
expect(localStorage.getItem("theme")).toBe("dark");
});
it("hands control back to the system preference when asked", async () => {
it("switches back to light on the next click", async () => {
renderToggle();
await openMenu();
await pick(/^Dark/);
await userEvent.click(toggle());
await pick("System");
await userEvent.click(toggle());
expect(document.documentElement).not.toHaveClass("dark");
expect(localStorage.getItem("theme")).toBe("light");
});
it("names the mode the click will switch to, so the button says what it does", async () => {
renderToggle();
expect(screen.getByRole("button", { name: "Switch to dark mode (beta)" })).toBeInTheDocument();
await userEvent.click(toggle());
expect(await screen.findByRole("button", { name: "Switch to light mode" })).toBeInTheDocument();
});
it("leaves a stored system preference following the OS until the user clicks", () => {
localStorage.setItem("theme", "system");
renderToggle();
expect(localStorage.getItem("theme")).toBe("system");
expect(document.documentElement).not.toHaveClass("dark");
});
it("marks dark as beta in the menu, and leaves the other choices unmarked", async () => {
it("keeps the beta marker out of the toolbar label once dark is on", async () => {
renderToggle();
await openMenu();
expect(screen.getByRole("menuitemradio", { name: /^Dark/ })).toHaveTextContent("Beta");
expect(screen.getByRole("menuitemradio", { name: "Light" })).not.toHaveTextContent("Beta");
expect(screen.getByRole("menuitemradio", { name: "System" })).not.toHaveTextContent("Beta");
});
await userEvent.click(toggle());
it("keeps the beta marker inside the menu rather than in the toolbar", async () => {
renderToggle();
await openMenu();
await pick(/^Dark/);
expect(screen.getByRole("button", { name: "Theme" })).not.toHaveTextContent("Beta");
expect(toggle()).not.toHaveTextContent("Beta");
expect(toggle()).toHaveAccessibleName("Switch to light mode");
});
});

View file

@ -1,57 +1,27 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const THEMES = [
{ value: "system", label: "System", Icon: Monitor, beta: false },
{ value: "light", label: "Light", Icon: Sun, beta: false },
{ value: "dark", label: "Dark", Icon: Moon, beta: true },
] as const;
const ThemeToggle: React.FC = () => {
const { theme, setTheme, resolvedTheme } = useTheme();
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)";
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="Theme" title="Theme" className="text-muted-foreground" />
}
>
{resolvedTheme === "dark" ? <Moon /> : <Sun />}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuRadioGroup value={theme ?? "light"} onValueChange={setTheme}>
{THEMES.map(({ value, label, Icon, beta }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon />
{label}
{beta && (
<Badge
variant="secondary"
className="px-1 py-0 text-[10px] font-medium text-muted-foreground"
title="Dark mode is still being rolled out, so some surfaces may not be styled yet"
>
Beta
</Badge>
)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon-sm"
aria-label={label}
title={label}
className="text-muted-foreground"
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Moon /> : <Sun />}
</Button>
);
};

View file

@ -9,12 +9,12 @@ import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
import { Badge } from "@/components/ui/badge";
import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import Link from "next/link";
import React from "react";
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
import { DocsLink } from "./Navbar/DocsLink/DocsLink";
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
import { cn } from "@/lib/cva.config";
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
@ -143,16 +143,7 @@ const Navbar: React.FC<NavbarProps> = ({
aria-label="Product documentation"
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-border pl-4" : ""}`}
>
<a
href="https://docs.litellm.ai/docs/"
target="_blank"
rel="noopener noreferrer"
className={NAV_PRODUCT_LINK_CLASS}
>
Docs
{/* Layout parity with Blog chevron — intentional single-level link */}
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
</a>
<DocsLink />
<BlogDropdown />
</nav>