Skip to content

Commit

Permalink
fix(Icon, SVGIconContainer): add generic type param (#6285)
Browse files Browse the repository at this point in the history
  • Loading branch information
adidahiya authored Jul 19, 2023
1 parent 89163bd commit da85370
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 91 deletions.
69 changes: 55 additions & 14 deletions packages/core/src/components/icon/icon.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,12 @@ The HTML element rendered by `<Icon>` can be customized with the `tagName` prop
(defaults to `span`), and additional props are passed to this element.

Data files in the __@blueprintjs/icons__ package provide SVG path information
for Blueprint's 300+ icons for 16px and 20px grids. The `icon` prop dictates
which SVG is rendered and `size` determines which pixel grid is used:
`size >= 20` will use the 20px grid and smaller icons will use the 16px
grid.
for Blueprint's 500+ icons for 16px and 20px grids. The `icon` prop specifies
which SVG is rendered and the `size` prop determines which pixel grid is used:
`size >= 20` will use the 20px grid and smaller icons will use the 16px grid.

If `title` is not provided to an Icon, `aria-hidden` will be set to true as
it will be assumed that the icon is decorative if not labeled.
If `title` is _not_ provided to an `<Icon>`, `aria-hidden` will be set to true as
it will be assumed that the icon is decorative since it is unlabeled.

```tsx
import { Icon, IconSize } from "@blueprintjs/core";
Expand All @@ -57,22 +56,64 @@ import { Icon, IconSize } from "@blueprintjs/core";
<Icon icon="add" onClick={this.handleAdd} onKeyDown={this.handleAddKeys} />
```

Custom sizes are supported. The following React component:
Custom sizes are supported. The following React element:

```tsx
<Icon icon="globe" size={30} />
```

...renders this HTML markup:

```html
<svg class="@ns-icon" data-icon="globe" width="30" height="30" viewBox="0 0 20 20">
<title>globe</title>
<path d="..."></path>
</svg>
```xml
<span class="@ns-icon @ns-icon-globe" aria-hidden="true">
<svg data-icon="globe" width="30" height="30" viewBox="0 0 20 20" role="img">
<path d="..."></path>
</svg>
</span>
```

@interface IconProps
@## Props interface

@interface DefaultIconProps

@## DOM attributes

The `<Icon>` component forwards extra HTML attributes to its root DOM element. By default,
the root element is a `<span>` wrapper around the icon `<svg>`. The tag name of this element
may be customized via the `tagName` prop as either:

- a custom HTML tag name (for example `<div>` instead of the default `<span>` wrapper), or
- `null`, which makes the component omit the wrapper element and only render the `<svg>` as its root element

By default, `<Icon>` supports a limited set of DOM attributes which are assignable to _all_ HTML and SVG
elements. In some cases, you may want to use more specific attributes which are only available on HTML elements
or SVG elements. The `<Icon>` component has a generic type which allows for this more advanced usage. You can
specify a type parameter on the component opening tag to (for example) set an HTML-only attribute:

```tsx
import { Icon } from "@blueprintjs/core";
import * as React from "react";

function Example() {
const [isDraggable, setIsDraggable] = React.useState();
// explicitly declare type of the root element so that we can set the "draggable" DOM attribute
return <Icon<HTMLSpanElement> icon="drag-handle-horizontal" draggable={isDraggable} />;
}
```

Another use case for this type parameter API may be to get the correct type definition for an event handler
on the root element when _omitting_ the icon wrapper element:

```tsx
import { Icon } from "@blueprintjs/core";
import * as React from "react";

function Example() {
const handleClick: React.MouseEventHandler<SVGSVGElement> = () => { /* ... */ };
// explicitly declare type of the root element so that we can narrow the type of the event handler
return <Icon<SVGSVGElement> icon="add" onClick={handleClick} tagName={null} />;
}
```

@## Static components

Expand All @@ -84,7 +125,7 @@ Note that some `<Icon>` props are not yet supported for these components, such a

@reactExample IconGeneratedComponentExample

@interface SVGIconProps
@interface DefaultSVGIconProps

@## CSS API

Expand Down
47 changes: 37 additions & 10 deletions packages/core/src/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import classNames from "classnames";
import * as React from "react";

import {
DefaultSVGIconProps,
IconName,
IconPaths,
Icons,
IconSize,
SVGIconAttributes,
SVGIconContainer,
SVGIconProps,
} from "@blueprintjs/icons";
Expand All @@ -32,7 +32,7 @@ import { Classes, DISPLAYNAME_PREFIX, IntentProps, MaybeElement, Props, removeNo
// re-export for convenience, since some users won't be importing from or have a direct dependency on the icons package
export { IconName, IconSize };

export interface IconProps extends IntentProps, Props, SVGIconProps, SVGIconAttributes {
export interface IconOwnProps {
/**
* Whether the component should automatically load icon contents using an async import.
*
Expand Down Expand Up @@ -69,12 +69,37 @@ export interface IconProps extends IntentProps, Props, SVGIconProps, SVGIconAttr
svgProps?: React.HTMLAttributes<SVGElement>;
}

// 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 `<Icon>` component which may be parameterized by its root element type.
*
* @see https://blueprintjs.com/docs/#core/components/icon.dom-attributes
*/
export type IconProps<T extends Element = Element> = IntentProps & Props & SVGIconProps<T> & IconOwnProps;

/**
* The default `<Icon>` props interface, equivalent to `IconProps` with its default type parameter.
* This is primarly exported for documentation purposes; users should reference `IconProps<T>` instead.
*/
export interface DefaultIconProps extends IntentProps, Props, DefaultSVGIconProps, IconOwnProps {
// empty interface for documentation purposes (documentalist handles this better than the IconProps<T> type alias)
}

// Type hack required to make forwardRef work with generic components. Note that this slows down TypeScript
// compilation, but it better than the alternative of globally augmenting "@types/react".
// see https://stackoverflow.com/a/73795494/7406866
interface GenericIcon extends React.FC<IconProps<Element>> {
<T extends Element = Element>(props: IconProps<T>): React.ReactElement | null;
}

/**
* Icon component.
*
* @see https://blueprintjs.com/docs/#core/components/icon
*/
export const Icon: React.FC<IconProps> = React.forwardRef<any, IconProps>((props, ref) => {
// eslint-disable-next-line prefer-arrow-callback
export const Icon: GenericIcon = React.forwardRef(function <T extends Element>(props: IconProps<T>, ref: React.Ref<T>) {
const { autoLoad, className, color, icon, intent, tagName, svgProps, title, htmlTitle, ...htmlProps } = props;

// Preserve Blueprint v4.x behavior: iconSize prop takes predecence, then size prop, then fall back to default value
Expand Down Expand Up @@ -148,22 +173,24 @@ export const Icon: React.FC<IconProps> = React.forwardRef<any, IconProps>((props
});
} else {
const pathElements = iconPaths.map((d, i) => <path d={d} key={i} fillRule="evenodd" />);
// 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 <any>.
// see https://github.com/microsoft/TypeScript/issues/24929, https://github.com/microsoft/TypeScript/issues/33014
return (
<SVGIconContainer
<SVGIconContainer<any>
children={pathElements}
// don't forward Classes.iconClass(icon) here, since the container will render that class
className={classNames(Classes.intentClass(intent), className)}
color={color}
htmlTitle={htmlTitle}
iconName={icon}
ref={ref}
size={size}
svgProps={svgProps}
tagName={tagName}
title={title}
htmlTitle={htmlTitle}
ref={ref}
svgProps={svgProps}
{...removeNonHTMLProps(htmlProps)}
>
{pathElements}
</SVGIconContainer>
/>
);
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export { Blockquote, Code, H1, H2, H3, H4, H5, H6, Label, OL, Pre, UL } from "./
export { HTMLSelect, HTMLSelectIconName, HTMLSelectProps } from "./html-select/htmlSelect";
export { HTMLTable, HTMLTableProps } from "./html-table/htmlTable";
export * from "./hotkeys";
export { Icon, IconName, IconProps, IconSize } from "./icon/icon";
export { DefaultIconProps, Icon, IconName, IconProps, IconSize } from "./icon/icon";
export { Menu, MenuProps } from "./menu/menu";
export { MenuDivider, MenuDividerProps } from "./menu/menuDivider";
export { MenuItem, MenuItemProps } from "./menu/menuItem";
Expand Down
18 changes: 15 additions & 3 deletions packages/core/test/icon/iconTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,24 @@ describe("<Icon>", () => {
});

it("supports mouse event handlers of type React.MouseEventHandler", () => {
const handleClick: React.MouseEventHandler = () => {
return;
};
const handleClick: React.MouseEventHandler = () => undefined;
mount(<Icon icon="add" onClick={handleClick} />);
});

it("accepts HTML attributes", () => {
mount(<Icon<HTMLSpanElement> icon="drag-handle-vertical" draggable={false} />);
});

it("accepts generic type param specifying the type of the root element", () => {
const handleClick: React.MouseEventHandler<HTMLSpanElement> = () => undefined;
mount(<Icon<HTMLSpanElement> icon="add" onClick={handleClick} />);
});

it("allows specifying the root element as <svg> when tagName={null}", () => {
const handleClick: React.MouseEventHandler<SVGSVGElement> = () => undefined;
mount(<Icon<SVGSVGElement> icon="add" onClick={handleClick} tagName={null} />);
});

/** Asserts that rendered icon has an SVG path. */
async function assertIconHasPath(icon: React.ReactElement<IconProps>, iconName: IconName) {
const wrapper = mount(icon);
Expand Down
2 changes: 1 addition & 1 deletion packages/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
export { IconSvgPaths16, IconSvgPaths20, getIconPaths } from "./allPaths";

export { Icons, IconLoaderOptions, IconPathsLoader } from "./iconLoader";
export { SVGIconAttributes, SVGIconProps } from "./svgIconProps";
export { DefaultSVGIconAttributes, DefaultSVGIconProps, SVGIconAttributes, SVGIconProps } from "./svgIconProps";
export { SVGIconContainer, SVGIconContainerProps } from "./svgIconContainer";
export { getIconContentString, IconCodepoints } from "./iconCodepoints";
export { IconName, IconNames } from "./iconNames";
Expand Down
120 changes: 67 additions & 53 deletions packages/icons/src/svgIconContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { IconSize } from "./iconTypes";
import { uniqueId } from "./jsUtils";
import type { SVGIconProps } from "./svgIconProps";

export interface SVGIconContainerProps extends Omit<SVGIconProps, "children"> {
export type SVGIconContainerProps<T extends Element> = Omit<SVGIconProps<T>, "children"> & {
/**
* Icon name.
*/
Expand All @@ -32,60 +32,74 @@ export interface SVGIconContainerProps extends Omit<SVGIconProps, "children"> {
* Icon contents, loaded via `IconLoader` and specified as `<path>` elements.
*/
children: JSX.Element | JSX.Element[];
};

// Type hack required to make forwardRef work with generic components. Note that this slows down TypeScript
// compilation, but it better than the alternative of globally augmenting "@types/react".
// see https://stackoverflow.com/a/73795494/7406866
interface GenericSVGIconContainer extends React.FC<SVGIconContainerProps<Element>> {
<T extends Element = Element>(props: SVGIconContainerProps<T>): React.ReactElement | null;
}

export const SVGIconContainer: React.FC<SVGIconContainerProps> = React.forwardRef<any, SVGIconContainerProps>(
(props, ref) => {
const {
children,
className,
color,
htmlTitle,
iconName,
size = IconSize.STANDARD,
svgProps,
tagName = "span",
title,
...htmlProps
} = props;
// eslint-disable-next-line prefer-arrow-callback
export const SVGIconContainer: GenericSVGIconContainer = React.forwardRef(function <T extends Element>(
props: SVGIconContainerProps<T>,
ref: React.Ref<T>,
) {
const {
children,
className,
color,
htmlTitle,
iconName,
size = IconSize.STANDARD,
svgProps,
tagName = "span",
title,
...htmlProps
} = props;

const isLarge = size >= IconSize.LARGE;
const pixelGridSize = isLarge ? IconSize.LARGE : IconSize.STANDARD;
const viewBox = `0 0 ${pixelGridSize} ${pixelGridSize}`;
const titleId = uniqueId("iconTitle");
const sharedSvgProps = {
"data-icon": iconName,
fill: color,
height: size,
role: "img",
viewBox,
width: size,
...svgProps,
};
const isLarge = size >= IconSize.LARGE;
const pixelGridSize = isLarge ? IconSize.LARGE : IconSize.STANDARD;
const viewBox = `0 0 ${pixelGridSize} ${pixelGridSize}`;
const titleId = uniqueId("iconTitle");
const sharedSvgProps = {
"data-icon": iconName,
fill: color,
height: size,
role: "img",
viewBox,
width: size,
...svgProps,
};

if (tagName === null) {
return (
<svg aria-labelledby={title ? titleId : undefined} ref={ref} {...sharedSvgProps} {...htmlProps}>
{title && <title id={titleId}>{title}</title>}
{children}
</svg>
);
} else {
return React.createElement(
tagName,
{
...htmlProps,
"aria-hidden": title ? undefined : true,
className: classNames(Classes.ICON, `${Classes.ICON}-${iconName}`, className),
ref,
title: htmlTitle,
},
<svg {...sharedSvgProps}>
{title && <title>{title}</title>}
{children}
</svg>,
);
}
},
);
if (tagName === null) {
return (
<svg
aria-labelledby={title ? titleId : undefined}
ref={ref as React.Ref<SVGSVGElement>}
{...sharedSvgProps}
{...htmlProps}
>
{title && <title id={titleId}>{title}</title>}
{children}
</svg>
);
} else {
return React.createElement(
tagName,
{
...htmlProps,
"aria-hidden": title ? undefined : true,
className: classNames(Classes.ICON, `${Classes.ICON}-${iconName}`, className),
ref,
title: htmlTitle,
},
<svg {...sharedSvgProps}>
{title && <title>{title}</title>}
{children}
</svg>,
);
}
});
SVGIconContainer.displayName = "Blueprint5.SVGIconContainer";
Loading

1 comment on commit da85370

@adidahiya
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix(Icon, SVGIconContainer): add generic type param (#6285)

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Please sign in to comment.