/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ import { render, fireEvent, act } from "@testing-library/react"; import { actionTypes as at } from "common/Actions.mjs"; import { TopicNavigation } from "content-src/components/DiscoveryStreamComponents/TopicNavigation/TopicNavigation"; const GAP = 10; const SECTIONS = [ { sectionKey: "top_stories_section", title: "Popular Today", data: [{}] }, { sectionKey: "politics", title: "Politics", data: [{}] }, { sectionKey: "science", title: "Science", data: [{}] }, { sectionKey: "food", title: "Food", data: [{}] }, ]; // Every topic is 100px wide, so three of them plus two gaps occupy 320px. const ITEM_WIDTHS = { Politics: 100, Science: 100, Food: 100 }; // Mutable so a test can describe the layout it wants before rendering. let containerWidth; let itemWidths; let controlWidth; let observers; class MockResizeObserver { constructor(callback) { this.callback = callback; this.targets = new Set(); observers.push(this); } observe(element) { this.targets.add(element); } unobserve(element) { this.targets.delete(element); } disconnect() { this.targets.clear(); observers = observers.filter(observer => observer !== this); } } // Hand the live observer a delivery for everything it watches, the way the // platform would after a resize. function deliverResize() { const observer = observers[observers.length - 1]; const entries = [...observer.targets].map(target => ({ target })); act(() => observer.callback(entries, observer)); } function rect(width) { return { width, height: 20, top: 0, left: 0, right: width, bottom: 20 }; } const chipTitles = container => [...container.querySelectorAll(".topic-navigation-list li")].map( li => li.textContent ); const menuTitles = container => [...container.querySelectorAll("panel-item")].map(item => item.textContent); const chipButton = (container, title) => [...container.querySelectorAll(".topic-navigation-list li")] .find(li => li.textContent === title) ?.querySelector("moz-button"); const moreButton = container => container.querySelector(".topic-navigation-overflow moz-button"); function renderNav(sections = SECTIONS) { const dispatch = jest.fn(); return { dispatch, ...render(), }; } // A section in the page for a topic to scroll to, matching what CardSections // renders: a heading carrying the id, and cards inside .ds-section. function addSectionToPage(sectionKey) { const section = document.createElement("section"); section.className = "ds-section"; section.innerHTML = `

Heading

First card Second card `; document.body.appendChild(section); return section; } describe("TopicNavigation", () => { beforeEach(() => { containerWidth = 1000; itemWidths = ITEM_WIDTHS; controlWidth = 50; observers = []; globalThis.ResizeObserver = MockResizeObserver; Element.prototype.scrollIntoView = jest.fn(); jest .spyOn(Element.prototype, "getBoundingClientRect") .mockImplementation(function () { if (this.classList.contains("topic-navigation")) { return rect(containerWidth); } if (this.classList.contains("topic-navigation-overflow")) { return rect(controlWidth); } if (this.localName === "li") { return rect(itemWidths[this.textContent] ?? 0); } return rect(0); }); jest .spyOn(globalThis, "getComputedStyle") .mockImplementation(() => ({ columnGap: `${GAP}px` })); }); afterEach(() => { jest.restoreAllMocks(); delete globalThis.ResizeObserver; delete Element.prototype.scrollIntoView; document.body.innerHTML = ""; }); it("lists the topics in feed order, without Popular Today", () => { const { container } = renderNav(); expect(chipTitles(container)).toEqual(["Politics", "Science", "Food"]); }); describe("overflow", () => { it("shows no More button when every topic fits", () => { const { container } = renderNav(); expect(moreButton(container)).toBeNull(); }); it("moves the topics that don't fit into the dropdown", () => { // 319px leaves 259px once the More button and its gap are reserved, // which fits two topics and the gap between them. containerWidth = 319; const { container } = renderNav(); expect(chipTitles(container)).toEqual(["Politics", "Science"]); expect(moreButton(container)).not.toBeNull(); expect(menuTitles(container)).toEqual(["Food"]); }); it("recomputes when the container resizes", () => { const { container } = renderNav(); expect(chipTitles(container)).toHaveLength(3); containerWidth = 319; deliverResize(); expect(chipTitles(container)).toEqual(["Politics", "Science"]); expect(menuTitles(container)).toEqual(["Food"]); }); }); describe("measuring", () => { it("stays hidden until the widths are real, then settles", () => { // Buttons that haven't rendered their contents report zero widths, which // must not be cached as if they were measurements. itemWidths = {}; const { container } = renderNav(); const nav = container.querySelector(".topic-navigation"); expect(nav).toHaveClass("is-measuring"); expect(chipTitles(container)).toHaveLength(3); itemWidths = ITEM_WIDTHS; deliverResize(); expect(nav).not.toHaveClass("is-measuring"); expect(chipTitles(container)).toEqual(["Politics", "Science", "Food"]); }); it("ignores a width that differs by a fraction of a pixel", () => { containerWidth = 319; const { container } = renderNav(); expect(chipTitles(container)).toHaveLength(2); // Layout rounding, not a real change. Treating it as one restarts // measuring, and the topics coming back report zero widths until they // render, so the pass can't finish and the strip stays hidden. controlWidth = 50.00001; itemWidths = {}; deliverResize(); const nav = container.querySelector(".topic-navigation"); expect(nav).not.toHaveClass("is-measuring"); expect(chipTitles(container)).toHaveLength(2); }); it("re-measures when a width really changes", () => { containerWidth = 319; const { container } = renderNav(); expect(chipTitles(container)).toHaveLength(2); // A wider More button leaves room for one topic instead of two. controlWidth = 200; deliverResize(); expect(chipTitles(container)).toEqual(["Politics"]); expect(menuTitles(container)).toEqual(["Science", "Food"]); }); }); describe("activation", () => { it("scrolls to the section, animated", () => { addSectionToPage("science"); const { container } = renderNav(); fireEvent.click(chipButton(container, "Science")); const heading = document.getElementById("section-title-science"); expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start", }); }); it("reports a click on the strip", () => { const { container, dispatch } = renderNav(); fireEvent.click(chipButton(container, "Science")); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: at.TOPIC_NAVIGATION_CLICK, data: { topic: "science", event_source: "TOPIC_STRIP" }, }) ); }); it("reports a dropdown pick as coming from the menu", () => { containerWidth = 319; const { container, dispatch } = renderNav(); fireEvent.click(container.querySelector("panel-item")); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: at.TOPIC_NAVIGATION_CLICK, data: { topic: "food", event_source: "OVERFLOW_MENU" }, }) ); }); it("moves focus to the section's first card on keyboard activation", () => { const section = addSectionToPage("science"); const { container } = renderNav(); fireEvent.click(chipButton(container, "Science"), { detail: 0 }); expect(document.activeElement).toBe( section.querySelector("a.ds-card-link") ); }); it("leaves focus alone on a pointer click", () => { addSectionToPage("science"); const { container } = renderNav(); const chip = chipButton(container, "Science"); fireEvent.click(chip, { detail: 1 }); expect(document.activeElement).not.toHaveClass("ds-card-link"); }); }); describe("keyboard navigation", () => { it("gives the strip a single tab stop", () => { const { container } = renderNav(); const tabbable = [ ...container.querySelectorAll('moz-button[tabindex="0"]'), ]; expect(tabbable).toHaveLength(1); expect(tabbable[0]).toBe(chipButton(container, "Politics")); }); it("moves along the strip with arrow keys", () => { const { container } = renderNav(); const nav = container.querySelector(".topic-navigation"); fireEvent.keyDown(nav, { key: "ArrowRight" }); expect(document.activeElement).toBe(chipButton(container, "Science")); expect(chipButton(container, "Science")).toHaveAttribute("tabindex", "0"); expect(chipButton(container, "Politics")).toHaveAttribute( "tabindex", "-1" ); }); it("treats the More button as the last arrow stop", () => { containerWidth = 319; const { container } = renderNav(); const nav = container.querySelector(".topic-navigation"); fireEvent.keyDown(nav, { key: "End" }); expect(document.activeElement).toBe(moreButton(container)); }); }); });