mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
refactor(ui): move sidebar chrome onto shadcn primitives and whiten the sidebar
Follow-up to the full-height sidebar shell. Replaces hand-rolled markup in the sidebar, top bar, and account/usage docks with shadcn primitives so the surface reuses shared components instead of one-off elements Adds three Base-UI-native primitives that follow the existing conventions (cva + data-slot, no Radix): Meter, Avatar, and Breadcrumb. Badge gains a render prop via Base UI useRender so it can render as an anchor, which activates the variant's existing [a&] styles Wires the redesigned surface onto them: the version tag is an outline Badge linking to the release notes, the breadcrumb uses the Breadcrumb primitive, the Docs link is a ghost Button, the account initials use Avatar, and the Enterprise usage card uses Collapsible for its open state and Meter for the seat and team gauges. Meter is the correct element for a used-of-total measurement and Base UI ships it natively, so it replaces the hand-rolled bars Sets the sidebar background to pure white by pointing the --sidebar token at oklch(1 0 0) in light mode, which keeps SidebarUsageCard consistent since it shares the token; the dark block is left untouched Also repairs layout.test.tsx, which the shell refactor had left red: the dashboard shell now renders DashboardHeader in place of the old navbar, so the test mocks DashboardHeader and asserts on it instead of the navbar the layout no longer mounts
This commit is contained in:
parent
914b2341e0
commit
7d63e452f1
15 changed files with 528 additions and 92 deletions
|
|
@ -13,8 +13,8 @@ vi.mock("next/navigation", () => ({
|
|||
usePathname: vi.fn(() => "/ui/guardrails"),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/navbar", () => ({
|
||||
default: () => <div data-testid="navbar" />,
|
||||
vi.mock("@/components/DashboardHeader", () => ({
|
||||
DashboardHeader: () => <div data-testid="dashboard-header" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/components/SidebarProvider", () => ({
|
||||
|
|
@ -76,12 +76,12 @@ describe("(dashboard) Layout", () => {
|
|||
|
||||
await waitFor(() => expect(screen.getByTestId("loading-screen")).toBeTruthy());
|
||||
expect(screen.queryByTestId("page-content")).toBeNull();
|
||||
expect(screen.queryByTestId("navbar")).toBeNull();
|
||||
expect(screen.queryByTestId("dashboard-header")).toBeNull();
|
||||
|
||||
pendingUiConfig.resolve();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("page-content")).toBeTruthy());
|
||||
expect(screen.getByTestId("navbar")).toBeTruthy();
|
||||
expect(screen.getByTestId("dashboard-header")).toBeTruthy();
|
||||
expect(screen.queryByTestId("loading-screen")).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ describe("(dashboard) Layout", () => {
|
|||
expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/onboarding?invitation_id=abc123")),
|
||||
);
|
||||
expect(screen.queryByTestId("page-content")).toBeNull();
|
||||
expect(screen.queryByTestId("navbar")).toBeNull();
|
||||
expect(screen.queryByTestId("dashboard-header")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0.002 247.839);
|
||||
--sidebar: oklch(1 0 0);
|
||||
--sidebar-foreground: oklch(0.13 0.028 261.692);
|
||||
--sidebar-primary: oklch(0.21 0.034 264.665);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getBreadcrumb } from "@/components/leftnav";
|
||||
import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
|
||||
|
|
@ -35,15 +42,19 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
|
|||
|
||||
return (
|
||||
<header className="flex h-14 flex-none items-center justify-between gap-4 border-b border-border bg-background px-4">
|
||||
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||
{section && (
|
||||
<>
|
||||
<span className="whitespace-nowrap text-muted-foreground">{section}</span>
|
||||
<ChevronRight className="size-4 flex-none text-muted-foreground" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate font-medium text-foreground">{title}</span>
|
||||
</nav>
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{section && (
|
||||
<>
|
||||
<BreadcrumbItem className="whitespace-nowrap">{section}</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="flex flex-none items-center gap-1">
|
||||
{showWorkerSwitch && (
|
||||
|
|
@ -52,14 +63,15 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
|
|||
<Separator orientation="vertical" className="mx-1.5 h-5" />
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
<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
|
||||
</a>
|
||||
</Button>
|
||||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
<Separator orientation="vertical" className="mx-1.5 h-5" />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import type { MenuProps } from "antd";
|
||||
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
|
|
@ -249,13 +250,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
aria-haspopup="menu"
|
||||
title={collapsed ? displayName : undefined}
|
||||
>
|
||||
<span
|
||||
className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white shadow-inner ring-1 ring-black/5"
|
||||
style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<Avatar className="size-[30px] shadow-inner ring-1 ring-black/5" aria-hidden>
|
||||
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 leading-tight">
|
||||
|
|
@ -273,13 +272,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white shadow-inner ring-1 ring-black/5"
|
||||
style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<Avatar className="shadow-inner ring-1 ring-black/5" aria-hidden>
|
||||
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
|
||||
{displayName}
|
||||
</span>
|
||||
|
|
|
|||
115
ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx
Normal file
115
ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SidebarUsageCard from "./SidebarUsageCard";
|
||||
|
||||
vi.mock("./networking", () => ({ getRemainingUsers: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({
|
||||
useDisableUsageIndicator: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/license/useLicenseInfo", () => ({
|
||||
useLicenseInfo: vi.fn(() => ({ data: null })),
|
||||
}));
|
||||
|
||||
import { getRemainingUsers } from "./networking";
|
||||
|
||||
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
const SEATS_DATA = {
|
||||
total_users: 100,
|
||||
total_users_used: 20,
|
||||
total_users_remaining: 80,
|
||||
total_teams: null,
|
||||
total_teams_used: 0,
|
||||
total_teams_remaining: null,
|
||||
};
|
||||
|
||||
const OVER_LIMIT_DATA = {
|
||||
total_users: 100,
|
||||
total_users_used: 130,
|
||||
total_users_remaining: -30,
|
||||
total_teams: null,
|
||||
total_teams_used: 0,
|
||||
total_teams_remaining: null,
|
||||
};
|
||||
|
||||
const NO_LIMITS_DATA = {
|
||||
total_users: null,
|
||||
total_users_used: 186,
|
||||
total_users_remaining: null,
|
||||
total_teams: null,
|
||||
total_teams_used: 125,
|
||||
total_teams_remaining: null,
|
||||
};
|
||||
|
||||
describe("SidebarUsageCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetRemainingUsers.mockResolvedValue(SEATS_DATA);
|
||||
});
|
||||
|
||||
it("renders an expanded seat meter reporting value and range when data loads", async () => {
|
||||
const { container } = renderWithClient(
|
||||
<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />,
|
||||
);
|
||||
|
||||
await screen.findByText("Enterprise usage");
|
||||
const meter = await screen.findByRole("meter");
|
||||
expect(meter).toHaveAttribute("aria-valuenow", "20");
|
||||
expect(meter).toHaveAttribute("aria-valuemax", "100");
|
||||
expect(screen.getByText("Seats")).toBeInTheDocument();
|
||||
|
||||
const indicator = container.querySelector('[data-slot="meter-indicator"]');
|
||||
expect(indicator).toHaveStyle({ width: "20%" });
|
||||
});
|
||||
|
||||
it("collapses the meter panel when the trigger is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
|
||||
|
||||
await screen.findByRole("meter");
|
||||
await user.click(screen.getByRole("button", { name: /Enterprise usage/i }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("meter")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("flags over-limit usage with a destructive, capped indicator", async () => {
|
||||
mockGetRemainingUsers.mockResolvedValue(OVER_LIMIT_DATA);
|
||||
|
||||
const { container } = renderWithClient(
|
||||
<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />,
|
||||
);
|
||||
|
||||
await screen.findByRole("meter");
|
||||
const indicator = container.querySelector('[data-slot="meter-indicator"]');
|
||||
expect(indicator).toHaveClass("bg-destructive");
|
||||
expect(indicator).toHaveStyle({ width: "100%" });
|
||||
});
|
||||
|
||||
it("renders nothing when neither seat nor team limits are set", async () => {
|
||||
mockGetRemainingUsers.mockResolvedValue(NO_LIMITS_DATA);
|
||||
|
||||
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Enterprise usage")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("shows a collapsed rail button that expands the sidebar", async () => {
|
||||
const onExpandRail = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithClient(<SidebarUsageCard accessToken="token" collapsed onExpandRail={onExpandRail} />);
|
||||
|
||||
const rail = await screen.findByTitle("Enterprise usage");
|
||||
await user.click(rail);
|
||||
expect(onExpandRail).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Award, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getRemainingUsers } from "./networking";
|
||||
|
||||
interface SidebarUsageCardProps {
|
||||
|
|
@ -13,7 +13,7 @@ interface SidebarUsageCardProps {
|
|||
onExpandRail: () => void;
|
||||
}
|
||||
|
||||
interface Meter {
|
||||
interface MeterData {
|
||||
label: string;
|
||||
used: number;
|
||||
total: number;
|
||||
|
|
@ -35,24 +35,21 @@ const meterBarClass = (pct: number): string => {
|
|||
return "bg-sidebar-primary";
|
||||
};
|
||||
|
||||
const Meter = ({ label, used, total }: Meter) => {
|
||||
const UsageMeter = ({ label, used, total }: MeterData) => {
|
||||
const pct = total > 0 ? (used / total) * 100 : 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Meter value={used} max={total} aria-valuetext={`${used.toLocaleString()} of ${total.toLocaleString()}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<MeterLabel>{label}</MeterLabel>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
<span className="text-foreground">{used.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground"> / {total.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-[width] duration-300", meterBarClass(pct))}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MeterTrack>
|
||||
<MeterIndicator className={meterBarClass(pct)} />
|
||||
</MeterTrack>
|
||||
</Meter>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +63,7 @@ const remainingUsersQuery = (accessToken: string | null) => ({
|
|||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const buildMeters = (data: RemainingUsage | null): Meter[] => {
|
||||
const buildMeters = (data: RemainingUsage | null): MeterData[] => {
|
||||
if (!data) return [];
|
||||
return [
|
||||
...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []),
|
||||
|
|
@ -82,7 +79,6 @@ const buildMeters = (data: RemainingUsage | null): Meter[] => {
|
|||
*/
|
||||
export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) {
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const [open, setOpen] = useState(true);
|
||||
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
|
||||
const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken));
|
||||
const data = usageData ?? null;
|
||||
|
|
@ -111,12 +107,8 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
|
|||
const meters = buildMeters(data);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-sidebar-border bg-sidebar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-sidebar-accent"
|
||||
>
|
||||
<Collapsible defaultOpen className="overflow-hidden rounded-xl border border-sidebar-border bg-sidebar">
|
||||
<CollapsibleTrigger className="group/usage flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-sidebar-accent">
|
||||
<span className="flex size-[26px] flex-none items-center justify-center rounded-md bg-sidebar-primary/10 text-sidebar-primary">
|
||||
<Award className="size-4" strokeWidth={1.75} />
|
||||
</span>
|
||||
|
|
@ -124,22 +116,18 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
|
|||
<span className="block text-[13px] font-semibold text-foreground">Enterprise usage</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{subtitle}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("size-4 flex-none text-muted-foreground transition-transform", !open && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<ChevronDown className="size-4 flex-none -rotate-90 text-muted-foreground transition-transform group-data-[panel-open]/usage:rotate-0" />
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{open && (
|
||||
<div className="flex flex-col gap-3 px-3 pt-0.5 pb-3">
|
||||
{isLoading && meters.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : (
|
||||
meters.map((m) => <Meter key={m.label} {...m} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleContent className="flex flex-col gap-3 px-3 pt-0.5 pb-3">
|
||||
{isLoading && meters.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : (
|
||||
meters.map((m) => <UsageMeter key={m.label} {...m} />)
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadine
|
|||
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -584,14 +585,13 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
/>
|
||||
</Link>
|
||||
{version && (
|
||||
<a
|
||||
href="https://docs.litellm.ai/release_notes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded border border-border px-1.5 py-px font-mono text-[10px] font-medium text-muted-foreground transition-colors hover:text-foreground group-data-[collapsed=true]/sidebar:hidden"
|
||||
<Badge
|
||||
variant="outline"
|
||||
render={<a href="https://docs.litellm.ai/release_notes" target="_blank" rel="noopener noreferrer" />}
|
||||
className="px-1.5 py-0 font-mono text-[10px] font-medium text-muted-foreground group-data-[collapsed=true]/sidebar:hidden"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{onToggleCollapsed && (
|
||||
|
|
|
|||
15
ui/litellm-dashboard/src/components/ui/avatar.test.tsx
Normal file
15
ui/litellm-dashboard/src/components/ui/avatar.test.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarFallback } from "./avatar";
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("renders the fallback initials when no image is provided", () => {
|
||||
render(
|
||||
<Avatar>
|
||||
<AvatarFallback>AB</AvatarFallback>
|
||||
</Avatar>,
|
||||
);
|
||||
const fallback = screen.getByText("AB");
|
||||
expect(fallback).toHaveAttribute("data-slot", "avatar-fallback");
|
||||
});
|
||||
});
|
||||
48
ui/litellm-dashboard/src/components/ui/avatar.tsx
Normal file
48
ui/litellm-dashboard/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const Avatar = React.forwardRef<React.ComponentRef<typeof AvatarPrimitive.Root>, AvatarPrimitive.Root.Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Avatar.displayName = "Avatar";
|
||||
|
||||
const AvatarImage = React.forwardRef<React.ComponentRef<typeof AvatarPrimitive.Image>, AvatarPrimitive.Image.Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
data-slot="avatar-image"
|
||||
className={cn("size-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AvatarImage.displayName = "AvatarImage";
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
|
||||
AvatarPrimitive.Fallback.Props
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
data-slot="avatar-fallback"
|
||||
className={cn("flex size-full items-center justify-center rounded-full text-xs font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = "AvatarFallback";
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
32
ui/litellm-dashboard/src/components/ui/badge.test.tsx
Normal file
32
ui/litellm-dashboard/src/components/ui/badge.test.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders a span carrying the badge slot and variant classes by default", () => {
|
||||
render(<Badge variant="outline">v1.2.3</Badge>);
|
||||
const badge = screen.getByText("v1.2.3");
|
||||
expect(badge.tagName).toBe("SPAN");
|
||||
expect(badge).toHaveAttribute("data-slot", "badge");
|
||||
expect(badge).toHaveAttribute("data-variant", "outline");
|
||||
expect(badge).toHaveClass("border-border");
|
||||
});
|
||||
|
||||
it("renders as an anchor via the render prop while keeping badge styling", () => {
|
||||
render(
|
||||
<Badge variant="outline" render={<a href="https://docs.litellm.ai/release_notes" />}>
|
||||
v1.2.3
|
||||
</Badge>,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "v1.2.3" });
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link).toHaveAttribute("href", "https://docs.litellm.ai/release_notes");
|
||||
expect(link).toHaveAttribute("data-slot", "badge");
|
||||
expect(link).toHaveClass("border-border");
|
||||
});
|
||||
|
||||
it("lets className win over variant classes through twMerge", () => {
|
||||
render(<Badge className="text-muted-foreground">x</Badge>);
|
||||
expect(screen.getByText("x")).toHaveClass("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type VariantProps } from "cva";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
|
||||
import { cn, cva } from "@/lib/cva.config";
|
||||
|
||||
|
|
@ -21,18 +24,24 @@ const badgeVariants = cva({
|
|||
},
|
||||
});
|
||||
|
||||
const Badge = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span"> & VariantProps<typeof badgeVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type BadgeProps = React.ComponentPropsWithoutRef<"span"> &
|
||||
VariantProps<typeof badgeVariants> & {
|
||||
render?: useRender.RenderProp;
|
||||
};
|
||||
|
||||
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant = "default", render, ...props }, ref) =>
|
||||
useRender({
|
||||
render: render ?? <span />,
|
||||
ref,
|
||||
props: {
|
||||
"data-slot": "badge",
|
||||
"data-variant": variant,
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
...props,
|
||||
},
|
||||
}),
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
|
|
|||
38
ui/litellm-dashboard/src/components/ui/breadcrumb.test.tsx
Normal file
38
ui/litellm-dashboard/src/components/ui/breadcrumb.test.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./breadcrumb";
|
||||
|
||||
describe("Breadcrumb", () => {
|
||||
it("renders a labelled nav and marks the current page", () => {
|
||||
render(
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>Observability</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Logs</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>,
|
||||
);
|
||||
expect(screen.getByRole("navigation", { name: "breadcrumb" })).toBeInTheDocument();
|
||||
const page = screen.getByText("Logs");
|
||||
expect(page).toHaveAttribute("aria-current", "page");
|
||||
expect(page).toHaveAttribute("data-slot", "breadcrumb-page");
|
||||
});
|
||||
|
||||
it("renders a presentational separator", () => {
|
||||
const { container } = render(
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>A</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>B</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>,
|
||||
);
|
||||
const sep = container.querySelector('[data-slot="breadcrumb-separator"]');
|
||||
expect(sep).toHaveAttribute("aria-hidden", "true");
|
||||
expect(sep?.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
78
ui/litellm-dashboard/src/components/ui/breadcrumb.tsx
Normal file
78
ui/litellm-dashboard/src/components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import * as React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const Breadcrumb = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<"nav">>(({ ...props }, ref) => (
|
||||
<nav ref={ref} aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
));
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn("flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<HTMLAnchorElement, React.ComponentPropsWithoutRef<"a">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<a
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
||||
({ children, className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
),
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator };
|
||||
35
ui/litellm-dashboard/src/components/ui/meter.test.tsx
Normal file
35
ui/litellm-dashboard/src/components/ui/meter.test.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "./meter";
|
||||
|
||||
const renderMeter = (value: number, max: number) =>
|
||||
render(
|
||||
<Meter value={value} max={max}>
|
||||
<MeterLabel>Seats</MeterLabel>
|
||||
<MeterTrack>
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</Meter>,
|
||||
);
|
||||
|
||||
describe("Meter", () => {
|
||||
it("exposes the value and range through the meter role", () => {
|
||||
renderMeter(5, 10);
|
||||
const meter = screen.getByRole("meter");
|
||||
expect(meter).toHaveAttribute("aria-valuenow", "5");
|
||||
expect(meter).toHaveAttribute("aria-valuemax", "10");
|
||||
expect(screen.getByText("Seats")).toHaveAttribute("data-slot", "meter-label");
|
||||
});
|
||||
|
||||
it("fills the indicator proportionally to value/max", () => {
|
||||
const { container } = renderMeter(5, 10);
|
||||
const indicator = container.querySelector('[data-slot="meter-indicator"]');
|
||||
expect(indicator).toHaveStyle({ width: "50%" });
|
||||
});
|
||||
|
||||
it("caps the indicator at 100% when the value exceeds the max", () => {
|
||||
const { container } = renderMeter(15, 10);
|
||||
const indicator = container.querySelector('[data-slot="meter-indicator"]');
|
||||
expect(indicator).toHaveStyle({ width: "100%" });
|
||||
});
|
||||
});
|
||||
69
ui/litellm-dashboard/src/components/ui/meter.tsx
Normal file
69
ui/litellm-dashboard/src/components/ui/meter.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { Meter as MeterPrimitive } from "@base-ui/react/meter";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const Meter = React.forwardRef<React.ComponentRef<typeof MeterPrimitive.Root>, MeterPrimitive.Root.Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<MeterPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="meter"
|
||||
className={cn("flex w-full flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Meter.displayName = "Meter";
|
||||
|
||||
const MeterLabel = React.forwardRef<React.ComponentRef<typeof MeterPrimitive.Label>, MeterPrimitive.Label.Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<MeterPrimitive.Label
|
||||
ref={ref}
|
||||
data-slot="meter-label"
|
||||
className={cn("text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
MeterLabel.displayName = "MeterLabel";
|
||||
|
||||
const MeterValue = React.forwardRef<React.ComponentRef<typeof MeterPrimitive.Value>, MeterPrimitive.Value.Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<MeterPrimitive.Value
|
||||
ref={ref}
|
||||
data-slot="meter-value"
|
||||
className={cn("text-xs font-medium tabular-nums", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
MeterValue.displayName = "MeterValue";
|
||||
|
||||
const MeterTrack = React.forwardRef<React.ComponentRef<typeof MeterPrimitive.Track>, MeterPrimitive.Track.Props>(
|
||||
({ className, ...props }, ref) => (
|
||||
<MeterPrimitive.Track
|
||||
ref={ref}
|
||||
data-slot="meter-track"
|
||||
className={cn("h-1.5 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
MeterTrack.displayName = "MeterTrack";
|
||||
|
||||
const MeterIndicator = React.forwardRef<
|
||||
React.ComponentRef<typeof MeterPrimitive.Indicator>,
|
||||
MeterPrimitive.Indicator.Props
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MeterPrimitive.Indicator
|
||||
ref={ref}
|
||||
data-slot="meter-indicator"
|
||||
className={cn("h-full rounded-full bg-primary transition-[width] duration-300", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MeterIndicator.displayName = "MeterIndicator";
|
||||
|
||||
export { Meter, MeterLabel, MeterValue, MeterTrack, MeterIndicator };
|
||||
Loading…
Add table
Reference in a new issue