// @vitest-environment jsdom
//
// The chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { Chip, DismissibleChip } from "./Chip";
import { chipClass } from "./chipStyles";
afterEach(cleanup);
describe("Chip", () => {
it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => {
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
render(
,
);
const chip = screen.getByRole("button", { name: "2024" });
expect(chip.className).toContain("rounded-full");
expect(chip.className).toContain("h-6");
expect(chip.className).toContain("touch-target");
expect(chip.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(chip);
expect(onSubmit).not.toHaveBeenCalled();
});
it("a removing chip is named by the removal and shows the ×", () => {
const onClick = vi.fn();
render(
Music
,
);
const chip = screen.getByRole("button", { name: "Remove Music" });
expect(chip.getAttribute("title")).toBe("Remove Music");
expect(chip.className).toContain("h-7");
expect(chip.querySelector("svg")).not.toBeNull();
fireEvent.click(chip);
expect(onClick).toHaveBeenCalledOnce();
});
// The feed's and Plex's active-filter chips unmount with the filter they remove,
// and a keyboard press left the focus on . The chip hands it on itself, for every caller.
it("a removing chip pressed from the keyboard hands the focus to its neighbour", () => {
function Row() {
const [chips, setChips] = useState(["Music", "News"]);
return (
);
}
render();
const music = screen.getByRole("button", { name: "Remove Music" });
act(() => music.focus());
fireEvent.click(music, { detail: 0 });
expect(screen.queryByRole("button", { name: "Remove Music" })).toBeNull();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Remove News" }));
});
// The neighbour it hands the focus to can go in the same removal — the "Clear all" that
// only shows beside enough filters. The focus then goes to the chip before, not to .
it("a removing chip whose neighbour goes with it hands the focus to the chip before", async () => {
function Row() {
const [chips, setChips] = useState(["Music", "News", "Sport"]);
return (
);
}
render();
const sport = screen.getByRole("button", { name: "Remove Sport" });
act(() => sport.focus());
fireEvent.click(sport, { detail: 0 });
expect(screen.queryByRole("button", { name: "Clear all" })).toBeNull();
// the chip right before it — not the row's first control, which Music also is
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Remove News" })),
);
});
it("the measuring mirror lays out exactly like the chip it measures", () => {
render(
tag
,
);
const cls = screen.getByRole("button", { name: "tag" }).className;
for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c);
});
});
describe("DismissibleChip", () => {
it("is two sibling controls at most, never a button in a button", () => {
const onAccept = vi.fn();
const onDismiss = vi.fn();
const { container } = render(
Music
,
);
expect(container.querySelector("button button")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" }));
fireEvent.click(screen.getByRole("button", { name: "Not now" }));
expect(onAccept).toHaveBeenCalledOnce();
expect(onDismiss).toHaveBeenCalledOnce();
});
it("with no body action the label is text, and only the × is a control", () => {
render(
{}} dismissLabel="Remove Year: 2024">
Year: 2024
,
);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(screen.getByText("Year: 2024").closest("button")).toBeNull();
});
});