diff --git a/packages/react-icons/src/__tests__/createIcon.test.tsx b/packages/react-icons/src/__tests__/createIcon.test.tsx index cfc8dd2115f..edd147cb2be 100644 --- a/packages/react-icons/src/__tests__/createIcon.test.tsx +++ b/packages/react-icons/src/__tests__/createIcon.test.tsx @@ -1,5 +1,7 @@ +// eslint-disable-next-line no-restricted-imports -- test file excluded from package tsconfig; default import satisfies TS/JSX +import React from 'react'; import { render, screen } from '@testing-library/react'; -import { IconDefinition, CreateIconProps, createIcon, SVGPathObject } from '../createIcon'; +import { IconDefinition, CreateIconProps, createIcon, LegacyFlatIconDefinition, SVGPathObject } from '../createIcon'; const multiPathIcon: IconDefinition = { name: 'IconName', @@ -43,7 +45,48 @@ 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('accepts CreateIconProps with nested icon using deprecated svgPath field', () => { + const nestedLegacyPath: CreateIconProps = { + name: 'NestedLegacyPathIcon', + icon: { + width: 8, + height: 8, + svgPath: 'nested-legacy-d' + } + }; + const NestedIcon = createIcon(nestedLegacyPath); + render(); + expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', 'nested-legacy-d'); +}); + +test('throws when nested CreateIconProps omits icon', () => { + expect(() => + createIcon({ + name: 'MissingDefaultIcon', + rhUiIcon: null + }) + ).toThrow( + '@patternfly/react-icons: createIcon requires an `icon` definition when using nested CreateIconProps (name: MissingDefaultIcon).' + ); }); test('sets correct svgPath if array', () => { @@ -98,3 +141,77 @@ 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: CreateIconProps = { + name: 'DualMappedIcon', + icon: defaultIconDef, + rhUiIcon: rhUiIconDef + }; + + const DualMappedIcon = createIcon(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(() => {}); + const IconNoRhMapping = createIcon({ + 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); + + warnSpy.mockRestore(); + }); +}); diff --git a/packages/react-icons/src/createIcon.tsx b/packages/react-icons/src/createIcon.tsx index 7194732a872..f119d892493 100644 --- a/packages/react-icons/src/createIcon.tsx +++ b/packages/react-icons/src/createIcon.tsx @@ -5,22 +5,42 @@ 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; } +/** Icon metadata using the current `svgPathData` field name. */ +export interface IconDefinitionWithSvgPathData extends IconDefinitionBase { + svgPathData: string | SVGPathObject[]; +} + +/** + * @deprecated Use {@link IconDefinitionWithSvgPathData} with `svgPathData` instead. + */ +export interface IconDefinitionWithSvgPath extends IconDefinitionBase { + svgPath: string | SVGPathObject[]; +} + +/** Describes SVG path content for one icon variant (default or rh-ui). */ +export type IconDefinition = IconDefinitionWithSvgPathData | IconDefinitionWithSvgPath; + export interface CreateIconProps { name?: string; icon?: IconDefinition; rhUiIcon?: IconDefinition | null; } +/** + * @deprecated The previous `createIcon` accepted a flat {@link IconDefinition} with top-level + * `svgPath`. Pass {@link CreateIconProps} with a nested `icon` field instead. + */ +export type LegacyFlatIconDefinition = IconDefinition; + export interface SVGIconProps extends Omit, 'ref'> { title?: string; className?: string; @@ -30,7 +50,62 @@ export interface SVGIconProps extends Omit, 'ref'> { let currentId = 0; -const createSvg = (icon: IconDefinition, iconClassName: string) => { +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'); +} + +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 + }; +} + +function isNestedCreateIconProps(arg: object): arg is CreateIconProps { + return 'icon' in arg || 'rhUiIcon' in arg; +} + +/** Props after resolving legacy `svgPath` and flat `createIcon` arguments. */ +interface NormalizedCreateIconProps { + name?: string; + icon?: IconDefinitionWithSvgPathData; + rhUiIcon: IconDefinitionWithSvgPathData | null; +} + +function normalizeCreateIconArg(arg: CreateIconProps | LegacyFlatIconDefinition): NormalizedCreateIconProps { + if (isNestedCreateIconProps(arg)) { + const p = arg as CreateIconProps; + if (p.icon == null) { + const label = p.name != null ? ` (name: ${String(p.name)})` : ''; + throw new Error( + `@patternfly/react-icons: createIcon requires an \`icon\` definition when using nested CreateIconProps${label}.` + ); + } + return { + name: p.name, + icon: normalizeIconDefinition(p.icon), + rhUiIcon: p.rhUiIcon != null ? normalizeIconDefinition(p.rhUiIcon) : null + }; + } + return { + name: (arg as LegacyFlatIconDefinition).name, + icon: normalizeIconDefinition(arg as IconDefinition), + rhUiIcon: null + }; +} + +const createSvg = (icon: IconDefinitionWithSvgPathData, iconClassName: string) => { const { xOffset, yOffset, width, height, svgPathData, svgClassName } = icon ?? {}; const _xOffset = xOffset ?? 0; const _yOffset = yOffset ?? 0; @@ -62,18 +137,38 @@ const createSvg = (icon: IconDefinition, iconClassName: string) => { }; /** - * Factory to create Icon class components for consumers + * Builds a React **class** component that renders a PatternFly SVG icon (`role="img"`, optional `` for a11y). + * + * **Argument shape — pick one:** + * + * 1. **`CreateIconProps` (preferred)** — `{ name?, icon?, rhUiIcon? }`. Dimensions and path data sit on `icon` + * (and optionally on `rhUiIcon` for Red Hat UI–mapped icons). If the object **has an `icon` or `rhUiIcon` key** + * (including `rhUiIcon: null`), this shape is assumed. + * + * 2. **Legacy flat `IconDefinition`** — the same fields as `icon`, but at the **top level** (no nested `icon`). + * Still accepted so existing callers are not broken. Prefer migrating to `CreateIconProps`. + * + * **Path data on each `IconDefinition`:** use `svgPathData` (string or {@link SVGPathObject}[]). The old name + * `svgPath` is deprecated but still read; `svgPathData` wins if both are present. + * + * **Default vs RH UI rendering:** If `rhUiIcon` is set and the consumer does **not** pass `set` on the component, + * the output is an outer `<svg.pf-v6-svg>` containing **two** inner `<svg>`s (default + rh-ui) so CSS can swap + * which variant is visible. If `set` is `"default"` or `"rh-ui"`, a **single** flat `<svg>` is rendered for that + * variant. Requesting `set="rh-ui"` when there is no `rhUiIcon` falls back to the default glyph and logs a + * `console.warn` (see implementation). + * + * @param arg Icon configuration: either {@link CreateIconProps} (nested `icon` / `rhUiIcon`) or a legacy flat + * {@link LegacyFlatIconDefinition}. Runtime detection follows the rules in **Argument shape** above. + * @returns A `ComponentClass<SVGIconProps>` — render it as `<YourIcon />` or with `title`, `className`, `set`, etc. */ -export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): React.ComponentClass<SVGIconProps> { +export function createIcon(arg: CreateIconProps | LegacyFlatIconDefinition): React.ComponentClass<SVGIconProps> { + const { name, icon, rhUiIcon = null } = normalizeCreateIconArg(arg); + return class SVGIcon extends Component<SVGIconProps> { static displayName = name; id = `icon-title-${currentId++}`; - constructor(props: SVGIconProps) { - super(props); - } - render() { const { title, className: propsClassName, set, ...props } = this.props; @@ -92,8 +187,10 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re } 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 ?? {}; + const iconData: IconDefinitionWithSvgPathData | undefined = + set !== undefined && set === 'rh-ui' && rhUiIcon !== null ? rhUiIcon : icon; + const { xOffset, yOffset, width, height, svgPathData, svgClassName } = + iconData ?? ({} as Partial<IconDefinitionWithSvgPathData>); const _xOffset = xOffset ?? 0; const _yOffset = yOffset ?? 0; const viewBox = [_xOffset, _yOffset, width, height].join(' ');