mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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:
parent
31321883e7
commit
b23632f628
10 changed files with 130 additions and 104 deletions
|
|
@ -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" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 ";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue