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 `` with viewBox and path(s) for the dual-SVG (CSS swap) layout. */ +const createSvg = (icon: IconDefinitionWithSvgPathData, iconClassName: string) => { const { xOffset, yOffset, width, height, svgPathData, svgClassName } = icon ?? {}; const _xOffset = xOffset ?? 0; const _yOffset = yOffset ?? 0; @@ -64,11 +132,26 @@ const createSvg = (icon: IconDefinition, iconClassName: string) => { }; /** - * Factory to create Icon class components for consumers + * Preferred factory for **nested** icon config (`icon` and optional `rhUiIcon`). Package-generated icons use this. + * + * @param name Optional display name for the component; falls back to `icon.name` when not set. + * @see {@link createIcon} for the legacy **flat** argument shape. */ -export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): React.ComponentClass { +export function createIconBase({ + name, + icon, + rhUiIcon = null +}: CreateIconBaseProps): React.ComponentClass { + if (icon == null) { + const label = name != null ? ` (name: ${String(name)})` : ''; + throw new Error(`@patternfly/react-icons: createIconBase requires an \`icon\` definition${label}.`); + } + const normalizedIcon = normalizeIconDefinition(icon); + const normalizedRhUiIcon = rhUiIcon != null ? normalizeIconDefinition(rhUiIcon) : null; + const displayName = name ?? icon.name; + return class SVGIcon extends Component { - static displayName = name; + static displayName = displayName; id = `icon-title-${currentId++}`; @@ -76,10 +159,7 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re noDefaultStyle: false }; - constructor(props: SVGIconProps) { - super(props); - } - + /** Renders one root ``; either a single variant or nested inner SVGs for RH UI swap. */ render() { const { title, className: propsClassName, set, noDefaultStyle, ...props } = this.props; @@ -90,16 +170,18 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re classNames.push(propsClassName); } - if (set === 'rh-ui' && rhUiIcon === null) { + if (set === 'rh-ui' && normalizedRhUiIcon === null) { // eslint-disable-next-line no-console console.warn( - `Set "rh-ui" was provided for ${name}, but no rh-ui icon data exists for this icon. The default icon will be rendered.` + `Set "rh-ui" was provided for ${displayName}, but no rh-ui icon data exists for this icon. The default icon will be rendered.` ); } - if ((set === undefined && rhUiIcon === null) || set !== undefined) { - const iconData = set !== undefined && set === 'rh-ui' && rhUiIcon !== null ? rhUiIcon : icon; - const { xOffset, yOffset, width, height, svgPathData, svgClassName } = iconData ?? {}; + if ((set === undefined && normalizedRhUiIcon === null) || set !== undefined) { + const iconData: IconDefinitionWithSvgPathData | undefined = + set !== undefined && set === 'rh-ui' && normalizedRhUiIcon !== null ? normalizedRhUiIcon : normalizedIcon; + const { xOffset, yOffset, width, height, svgPathData, svgClassName } = + iconData ?? ({} as Partial); const _xOffset = xOffset ?? 0; const _yOffset = yOffset ?? 0; const viewBox = [_xOffset, _yOffset, width, height].join(' '); @@ -146,11 +228,24 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re {...(props as Omit, 'ref'>)} // Lie. > {hasTitle && {title}} - {icon && createSvg(icon, 'pf-v6-icon-default')} - {rhUiIcon && createSvg(rhUiIcon, 'pf-v6-icon-rh-ui')} + {normalizedIcon && createSvg(normalizedIcon, 'pf-v6-icon-default')} + {normalizedRhUiIcon && createSvg(normalizedRhUiIcon, 'pf-v6-icon-rh-ui')} ); } } }; } + +/** + * Legacy-friendly factory: **flat** {@link IconDefinition} fields (plus optional `rhUiIcon`) and delegates to + * {@link createIconBase}. For nested configs, use {@link createIconBase} directly. + */ +export function createIcon(props: CreateIconLegacyProps): React.ComponentClass { + const { rhUiIcon, ...icon } = props; + return createIconBase({ + name: icon.name, + icon, + rhUiIcon: rhUiIcon ?? null + }); +}