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..8f8f7698dbd 100644
--- a/packages/react-icons/src/__tests__/createIcon.test.tsx
+++ b/packages/react-icons/src/__tests__/createIcon.test.tsx
@@ -1,5 +1,12 @@
import { render, screen } from '@testing-library/react';
-import { IconDefinition, CreateIconProps, createIcon, SVGPathObject } from '../createIcon';
+import {
+ IconDefinition,
+ CreateIconBaseProps,
+ createIcon,
+ createIconBase,
+ LegacyFlatIconDefinition,
+ SVGPathObject
+} from '../createIcon';
const multiPathIcon: IconDefinition = {
name: 'IconName',
@@ -28,24 +35,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 +64,37 @@ 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 legacy flat createIcon({ svgPath }) shape', () => {
+ const legacyDef: LegacyFlatIconDefinition = {
+ 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('createIconBase accepts nested icon with deprecated svgPath field', () => {
+ const nestedLegacyPath: CreateIconBaseProps = {
+ name: 'NestedLegacyPathIcon',
+ icon: {
+ width: 8,
+ height: 8,
+ svgPath: 'nested-legacy-d'
+ }
+ };
+ const NestedIcon = createIconBase(nestedLegacyPath);
+ render();
+ expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', 'nested-legacy-d');
});
test('sets correct svgClassName by default', () => {
@@ -75,6 +112,15 @@ test('does not set svgClassName when noDefaultStyle is true', () => {
expect(screen.getByRole('img', { hidden: true })).not.toHaveClass('pf-v6-icon-rh-standard');
});
+test('throws when createIconBase omits icon', () => {
+ expect(() =>
+ createIconBase({
+ name: 'MissingDefaultIcon',
+ rhUiIcon: null
+ } as any)
+ ).toThrow('@patternfly/react-icons: createIconBase requires an `icon` definition (name: MissingDefaultIcon).');
+});
+
test('sets correct svgPath if array', () => {
render();
const paths = screen.getByRole('img', { hidden: true }).querySelectorAll('path');
@@ -127,3 +173,79 @@ 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();
+ }
+ });
+});
diff --git a/packages/react-icons/src/createIcon.tsx b/packages/react-icons/src/createIcon.tsx
index 0286134575b..0c6e51f0f8d 100644
--- a/packages/react-icons/src/createIcon.tsx
+++ b/packages/react-icons/src/createIcon.tsx
@@ -5,22 +5,65 @@ export interface SVGPathObject {
className?: string;
}
-export interface IconDefinition {
+export interface IconDefinitionBase {
name?: string;
width: number;
height: number;
- svgPathData: string | SVGPathObject[];
xOffset?: number;
yOffset?: number;
svgClassName?: string;
}
-export interface CreateIconProps {
+/**
+ * SVG path content for one icon variant (default or rh-ui). At runtime at least one of
+ * `svgPathData` or `svgPath` must be set; if both are present, `svgPathData` is used.
+ */
+export interface IconDefinition extends IconDefinitionBase {
+ svgPathData?: string | SVGPathObject[];
+ /**
+ * @deprecated Use {@link IconDefinition.svgPathData} instead.
+ */
+ svgPath?: string | SVGPathObject[];
+}
+
+/** Narrows {@link IconDefinition} to the preferred shape with required `svgPathData`. */
+export type IconDefinitionWithSvgPathData = Required> & IconDefinition;
+
+/**
+ * @deprecated Use {@link IconDefinition} with `svgPathData` instead.
+ * Narrows {@link IconDefinition} to the legacy shape with required `svgPath`.
+ */
+export type IconDefinitionWithSvgPath = Required> & IconDefinition;
+
+/**
+ * Props for {@link createIconBase} — nested icon definition(s). Used by generated icons and callers
+ * that already structure data as `{ icon, rhUiIcon? }`.
+ */
+export interface CreateIconBaseProps {
name?: string;
- icon?: IconDefinition;
+ icon: IconDefinition;
rhUiIcon?: IconDefinition | null;
}
+/**
+ * @deprecated Use {@link CreateIconBaseProps} instead.
+ */
+export type CreateIconProps = CreateIconBaseProps;
+
+/**
+ * Props for {@link createIcon} — flat {@link IconDefinition} fields at the top level, optionally with
+ * `rhUiIcon`, matching the pre–nested-config API.
+ */
+export type CreateIconLegacyProps = IconDefinition & {
+ rhUiIcon?: IconDefinition | null;
+};
+
+/**
+ * @deprecated The previous `createIcon` accepted only a flat {@link IconDefinition}. Use {@link createIcon}
+ * for that shape, or {@link createIconBase} with nested `icon` / `rhUiIcon`.
+ */
+export type LegacyFlatIconDefinition = IconDefinition;
+
export interface SVGIconProps extends Omit, 'ref'> {
title?: string;
className?: string;
@@ -32,7 +75,32 @@ export interface SVGIconProps extends Omit, 'ref'> {
let currentId = 0;
-const createSvg = (icon: IconDefinition, iconClassName: string) => {
+/** Returns path data from `svgPathData` or deprecated `svgPath` (prefers `svgPathData` when both exist). */
+function resolveSvgPathData(icon: IconDefinition): string | SVGPathObject[] {
+ if ('svgPathData' in icon && icon.svgPathData !== undefined) {
+ return icon.svgPathData;
+ }
+ if ('svgPath' in icon && icon.svgPath !== undefined) {
+ return icon.svgPath;
+ }
+ throw new Error('@patternfly/react-icons: IconDefinition must define svgPathData or svgPath');
+}
+
+/** Produces a single {@link IconDefinitionWithSvgPathData} for internal rendering. */
+function normalizeIconDefinition(icon: IconDefinition): IconDefinitionWithSvgPathData {
+ return {
+ name: icon.name,
+ width: icon.width,
+ height: icon.height,
+ svgPathData: resolveSvgPathData(icon),
+ xOffset: icon.xOffset,
+ yOffset: icon.yOffset,
+ svgClassName: icon.svgClassName
+ };
+}
+
+/** Renders an inner `