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

View File

@ -9,8 +9,6 @@ const commands = [];
const compile = { const compile = {
// Compile TypeScript src -> lib // Compile TypeScript src -> lib
lib: true, lib: true,
// Fix types for icon components
cleanup: true,
// Generate bundle from compiled files lib -> dist // Generate bundle from compiled files lib -> dist
dist: true, dist: true,
// Generate TypeScript definitions in dist // 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", "name": "@iconify/react",
"description": "Iconify icon component for React.", "description": "Iconify icon component for React.",
"author": "Vjacheslav Trushkin", "author": "Vjacheslav Trushkin",
"version": "4.1.1", "version": "5.0.0-beta.1",
"publishConfig": {
"access": "public",
"tag": "next"
},
"license": "MIT", "license": "MIT",
"bugs": "https://github.com/iconify/iconify/issues", "bugs": "https://github.com/iconify/iconify/issues",
"homepage": "https://iconify.design/", "homepage": "https://iconify.design/",
@ -20,7 +24,6 @@
"build:dist": "rollup -c rollup.config.mjs", "build:dist": "rollup -c rollup.config.mjs",
"prebuild:api": "api-extractor run --local --verbose --config api-extractor.offline.json", "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:api": "api-extractor run --local --verbose --config api-extractor.iconify.json",
"build:cleanup": "node cleanup",
"test": "jest --runInBand" "test": "jest --runInBand"
}, },
"main": "dist/iconify.js", "main": "dist/iconify.js",

View File

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

View File

@ -1,6 +1,4 @@
'use client'; import { forwardRef, createElement, type Ref } from 'react';
import React from 'react';
import type { IconifyIcon, IconifyJSON } from '@iconify/types'; import type { IconifyIcon, IconifyJSON } from '@iconify/types';
import type { IconifyIconSize } from '@iconify/utils/lib/customisations/defaults'; import type { IconifyIconSize } from '@iconify/utils/lib/customisations/defaults';
import { defaultIconProps } from '@iconify/utils/lib/icon/defaults'; import { defaultIconProps } from '@iconify/utils/lib/icon/defaults';
@ -11,7 +9,7 @@ import type {
IconifyIconProps, IconifyIconProps,
IconifyRenderMode, IconifyRenderMode,
IconProps, IconProps,
IconRef, IconElement,
} from './props'; } from './props';
import { render } from './render'; import { render } from './render';
@ -31,67 +29,59 @@ export { IconifyIcon, IconifyJSON, IconifyIconSize, IconifyRenderMode };
const storage: Record<string, IconifyIcon> = Object.create(null); const storage: Record<string, IconifyIcon> = Object.create(null);
/** /**
* Generate icon * Component
*/ */
function component( interface InternalIconProps extends IconProps {
props: IconProps, _ref?: Ref<IconElement> | null;
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 function IconComponent(props: InternalIconProps): JSX.Element {
if ( const icon = props.icon;
icon === null || const data = typeof icon === 'string' ? storage[icon] : icon;
typeof icon !== 'object' ||
typeof icon.body !== 'string' if (!data) {
) {
return props.children return props.children
? (props.children as JSX.Element) ? (props.children as JSX.Element)
: React.createElement('span', {}); : createElement('span', {});
} }
// Valid icon: render it
return render( return render(
{ {
...defaultIconProps, ...defaultIconProps,
...icon, ...data,
}, },
props, props,
inline, typeof icon === 'string' ? icon : undefined
ref as IconRef
); );
} }
// Component type
type IconComponentType = (props: IconProps) => JSX.Element;
/** /**
* Block icon * Block icon
* *
* @param props - Component properties * @param props - Component properties
*/ */
export const Icon = React.forwardRef<IconRef, IconProps>(function Icon( export const Icon = forwardRef<IconElement, IconProps>((props, ref) =>
props, IconComponent({
ref ...props,
) { _ref: ref,
return component(props, false, ref); })
}); ) as IconComponentType;
/** /**
* Inline icon (has negative verticalAlign that makes it behave like icon font) * Inline icon (has negative verticalAlign that makes it behave like icon font)
* *
* @param props - Component properties * @param props - Component properties
*/ */
export const InlineIcon = React.forwardRef<IconRef, IconProps>( export const InlineIcon = forwardRef<IconElement, IconProps>((props, ref) =>
function InlineIcon(props, ref) { IconComponent({
return component(props, true, ref); inline: true,
} ...props,
); _ref: ref,
})
) as IconComponentType;
/** /**
* Add icon to storage, allowing to call it by name * 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 // Unique id, used as base for ids for shapes. Use it to get consistent ids for server side rendering
id?: string; 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 // Callback to call when icon data has been loaded. Used only for icons loaded from API
onLoad?: IconifyIconOnLoad; onLoad?: IconifyIconOnLoad;
} }
@ -62,13 +65,12 @@ export interface IconifyIconProps extends IconifyIconCustomisations {
*/ */
type IconifyElementProps = SVGProps<SVGSVGElement>; type IconifyElementProps = SVGProps<SVGSVGElement>;
export type IconRef = RefAttributes<SVGSVGElement>; /**
* Reference for SVG element
export interface ReactRefProp { */
ref?: IconRef; export type IconElement = SVGSVGElement | HTMLSpanElement;
}
/** /**
* Mix of icon properties and SVGSVGElement properties * 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 { createElement } from 'react';
import type { SVGProps } from 'react'; import type { SVGProps, CSSProperties } from 'react';
import type { IconifyIcon } from '@iconify/types'; import type { IconifyIcon } from '@iconify/types';
import { mergeCustomisations } from '@iconify/utils/lib/customisations/merge'; import { mergeCustomisations } from '@iconify/utils/lib/customisations/merge';
import { flipFromString } from '@iconify/utils/lib/customisations/flip'; import { flipFromString } from '@iconify/utils/lib/customisations/flip';
@ -13,9 +13,9 @@ import type {
IconifyIconCustomisations, IconifyIconCustomisations,
IconifyRenderMode, IconifyRenderMode,
IconProps, IconProps,
IconRef,
} from './props'; } from './props';
import { defaultExtendedIconCustomisations } from './props'; import { defaultExtendedIconCustomisations } from './props';
import { stringToIcon } from '@iconify/utils/lib/icon/name';
/** /**
* Default SVG attributes * Default SVG attributes
@ -85,14 +85,11 @@ export const render = (
// Partial properties // Partial properties
props: IconProps, props: IconProps,
// True if icon should have vertical-align added // Icon name
inline: boolean, name?: string
// Optional reference for SVG/SPAN, extracted by React.forwardRef()
ref?: IconRef
): JSX.Element => { ): JSX.Element => {
// Get default properties // Get default properties
const defaultProps = inline const defaultProps = props.inline
? inlineDefaults ? inlineDefaults
: defaultExtendedIconCustomisations; : defaultExtendedIconCustomisations;
@ -103,14 +100,29 @@ export const render = (
const mode: IconifyRenderMode = props.mode || 'svg'; const mode: IconifyRenderMode = props.mode || 'svg';
// Create style // Create style
const style: React.CSSProperties = {}; const style: CSSProperties = {};
const customStyle = props.style || {}; const customStyle = props.style || {};
// Create SVG component properties // Create SVG component properties
const componentProps = { const componentProps = {
...(mode === 'svg' ? svgDefaults : {}), ...(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 // Get element properties
for (let key in props) { for (let key in props) {
@ -125,8 +137,19 @@ export const render = (
case 'children': case 'children':
case 'onLoad': case 'onLoad':
case 'mode': case 'mode':
case 'ssr':
break;
// Forward ref
case '_ref': case '_ref':
case '_inline': componentProps.ref = value;
break;
// Merge class names
case 'className':
componentProps[key] =
(componentProps[key] ? componentProps[key] + ' ' : '') +
value;
break; break;
// Boolean attributes // Boolean attributes
@ -210,7 +233,7 @@ export const render = (
) )
), ),
}; };
return React.createElement('svg', componentProps); return createElement('svg', componentProps);
} }
// Render <span> with style // Render <span> with style
@ -235,7 +258,7 @@ export const render = (
...commonProps, ...commonProps,
...(useMask ? monotoneProps : coloredProps), ...(useMask ? monotoneProps : coloredProps),
...customStyle, ...customStyle,
} as React.CSSProperties; } as CSSProperties;
return React.createElement('span', componentProps); return createElement('span', componentProps);
}; };