Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions apps/app/src/components/ui/tab-pill.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TabPill
label="Browser"
leadingVisual={<span data-testid="leading-visual">B</span>}
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(
Expand Down Expand Up @@ -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(
<TabPill
label="Browser"
leadingVisual={<span data-testid="leading-visual">B</span>}
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");
});
});
12 changes: 6 additions & 6 deletions apps/app/src/components/ui/tab-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,7 +51,7 @@ export function TabPill({
return (
<div
className={cn(
`group/tab-pill relative inline-flex h-7 shrink-0 items-center rounded-md ${LIST_HOVER_TRANSITION} max-md:pointer-coarse:h-9`,
`group/tab-pill relative inline-flex h-7 shrink-0 items-center rounded-md ${LIST_HOVER_TRANSITION} pointer-coarse:h-9`,
COARSE_POINTER_TEXT_SM_CLASS,
isActive
? cn(CONTEXT_SELECTION_SURFACE_CLASS, "text-foreground")
Expand All @@ -75,7 +75,7 @@ export function TabPill({
TAB_PILL_LEADING_VISUAL_CLASS,
!iconOnly && "mr-1.5",
closeAction
? "group-hover/tab-pill:opacity-0 tab-pill-close-focus-visible:opacity-0 max-md:pointer-coarse:opacity-0"
? "group-hover/tab-pill:opacity-0 tab-pill-close-focus-visible:opacity-0 pointer-coarse:opacity-0"
: null,
)}
>
Expand Down