/* * Copyright 2017 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 classNames from "classnames"; import { cloneElement, createElement, forwardRef, isValidElement, useEffect, useState } from "react"; import { type DefaultSVGIconProps, type IconName, type IconPaths, Icons, IconSize, SVGIconContainer, type SVGIconProps, } from "@blueprintjs/icons"; import { Classes, DISPLAYNAME_PREFIX, type IntentProps, type MaybeElement, type Props, removeNonHTMLProps, } from "../../common"; import { isBlueprintIconElement } from "../../common/utils"; // re-export for convenience, since some users won't be importing from or have a direct dependency on the icons package export { type IconName, IconSize }; export interface IconOwnProps { /** * Whether the component should automatically load icon contents using an async import. * * @default true */ autoLoad?: boolean; /** * Name of a Blueprint UI icon, or an icon element, to render. This prop is * required because it determines the content of the component, but it can * be explicitly set to falsy values to render nothing. * * - If `null` or `undefined` or `false`, this component will render nothing. * - If given an `IconName` (a string literal union of all icon names), that * icon will be rendered as an `` with `` tags. Unknown strings * will render a blank icon to occupy space. * - If given a `React.JSX.Element`, that element is cloned with the * parent-provided `className` and intent class merged onto its root. If the * element is a Blueprint icon component (from `@blueprintjs/icons`), DOM * attributes and the `color` and `size` props are also forwarded onto it, * with the element's own `color`/`size` taking precedence; for any other * element type they are not forwarded. Other props on this component are * ignored. This type is supported to simplify icon support in other * Blueprint components. As a consumer, you should avoid using * `}` directly; simply render `` instead. */ icon: IconName | MaybeElement; /** Props to apply to the `SVG` element */ svgProps?: React.HTMLAttributes; } // N.B. the following inteface is defined as a type alias instead of an interface due to a TypeScript limitation // where interfaces cannot extend conditionally-defined union types. /** * Generic interface for the `` component which may be parameterized by its root element type. * * @see https://blueprintjs.com/docs/#core/components/icon.dom-attributes */ export type IconProps = IntentProps & Props & SVGIconProps & IconOwnProps; /** * The default `` props interface, equivalent to `IconProps` with its default type parameter. * This is primarly exported for documentation purposes; users should reference `IconProps` instead. */ export interface DefaultIconProps extends IntentProps, Props, DefaultSVGIconProps, IconOwnProps { // empty interface for documentation purposes (documentalist handles this better than the IconProps type alias) } /** * Generic icon component type. This is essentially a type hack required to make forwardRef work with generic * components. Note that this slows down TypeScript compilation, but it's better than the alternative of globally * augmenting "@types/react". * * @see https://stackoverflow.com/a/73795494/7406866 */ export interface IconComponent extends React.FC> { (props: IconProps): React.ReactNode; } /** * Icon component. * * @see https://blueprintjs.com/docs/#core/components/icon */ export const Icon: IconComponent = forwardRef((props: IconProps, ref: React.Ref) => { const { autoLoad = true, className, color, icon, intent, tagName = "span", svgProps, title, htmlTitle, ...htmlProps } = props; const size = props.size ?? IconSize.STANDARD; const [iconPaths, setIconPaths] = useState(() => typeof icon === "string" ? Icons.getPaths(icon, size) : undefined, ); useEffect(() => { let shouldCancelIconLoading = false; if (typeof icon === "string") { // The icon may have been loaded already, in which case we can simply grab it. // N.B. when `autoLoad={true}`, we can't rely on simply calling Icons.load() here to re-load an icon module // which has already been loaded & cached, since it may have been loaded with special loading options which // this component knows nothing about. const loadedIconPaths = Icons.getPaths(icon, size); if (loadedIconPaths !== undefined) { setIconPaths(loadedIconPaths); } else if (autoLoad) { Icons.load(icon, size) .then(() => { // if this effect expired by the time icon loaded, then don't set state if (!shouldCancelIconLoading) { setIconPaths(Icons.getPaths(icon, size)); } }) .catch(reason => { console.error(`[Blueprint] Icon '${icon}' (${size}px) could not be loaded.`, reason); }); } else { console.error( `[Blueprint] Icon '${icon}' (${size}px) is not loaded yet and autoLoad={false}, did you call Icons.load('${icon}', ${size})?`, ); } } return () => { shouldCancelIconLoading = true; }; }, [autoLoad, icon, size]); if (icon == null || typeof icon === "boolean") { return null; } else if (typeof icon !== "string") { if (isValidElement>(icon)) { // `className` + intent class are merged onto every element icon const mergedClassName = classNames(icon.props.className, className, Classes.intentClass(intent)); // DOM attributes and `size`/`color` are forwarded only onto recognized Blueprint icon components, // which accept them; forwarding onto an arbitrary element could inject props it does not understand. // The element's own `size`/`color` win, and a key is omitted entirely when its resolved value is // nullish, so we never write `size`/`color` as `undefined`. if (isBlueprintIconElement(icon)) { const iconElementProps: SVGIconProps = { ...removeNonHTMLProps(htmlProps), className: mergedClassName, }; const resolvedSize = icon.props.size ?? props.size; if (resolvedSize != null) { iconElementProps.size = resolvedSize; } const resolvedColor = icon.props.color ?? color; if (resolvedColor != null) { iconElementProps.color = resolvedColor; } return cloneElement(icon, iconElementProps); } return cloneElement(icon, { className: mergedClassName }); } return icon; } if (iconPaths == null) { // fall back to icon font if unloaded or unable to load SVG implementation const sizeClass = size === IconSize.STANDARD ? Classes.ICON_STANDARD : size === IconSize.LARGE ? Classes.ICON_LARGE : undefined; return createElement(tagName || "span", { "aria-hidden": title ? undefined : true, ...removeNonHTMLProps(htmlProps), className: classNames( Classes.ICON, sizeClass, Classes.iconClass(icon), Classes.intentClass(intent), className, ), "data-icon": icon, ref, title: htmlTitle, }); } else { const pathElements = iconPaths.map((d, i) => ); // HACKHACK: there is no good way to narrow the type of SVGIconContainerProps here because of the use // of a conditional type within the type union that defines that interface. So we cast to . // see https://github.com/microsoft/TypeScript/issues/24929, https://github.com/microsoft/TypeScript/issues/33014 return ( children={pathElements} // don't forward `Classes.ICON` or `Classes.iconClass(icon)` here, since the container will render those classes className={classNames(Classes.intentClass(intent), className)} color={color} htmlTitle={htmlTitle} iconName={icon} ref={ref} size={size} svgProps={svgProps} tagName={tagName} title={title} {...removeNonHTMLProps(htmlProps)} /> ); } }); Icon.displayName = `${DISPLAYNAME_PREFIX}.Icon`;