import { render, fireEvent } from "@testing-library/react";
import { WrapWithProvider } from "test/jest/test-utils";
import {
TopSite,
TopSiteLink,
TopSiteAddButton,
_TopSiteList as TopSiteList,
} from "content-src/components/TopSites/TopSite";
import { buildTopSitesList } from "content-src/components/TopSites/TopSiteListContainer";
import { actionTypes as at } from "common/Actions.mjs";
const DEFAULT_LINK = {
url: "https://example.com",
hostname: "example.com",
title: "Example",
label: "Example",
iconType: "no_image",
};
// Unique urls keep React keys distinct when the rows are rendered.
const makeRows = n =>
Array.from({ length: n }, (_, i) => ({
...DEFAULT_LINK,
url: `https://example${i}.com`,
}));
const LIST_PROPS = {
dispatch: jest.fn(),
onDragEvent: jest.fn(),
topSiteIconType: () => "no_image",
topSitesMaxSitesPerRow: 8,
TopSitesRows: 1,
App: { isForStartupCache: {} },
Prefs: { values: {} },
};
function renderList(sites) {
return render(
);
}
describe("", () => {
it("should render", () => {
const { container } = render(
);
expect(container.querySelector(".top-site-outer")).toBeInTheDocument();
});
});
describe("buildTopSitesList Add button placement", () => {
function getSites(rows, { rowsCount = 1, perRow = 8 } = {}) {
return buildTopSitesList(rows, rowsCount, perRow);
}
function addButtonIndex(sites) {
return sites.findIndex(site => site?.isAddButton);
}
it("places the Add button in the first slot when there are no shortcuts", () => {
expect(addButtonIndex(getSites([]))).toBe(0);
});
it("keeps the Add button visible after the first shortcut is added", () => {
// Regression for Bug 2046956: starting from zero shortcuts and adding one
// used to default the Add button to the last slot, where pinning the new
// shortcut pushed the button out of bounds and hid it.
expect(addButtonIndex(getSites([DEFAULT_LINK]))).toBe(1);
});
it("places the Add button right after the last shortcut", () => {
expect(
addButtonIndex(getSites([DEFAULT_LINK, DEFAULT_LINK, DEFAULT_LINK]))
).toBe(3);
});
it("keeps the Add button when the grid is full but can still grow a row", () => {
expect(
addButtonIndex(getSites(makeRows(8), { rowsCount: 1, perRow: 8 }))
).toBe(8);
});
it("drops the Add button when the grid is full at the max rows", () => {
expect(
addButtonIndex(getSites(makeRows(32), { rowsCount: 4, perRow: 8 }))
).toBe(-1);
});
});
describe("", () => {
it("renders an in-grid tile with a large button", () => {
const { container } = render(
);
const tile = container.querySelector("li.add-button-tile");
expect(tile).toBeInTheDocument();
const button = tile.querySelector("moz-button.add-button");
expect(button).toBeInTheDocument();
expect(button.getAttribute("size")).toBe("large");
});
it("renders a hover overlay (default size) when not in-grid", () => {
const { container } = render(
);
const overlay = container.querySelector("div.add-button-hidden");
expect(overlay).toBeInTheDocument();
expect(
container.querySelector("li.add-button-tile")
).not.toBeInTheDocument();
const button = overlay.querySelector("moz-button.add-button");
expect(button).toBeInTheDocument();
expect(button.hasAttribute("size")).toBe(false);
});
it("carries the slot's responsive hide class on the in-grid tile", () => {
const { container } = render(
);
expect(
container.querySelector("li.add-button-tile.hide-for-small")
).toBeInTheDocument();
});
it("dispatches TOP_SITES_EDIT with its index when clicked", () => {
const dispatch = jest.fn();
const { container } = render(
);
fireEvent.click(container.querySelector("moz-button"));
expect(dispatch).toHaveBeenCalledWith({
type: at.TOP_SITES_EDIT,
data: { index: 7 },
});
});
});
describe(" Add button rendering", () => {
it("renders the add button in-grid when the row isn't full", () => {
const { container } = renderList(buildTopSitesList(makeRows(2), 1, 8));
expect(container.querySelector(".add-button-tile")).toBeInTheDocument();
expect(
container.querySelector(".add-button-hidden")
).not.toBeInTheDocument();
});
it("renders the add button as a hover overlay when the row is full", () => {
const { container } = renderList(buildTopSitesList(makeRows(8), 1, 8));
expect(container.querySelector(".add-button-hidden")).toBeInTheDocument();
expect(container.querySelector(".add-button-tile")).not.toBeInTheDocument();
});
});
describe(" arrow-key navigation", () => {
// The flat focus order onKeyDown walks: each tile's link plus either
// add-button variant (both carry .add-button), in DOM order.
function focusTargetsFor(sites) {
const { container } = renderList(sites);
return [...container.querySelectorAll("a, .add-button")];
}
it("moves focus to the next tile on ArrowRight", () => {
const targets = focusTargetsFor(buildTopSitesList(makeRows(3), 1, 8));
targets[0].focus();
fireEvent.keyDown(targets[0], { key: "ArrowRight" });
expect(document.activeElement).toBe(targets[1]);
});
it("moves focus to the previous tile on ArrowLeft", () => {
const targets = focusTargetsFor(buildTopSitesList(makeRows(3), 1, 8));
targets[1].focus();
fireEvent.keyDown(targets[1], { key: "ArrowLeft" });
expect(document.activeElement).toBe(targets[0]);
});
it("steps onto the in-grid add button from the last tile", () => {
// 2 tiles + a free slot: the add button renders in-grid as the last target.
const targets = focusTargetsFor(buildTopSitesList(makeRows(2), 1, 8));
const addButton = targets.at(-1);
expect(addButton).toHaveClass("add-button");
targets[1].focus();
fireEvent.keyDown(targets[1], { key: "ArrowRight" });
expect(document.activeElement).toBe(addButton);
});
it("steps onto the full-row overlay add button and back", () => {
// Full row: the add button renders as the nested overlay, still the last
// target — reached and left the same way as the in-grid variant.
const targets = focusTargetsFor(buildTopSitesList(makeRows(8), 1, 8));
const overlay = targets.at(-1);
const lastTile = targets.at(-2);
expect(overlay).toHaveClass("add-button");
lastTile.focus();
fireEvent.keyDown(lastTile, { key: "ArrowRight" });
expect(document.activeElement).toBe(overlay);
fireEvent.keyDown(overlay, { key: "ArrowLeft" });
expect(document.activeElement).toBe(lastTile);
});
it("wraps from the last focus target to the first and back", () => {
const targets = focusTargetsFor(buildTopSitesList(makeRows(3), 1, 8));
const [first] = targets;
const last = targets.at(-1);
last.focus();
fireEvent.keyDown(last, { key: "ArrowRight" });
expect(document.activeElement).toBe(first);
fireEvent.keyDown(first, { key: "ArrowLeft" });
expect(document.activeElement).toBe(last);
});
});
describe("TopSiteLink #_allowDrop with grouped pins", () => {
const topsiteDrag = { dataTransfer: { types: ["text/topsite-index"] } };
const makeLink = link => new TopSiteLink({ groupedPinsEnabled: true, link });
it("allows dropping on a pinned tile", () => {
expect(makeLink({ isPinned: true })._allowDrop(topsiteDrag)).toBe(true);
});
it("rejects dropping on a frecent (non-pinned) tile", () => {
expect(makeLink({ isPinned: false })._allowDrop(topsiteDrag)).toBe(false);
});
it("stays rejected on a frecent tile even with a stale dragged flag", () => {
// Regression: `dragged` is only reset on the next mousedown, so a cancelled
// drag used to leave it true here and wrongly mark this slot droppable.
const link = makeLink({ isPinned: false });
link.dragged = true;
expect(link._allowDrop(topsiteDrag)).toBe(false);
});
});