Skip to content

Commit c6580be

Browse files
committed
fix(createIcon): Fix broken API in createIcon.tsx
1 parent 29497f3 commit c6580be

2 files changed

Lines changed: 232 additions & 13 deletions

File tree

packages/react-icons/src/__tests__/createIcon.test.tsx

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
// eslint-disable-next-line no-restricted-imports -- test file excluded from package tsconfig; default import satisfies TS/JSX
2+
import React from 'react';
13
import { render, screen } from '@testing-library/react';
2-
import { IconDefinition, CreateIconProps, createIcon, SVGPathObject } from '../createIcon';
4+
import { IconDefinition, CreateIconProps, createIcon, LegacyFlatIconDefinition, SVGPathObject } from '../createIcon';
35

46
const multiPathIcon: IconDefinition = {
57
name: 'IconName',
@@ -57,7 +59,37 @@ test('sets correct viewBox', () => {
5759

5860
test('sets correct svgPath if string', () => {
5961
render(<SVGIcon />);
60-
expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', iconDef.svgPath);
62+
expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute(
63+
'd',
64+
singlePathIcon.svgPathData
65+
);
66+
});
67+
68+
test('accepts legacy flat createIcon({ svgPath }) shape', () => {
69+
const legacyDef: LegacyFlatIconDefinition = {
70+
name: 'LegacyIcon',
71+
width: 10,
72+
height: 20,
73+
svgPath: 'legacy-path',
74+
svgClassName: 'legacy-svg'
75+
};
76+
const LegacySVGIcon = createIcon(legacyDef);
77+
render(<LegacySVGIcon />);
78+
expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', 'legacy-path');
79+
});
80+
81+
test('accepts CreateIconProps with nested icon using deprecated svgPath field', () => {
82+
const nestedLegacyPath: CreateIconProps = {
83+
name: 'NestedLegacyPathIcon',
84+
icon: {
85+
width: 8,
86+
height: 8,
87+
svgPath: 'nested-legacy-d'
88+
}
89+
};
90+
const NestedIcon = createIcon(nestedLegacyPath);
91+
render(<NestedIcon />);
92+
expect(screen.getByRole('img', { hidden: true }).querySelector('path')).toHaveAttribute('d', 'nested-legacy-d');
6193
});
6294

6395
test('sets correct svgClassName by default', () => {
@@ -127,3 +159,77 @@ test('additional props should be spread to the root svg element', () => {
127159
render(<SVGIcon data-testid="icon" />);
128160
expect(screen.getByTestId('icon')).toBeInTheDocument();
129161
});
162+
163+
describe('rh-ui mapping: nested SVGs, set prop, and warnings', () => {
164+
const defaultPath = 'M0 0-default';
165+
const rhUiPath = 'M0 0-rh-ui';
166+
167+
const defaultIconDef: IconDefinition = {
168+
name: 'DefaultVariant',
169+
width: 16,
170+
height: 16,
171+
svgPathData: defaultPath
172+
};
173+
174+
const rhUiIconDef: IconDefinition = {
175+
name: 'RhUiVariant',
176+
width: 16,
177+
height: 16,
178+
svgPathData: rhUiPath
179+
};
180+
181+
const dualConfig: CreateIconProps = {
182+
name: 'DualMappedIcon',
183+
icon: defaultIconDef,
184+
rhUiIcon: rhUiIconDef
185+
};
186+
187+
const DualMappedIcon = createIcon(dualConfig);
188+
189+
test('renders two nested inner svgs when rhUiIcon is set and `set` is omitted (swap layout)', () => {
190+
render(<DualMappedIcon />);
191+
const root = screen.getByRole('img', { hidden: true });
192+
expect(root).toHaveClass('pf-v6-svg');
193+
const innerSvgs = root.querySelectorAll(':scope > svg');
194+
expect(innerSvgs).toHaveLength(2);
195+
expect(root?.querySelector('.pf-v6-icon-default path')).toHaveAttribute('d', defaultPath);
196+
expect(root?.querySelector('.pf-v6-icon-rh-ui path')).toHaveAttribute('d', rhUiPath);
197+
});
198+
199+
test('set="default" renders a single flat svg using the default icon paths', () => {
200+
render(<DualMappedIcon set="default" />);
201+
const root = screen.getByRole('img', { hidden: true });
202+
expect(root.querySelectorAll(':scope > svg')).toHaveLength(0);
203+
expect(root).toHaveAttribute('viewBox', '0 0 16 16');
204+
expect(root.querySelector('path')).toHaveAttribute('d', defaultPath);
205+
expect(root.querySelectorAll('svg')).toHaveLength(0);
206+
});
207+
208+
test('set="rh-ui" renders a single flat svg using the rh-ui icon paths', () => {
209+
render(<DualMappedIcon set="rh-ui" />);
210+
const root = screen.getByRole('img', { hidden: true });
211+
expect(root.querySelectorAll(':scope > svg')).toHaveLength(0);
212+
expect(root.querySelector('path')).toHaveAttribute('d', rhUiPath);
213+
expect(root.querySelectorAll('svg')).toHaveLength(0);
214+
});
215+
216+
test('set="rh-ui" with no rhUiIcon mapping falls back to default and warns', () => {
217+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
218+
const IconNoRhMapping = createIcon({
219+
name: 'NoRhMappingIcon',
220+
icon: defaultIconDef,
221+
rhUiIcon: null
222+
});
223+
224+
render(<IconNoRhMapping set="rh-ui" />);
225+
226+
expect(warnSpy).toHaveBeenCalledWith(
227+
'Set "rh-ui" was provided for NoRhMappingIcon, but no rh-ui icon data exists for this icon. The default icon will be rendered.'
228+
);
229+
const root = screen.getByRole('img', { hidden: true });
230+
expect(root.querySelector('path')).toHaveAttribute('d', defaultPath);
231+
expect(root.querySelectorAll('svg')).toHaveLength(0);
232+
233+
warnSpy.mockRestore();
234+
});
235+
});

packages/react-icons/src/createIcon.tsx

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,49 @@ export interface SVGPathObject {
55
className?: string;
66
}
77

8-
export interface IconDefinition {
8+
export interface IconDefinitionBase {
99
name?: string;
1010
width: number;
1111
height: number;
12-
svgPathData: string | SVGPathObject[];
1312
xOffset?: number;
1413
yOffset?: number;
1514
svgClassName?: string;
1615
}
1716

17+
/**
18+
* SVG path content for one icon variant (default or rh-ui). At runtime at least one of
19+
* `svgPathData` or `svgPath` must be set; if both are present, `svgPathData` is used.
20+
*/
21+
export interface IconDefinition extends IconDefinitionBase {
22+
svgPathData?: string | SVGPathObject[];
23+
/**
24+
* @deprecated Use {@link IconDefinition.svgPathData} instead.
25+
*/
26+
svgPath?: string | SVGPathObject[];
27+
}
28+
29+
/** Narrows {@link IconDefinition} to the preferred shape with required `svgPathData`. */
30+
export type IconDefinitionWithSvgPathData = Required<Pick<IconDefinition, 'svgPathData'>> & IconDefinition;
31+
32+
/**
33+
* @deprecated Use {@link IconDefinition} with `svgPathData` instead.
34+
* Narrows {@link IconDefinition} to the legacy shape with required `svgPath`.
35+
*/
36+
export type IconDefinitionWithSvgPath = Required<Pick<IconDefinition, 'svgPath'>> & IconDefinition;
37+
38+
/** When passing `icon` or `rhUiIcon` keys (nested form), `icon` is required at runtime. */
1839
export interface CreateIconProps {
1940
name?: string;
2041
icon?: IconDefinition;
2142
rhUiIcon?: IconDefinition | null;
2243
}
2344

45+
/**
46+
* @deprecated The previous `createIcon` accepted a flat {@link IconDefinition} with top-level
47+
* `svgPath`. Pass {@link CreateIconProps} with a nested `icon` field instead.
48+
*/
49+
export type LegacyFlatIconDefinition = IconDefinition;
50+
2451
export interface SVGIconProps extends Omit<React.HTMLProps<SVGElement>, 'ref'> {
2552
title?: string;
2653
className?: string;
@@ -32,7 +59,70 @@ export interface SVGIconProps extends Omit<React.HTMLProps<SVGElement>, 'ref'> {
3259

3360
let currentId = 0;
3461

35-
const createSvg = (icon: IconDefinition, iconClassName: string) => {
62+
/** Returns path data from `svgPathData` or deprecated `svgPath` (prefers `svgPathData` when both exist). */
63+
function resolveSvgPathData(icon: IconDefinition): string | SVGPathObject[] {
64+
if ('svgPathData' in icon && icon.svgPathData !== undefined) {
65+
return icon.svgPathData;
66+
}
67+
if ('svgPath' in icon && icon.svgPath !== undefined) {
68+
return icon.svgPath;
69+
}
70+
throw new Error('@patternfly/react-icons: IconDefinition must define svgPathData or svgPath');
71+
}
72+
73+
/** Produces a single {@link IconDefinitionWithSvgPathData} for internal rendering. */
74+
function normalizeIconDefinition(icon: IconDefinition): IconDefinitionWithSvgPathData {
75+
return {
76+
name: icon.name,
77+
width: icon.width,
78+
height: icon.height,
79+
svgPathData: resolveSvgPathData(icon),
80+
xOffset: icon.xOffset,
81+
yOffset: icon.yOffset,
82+
svgClassName: icon.svgClassName
83+
};
84+
}
85+
86+
/** True when the argument uses the nested `CreateIconProps` shape (`icon` and/or `rhUiIcon` keys). */
87+
function isNestedCreateIconProps(arg: object): arg is CreateIconProps {
88+
return 'icon' in arg || 'rhUiIcon' in arg;
89+
}
90+
91+
/** Props after resolving legacy `svgPath` and flat `createIcon` arguments. */
92+
interface NormalizedCreateIconProps {
93+
name?: string;
94+
icon?: IconDefinitionWithSvgPathData;
95+
rhUiIcon: IconDefinitionWithSvgPathData | null;
96+
}
97+
98+
/**
99+
* Coerces legacy flat or nested props into normalized {@link NormalizedCreateIconProps}.
100+
* Nested input must include a non-null `icon` or throws.
101+
*/
102+
function normalizeCreateIconArg(arg: CreateIconProps | LegacyFlatIconDefinition): NormalizedCreateIconProps {
103+
if (isNestedCreateIconProps(arg)) {
104+
const p = arg as CreateIconProps;
105+
if (p.icon == null) {
106+
const label = p.name != null ? ` (name: ${String(p.name)})` : '';
107+
throw new Error(
108+
`@patternfly/react-icons: createIcon requires an \`icon\` definition when using nested CreateIconProps${label}.`
109+
);
110+
}
111+
return {
112+
name: p.name,
113+
icon: normalizeIconDefinition(p.icon),
114+
rhUiIcon: p.rhUiIcon != null ? normalizeIconDefinition(p.rhUiIcon) : null
115+
};
116+
}
117+
return {
118+
name: (arg as LegacyFlatIconDefinition).name,
119+
icon: normalizeIconDefinition(arg as IconDefinition),
120+
rhUiIcon: null
121+
};
122+
}
123+
124+
/** Renders an inner `<svg>` with viewBox and path(s) for the dual-SVG (CSS swap) layout. */
125+
const createSvg = (icon: IconDefinitionWithSvgPathData, iconClassName: string) => {
36126
const { xOffset, yOffset, width, height, svgPathData, svgClassName } = icon ?? {};
37127
const _xOffset = xOffset ?? 0;
38128
const _yOffset = yOffset ?? 0;
@@ -64,9 +154,33 @@ const createSvg = (icon: IconDefinition, iconClassName: string) => {
64154
};
65155

66156
/**
67-
* Factory to create Icon class components for consumers
157+
* Builds a React **class** component that renders a PatternFly SVG icon (`role="img"`, optional `<title>` for a11y).
158+
*
159+
* **Argument shape — pick one:**
160+
*
161+
* 1. **`CreateIconProps` (preferred)** — `{ name?, icon?, rhUiIcon? }`. Dimensions and path data sit on `icon`
162+
* (and optionally on `rhUiIcon` for Red Hat UI–mapped icons). If the object **has an `icon` or `rhUiIcon` key**
163+
* (including `rhUiIcon: null`), this shape is assumed.
164+
*
165+
* 2. **Legacy flat `IconDefinition`** — the same fields as `icon`, but at the **top level** (no nested `icon`).
166+
* Still accepted so existing callers are not broken. Prefer migrating to `CreateIconProps`.
167+
*
168+
* **Path data on each `IconDefinition`:** use `svgPathData` (string or {@link SVGPathObject}[]). The old name
169+
* `svgPath` is deprecated but still read; `svgPathData` wins if both are present.
170+
*
171+
* **Default vs RH UI rendering:** If `rhUiIcon` is set and the consumer does **not** pass `set` on the component,
172+
* the output is an outer `<svg.pf-v6-svg>` containing **two** inner `<svg>`s (default + rh-ui) so CSS can swap
173+
* which variant is visible. If `set` is `"default"` or `"rh-ui"`, a **single** flat `<svg>` is rendered for that
174+
* variant. Requesting `set="rh-ui"` when there is no `rhUiIcon` falls back to the default glyph and logs a
175+
* `console.warn` (see implementation).
176+
*
177+
* @param arg Icon configuration: either {@link CreateIconProps} (nested `icon` / `rhUiIcon`) or a legacy flat
178+
* {@link LegacyFlatIconDefinition}. Runtime detection follows the rules in **Argument shape** above.
179+
* @returns A `ComponentClass<SVGIconProps>` — render it as `<YourIcon />` or with `title`, `className`, `set`, etc.
68180
*/
69-
export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): React.ComponentClass<SVGIconProps> {
181+
export function createIcon(arg: CreateIconProps | LegacyFlatIconDefinition): React.ComponentClass<SVGIconProps> {
182+
const { name, icon, rhUiIcon = null } = normalizeCreateIconArg(arg);
183+
70184
return class SVGIcon extends Component<SVGIconProps> {
71185
static displayName = name;
72186

@@ -76,10 +190,7 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re
76190
noDefaultStyle: false
77191
};
78192

79-
constructor(props: SVGIconProps) {
80-
super(props);
81-
}
82-
193+
/** Renders one root `<svg>`; either a single variant or nested inner SVGs for RH UI swap. */
83194
render() {
84195
const { title, className: propsClassName, set, noDefaultStyle, ...props } = this.props;
85196

@@ -98,8 +209,10 @@ export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): Re
98209
}
99210

100211
if ((set === undefined && rhUiIcon === null) || set !== undefined) {
101-
const iconData = set !== undefined && set === 'rh-ui' && rhUiIcon !== null ? rhUiIcon : icon;
102-
const { xOffset, yOffset, width, height, svgPathData, svgClassName } = iconData ?? {};
212+
const iconData: IconDefinitionWithSvgPathData | undefined =
213+
set !== undefined && set === 'rh-ui' && rhUiIcon !== null ? rhUiIcon : icon;
214+
const { xOffset, yOffset, width, height, svgPathData, svgClassName } =
215+
iconData ?? ({} as Partial<IconDefinitionWithSvgPathData>);
103216
const _xOffset = xOffset ?? 0;
104217
const _yOffset = yOffset ?? 0;
105218
const viewBox = [_xOffset, _yOffset, width, height].join(' ');

0 commit comments

Comments
 (0)