2
0
mirror of https://github.com/iconify/iconify.git synced 2024-09-28 13:09:07 +00:00

chore: rewrite react component

This commit is contained in:
Vjacheslav Trushkin 2024-04-27 22:17:17 +03:00
parent 59bf4012b5
commit e244ddea3d
8 changed files with 181 additions and 316 deletions

View File

@ -12,7 +12,11 @@ export function InlineDemo() {
id="inline-demo-block-offline"
icon="experiment2"
/>
<FullIcon id="inline-demo-block-full" icon="experiment2" />
<FullIcon
id="inline-demo-block-full"
icon="experiment2"
ssr={true}
/>
</div>
<div>
Inline icon (behaving line text / icon font):
@ -27,6 +31,7 @@ export function InlineDemo() {
icon="experiment2"
inline={true}
mode="style"
ssr={true}
/>
</div>
</section>

View File

@ -9,8 +9,6 @@ const commands = [];
const compile = {
// Compile TypeScript src -> lib
lib: true,
// Fix types for icon components
cleanup: true,
// Generate bundle from compiled files lib -> dist
dist: true,
// Generate TypeScript definitions in dist

View File

@ -1,48 +0,0 @@
const fs = require('fs');
const reactSearch = "import React from 'react';";
const reactImport = 'React';
function fixTypes(filename) {
let content = fs.readFileSync(__dirname + filename, 'utf8');
if (content.indexOf(reactSearch) === -1) {
throw new Error(`Missing React import in ${filename}`);
}
let replaced = false;
['Icon', 'InlineIcon'].forEach((name) => {
const searchStart = `export declare const ${name}: ${reactImport}.ForwardRefExoticComponent`;
const searchEnd = `& ${reactImport}.RefAttributes<IconRef>>;`;
const replace = `export declare const ${name}: (props: IconProps) => ${reactImport}.ReactElement<IconProps, string | ${reactImport}.JSXElementConstructor<any>>;`;
if (content.indexOf(replace) !== -1) {
// Already replaced
return;
}
const parts = content.split(searchStart);
if (parts.length !== 2) {
throw new Error(
`Error replacing types for component ${name} in ${filename}`
);
}
const contentStart = parts.shift();
const parts2 = parts.shift().split(searchEnd);
if (parts2.length < 2) {
throw new Error(
`Error replacing types for component ${name} in ${filename}`
);
}
parts2.shift();
content = contentStart + replace + parts2.join(searchEnd);
replaced = true;
});
if (replaced) {
fs.writeFileSync(__dirname + filename, content, 'utf8');
console.log(`Fixed component types in ${filename}`);
}
}
fixTypes('/lib/iconify.d.ts');
fixTypes('/lib/offline.d.ts');

View File

@ -2,7 +2,11 @@
"name": "@iconify/react",
"description": "Iconify icon component for React.",
"author": "Vjacheslav Trushkin",
"version": "4.1.1",
"version": "5.0.0-beta.1",
"publishConfig": {
"access": "public",
"tag": "next"
},
"license": "MIT",
"bugs": "https://github.com/iconify/iconify/issues",
"homepage": "https://iconify.design/",
@ -20,7 +24,6 @@
"build:dist": "rollup -c rollup.config.mjs",
"prebuild:api": "api-extractor run --local --verbose --config api-extractor.offline.json",
"build:api": "api-extractor run --local --verbose --config api-extractor.iconify.json",
"build:cleanup": "node cleanup",
"test": "jest --runInBand"
},
"main": "dist/iconify.js",

View File

@ -1,11 +1,14 @@
'use client';
import React from 'react';
import {
useEffect,
useState,
forwardRef,
createElement,
type Ref,
} from 'react';
import type { IconifyJSON, IconifyIcon } from '@iconify/types';
// Core
import type { IconifyIconName } from '@iconify/utils/lib/icon/name';
import { stringToIcon } from '@iconify/utils/lib/icon/name';
import type { IconifyIconSize } from '@iconify/utils/lib/customisations/defaults';
import type { IconifyStorageFunctions } from '@iconify/core/lib/storage/functions';
import {
@ -73,7 +76,7 @@ import type {
IconifyIconProps,
IconifyRenderMode,
IconProps,
IconRef,
IconElement,
} from './props';
// Render SVG
@ -218,240 +221,129 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') {
* Component
*/
interface InternalIconProps extends IconProps {
_ref?: IconRef;
_inline: boolean;
_ref?: Ref<IconElement> | null;
}
interface IconComponentData {
data: IconifyIcon;
classes?: string[];
}
interface IconComponentState {
icon: IconComponentData | null;
}
interface ComponentAbortData {
function IconComponent(props: InternalIconProps): JSX.Element {
interface State {
// Currently rendered icon
name: string;
abort: IconifyIconLoaderAbort;
}
class IconComponent extends React.Component<
InternalIconProps,
IconComponentState
> {
protected _icon: string;
protected _loading: ComponentAbortData | null;
constructor(props: InternalIconProps) {
super(props);
this.state = {
// Render placeholder before component is mounted
icon: null,
};
// Icon data, null if missing
data?: IconifyIcon | null;
}
const [mounted, setMounted] = useState(!!props.ssr);
/**
* Abort loading icon
*/
_abortLoading() {
if (this._loading) {
this._loading.abort();
this._loading = null;
interface AbortState {
callback?: IconifyIconLoaderAbort;
}
}
/**
* Update state
*/
_setData(icon: IconComponentData | null) {
if (this.state.icon !== icon) {
this.setState({
icon,
const [abort, setAbort] = useState<AbortState>({});
const [state, setState] = useState<State>({
name: '',
});
// Cancel loading
function cleanup() {
const callback = abort.callback;
if (callback) {
callback();
setAbort({});
}
}
/**
* Check if icon should be loaded
*/
_checkIcon(changed: boolean) {
const state = this.state;
const icon = this.props.icon;
// Icon is an object
if (
typeof icon === 'object' &&
icon !== null &&
typeof icon.body === 'string'
) {
// Stop loading
this._icon = '';
this._abortLoading();
if (changed || state.icon === null) {
// Set data if it was changed
this._setData({
data: icon,
// Update state
function updateState() {
const name = props.icon;
if (typeof name === 'object') {
// Icon as object
cleanup();
setState({
name: '',
data: name,
});
}
return;
}
// Invalid icon?
let iconName: IconifyIconName | null;
if (
typeof icon !== 'string' ||
(iconName = stringToIcon(icon, false, true)) === null
) {
this._abortLoading();
this._setData(null);
return;
}
// Load icon
const data = getIconData(iconName);
if (!data) {
// Icon data is not available
if (!this._loading || this._loading.name !== icon) {
// New icon to load
this._abortLoading();
this._icon = '';
this._setData(null);
if (data !== null) {
// Icon was not loaded
this._loading = {
name: icon,
abort: loadIcons(
[iconName],
this._checkIcon.bind(this, false)
),
};
}
}
return;
}
// Icon data is available
if (this._icon !== icon || state.icon === null) {
// New icon or icon has been loaded
this._abortLoading();
this._icon = icon;
// Add classes
const classes: string[] = ['iconify'];
if (iconName.prefix !== '') {
classes.push('iconify--' + iconName.prefix);
}
if (iconName.provider !== '') {
classes.push('iconify--' + iconName.provider);
}
// Set data
this._setData({
// New icon or got icon data
const data = getIconData(name);
if (state.name !== name || data !== state.data) {
cleanup();
setState({
name,
data,
classes,
});
if (this.props.onLoad) {
this.props.onLoad(icon);
if (data === undefined) {
// Load icon, update state when done
const callback = loadIcons([name], updateState);
setAbort({
callback,
});
} else if (data) {
// Icon data is available: trigger onLoad callback if present
props.onLoad?.(name);
}
}
}
/**
* Component mounted
*/
componentDidMount() {
this._checkIcon(false);
}
// Mounted state, cleanup for loader
useEffect(() => {
setMounted(true);
updateState();
return cleanup;
}, []);
/**
* Component updated
*/
componentDidUpdate(oldProps) {
if (oldProps.icon !== this.props.icon) {
this._checkIcon(true);
}
}
/**
* Abort loading
*/
componentWillUnmount() {
this._abortLoading();
}
/**
* Render
*/
render() {
const props = this.props;
const icon = this.state.icon;
if (icon === null) {
// Render placeholder
return props.children
? (props.children as JSX.Element)
: React.createElement('span', {});
}
// Add classes
let newProps = props;
if (icon.classes) {
newProps = {
...props,
className:
(typeof props.className === 'string'
? props.className + ' '
: '') + icon.classes.join(' '),
};
// Icon changed
useEffect(() => {
if (mounted) {
updateState();
}
}, [props.icon]);
// Render icon
const { name, data } = state;
if (!data) {
return props.children
? (props.children as JSX.Element)
: createElement('span', {});
}
return render(
{
...defaultIconProps,
...icon.data,
...data,
},
newProps,
props._inline,
props._ref
props,
name
);
}
}
// Component type
type IconComponentType = (props: IconProps) => JSX.Element;
/**
* Block icon
*
* @param props - Component properties
*/
export const Icon = React.forwardRef<IconRef, IconProps>(function Icon(
props,
ref
) {
const newProps = {
export const Icon = forwardRef<IconElement, IconProps>((props, ref) =>
IconComponent({
...props,
_ref: ref,
_inline: false,
} as InternalIconProps;
return React.createElement(IconComponent, newProps);
});
})
) as IconComponentType;
/**
* Inline icon (has negative verticalAlign that makes it behave like icon font)
*
* @param props - Component properties
*/
export const InlineIcon = React.forwardRef<IconRef, IconProps>(
function InlineIcon(props, ref) {
const newProps = {
export const InlineIcon = forwardRef<IconElement, IconProps>((props, ref) =>
IconComponent({
inline: true,
...props,
_ref: ref,
_inline: true,
} as InternalIconProps;
return React.createElement(IconComponent, newProps);
}
);
})
) as IconComponentType;
/**
* Internal API

View File

@ -1,6 +1,4 @@
'use client';
import React from 'react';
import { forwardRef, createElement, type Ref } from 'react';
import type { IconifyIcon, IconifyJSON } from '@iconify/types';
import type { IconifyIconSize } from '@iconify/utils/lib/customisations/defaults';
import { defaultIconProps } from '@iconify/utils/lib/icon/defaults';
@ -11,7 +9,7 @@ import type {
IconifyIconProps,
IconifyRenderMode,
IconProps,
IconRef,
IconElement,
} from './props';
import { render } from './render';
@ -31,67 +29,59 @@ export { IconifyIcon, IconifyJSON, IconifyIconSize, IconifyRenderMode };
const storage: Record<string, IconifyIcon> = Object.create(null);
/**
* Generate icon
* Component
*/
function component(
props: IconProps,
inline: boolean,
ref?: React.ForwardedRef<IconRef>
): JSX.Element {
// Split properties
const propsIcon = props.icon;
const icon =
typeof propsIcon === 'string'
? storage[propsIcon]
: typeof propsIcon === 'object'
? propsIcon
: null;
// Validate icon object
if (
icon === null ||
typeof icon !== 'object' ||
typeof icon.body !== 'string'
) {
return props.children
? (props.children as JSX.Element)
: React.createElement('span', {});
interface InternalIconProps extends IconProps {
_ref?: Ref<IconElement> | null;
}
function IconComponent(props: InternalIconProps): JSX.Element {
const icon = props.icon;
const data = typeof icon === 'string' ? storage[icon] : icon;
if (!data) {
return props.children
? (props.children as JSX.Element)
: createElement('span', {});
}
// Valid icon: render it
return render(
{
...defaultIconProps,
...icon,
...data,
},
props,
inline,
ref as IconRef
typeof icon === 'string' ? icon : undefined
);
}
// Component type
type IconComponentType = (props: IconProps) => JSX.Element;
/**
* Block icon
*
* @param props - Component properties
*/
export const Icon = React.forwardRef<IconRef, IconProps>(function Icon(
props,
ref
) {
return component(props, false, ref);
});
export const Icon = forwardRef<IconElement, IconProps>((props, ref) =>
IconComponent({
...props,
_ref: ref,
})
) as IconComponentType;
/**
* Inline icon (has negative verticalAlign that makes it behave like icon font)
*
* @param props - Component properties
*/
export const InlineIcon = React.forwardRef<IconRef, IconProps>(
function InlineIcon(props, ref) {
return component(props, true, ref);
}
);
export const InlineIcon = forwardRef<IconElement, IconProps>((props, ref) =>
IconComponent({
inline: true,
...props,
_ref: ref,
})
) as IconComponentType;
/**
* Add icon to storage, allowing to call it by name

View File

@ -53,6 +53,9 @@ export interface IconifyIconProps extends IconifyIconCustomisations {
// Unique id, used as base for ids for shapes. Use it to get consistent ids for server side rendering
id?: string;
// If true, icon will be rendered without waiting for component to mount, such as when rendering on server side
ssr?: boolean;
// Callback to call when icon data has been loaded. Used only for icons loaded from API
onLoad?: IconifyIconOnLoad;
}
@ -62,13 +65,12 @@ export interface IconifyIconProps extends IconifyIconCustomisations {
*/
type IconifyElementProps = SVGProps<SVGSVGElement>;
export type IconRef = RefAttributes<SVGSVGElement>;
export interface ReactRefProp {
ref?: IconRef;
}
/**
* Reference for SVG element
*/
export type IconElement = SVGSVGElement | HTMLSpanElement;
/**
* Mix of icon properties and SVGSVGElement properties
*/
export type IconProps = IconifyElementProps & IconifyIconProps & ReactRefProp;
export type IconProps = IconifyElementProps & IconifyIconProps;

View File

@ -1,5 +1,5 @@
import React from 'react';
import type { SVGProps } from 'react';
import { createElement } from 'react';
import type { SVGProps, CSSProperties } from 'react';
import type { IconifyIcon } from '@iconify/types';
import { mergeCustomisations } from '@iconify/utils/lib/customisations/merge';
import { flipFromString } from '@iconify/utils/lib/customisations/flip';
@ -13,9 +13,9 @@ import type {
IconifyIconCustomisations,
IconifyRenderMode,
IconProps,
IconRef,
} from './props';
import { defaultExtendedIconCustomisations } from './props';
import { stringToIcon } from '@iconify/utils/lib/icon/name';
/**
* Default SVG attributes
@ -85,14 +85,11 @@ export const render = (
// Partial properties
props: IconProps,
// True if icon should have vertical-align added
inline: boolean,
// Optional reference for SVG/SPAN, extracted by React.forwardRef()
ref?: IconRef
// Icon name
name?: string
): JSX.Element => {
// Get default properties
const defaultProps = inline
const defaultProps = props.inline
? inlineDefaults
: defaultExtendedIconCustomisations;
@ -103,14 +100,29 @@ export const render = (
const mode: IconifyRenderMode = props.mode || 'svg';
// Create style
const style: React.CSSProperties = {};
const style: CSSProperties = {};
const customStyle = props.style || {};
// Create SVG component properties
const componentProps = {
...(mode === 'svg' ? svgDefaults : {}),
ref,
};
if (name) {
const iconName = stringToIcon(name, false, true);
if (iconName) {
const classNames: string[] = ['iconify'];
const props: (keyof typeof iconName)[] = [
'provider',
'prefix',
] as const;
for (const prop of props) {
if (iconName[prop]) {
classNames.push('iconify--' + iconName[prop]);
}
}
componentProps.className = classNames.join(' ');
}
}
// Get element properties
for (let key in props) {
@ -125,8 +137,19 @@ export const render = (
case 'children':
case 'onLoad':
case 'mode':
case 'ssr':
break;
// Forward ref
case '_ref':
case '_inline':
componentProps.ref = value;
break;
// Merge class names
case 'className':
componentProps[key] =
(componentProps[key] ? componentProps[key] + ' ' : '') +
value;
break;
// Boolean attributes
@ -210,7 +233,7 @@ export const render = (
)
),
};
return React.createElement('svg', componentProps);
return createElement('svg', componentProps);
}
// Render <span> with style
@ -235,7 +258,7 @@ export const render = (
...commonProps,
...(useMask ? monotoneProps : coloredProps),
...customStyle,
} as React.CSSProperties;
} as CSSProperties;
return React.createElement('span', componentProps);
return createElement('span', componentProps);
};