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:
Yuneng Jiang 2026-07-10 10:30:36 -07:00
parent 914b2341e0
commit 7d63e452f1
No known key found for this signature in database
15 changed files with 528 additions and 92 deletions

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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" />

View file

@ -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>

View 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();
});
});

View file

@ -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>
);
}

View file

@ -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 && (

View 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");
});
});

View 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 };

View 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");
});
});

View file

@ -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 };

View 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();
});
});

View 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 };

View 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%" });
});
});

View 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 };