diff --git a/packages/react-icons/scripts/writeIcons.mjs b/packages/react-icons/scripts/writeIcons.mjs index e2f2703f257..a8130e91e9e 100644 --- a/packages/react-icons/scripts/writeIcons.mjs +++ b/packages/react-icons/scripts/writeIcons.mjs @@ -7,9 +7,9 @@ import { pfToRhIcons } from './icons/pfToRhIcons.mjs'; import * as url from 'url'; const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); -// Import createIcon from compiled dist (build:esm must run first) +// Import createIconBase from compiled dist (build:esm must run first) const createIconModule = await import('../dist/esm/createIcon.js'); -const createIcon = createIconModule.createIcon; +const createIconBase = createIconModule.createIconBase; const outDir = join(__dirname, '../dist'); const staticDir = join(outDir, 'static'); @@ -27,7 +27,7 @@ exports.${jsName}Config = { icon: ${JSON.stringify(icon)}, rhUiIcon: ${rhUiIcon ? JSON.stringify(rhUiIcon) : 'null'}, }; -exports.${jsName} = require('../createIcon').createIcon(exports.${jsName}Config); +exports.${jsName} = require('../createIcon').createIconBase(exports.${jsName}Config); exports["default"] = exports.${jsName}; `.trim() ); @@ -36,7 +36,7 @@ exports["default"] = exports.${jsName}; const writeESMExport = (fname, jsName, icon, rhUiIcon = null) => { outputFileSync( join(outDir, 'esm/icons', `${fname}.js`), - `import { createIcon } from '../createIcon.js'; + `import { createIconBase } from '../createIcon.js'; export const ${jsName}Config = { name: '${jsName}', @@ -44,7 +44,7 @@ export const ${jsName}Config = { rhUiIcon: ${rhUiIcon ? JSON.stringify(rhUiIcon) : 'null'}, }; -export const ${jsName} = createIcon(${jsName}Config); +export const ${jsName} = createIconBase(${jsName}Config); export default ${jsName}; `.trim() @@ -68,7 +68,7 @@ export default ${jsName}; }; /** - * Generates a static SVG string from icon data using createIcon + * Generates a static SVG string from icon data using createIconBase * @param {string} iconName The name of the icon * @param {object} icon The icon data object * @returns {string} Static SVG markup @@ -76,8 +76,8 @@ export default ${jsName}; function generateStaticSVG(iconName, icon) { const jsName = `${toCamel(iconName)}Icon`; - // Create icon component using createIcon - const IconComponent = createIcon({ + // Create icon component using createIconBase + const IconComponent = createIconBase({ name: jsName, icon }); diff --git a/packages/react-icons/src/__tests__/createIcon.test.tsx b/packages/react-icons/src/__tests__/createIcon.test.tsx index 16f80fd8d90..6075553cd0b 100644 --- a/packages/react-icons/src/__tests__/createIcon.test.tsx +++ b/packages/react-icons/src/__tests__/createIcon.test.tsx @@ -1,5 +1,8 @@ import { render, screen } from '@testing-library/react'; -import { IconDefinition, CreateIconProps, createIcon, SVGPathObject } from '../createIcon'; +import { IconDefinition, CreateIconProps, createIcon, createIconBase, SVGPathObject } from '../createIcon'; + +/** Mirrors the non-exported argument type of {@link createIconBase} for tests. */ +type CreateIconBaseProps = Parameters[0]; const multiPathIcon: IconDefinition = { name: 'IconName', @@ -28,24 +31,24 @@ const rhStandardIcon: IconDefinition = { svgClassName: 'pf-v6-icon-rh-standard' }; -const iconDef: CreateIconProps = { +const iconDef: CreateIconBaseProps = { name: 'SinglePathIconName', icon: singlePathIcon }; -const iconDefWithArrayPath: CreateIconProps = { +const iconDefWithArrayPath: CreateIconBaseProps = { name: 'MultiPathIconName', icon: multiPathIcon }; -const iconDefWithRhStandard: CreateIconProps = { +const iconDefWithRhStandard: CreateIconBaseProps = { name: 'RhStandardIconName', icon: rhStandardIcon }; -const SVGIcon = createIcon(iconDef); -const SVGArrayIcon = createIcon(iconDefWithArrayPath); -const RhStandardIcon = createIcon(iconDefWithRhStandard); +const SVGIcon = createIconBase(iconDef); +const SVGArrayIcon = createIconBase(iconDefWithArrayPath); +const RhStandardIcon = createIconBase(iconDefWithRhStandard); test('sets correct viewBox', () => { render(); @@ -57,7 +60,23 @@ test('sets correct viewBox', () => { test('sets correct svgPath if string', () => { render(); - expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', iconDef.svgPath); + expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute( + 'd', + singlePathIcon.svgPathData + ); +}); + +test('accepts flat createIcon({ svgPath }) shape', () => { + const legacyDef: CreateIconProps = { + name: 'LegacyIcon', + width: 10, + height: 20, + svgPath: 'legacy-path', + svgClassName: 'legacy-svg' + }; + const LegacySVGIcon = createIcon(legacyDef); + render(); + expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', 'legacy-path'); }); test('sets correct svgClassName by default', () => { @@ -127,3 +146,90 @@ test('additional props should be spread to the root svg element', () => { render(); expect(screen.getByTestId('icon')).toBeInTheDocument(); }); + +describe('rh-ui mapping: nested SVGs, set prop, and warnings', () => { + const defaultPath = 'M0 0-default'; + const rhUiPath = 'M0 0-rh-ui'; + + const defaultIconDef: IconDefinition = { + name: 'DefaultVariant', + width: 16, + height: 16, + svgPathData: defaultPath + }; + + const rhUiIconDef: IconDefinition = { + name: 'RhUiVariant', + width: 16, + height: 16, + svgPathData: rhUiPath + }; + + const dualConfig: CreateIconBaseProps = { + name: 'DualMappedIcon', + icon: defaultIconDef, + rhUiIcon: rhUiIconDef + }; + + const DualMappedIcon = createIconBase(dualConfig); + + test('renders two nested inner svgs when rhUiIcon is set and `set` is omitted (swap layout)', () => { + render(); + const root = screen.getByRole('img', { hidden: true }); + expect(root).toHaveClass('pf-v6-svg'); + const innerSvgs = root.querySelectorAll(':scope > svg'); + expect(innerSvgs).toHaveLength(2); + expect(root?.querySelector('.pf-v6-icon-default path')).toHaveAttribute('d', defaultPath); + expect(root?.querySelector('.pf-v6-icon-rh-ui path')).toHaveAttribute('d', rhUiPath); + }); + + test('set="default" renders a single flat svg using the default icon paths', () => { + render(); + const root = screen.getByRole('img', { hidden: true }); + expect(root.querySelectorAll(':scope > svg')).toHaveLength(0); + expect(root).toHaveAttribute('viewBox', '0 0 16 16'); + expect(root.querySelector('path')).toHaveAttribute('d', defaultPath); + expect(root.querySelectorAll('svg')).toHaveLength(0); + }); + + test('set="rh-ui" renders a single flat svg using the rh-ui icon paths', () => { + render(); + const root = screen.getByRole('img', { hidden: true }); + expect(root.querySelectorAll(':scope > svg')).toHaveLength(0); + expect(root.querySelector('path')).toHaveAttribute('d', rhUiPath); + expect(root.querySelectorAll('svg')).toHaveLength(0); + }); + + test('set="rh-ui" with no rhUiIcon mapping falls back to default and warns', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const IconNoRhMapping = createIconBase({ + name: 'NoRhMappingIcon', + icon: defaultIconDef, + rhUiIcon: null + }); + + render(); + + expect(warnSpy).toHaveBeenCalledWith( + 'Set "rh-ui" was provided for NoRhMappingIcon, but no rh-ui icon data exists for this icon. The default icon will be rendered.' + ); + const root = screen.getByRole('img', { hidden: true }); + expect(root.querySelector('path')).toHaveAttribute('d', defaultPath); + expect(root.querySelectorAll('svg')).toHaveLength(0); + } finally { + warnSpy.mockRestore(); + } + }); + + test('warns when createIconBase omits icon', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + createIconBase({ + name: 'MissingDefaultIcon', + rhUiIcon: null + }); + expect(warnSpy).toHaveBeenCalledWith( + '@patternfly/react-icons: createIconBase is missing an `icon` definition (name: MissingDefaultIcon).' + ); + }); +}); diff --git a/packages/react-icons/src/createIcon.tsx b/packages/react-icons/src/createIcon.tsx index 0286134575b..48549b05ef8 100644 --- a/packages/react-icons/src/createIcon.tsx +++ b/packages/react-icons/src/createIcon.tsx @@ -1,26 +1,41 @@ -import { Component } from 'react'; +import { Component, type ComponentClass, type ReactNode } from 'react'; export interface SVGPathObject { path: string; className?: string; } -export interface IconDefinition { +/** Icon data format */ +export interface IconData { name?: string; width: number; height: number; - svgPathData: string | SVGPathObject[]; xOffset?: number; yOffset?: number; svgClassName?: string; + svgPathData?: string | SVGPathObject[]; } -export interface CreateIconProps { +/** Internal API props */ +interface CreateIconBaseProps { + name?: string; + icon?: IconData; + rhUiIcon?: IconData | null; +} + +/** Public API props */ +export interface IconDefinition { name?: string; - icon?: IconDefinition; - rhUiIcon?: IconDefinition | null; + width: number; + height: number; + xOffset?: number; + yOffset?: number; + svgPath?: string | SVGPathObject[]; + svgClassName?: string; + rhUiIcon?: IconData | null; } +/** Additional svg props */ export interface SVGIconProps extends Omit, 'ref'> { title?: string; className?: string; @@ -32,13 +47,24 @@ export interface SVGIconProps extends Omit, 'ref'> { let currentId = 0; -const createSvg = (icon: IconDefinition, iconClassName: string) => { +/** Returns svg path(s) from a given svg data object. */ +function getSvgPaths(svgPathData: string | SVGPathObject[] | undefined): ReactNode { + return svgPathData && Array.isArray(svgPathData) ? ( + svgPathData.map((pathObject, index) => ( + + )) + ) : ( + + ); +} + +const createInnerSvg = (icon: IconData, iconClassName: string) => { const { xOffset, yOffset, width, height, svgPathData, svgClassName } = icon ?? {}; const _xOffset = xOffset ?? 0; const _yOffset = yOffset ?? 0; const viewBox = [_xOffset, _yOffset, width, height].join(' '); - const classNames = []; + const classNames: string[] = []; if (svgClassName) { classNames.push(svgClassName); @@ -47,26 +73,24 @@ const createSvg = (icon: IconDefinition, iconClassName: string) => { classNames.push(iconClassName); } - const svgPaths = - svgPathData && Array.isArray(svgPathData) ? ( - svgPathData.map((pathObject, index) => ( - - )) - ) : ( - - ); - return ( - {svgPaths} + {getSvgPaths(svgPathData)} ); }; /** - * Factory to create Icon class components for consumers + * Internal API for creating icons. Subject to change at any time. Please use `createIcon` instead. + * @internal */ -export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): React.ComponentClass { +export function createIconBase({ name, icon, rhUiIcon = null }: CreateIconBaseProps): ComponentClass { + if (icon == null) { + // eslint-disable-next-line no-console + console.warn( + `@patternfly/react-icons: createIconBase is missing an \`icon\` definition (name: ${name ?? 'unknown'}).` + ); + } return class SVGIcon extends Component { static displayName = name; @@ -76,10 +100,6 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re noDefaultStyle: false }; - constructor(props: SVGIconProps) { - super(props); - } - render() { const { title, className: propsClassName, set, noDefaultStyle, ...props } = this.props; @@ -108,15 +128,6 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re classNames.push(svgClassName); } - const svgPaths = - svgPathData && Array.isArray(svgPathData) ? ( - svgPathData.map((pathObject, index) => ( - - )) - ) : ( - - ); - return ( , 'ref'>)} // Lie. > {hasTitle && {title}} - {svgPaths} - - ); - } else { - return ( - , 'ref'>)} // Lie. - > - {hasTitle && {title}} - {icon && createSvg(icon, 'pf-v6-icon-default')} - {rhUiIcon && createSvg(rhUiIcon, 'pf-v6-icon-rh-ui')} + {getSvgPaths(svgPathData)} ); } + return ( + , 'ref'>)} // Lie. + > + {hasTitle && {title}} + {icon && createInnerSvg(icon, 'pf-v6-icon-default')} + {rhUiIcon && createInnerSvg(rhUiIcon, 'pf-v6-icon-rh-ui')} + + ); } }; } + +/** Public API for creating icons. Will be maintained up to breaking releases. */ +export function createIcon(props: IconDefinition): ComponentClass { + const { rhUiIcon = null, ...rest } = props; + const icon: IconData = { + name: rest.name, + width: rest.width, + height: rest.height, + xOffset: rest.xOffset, + yOffset: rest.yOffset, + svgClassName: rest.svgClassName, + svgPathData: rest.svgPath + }; + return createIconBase({ name: icon.name, icon, rhUiIcon }); +} diff --git a/packages/react-icons/tsconfig.json b/packages/react-icons/tsconfig.json index bdbed28e912..5f141861a34 100644 --- a/packages/react-icons/tsconfig.json +++ b/packages/react-icons/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./dist/esm", + "stripInternal": true, "rootDirs": [ "src", "src/icons",