Skip to content

Commit 99aa02e

Browse files
committed
feat: add gradient support to Circles component and enhance type definitions (fixes #188)
1 parent dc00f00 commit 99aa02e

File tree

5 files changed

+119
-60
lines changed

5 files changed

+119
-60
lines changed

package-lock.json

Lines changed: 18 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@
5252
"jest": "^30.1.3",
5353
"jest-environment-jsdom": "^30.1.2",
5454
"parcel": "^2.15.4",
55-
"tsup": "^8.3.0",
5655
"prettier": "3.6.2",
5756
"react": "19.1.1",
5857
"react-dom": "19.1.1",
5958
"ts-jest": "^29.4.4",
59+
"tsup": "^8.3.0",
6060
"typescript": "^5.9.2"
6161
},
6262
"peerDependencies": {

src/loader/circles.tsx

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,65 @@ import React, { FunctionComponent, ReactElement } from 'react'
22
import { BaseProps, DEFAULT_COLOR, DEFAULT_WAI_ARIA_ATTRIBUTE } from '../type'
33
import { SvgWrapper } from '../shared/svg-wrapper'
44
import { SVG_NAMESPACE } from '../shared/constants'
5+
import { useGradient } from '../shared/gradient'
56

67
interface CirclesProps extends BaseProps {}
78

89
export const Circles: FunctionComponent<CirclesProps> = ({
910
height = 80,
1011
width = 80,
1112
color = DEFAULT_COLOR,
13+
colors,
14+
gradientType,
15+
gradientAngle,
1216
ariaLabel = 'circles-loading',
1317
wrapperStyle,
1418
wrapperClass,
1519
visible = true,
16-
}): ReactElement => (
17-
<SvgWrapper
18-
style={wrapperStyle}
19-
$visible={visible}
20-
className={wrapperClass}
21-
aria-label={ariaLabel}
22-
data-testid="circles-loading"
23-
{...DEFAULT_WAI_ARIA_ATTRIBUTE}
24-
>
25-
<svg
26-
width={width}
27-
height={height}
28-
viewBox="0 0 135 135"
29-
xmlns={SVG_NAMESPACE}
30-
fill={color}
31-
data-testid="circles-svg"
20+
}): ReactElement => {
21+
const { defs, url } = useGradient({ colors, gradientType, gradientAngle })
22+
const fillValue = url || color
23+
return (
24+
<SvgWrapper
25+
style={wrapperStyle}
26+
$visible={visible}
27+
className={wrapperClass}
28+
aria-label={ariaLabel}
29+
data-testid="circles-loading"
30+
{...DEFAULT_WAI_ARIA_ATTRIBUTE}
3231
>
33-
<title>circles-loading</title>
34-
<desc>Animated representation of circles</desc>
35-
<path d="M67.447 58c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10zm9.448 9.447c0 5.523 4.477 10 10 10 5.522 0 10-4.477 10-10s-4.478-10-10-10c-5.523 0-10 4.477-10 10zm-9.448 9.448c-5.523 0-10 4.477-10 10 0 5.522 4.477 10 10 10s10-4.478 10-10c0-5.523-4.477-10-10-10zM58 67.447c0-5.523-4.477-10-10-10s-10 4.477-10 10 4.477 10 10 10 10-4.477 10-10z">
36-
<animateTransform
37-
attributeName="transform"
38-
type="rotate"
39-
from="0 67 67"
40-
to="-360 67 67"
41-
dur="2.5s"
42-
repeatCount="indefinite"
43-
/>
44-
</path>
45-
<path d="M28.19 40.31c6.627 0 12-5.374 12-12 0-6.628-5.373-12-12-12-6.628 0-12 5.372-12 12 0 6.626 5.372 12 12 12zm30.72-19.825c4.686 4.687 12.284 4.687 16.97 0 4.686-4.686 4.686-12.284 0-16.97-4.686-4.687-12.284-4.687-16.97 0-4.687 4.686-4.687 12.284 0 16.97zm35.74 7.705c0 6.627 5.37 12 12 12 6.626 0 12-5.373 12-12 0-6.628-5.374-12-12-12-6.63 0-12 5.372-12 12zm19.822 30.72c-4.686 4.686-4.686 12.284 0 16.97 4.687 4.686 12.285 4.686 16.97 0 4.687-4.686 4.687-12.284 0-16.97-4.685-4.687-12.283-4.687-16.97 0zm-7.704 35.74c-6.627 0-12 5.37-12 12 0 6.626 5.373 12 12 12s12-5.374 12-12c0-6.63-5.373-12-12-12zm-30.72 19.822c-4.686-4.686-12.284-4.686-16.97 0-4.686 4.687-4.686 12.285 0 16.97 4.686 4.687 12.284 4.687 16.97 0 4.687-4.685 4.687-12.283 0-16.97zm-35.74-7.704c0-6.627-5.372-12-12-12-6.626 0-12 5.373-12 12s5.374 12 12 12c6.628 0 12-5.373 12-12zm-19.823-30.72c4.687-4.686 4.687-12.284 0-16.97-4.686-4.686-12.284-4.686-16.97 0-4.687 4.686-4.687 12.284 0 16.97 4.686 4.687 12.284 4.687 16.97 0z">
46-
<animateTransform
47-
attributeName="transform"
48-
type="rotate"
49-
from="0 67 67"
50-
to="360 67 67"
51-
dur="8s"
52-
repeatCount="indefinite"
53-
/>
54-
</path>
55-
</svg>
56-
</SvgWrapper>
57-
)
32+
<svg
33+
width={width}
34+
height={height}
35+
viewBox="0 0 135 135"
36+
xmlns={SVG_NAMESPACE}
37+
fill={fillValue}
38+
data-testid="circles-svg"
39+
>
40+
{defs && <defs>{defs}</defs>}
41+
<title>circles-loading</title>
42+
<desc>Animated representation of circles</desc>
43+
<path d="M67.447 58c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10zm9.448 9.447c0 5.523 4.477 10 10 10 5.522 0 10-4.477 10-10s-4.478-10-10-10c-5.523 0-10 4.477-10 10zm-9.448 9.448c-5.523 0-10 4.477-10 10 0 5.522 4.477 10 10 10s10-4.478 10-10c0-5.523-4.477-10-10-10zM58 67.447c0-5.523-4.477-10-10-10s-10 4.477-10 10 4.477 10 10 10 10-4.477 10-10z">
44+
<animateTransform
45+
attributeName="transform"
46+
type="rotate"
47+
from="0 67 67"
48+
to="-360 67 67"
49+
dur="2.5s"
50+
repeatCount="indefinite"
51+
/>
52+
</path>
53+
<path d="M28.19 40.31c6.627 0 12-5.374 12-12 0-6.628-5.373-12-12-12-6.628 0-12 5.372-12 12 0 6.626 5.372 12 12 12zm30.72-19.825c4.686 4.687 12.284 4.687 16.97 0 4.686-4.686 4.686-12.284 0-16.97-4.686-4.687-12.284-4.687-16.97 0-4.687 4.686-4.687 12.284 0 16.97zm35.74 7.705c0 6.627 5.37 12 12 12 6.626 0 12-5.373 12-12 0-6.628-5.374-12-12-12-6.63 0-12 5.372-12 12zm19.822 30.72c-4.686 4.686-4.686 12.284 0 16.97 4.687 4.686 12.285 4.686 16.97 0 4.687-4.686 4.687-12.284 0-16.97-4.685-4.687-12.283-4.687-16.97 0zm-7.704 35.74c-6.627 0-12 5.37-12 12 0 6.626 5.373 12 12 12s12-5.374 12-12c0-6.63-5.373-12-12-12zm-30.72 19.822c-4.686-4.686-12.284-4.686-16.97 0-4.686 4.687-4.686 12.285 0 16.97 4.686 4.687 12.284 4.687 16.97 0 4.687-4.685 4.687-12.283 0-16.97zm-35.74-7.704c0-6.627-5.372-12-12-12-6.626 0-12 5.373-12 12s5.374 12 12 12c6.628 0 12-5.373 12-12zm-19.823-30.72c4.687-4.686 4.687-12.284 0-16.97-4.686-4.686-12.284-4.686-16.97 0-4.687 4.686-4.687 12.284 0 16.97 4.686 4.687 12.284 4.687 16.97 0z">
54+
<animateTransform
55+
attributeName="transform"
56+
type="rotate"
57+
from="0 67 67"
58+
to="360 67 67"
59+
dur="8s"
60+
repeatCount="indefinite"
61+
/>
62+
</path>
63+
</svg>
64+
</SvgWrapper>
65+
)
66+
}

src/shared/gradient.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React, { useMemo } from 'react'
2+
3+
export interface GradientConfig {
4+
colors?: string[]
5+
gradientType?: 'linear' | 'radial'
6+
gradientAngle?: number
7+
}
8+
9+
export const useGradient = ({
10+
colors,
11+
gradientType = 'linear',
12+
gradientAngle = 0,
13+
}: GradientConfig) => {
14+
const isGradient = !!colors && colors.length > 1
15+
// Lightweight id generator to avoid pulling ESM-only libs for test env
16+
const gradientId = useMemo(() => (isGradient ? `rls-grad-${Math.random().toString(36).slice(2, 8)}` : undefined), [isGradient])
17+
18+
const defs = useMemo(() => {
19+
if (!isGradient || !gradientId || !colors) return null
20+
if (gradientType === 'radial') {
21+
return (
22+
<radialGradient id={gradientId} cx="50%" cy="50%" r="50%">
23+
{colors.map((c, i) => (
24+
<stop key={c + i} offset={`${(i / (colors.length - 1)) * 100}%`} stopColor={c} />
25+
))}
26+
</radialGradient>
27+
)
28+
}
29+
return (
30+
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%" gradientTransform={`rotate(${gradientAngle})`}>
31+
{colors.map((c, i) => (
32+
<stop key={c + i} offset={`${(i / (colors.length - 1)) * 100}%`} stopColor={c} />
33+
))}
34+
</linearGradient>
35+
)
36+
}, [colors, gradientAngle, gradientId, gradientType, isGradient])
37+
38+
const url = gradientId ? `url(#${gradientId})` : undefined
39+
return { isGradient, gradientId, defs, url }
40+
}

src/type.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,14 @@ export interface PrimaryProps {
2020

2121
export interface BaseProps extends PrimaryProps {
2222
color?: string
23+
/**
24+
* Provide multiple colors to render a gradient instead of a solid color.
25+
* When 2 or more colors are supplied a gradient <defs> will be injected and
26+
* the primary color reference (fill or stroke) becomes url(#gradientId).
27+
*/
28+
colors?: string[]
29+
/** Type of gradient (linear or radial). Defaults to linear. */
30+
gradientType?: 'linear' | 'radial'
31+
/** Angle (in degrees) applied via rotate() transform for linear gradients. */
32+
gradientAngle?: number
2333
}

0 commit comments

Comments
 (0)