mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): make the theme toggle switch on one click and stop Docs looking dimmer than Blog
The top bar's theme control needed a click on the sun/moon, then a menu, then a choice, to do something every other product does in one click. It is now a plain button that flips between light and dark, with the beta marker moved into the label of the click that turns dark on. An explicit "system" choice is gone, but next-themes still follows the OS for anyone who has it stored and has not clicked yet. Docs and Blog also drifted apart in the gateway header: Blog rendered through the shared product-link class while Docs was a muted ghost button one size down, so Docs read as dimmer and sat 4px shorter. Both now go through a shared DocsLink component, which is also what the legacy navbar uses, so the pair cannot drift again.
This commit is contained in:
parent
22a349ee70
commit
e5dcc6873e
7 changed files with 101 additions and 101 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 />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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/";
|
||||
|
||||
export const DocsLink: React.FC = () => (
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
|
||||
Docs
|
||||
{/* Docs is a single outbound link; the hidden chevron keeps its box identical to the Blog dropdown trigger. */}
|
||||
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
|
||||
export default DocsLink;
|
||||
|
|
@ -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