// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Inbox } from "lucide-react";
import { SidebarNavItem, SidebarNavExpandedProvider } from "./SidebarNavItem";
import { TooltipProvider } from "@/components/ui/tooltip";
const sidebarState = vi.hoisted(() => ({
isMobile: false,
setSidebarOpen: () => {},
collapsed: false,
peeking: false,
}));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, to, className, ...props }: {
children: ReactNode | ((state: { isActive: boolean }) => ReactNode);
to: string;
className?: string | ((state: { isActive: boolean }) => string);
}) => {
const resolvedClassName = typeof className === "function" ? className({ isActive: false }) : className;
const resolvedChildren = typeof children === "function" ? children({ isActive: false }) : children;
return (
{resolvedChildren}
);
},
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => sidebarState,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("SidebarNavItem", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
sidebarState.collapsed = false;
sidebarState.peeking = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.clearAllMocks();
});
function render(node: ReactNode) {
act(() => {
root.render({node});
});
}
function link() {
return container.querySelector("a") as HTMLAnchorElement;
}
function classTokens(element: Element | null | undefined) {
return element?.className.toString().split(/\s+/).filter(Boolean) ?? [];
}
it("shows the full label and numeric badge when expanded", () => {
render();
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label?.className).not.toContain("sr-only");
// The numeric badge is rendered in full (not a dot) and no rail aria-label is set.
expect(container.textContent).toContain("28");
expect(link().getAttribute("aria-label")).toBeNull();
});
it("clips the label (kept in flow for 1:1 row height) and collapses the badge to a dot in the rail", () => {
sidebarState.collapsed = true;
render();
// The label stays in the DOM/a11y tree (not display:none) so screen readers
// still announce it. Unlike sr-only it is kept IN FLOW (zero-width, clipped,
// transparent) so it still contributes its line-height — that keeps the row
// exactly as tall as the expanded state, so the icon never shifts (PAP-10676).
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label).toBeTruthy();
expect(label?.className).not.toContain("sr-only");
expect(classTokens(label)).toContain("w-0");
expect(classTokens(label)).toContain("overflow-hidden");
// The numeric count is no longer rendered as text; it is a dot with an
// accessible text equivalent on the link.
expect(container.textContent).not.toContain("28 ");
expect(link().getAttribute("aria-label")).toBe("Inbox, 28 unread");
// Tooltip wraps the row; the trigger is the wrapper element so the NavLink's
// own flex className is preserved (PAP-10676), with the nested inside it.
expect(link().parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
});
it("surfaces the trailing status label in the rail aria-label", () => {
sidebarState.collapsed = true;
render(
}
trailingLabel="Invalid reporting chain"
/>,
);
// The trailing warning is hidden in the rail, so its text equivalent must
// ride on the link's accessible name.
expect(link().getAttribute("aria-label")).toBe("CodexCoder, Invalid reporting chain");
});
it("keeps the full presentation while peeking even when collapsed", () => {
sidebarState.collapsed = true;
sidebarState.peeking = true;
render();
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label?.className).not.toContain("sr-only");
expect(container.textContent).toContain("28");
expect(link().getAttribute("aria-label")).toBeNull();
});
it("forces the full label inside an expanded contextual pane even when globally collapsed", () => {
// The takeover model collapses the global sidebar (collapsed=true) while the
// 240px SecondarySidebar still needs readable labels (PAP-10700). The
// provider must override the global rail collapse.
sidebarState.collapsed = true;
render(
,
);
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(classTokens(label)).not.toContain("w-0");
expect(classTokens(label)).toContain("flex-1");
// Full numeric badge, no rail aria-label, no tooltip wrapper.
expect(container.textContent).toContain("28");
expect(link().getAttribute("aria-label")).toBeNull();
expect(link().parentElement?.getAttribute("data-slot")).not.toBe("tooltip-trigger");
});
it("surfaces the live count in the rail aria-label", () => {
sidebarState.collapsed = true;
render();
expect(link().getAttribute("aria-label")).toBe("Dashboard, 3 live");
});
});