Skip to content

Commit c8da3a3

Browse files
authored
Merge pull request #625 from FortAwesome/feat/support-custom-gradient-fills
feat(style): add support for custom gradient fills
2 parents f54a9c2 + 2282d16 commit c8da3a3

File tree

11 files changed

+364
-6
lines changed

11 files changed

+364
-6
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ jobs:
3838
build-and-test:
3939
runs-on: ubuntu-latest
4040
needs: validate
41+
# Ensure we only run the full test matrix for pull requests and pushes to the main branch to save resources being spent on in-progress work.
42+
if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
4143
strategy:
4244
matrix:
4345
free-solid-svg-icons: [7.x, 6.x]

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"[typescriptreact]": {
2525
"editor.defaultFormatter": "esbenp.prettier-vscode"
2626
},
27+
"[yaml]": {
28+
"editor.defaultFormatter": "esbenp.prettier-vscode"
29+
},
2730
// Extension Settings
2831
"eslint.enable": true,
2932
"eslint.format.enable": false, // formatting is handled by prettier

src/__tests__/converter.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, { SVGAttributes } from 'react'
22

33
import type { AbstractElement } from '@fortawesome/fontawesome-svg-core'
44

@@ -79,6 +79,76 @@ describe('convert function performance', () => {
7979

8080
expect(mockCreateElement).toHaveBeenCalledTimes(2)
8181
})
82+
83+
it('should handle custom fill prop precedence', () => {
84+
const child = createMockElement('path', { fill: 'red', d: 'M0,0 L10,10' })
85+
const parent = createMockElement('svg', {}, [child])
86+
87+
convert(mockCreateElement, parent, {
88+
fill: 'blue',
89+
} as SVGAttributes<SVGSVGElement>)
90+
91+
expect(mockCreateElement).toHaveBeenNthCalledWith(
92+
1,
93+
'path',
94+
expect.objectContaining({
95+
fill: undefined, // Should be removed to allow prop to take precedence
96+
d: 'M0,0 L10,10',
97+
}),
98+
)
99+
100+
expect(mockCreateElement).toHaveBeenNthCalledWith(
101+
2,
102+
'svg',
103+
expect.objectContaining({
104+
fill: 'blue', // Should be applied to parent
105+
}),
106+
expect.objectContaining({
107+
props: {
108+
children: [],
109+
d: 'M0,0 L10,10',
110+
},
111+
type: 'path',
112+
}),
113+
)
114+
})
115+
116+
it('should handle gradientFill prop precedence', () => {
117+
const child = createMockElement('path', { fill: 'red', d: 'M0,0 L10,10' })
118+
const parent = createMockElement('svg', {}, [child])
119+
120+
convert(mockCreateElement, parent, {
121+
gradientFill: { id: 'gradient1' },
122+
} as FontAwesomeIconProps)
123+
124+
expect(mockCreateElement).toHaveBeenNthCalledWith(
125+
1,
126+
'path',
127+
expect.objectContaining({
128+
fill: undefined, // Should be removed to allow gradient to take precedence
129+
d: 'M0,0 L10,10',
130+
}),
131+
)
132+
133+
expect(mockCreateElement).toHaveBeenNthCalledWith(
134+
2,
135+
'radialGradient',
136+
expect.objectContaining({
137+
id: 'gradient1',
138+
}),
139+
expect.anything(), // Gradient stops would be here
140+
)
141+
142+
expect(mockCreateElement).toHaveBeenNthCalledWith(
143+
3,
144+
'svg',
145+
expect.objectContaining({
146+
fill: `url(#gradient1)`, // Should be applied to parent
147+
}),
148+
expect.anything(),
149+
expect.anything(),
150+
)
151+
})
82152
})
83153

84154
describe('style parsing performance', () => {

src/converter.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable unicorn/no-array-callback-reference */
12
import React, {
23
HTMLAttributes,
34
RefAttributes,
@@ -7,7 +8,9 @@ import React, {
78

89
import type { AbstractElement } from '@fortawesome/fontawesome-svg-core'
910

11+
import type { FontAwesomeIconProps } from './types/icon-props'
1012
import { camelize } from './utils/camelize'
13+
import { createGradientStops } from './utils/gradients'
1114

1215
function capitalize(val: string): string {
1316
return val.charAt(0).toUpperCase() + val.slice(1)
@@ -80,14 +83,34 @@ export function convert<
8083
element: Omit<AbstractElement, 'attributes'> & {
8184
attributes: AttributesOverride
8285
},
83-
extraProps: Attr & RefAttributes<El> = {} as Attr & RefAttributes<El>,
86+
extraProps: Attr &
87+
RefAttributes<El> & {
88+
gradientFill?: FontAwesomeIconProps['gradientFill']
89+
} = {} as Attr & RefAttributes<El>,
8490
): React.JSX.Element {
8591
if (typeof element === 'string') {
8692
return element
8793
}
8894

8995
const children = (element.children || []).map((child) => {
90-
return convert(createElement, child)
96+
let element = child
97+
98+
if (
99+
('fill' in extraProps || extraProps.gradientFill) &&
100+
child.tag === 'path' &&
101+
'fill' in child.attributes
102+
) {
103+
// If a `fill` prop or a gradient is provided, remove the `fill` attribute from child elements to allow the prop to take precedence
104+
element = {
105+
...child,
106+
attributes: {
107+
...child.attributes,
108+
fill: undefined,
109+
} as AttributesOverride,
110+
}
111+
}
112+
113+
return convert(createElement, element)
91114
})
92115

93116
const elementAttributes: AttributesOverride = element.attributes || {}
@@ -120,6 +143,7 @@ export function convert<
120143
style: existingStyle,
121144
role: existingRole,
122145
'aria-label': ariaLabel,
146+
gradientFill,
123147
...remaining
124148
} = extraProps
125149

@@ -139,6 +163,28 @@ export function convert<
139163
attrs['aria-hidden'] = 'false'
140164
}
141165

166+
// If a `gradientFill` prop is provided, set the fill attribute to reference the gradient and create the gradient element
167+
if (gradientFill) {
168+
attrs.fill = `url(#${gradientFill.id})`
169+
170+
const {
171+
type: gradientType,
172+
stops: gradientStops = [],
173+
...gradientProps
174+
} = gradientFill
175+
176+
children.unshift(
177+
createElement(
178+
gradientType === 'linear' ? 'linearGradient' : 'radialGradient',
179+
{
180+
...gradientProps,
181+
id: gradientFill.id,
182+
},
183+
gradientStops.map(createGradientStops),
184+
),
185+
)
186+
}
187+
142188
return createElement(element.tag, { ...attrs, ...remaining }, ...children)
143189
}
144190

src/types/gradients.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export type GradientStop = {
2+
/** The offset of the gradient stop, specified as a percentage (e.g., '0%', '50%', '100%') or a number between 0 and 1 (e.g., 0, 0.5, 1). */
3+
offset: string | number
4+
/** The color of the gradient stop, specified as a valid CSS color string (e.g., '#FF0000', 'rgb(255, 0, 0)', 'red'). */
5+
color: string
6+
/** The opacity of the gradient stop, specified as a number between 0 and 1 (e.g., 0, 0.5, 1). This is optional and defaults to 1 if not provided. */
7+
opacity?: number | undefined
8+
}
9+
10+
export type LinearGradient = {
11+
/**
12+
* The `id` of the gradient, which is used to reference the gradient in the `fill` attribute of the icon's svg element.
13+
* This must be a unique value to prevent conflicts with other elements on the page.
14+
*/
15+
id: string
16+
/** The x-coordinate of the start of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
17+
x1?: number | string | undefined
18+
/** The x-coordinate of the end of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
19+
x2?: number | string | undefined
20+
/** The y-coordinate of the start of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
21+
y1?: number | string | undefined
22+
/** The y-coordinate of the end of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
23+
y2?: number | string | undefined
24+
stops: GradientStop[]
25+
}
26+
27+
export type RadialGradient = {
28+
/**
29+
* The `id` of the gradient, which is used to reference the gradient in the `fill` attribute of the icon's svg element.
30+
* This must be a unique value to prevent conflicts with other elements on the page.
31+
*/
32+
id: string
33+
/** The x-coordinate of the center of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
34+
cx?: number | string | undefined
35+
/** The y-coordinate of the center of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
36+
cy?: number | string | undefined
37+
/** The radius of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
38+
r?: number | string | undefined
39+
/** The x-coordinate of the focal point of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
40+
fx?: number | string | undefined
41+
/** The y-coordinate of the focal point of the gradient. Can be a number or a string (e.g., '0%', '50%', '100%'). */
42+
fy?: number | string | undefined
43+
stops: GradientStop[]
44+
}
45+
46+
export type Gradient<T extends 'linear' | 'radial'> = T extends 'linear'
47+
? { type: 'linear' } & LinearGradient
48+
: T extends 'radial'
49+
? { type: 'radial' } & RadialGradient
50+
: never

src/types/icon-props.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { IconProp, SizeProp } from '@fortawesome/fontawesome-svg-core'
44

55
import type { AnimationProps } from './animation-props'
66
import { CSSVariables } from './css-variables'
7+
import { Gradient } from './gradients'
78
import type { TransformProps } from './transform-props'
89

910
export interface FontAwesomeIconProps
@@ -30,6 +31,57 @@ export interface FontAwesomeIconProps
3031
className?: string | undefined
3132
/** The color of the icon. Can be any valid CSS color value */
3233
color?: string | undefined
34+
/**
35+
* Creates a `<linearGradient />` or `<radialGradient />` element inside the icon svg, and applies it as a fill to the icon.
36+
*
37+
* If you also provide a `fill` prop, the `fill` prop will take precedence over the gradient.
38+
* Omit the `fill` prop to allow the gradient to be applied correctly.
39+
*
40+
* @example
41+
* Linear Gradient Example:
42+
* ```tsx
43+
* <FontAwesomeIcon
44+
* icon="coffee"
45+
* gradientFill={{
46+
* id: 'myGradient',
47+
* type: 'linear',
48+
* x1: '0%',
49+
* y1: '0%',
50+
* x2: '100%',
51+
* y2: '0%',
52+
* stops: [
53+
* { offset: '0%', color: '#FF5F6D' },
54+
* { offset: '100%', color: '#FFC371' },
55+
* ],
56+
* }}
57+
* />
58+
* ```
59+
*
60+
* @example
61+
* Radial Gradient Example:
62+
* ```tsx
63+
* <FontAwesomeIcon
64+
* icon="coffee"
65+
* gradientFill={{
66+
* id: 'myGradient',
67+
* type: 'radial',
68+
* r: '150%',
69+
* cx: '30%',
70+
* cy: '107%',
71+
* stops: [
72+
* { offset: '0', color: '#FDF497' },
73+
* { offset: '0.05', color: '#FDF497' },
74+
* { offset: '0.45', color: '#FD5949' },
75+
* { offset: '0.6', color: '#D6249F' },
76+
* { offset: '0.9', color: '#285AEB' },
77+
* ],
78+
* }}
79+
* />
80+
* ```
81+
*
82+
* NOTE: Only supports one gradient type, providing both linear and radial gradient props will have undesired side-effects.
83+
*/
84+
gradientFill?: Gradient<'linear'> | Gradient<'radial'> | undefined
3385
/**
3486
* Applies a border to the icon
3587
* @see {@link https://docs.fontawesome.com/web/use-with/react/style#bordered-icons}

0 commit comments

Comments
 (0)