diff --git a/packages/react/src/props.ts b/packages/react/src/props.ts index 97892f0..2e29002 100644 --- a/packages/react/src/props.ts +++ b/packages/react/src/props.ts @@ -4,6 +4,16 @@ import type { IconifyIconCustomisations as RawIconCustomisations } from '@iconif export { RawIconCustomisations }; +/** + * Icon render mode + * + * 'style' = 'bg' or 'mask', depending on icon content + * 'bg' = inline style using `background` + * 'mask' = inline style using `mask` + * 'inline' = inline SVG. + */ +export type IconifyRenderMode = 'style' | 'bg' | 'mask' | 'inline'; + // Allow rotation to be string /** * Icon customisations @@ -27,6 +37,9 @@ export interface IconifyIconProps extends IconifyIconCustomisations { // Icon object or icon name (must be added to storage using addIcon for offline package) icon: IconifyIcon | string; + // Render mode + mode?: IconifyRenderMode; + // Style color?: string; diff --git a/packages/react/src/render.ts b/packages/react/src/render.ts index 0b1fd74..a0f6db6 100644 --- a/packages/react/src/render.ts +++ b/packages/react/src/render.ts @@ -13,7 +13,9 @@ import { import { rotateFromString } from '@iconify/utils/lib/customisations/rotate'; import { iconToSVG } from '@iconify/utils/lib/svg/build'; import { replaceIDs } from '@iconify/utils/lib/svg/id'; -import type { IconProps, IconRef } from './props'; +import { iconToHTML } from '@iconify/utils/lib/svg/html'; +import { svgToURL } from '@iconify/utils/lib/svg/url'; +import type { IconifyRenderMode, IconProps, IconRef } from './props'; /** * Default SVG attributes @@ -26,6 +28,39 @@ const svgDefaults: SVGProps = { 'style': {}, // Include style if it isn't set to add verticalAlign later }; +/** + * Style modes + */ +const commonProps: Record = { + display: 'inline-block', +}; + +const monotoneProps: Record = { + backgroundColor: 'currentColor', +}; + +const coloredProps: Record = { + backgroundColor: 'transparent', +}; + +// Dynamically add common props to variables above +const propsToAdd: Record = { + Image: 'var(--svg)', + Repeat: 'no-repeat', + Size: '100% 100%', +}; +const propsToAddTo: Record> = { + webkitMask: monotoneProps, + mask: monotoneProps, + background: coloredProps, +}; +for (const prefix in propsToAddTo) { + const list = propsToAddTo[prefix]; + for (const prop in propsToAdd) { + list[prefix + prop] = propsToAdd[prop]; + } +} + /** * Default values for customisations for inline icon */ @@ -44,7 +79,7 @@ export const render = ( // True if icon should have vertical-align added inline: boolean, - // Optional reference for SVG, extracted by React.forwardRef() + // Optional reference for SVG/SPAN, extracted by React.forwardRef() ref?: IconRef ): JSX.Element => { // Get default properties @@ -56,14 +91,18 @@ export const render = ( props as FullIconCustomisations ); + // Check mode + const mode: IconifyRenderMode = props.mode || 'inline'; + // Create style - const style = - typeof props.style === 'object' && props.style !== null - ? props.style - : {}; + const style: React.CSSProperties = {}; + const customStyle = props.style || {}; // Create SVG component properties - const componentProps = { ...svgDefaults, ref, style }; + const componentProps = { + ...(mode === 'inline' ? svgDefaults : {}), + ref, + }; // Get element properties for (let key in props) { @@ -77,6 +116,7 @@ export const render = ( case 'style': case 'children': case 'onLoad': + case 'mode': case '_ref': case '_inline': break; @@ -135,29 +175,64 @@ export const render = ( // Generate icon const item = iconToSVG(icon, customisations); + const renderAttribs = item.attributes; - // Counter for ids based on "id" property to render icons consistently on server and client - let localCounter = 0; - let id = props.id; - if (typeof id === 'string') { - // Convert '-' to '_' to avoid errors in animations - id = id.replace(/-/g, '_'); - } - - // Add icon stuff - componentProps.dangerouslySetInnerHTML = { - __html: replaceIDs( - item.body, - id ? () => id + 'ID' + localCounter++ : 'iconifyReact' - ), - }; - for (let key in item.attributes) { - componentProps[key] = item.attributes[key]; - } - - if (item.inline && style.verticalAlign === void 0) { + // Inline display + if (item.inline) { style.verticalAlign = '-0.125em'; } - return React.createElement('svg', componentProps); + if (mode === 'inline') { + // Add style + componentProps.style = { + ...style, + ...customStyle, + }; + + // Add icon stuff + Object.assign(componentProps, renderAttribs); + + // Counter for ids based on "id" property to render icons consistently on server and client + let localCounter = 0; + let id = props.id; + if (typeof id === 'string') { + // Convert '-' to '_' to avoid errors in animations + id = id.replace(/-/g, '_'); + } + + // Add icon stuff + componentProps.dangerouslySetInnerHTML = { + __html: replaceIDs( + item.body, + id ? () => id + 'ID' + localCounter++ : 'iconifyReact' + ), + }; + return React.createElement('svg', componentProps); + } + + // Render with style + const { body, width, height } = icon; + const useMask = + mode === 'mask' || + (mode === 'bg' ? false : body.indexOf('currentColor') !== -1); + + // Generate SVG + const html = iconToHTML(body, { + ...renderAttribs, + width: width + '', + height: height + '', + }); + + // Generate style + componentProps.style = { + ...style, + '--svg': svgToURL(html), + 'width': renderAttribs.width, + 'height': renderAttribs.height, + ...commonProps, + ...(useMask ? monotoneProps : coloredProps), + ...customStyle, + } as React.CSSProperties; + + return React.createElement('span', componentProps); }; diff --git a/packages/react/tests/iconify/10-style-mode.test.js b/packages/react/tests/iconify/10-style-mode.test.js new file mode 100644 index 0000000..bc386b4 --- /dev/null +++ b/packages/react/tests/iconify/10-style-mode.test.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { Icon } from '../../dist/iconify'; +import renderer from 'react-test-renderer'; + +const iconData = { + body: '', + width: 24, + height: 24, +}; + +describe('Rendering as span', () => { + test('basic icon', () => { + const component = renderer.create( + { + // Should be called only for icons loaded from API + throw new Error('onLoad called for object!'); + }} + /> + ); + const tree = component.toJSON(); + + expect(tree).toMatchObject({ + type: 'span', + props: { + style: { + '--svg': `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' preserveAspectRatio='xMidYMid meet' viewBox='0 0 24 24'%3E%3Cpath d='M4 19h16v2H4zm5-4h11v2H9zm-5-4h16v2H4zm0-8h16v2H4zm5 4h11v2H9z' fill='currentColor'/%3E%3C/svg%3E")`, + 'width': '1em', + 'height': '1em', + 'display': 'inline-block', + 'backgroundColor': 'currentColor', + 'webkitMaskImage': 'var(--svg)', + 'webkitMaskRepeat': 'no-repeat', + 'webkitMaskSize': '100% 100%', + 'maskImage': 'var(--svg)', + 'maskRepeat': 'no-repeat', + 'maskSize': '100% 100%', + }, + }, + children: null, + }); + }); +}); diff --git a/packages/react/tests/iconify/20-attributes.test.js b/packages/react/tests/iconify/20-attributes.test.js index f8fec54..aa69dc7 100644 --- a/packages/react/tests/iconify/20-attributes.test.js +++ b/packages/react/tests/iconify/20-attributes.test.js @@ -3,8 +3,7 @@ import { Icon, InlineIcon } from '../../dist/iconify'; import renderer from 'react-test-renderer'; const iconData = { - body: - '', + body: '', width: 24, height: 24, }; @@ -67,8 +66,9 @@ describe('Passing attributes', () => { ); const tree = component.toJSON(); + // `style` overrides `color` expect(tree.props.style).toMatchObject({ - color: 'red', + color: 'green', }); }); diff --git a/packages/react/tests/offline/20-attributes.test.js b/packages/react/tests/offline/20-attributes.test.js index 63d0792..1e6ab5f 100644 --- a/packages/react/tests/offline/20-attributes.test.js +++ b/packages/react/tests/offline/20-attributes.test.js @@ -3,8 +3,7 @@ import { Icon, InlineIcon } from '../../dist/offline'; import renderer from 'react-test-renderer'; const iconData = { - body: - '', + body: '', width: 24, height: 24, }; @@ -69,8 +68,9 @@ describe('Passing attributes', () => { ); const tree = component.toJSON(); + // `style` overrides `color` expect(tree.props.style).toMatchObject({ - color: 'red', + color: 'green', }); });