import { act, cleanup, fireEvent, render, waitFor, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import SwatchGroup, { type SwatchData } from "./SwatchGroup"; import { TOUCH_TOOLTIP_DURATION_MS } from "./Tooltip"; /** * Device regression (iPhone Safari): touch has no hover and a tap is not * focus-visible, so swatch names were never shown on phones and color/image * swatches carried no visible name at all. A tap now reveals the tooltip * briefly and it always dismisses itself, so nothing stays stuck. */ const swatches: Record = { red: { type: "color", color: "#ff0000", label: "Red" }, blue: { type: "color", color: "#0000ff", label: "Blue" }, }; const originalInnerWidth = window.innerWidth; function setViewportWidth(width: number) { Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: width, }); } function createSelect(): HTMLSelectElement { const select = document.createElement("select"); select.name = "attribute_pa_color"; for (const value of ["", "red", "blue"]) { const option = document.createElement("option"); option.value = value; option.textContent = value || "Choose"; select.append(option); } document.body.append(select); return select; } async function renderGroup(settings: Record = {}) { const result = render( , ); await waitFor(() => expect(result.container.querySelectorAll('[role="radio"]')).toHaveLength(2), ); return result; } const radioFor = (container: HTMLElement, value: string): HTMLElement => container.querySelector(`[data-b3-wvs-radio-value="${value}"]`)!; const tooltip = () => document.querySelector('[role="tooltip"]'); // jsdom has no PointerEvent, so fireEvent.pointerDown(el, { pointerType }) // drops the pointer type. Define it on the dispatched event instead. function pointer( element: Element, type: "pointerdown" | "pointerup" | "pointercancel" | "pointerover", pointerType: string, ) { const event = new MouseEvent(type, { bubbles: true, cancelable: true }); Object.defineProperty(event, "pointerType", { value: pointerType }); fireEvent(element, event); } function tap(element: HTMLElement) { pointer(element, "pointerdown", "touch"); pointer(element, "pointerup", "touch"); fireEvent.click(element, { detail: 1 }); } beforeEach(() => { setViewportWidth(430); // iPhone: no hover-capable fine pointer. vi.stubGlobal( "matchMedia", vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), ); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setViewportWidth(originalInnerWidth); cleanup(); document.body.innerHTML = ""; }); describe("tap-revealed swatch tooltips", () => { it("shows the swatch name on tap, describes the radio, then dismisses itself", async () => { const { container } = await renderGroup(); vi.useFakeTimers(); const radio = radioFor(container, "red"); act(() => tap(radio)); expect(tooltip()?.textContent).toContain("Red"); expect(radio.getAttribute("aria-describedby")).toBe(tooltip()!.id); act(() => { vi.advanceTimersByTime(TOUCH_TOOLTIP_DURATION_MS); }); expect(tooltip()).toBeNull(); expect(radio.hasAttribute("aria-describedby")).toBe(false); }); it("survives the non-visible focus that follows a tap", async () => { const { container } = await renderGroup(); const radio = radioFor(container, "red"); act(() => tap(radio)); act(() => { radio.focus(); }); expect(tooltip()?.textContent).toContain("Red"); }); it("closes on a tap elsewhere and moves to the newly tapped swatch", async () => { const { container } = await renderGroup(); act(() => tap(radioFor(container, "red"))); expect(tooltip()?.textContent).toContain("Red"); act(() => { pointer(document.body, "pointerdown", "touch"); }); expect(tooltip()).toBeNull(); act(() => tap(radioFor(container, "blue"))); expect(tooltip()?.textContent).toContain("Blue"); expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1); }); it("closes when the page scrolls", async () => { const { container } = await renderGroup(); act(() => tap(radioFor(container, "red"))); expect(tooltip()).not.toBeNull(); act(() => { window.dispatchEvent(new Event("scroll")); }); expect(tooltip()).toBeNull(); }); it("does not open when the touch became a scroll", async () => { const { container } = await renderGroup(); const radio = radioFor(container, "red"); act(() => { pointer(radio, "pointerdown", "touch"); pointer(radio, "pointercancel", "touch"); fireEvent.click(radio, { detail: 1 }); }); expect(tooltip()).toBeNull(); }); it("ignores touch pointers for hover and keyboard-synthesized clicks", async () => { const { container } = await renderGroup(); const radio = radioFor(container, "red"); act(() => { // React derives pointerenter from pointerover. pointer(radio, "pointerover", "touch"); }); expect(tooltip()).toBeNull(); act(() => { pointer(radio, "pointerdown", "touch"); fireEvent.click(radio, { detail: 0 }); }); expect(tooltip()).toBeNull(); }); it("stays off when tooltips are disabled on mobile", async () => { const { container } = await renderGroup({ disable_tooltips_on_mobile: true, mobile_cutoff: 768, }); act(() => tap(radioFor(container, "red"))); expect(tooltip()).toBeNull(); }); it("does not open from a mouse click", async () => { const { container } = await renderGroup(); const radio = radioFor(container, "red"); act(() => { pointer(radio, "pointerdown", "mouse"); fireEvent.click(radio, { detail: 1 }); }); expect(tooltip()).toBeNull(); }); });