diff --git a/apps/app/src/components/ui/tab-pill.test.tsx b/apps/app/src/components/ui/tab-pill.test.tsx index d0fd0c2277..ab15313896 100644 --- a/apps/app/src/components/ui/tab-pill.test.tsx +++ b/apps/app/src/components/ui/tab-pill.test.tsx @@ -6,6 +6,43 @@ import { TabPill } from "./tab-pill"; afterEach(cleanup); +const EXPECTED_CLOSE_BUTTON_CLASS = + "pointer-events-none absolute left-1.5 top-1/2 z-10 -translate-y-1/2 inline-flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted-foreground/15 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none pointer-coarse:size-5 opacity-0 hover:opacity-100 group-hover/tab-pill:pointer-events-auto group-hover/tab-pill:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 disabled:opacity-30 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100"; +const EXPECTED_CLOSABLE_LEADING_VISUAL_CLASS = + "inline-flex size-4 shrink-0 items-center justify-center [&_svg]:size-3.5 pointer-coarse:size-5 pointer-coarse:[&_svg]:size-5 mr-1.5 group-hover/tab-pill:opacity-0 tab-pill-close-focus-visible:opacity-0 pointer-coarse:opacity-0"; +const EXPECTED_LEADING_VISUAL_CLASS = + "inline-flex size-4 shrink-0 items-center justify-center [&_svg]:size-3.5 pointer-coarse:size-5 pointer-coarse:[&_svg]:size-5 mr-1.5"; + +function getLeadingVisual() { + const leadingVisual = screen.getByTestId("leading-visual").parentElement; + if (!leadingVisual) { + throw new Error("Expected a leading visual wrapper"); + } + return leadingVisual; +} + +function renderClosableTab() { + render( + B} + title="Browser" + isActive={false} + onSelect={vi.fn()} + closeAction={{ onClose: vi.fn(), closeLabel: "Close Browser" }} + />, + ); + + const closeButton = screen.getByRole("button", { name: "Close Browser" }); + const closeIcon = closeButton.querySelector("svg"); + const pill = closeButton.parentElement; + if (!closeIcon || !pill) { + throw new Error("Expected a close icon and pill container"); + } + + return { closeButton, closeIcon, leadingVisual: getLeadingVisual(), pill }; +} + describe("TabPill", () => { it("keeps an icon-only tab reachable by its accessible name", () => { render( @@ -43,4 +80,76 @@ describe("TabPill", () => { .getAttribute("aria-pressed"), ).toBe("false"); }); + + it("reveals a close action for any coarse pointer", () => { + const { closeButton } = renderClosableTab(); + + expect(closeButton.className).toBe(EXPECTED_CLOSE_BUTTON_CLASS); + expect(closeButton.classList).toContain("pointer-coarse:opacity-100"); + expect(closeButton.classList).toContain( + "pointer-coarse:pointer-events-auto", + ); + }); + + it("hides a closable leading visual for any coarse pointer", () => { + const { leadingVisual } = renderClosableTab(); + + expect(leadingVisual.className).toBe( + EXPECTED_CLOSABLE_LEADING_VISUAL_CLASS, + ); + expect(leadingVisual.classList).toContain("pointer-coarse:opacity-0"); + }); + + it("keeps the fine-pointer close reveal classes", () => { + const { closeButton } = renderClosableTab(); + + expect(closeButton.classList).toContain("hover:opacity-100"); + expect(closeButton.classList).toContain( + "group-hover/tab-pill:pointer-events-auto", + ); + expect(closeButton.classList).toContain( + "group-hover/tab-pill:opacity-100", + ); + }); + + it("does not width-gate close visibility or pointer interaction", () => { + const { closeButton, closeIcon, leadingVisual, pill } = renderClosableTab(); + + expect(closeButton.classList).not.toContain( + "max-md:pointer-coarse:pointer-events-auto", + ); + expect(closeButton.classList).not.toContain( + "max-md:pointer-coarse:opacity-100", + ); + expect(leadingVisual.classList).not.toContain( + "max-md:pointer-coarse:opacity-0", + ); + expect( + [closeButton, closeIcon, leadingVisual, pill].flatMap((element) => + [ + "max-md:pointer-coarse:size-5", + "max-md:pointer-coarse:[&_svg]:size-5", + "max-md:pointer-coarse:h-9", + ].filter((className) => element.classList.contains(className)), + ), + ).toEqual([]); + }); + + it("keeps a leading visual visible without a close action", () => { + render( + B} + title="Browser" + isActive={false} + onSelect={vi.fn()} + closeAction={null} + />, + ); + + const leadingVisual = getLeadingVisual(); + + expect(leadingVisual.className).toBe(EXPECTED_LEADING_VISUAL_CLASS); + expect(leadingVisual.classList).not.toContain("pointer-coarse:opacity-0"); + }); }); diff --git a/apps/app/src/components/ui/tab-pill.tsx b/apps/app/src/components/ui/tab-pill.tsx index e268508d5f..4e63edb76a 100644 --- a/apps/app/src/components/ui/tab-pill.tsx +++ b/apps/app/src/components/ui/tab-pill.tsx @@ -7,11 +7,11 @@ import { CONTEXT_SELECTION_SURFACE_CLASS } from "./context-selection"; const TAB_PILL_DEFAULT_LABEL_MAX_WIDTH_CLASS = "max-w-[180px]"; const TAB_PILL_AFFORDANCE_BUTTON_BASE_CLASS = - "inline-flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted-foreground/15 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none max-md:pointer-coarse:size-5"; -const TAB_PILL_AFFORDANCE_ICON_CLASS = "size-3.5 max-md:pointer-coarse:size-5"; -const TAB_PILL_CLOSE_BUTTON_CLASS = `pointer-events-none absolute left-1.5 top-1/2 z-10 -translate-y-1/2 ${TAB_PILL_AFFORDANCE_BUTTON_BASE_CLASS} opacity-0 hover:opacity-100 group-hover/tab-pill:pointer-events-auto group-hover/tab-pill:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 disabled:opacity-30 max-md:pointer-coarse:pointer-events-auto max-md:pointer-coarse:opacity-100`; + "inline-flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted-foreground/15 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none pointer-coarse:size-5"; +const TAB_PILL_AFFORDANCE_ICON_CLASS = "size-3.5 pointer-coarse:size-5"; +const TAB_PILL_CLOSE_BUTTON_CLASS = `pointer-events-none absolute left-1.5 top-1/2 z-10 -translate-y-1/2 ${TAB_PILL_AFFORDANCE_BUTTON_BASE_CLASS} opacity-0 hover:opacity-100 group-hover/tab-pill:pointer-events-auto group-hover/tab-pill:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 disabled:opacity-30 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100`; const TAB_PILL_LEADING_VISUAL_CLASS = - "inline-flex size-4 shrink-0 items-center justify-center [&_svg]:size-3.5 max-md:pointer-coarse:size-5 max-md:pointer-coarse:[&_svg]:size-5"; + "inline-flex size-4 shrink-0 items-center justify-center [&_svg]:size-3.5 pointer-coarse:size-5 pointer-coarse:[&_svg]:size-5"; interface TabPillCloseAction { onClose: () => void; @@ -51,7 +51,7 @@ export function TabPill({ return (