Skip to content

Commit faf45e4

Browse files
committed
feat(icons): investigating pf-rh icon hotswapping
1 parent bc46a52 commit faf45e4

3 files changed

Lines changed: 471 additions & 45 deletions

File tree

packages/react-icons/scripts/writeIcons.mjs

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { outputFileSync, ensureDirSync } from 'fs-extra/esm';
33
import { generateIcons } from './generateIcons.mjs';
44
import { createElement } from 'react';
55
import { renderToString } from 'react-dom/server';
6-
6+
import { pfToRhIcons } from '../src/pfToRhIcons.js';
77
import * as url from 'url';
88
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
99

@@ -17,39 +17,31 @@ const staticDir = join(outDir, 'static');
1717
const removeSnake = (s) => s.toUpperCase().replace('-', '').replace('_', '');
1818
const toCamel = (s) => `${s[0].toUpperCase()}${s.substr(1).replace(/([-_][\w])/gi, removeSnake)}`;
1919

20-
const writeCJSExport = (fname, jsName, icon) => {
20+
const writeCJSExport = (fname, jsName, icon, rhUiIcon = null) => {
2121
outputFileSync(
2222
join(outDir, 'js/icons', `${fname}.js`),
2323
`"use strict"
2424
exports.__esModule = true;
2525
exports.${jsName}Config = {
2626
name: '${jsName}',
27-
height: ${icon.height},
28-
width: ${icon.width},
29-
svgPath: ${JSON.stringify(icon.svgPathData)},
30-
yOffset: ${icon.yOffset || 0},
31-
xOffset: ${icon.xOffset || 0},
32-
svgClassName: ${JSON.stringify(icon.svgClassName)},
27+
icon: ${JSON.stringify(icon)},
28+
rhUiIcon: ${rhUiIcon ? JSON.stringify(rhUiIcon) : 'null'},
3329
};
3430
exports.${jsName} = require('../createIcon').createIcon(exports.${jsName}Config);
3531
exports["default"] = exports.${jsName};
3632
`.trim()
3733
);
3834
};
3935

40-
const writeESMExport = (fname, jsName, icon) => {
36+
const writeESMExport = (fname, jsName, icon, rhUiIcon = null) => {
4137
outputFileSync(
4238
join(outDir, 'esm/icons', `${fname}.js`),
4339
`import { createIcon } from '../createIcon';
4440
4541
export const ${jsName}Config = {
4642
name: '${jsName}',
47-
height: ${icon.height},
48-
width: ${icon.width},
49-
svgPath: ${JSON.stringify(icon.svgPathData)},
50-
yOffset: ${icon.yOffset || 0},
51-
xOffset: ${icon.xOffset || 0},
52-
svgClassName: ${JSON.stringify(icon.svgClassName)},
43+
icon: ${JSON.stringify(icon)},
44+
rhUiIcon: ${rhUiIcon ? JSON.stringify(rhUiIcon) : 'null'},
5345
};
5446
5547
export const ${jsName} = createIcon(${jsName}Config);
@@ -59,17 +51,13 @@ export default ${jsName};
5951
);
6052
};
6153

62-
const writeDTSExport = (fname, jsName, icon) => {
54+
const writeDTSExport = (fname, jsName, icon, rhUiIcon = null) => {
6355
const text = `import { ComponentClass } from 'react';
6456
import { SVGIconProps } from '../createIcon';
6557
export declare const ${jsName}Config: {
6658
name: '${jsName}',
67-
height: ${icon.height},
68-
width: ${icon.width},
69-
svgPath: ${JSON.stringify(icon.svgPathData)},
70-
yOffset: ${icon.yOffset || 0},
71-
xOffset: ${icon.xOffset || 0},
72-
svgClassName: ${JSON.stringify(icon.svgClassName)},
59+
icon: ${JSON.stringify(icon)},
60+
rhUiIcon: ${rhUiIcon ? JSON.stringify(rhUiIcon) : 'null'},
7361
};
7462
export declare const ${jsName}: ComponentClass<SVGIconProps>;
7563
export default ${jsName};
@@ -133,9 +121,11 @@ function writeIcons(icons) {
133121
Object.entries(icons).forEach(([iconName, icon]) => {
134122
const fname = `${iconName}-icon`;
135123
const jsName = `${toCamel(iconName)}Icon`;
136-
writeESMExport(fname, jsName, icon);
137-
writeCJSExport(fname, jsName, icon);
138-
writeDTSExport(fname, jsName, icon);
124+
125+
const altIcon = pfToRhIcons[jsName] ? pfToRhIcons[jsName].icon : null;
126+
writeESMExport(fname, jsName, icon, altIcon);
127+
writeCJSExport(fname, jsName, icon, altIcon);
128+
writeDTSExport(fname, jsName, icon, altIcon);
139129

140130
index.push({ fname, jsName });
141131
});

packages/react-icons/src/createIcon.tsx

Lines changed: 140 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,96 @@ export interface SVGPathObject {
44
path: string;
55
className?: string;
66
}
7+
78
export interface IconDefinition {
89
name?: string;
910
width: number;
1011
height: number;
11-
svgPath: string | SVGPathObject[];
12+
svgPathData: string | SVGPathObject[];
1213
xOffset?: number;
1314
yOffset?: number;
1415
svgClassName?: string;
1516
}
1617

18+
export interface CreateIconProps extends IconDefinition {
19+
name?: string;
20+
icon?: IconDefinition;
21+
rhUiIcon?: IconDefinition | null;
22+
}
23+
1724
export interface SVGIconProps extends Omit<React.HTMLProps<SVGElement>, 'ref'> {
1825
title?: string;
1926
className?: string;
27+
/* Indicates the icon should render using alternate svg data for unified theme */
28+
set?: 'default' | 'unified';
2029
}
2130

2231
let currentId = 0;
32+
const canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement);
2333

2434
/**
2535
* Factory to create Icon class components for consumers
2636
*/
27-
export function createIcon({
28-
name,
29-
xOffset = 0,
30-
yOffset = 0,
31-
width,
32-
height,
33-
svgPath,
34-
svgClassName
35-
}: IconDefinition): React.ComponentClass<SVGIconProps> {
36-
return class SVGIcon extends Component<SVGIconProps> {
37+
export function createIcon({ name, icon, rhUiIcon = null }: CreateIconProps): React.ComponentClass<SVGIconProps> {
38+
return class SVGIcon extends Component<SVGIconProps, { themeClassVersion: number }> {
3739
static displayName = name;
3840

3941
id = `icon-title-${currentId++}`;
42+
private observer: MutationObserver | null = null;
43+
44+
constructor(props: SVGIconProps) {
45+
super(props);
46+
this.state = { themeClassVersion: 0 };
47+
}
48+
49+
componentDidMount() {
50+
if (rhUiIcon !== null && canUseDOM) {
51+
this.observer = new MutationObserver((mutations) => {
52+
for (const mutation of mutations) {
53+
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
54+
const target = mutation.target as HTMLElement;
55+
const hadClass = (mutation.oldValue || '').includes('pf-v6-theme-unified');
56+
const hasClass = target.classList.contains('pf-v6-theme-unified');
57+
58+
if (hadClass !== hasClass && this.props.set === undefined) {
59+
this.setState((prevState) => ({
60+
themeClassVersion: prevState.themeClassVersion + 1
61+
}));
62+
}
63+
}
64+
}
65+
});
66+
67+
this.observer.observe(document.documentElement, {
68+
attributes: true,
69+
attributeFilter: ['class'],
70+
attributeOldValue: true
71+
});
72+
}
73+
}
74+
75+
componentWillUnmount() {
76+
if (this.observer) {
77+
this.observer.disconnect();
78+
this.observer = null;
79+
}
80+
}
4081

4182
render() {
42-
const { title, className: propsClassName, ...props } = this.props;
83+
const { title, className: propsClassName, set, ...props } = this.props;
84+
85+
const shouldUseAltData =
86+
rhUiIcon !== null &&
87+
(set === 'unified' ||
88+
(set === undefined && canUseDOM && document.documentElement.classList.contains('pf-v6-theme-unified')));
89+
90+
const iconData = shouldUseAltData ? rhUiIcon : icon;
91+
const { xOffset, yOffset, width, height, svgClassName, svgPathData } = iconData ?? {};
92+
const _xOffset = xOffset ?? 0;
93+
const _yOffset = yOffset ?? 0;
4394

4495
const hasTitle = Boolean(title);
45-
const viewBox = [xOffset, yOffset, width, height].join(' ');
96+
const viewBox = [_xOffset, _yOffset, width, height].join(' ');
4697

4798
const classNames = ['pf-v6-svg'];
4899
if (svgClassName) {
@@ -52,6 +103,14 @@ export function createIcon({
52103
classNames.push(propsClassName);
53104
}
54105

106+
const svgPaths = Array.isArray(svgPathData) ? (
107+
svgPathData.map((pathObject, index) => (
108+
<path className={pathObject.className} key={`${pathObject.path}-${index}`} d={pathObject.path} />
109+
))
110+
) : (
111+
<path d={svgPathData as string} />
112+
);
113+
55114
return (
56115
<svg
57116
className={classNames.join(' ')}
@@ -65,15 +124,76 @@ export function createIcon({
65124
{...(props as Omit<React.SVGProps<SVGElement>, 'ref'>)} // Lie.
66125
>
67126
{hasTitle && <title id={this.id}>{title}</title>}
68-
{Array.isArray(svgPath) ? (
69-
svgPath.map((pathObject, index) => (
70-
<path className={pathObject.className} key={`${pathObject.path}-${index}`} d={pathObject.path} />
71-
))
72-
) : (
73-
<path d={svgPath} />
74-
)}
127+
{svgPaths}
75128
</svg>
76129
);
130+
131+
// Alternate CSS method tinkering
132+
// TODO: remove or refactor to use this method
133+
// Below works for paths, but not viewbox without needing the MutationObserver which would be nice to not have when using CSS method
134+
// May be able to use two separate svgs instead of paths instead if going this route
135+
136+
// const defaultSvgPathData = Array.isArray(icon?.svgPathData) ? (
137+
// icon?.svgPathData.map((pathObject, index) => (
138+
// <path className={pathObject.className} key={`${pathObject.path}-${index}`} d={pathObject.path} />
139+
// ))
140+
// ) : (
141+
// <path d={icon?.svgPathData as string} />
142+
// );
143+
144+
// const rhUiSvgPathData =
145+
// rhUiIcon?.svgPathData && Array.isArray(rhUiIcon?.svgPathData) ? (
146+
// rhUiIcon?.svgPathData.map((pathObject, index) => (
147+
// <path className={pathObject.className} key={`${pathObject.path}-${index}`} d={pathObject.path} />
148+
// ))
149+
// ) : (
150+
// <path d={rhUiIcon?.svgPathData as string} />
151+
// );
152+
153+
// const finalSvgPath =
154+
// rhUiIcon !== null ? (
155+
// <>
156+
// <g className="pf-icon">{defaultSvgPathData}</g>
157+
// <g className="rh-icon">{rhUiSvgPathData}</g>
158+
// </>
159+
// ) : (
160+
// pfSvgPathData
161+
// );
162+
163+
// return (
164+
// <svg
165+
// className={classNames.join(' ')}
166+
// viewBox={viewBox}
167+
// fill="currentColor"
168+
// aria-labelledby={hasTitle ? this.id : null}
169+
// aria-hidden={hasTitle ? null : true}
170+
// role="img"
171+
// width="1em"
172+
// height="1em"
173+
// {...(props as Omit<React.SVGProps<SVGElement>, 'ref'>)} // Lie.
174+
// >
175+
// {hasTitle && <title id={this.id}>{title}</title>}
176+
// <style type="text/css">
177+
// {/* Testing CSS visibility switching */}
178+
// {/* TODO: move to css file, add to global styles */}
179+
// {`
180+
// .pf-v6-theme-unified {
181+
// .pf-icon {
182+
// display: none;
183+
// }
184+
// .rh-icon {
185+
// display: block;
186+
// }
187+
// }
188+
// .rh-icon {
189+
// display: none;
190+
// }
191+
192+
// `}
193+
// </style>
194+
// {finalSvgPath}
195+
// </svg>
196+
// );
77197
}
78198
};
79199
}

0 commit comments

Comments
 (0)