/* * Copyright 2016 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mount } from "enzyme"; import { GraphIcon, type IconName, Icons, IconSize } from "@blueprintjs/icons"; import { Add, Airplane, Calendar, Graph } from "@blueprintjs/icons/lib/cjs/generated/16px/paths"; import { afterEach, beforeAll, describe, expect, it, type MockInstance, vi } from "@blueprintjs/test-commons/vitest"; import { Classes, DISPLAYNAME_PREFIX, Intent } from "../../common"; import { Icon, type IconProps } from "./icon"; describe("", () => { let iconLoader: MockInstance; beforeAll(() => { vi.spyOn(Icons, "load").mockResolvedValue(undefined); // stub the dynamic icon loader with a synchronous, static one iconLoader = vi.spyOn(Icons, "getPaths").mockImplementation((name: string) => { switch (name) { case "add": return Add; case "airplane": return Airplane; case "calendar": return Calendar; case "graph": return Graph; default: return undefined; } }); }); afterEach(() => { iconLoader?.mockClear(); }); it("tagName dictates HTML tag", async () => { const wrapper = mount(); wrapper.update(); expect(wrapper.find("i").exists()).toBe(true); }); it("size=16 renders standard size", async () => assertIconSize(, IconSize.STANDARD)); it("size=20 renders large size", async () => assertIconSize(, IconSize.LARGE)); it("forwards size onto an element icon", async () => assertIconSize(} size={IconSize.LARGE} />, IconSize.LARGE)); it("forwards a custom size onto an element icon", async () => assertIconSize(} size={128} />, 128)); it("element icon's own size takes precedence over the size", async () => // non-regression: the element's explicit size wins over the forwarded one assertIconSize(} size={128} />, IconSize.STANDARD)); it("element icon without a size falls back to the default size", async () => assertIconSize(} />, IconSize.STANDARD)); it("renders intent class", async () => { const wrapper = mount(); expect(wrapper.find(`.${Classes.INTENT_DANGER}`).exists()).toBe(true); }); it.skip("renders icon name", async () => { assertIconHasPath(, "calendar"); }); it("renders icon without color", async () => { assertIconColor(); }); it("renders icon color", async () => { assertIconColor(, "red"); }); it("forwards color onto an element icon", async () => { assertIconColor(} color="red" />, "red"); }); it("element icon's own color takes precedence over the color", async () => // non-regression: the element's explicit color wins over the forwarded one assertIconColor(} color="red" />, "blue")); it("forwards DOM attributes onto a Blueprint element icon", async () => { const user = userEvent.setup(); const onClick = vi.fn(); render(} onClick={onClick} tabIndex={0} />); const icon = screen.getByLabelText("graph"); expect(icon.getAttribute("tabindex")).toBe("0"); await user.click(icon); expect(onClick).toHaveBeenCalledOnce(); }); // A non-Blueprint element used as an icon: it renders its own reflecting only the props it // receives, so the assertions below can prove that size/color were NOT forwarded onto it. function CustomIcon(elementProps: { className?: string; color?: string; size?: number }) { return ( ); } CustomIcon.displayName = "CustomIcon"; it("does not forward DOM attributes onto a non-Blueprint element icon", async () => { const user = userEvent.setup(); const onClick = vi.fn(); const { container } = render(} onClick={onClick} />); await user.click(container.querySelector("svg")!); expect(onClick).not.toHaveBeenCalled(); }); it("does not forward size/color onto a non-Blueprint element icon, but merges className and intent", () => { const wrapper = mount( } size={IconSize.LARGE} color="red" className="custom" intent={Intent.DANGER} />, ); wrapper.update(); const svg = wrapper.find("svg"); // gated: size/color are not forwarded onto a non-Blueprint element expect(svg.prop("width")).toBeUndefined(); expect(svg.prop("height")).toBeUndefined(); expect(svg.prop("fill")).toBeUndefined(); // className + intent class are still merged onto element icons of any type (issue #8080) expect(svg.hasClass("custom")).toBe(true); expect(svg.hasClass(Classes.INTENT_DANGER)).toBe(true); }); it("does not forward size/color onto a host-element icon, but merges className and intent", () => { const wrapper = mount( } size={IconSize.LARGE} color="red" className="custom" intent={Intent.DANGER} />, ); wrapper.update(); const article = wrapper.find("article"); expect(article.prop("size")).toBeUndefined(); expect(article.prop("color")).toBeUndefined(); expect(article.hasClass("custom")).toBe(true); expect(article.hasClass(Classes.INTENT_DANGER)).toBe(true); }); it("generated icon components carry the displayName prefix that size/color gating relies on", () => { // Guards against core's DISPLAYNAME_PREFIX and the @blueprintjs/icons generators drifting apart, // which would silently disable size/color forwarding onto element icons. expect(GraphIcon.displayName).toBeDefined(); expect(GraphIcon.displayName!.startsWith(`${DISPLAYNAME_PREFIX}.Icon.`)).toBe(true); }); it("unknown icon name renders blank icon", async () => { const wrapper = mount(); wrapper.update(); expect(wrapper.find("path")).toHaveLength(0); }); it("prefixed icon renders blank icon", async () => { const wrapper = mount(); wrapper.update(); expect(wrapper.find("path")).toHaveLength(0); }); it("icon element passes through unchanged", async () => { // NOTE: This is supported to simplify usage of this component in other // Blueprint components which accept `icon?: IconName | React.JSX.Element`. const onClick = () => true; const wrapper = mount(} />); wrapper.update(); expect(wrapper.childAt(0).is("article")).toBe(true); expect(wrapper.find("article").prop("onClick")).toBe(onClick); // a forwarded `size` should not leak onto a non-icon element when none is provided expect(wrapper.find("article").prop("size")).toBeUndefined(); }); it("icon=undefined renders nothing", async () => { const wrapper = mount(); wrapper.update(); expect(wrapper.isEmptyRender()).toBe(true); }); it("title sets content of element", async () => { const wrapper = mount(); wrapper.update(); expect(wrapper.find("title").text()).toBe("bird"); }); it("does not add desc if title is not provided", () => { const icon = mount(); expect(icon.find("desc")).toHaveLength(0); }); it("applies aria-hidden=true if title is not defined", () => { const icon = mount(); expect(icon.find(`.${Classes.ICON}`).hostNodes().prop("aria-hidden")).toBe(true); }); it("supports mouse event handlers of type React.MouseEventHandler", () => { const handleClick: React.MouseEventHandler = () => undefined; mount(); }); it("accepts HTML attributes", () => { mount( icon="drag-handle-vertical" draggable={false} />); }); it("accepts generic type param specifying the type of the root element", () => { const handleClick: React.MouseEventHandler = () => undefined; mount( icon="add" onClick={handleClick} />); }); it("allows specifying the root element as when tagName={null}", () => { const handleClick: React.MouseEventHandler = () => undefined; const wrapper = mount( icon="add" onClick={handleClick} tagName={null} />); expect(wrapper.find("span").exists()).toBe(false); }); /** Asserts that rendered icon has an SVG path. */ async function assertIconHasPath(icon: React.ReactElement, iconName: IconName) { const wrapper = mount(icon); wrapper.update(); expect(wrapper.text()).toBe(iconName); expect(wrapper.find("path").length, "should find at least one path element").toBeGreaterThan(0); } /** Asserts that rendered icon has width/height equal to size. */ async function assertIconSize(icon: React.ReactElement, size: number) { const wrapper = mount(icon); wrapper.update(); const svg = wrapper.find("svg"); expect(svg.prop("width")).toBe(size); expect(svg.prop("height")).toBe(size); } /** Asserts that rendered icon has color equal to color. */ async function assertIconColor(icon: React.ReactElement, color?: string) { const wrapper = mount(icon); wrapper.update(); const svg = wrapper.find("svg"); expect(svg.prop("fill")).toEqual(color); } });