Skip to content

Commit 0129863

Browse files
committed
feat(icons): add withSpin HOC for spinning icon variants
Adds a reusable higher-order component that wraps any icon to apply a continuous CSS rotation animation, useful for loading indicators.
1 parent b621fbe commit 0129863

5 files changed

Lines changed: 108 additions & 11 deletions

File tree

packages/icons/src/__tests__/icons.test.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { createRef } from 'react';
22
import { render } from '@testing-library/react';
3-
import { IconClose, IconPlus, IconSearch, IconHeart, IconStar } from '../index';
3+
import { IconClose, IconPlus, IconSearch, IconHeart, IconStar, withSpin } from '../index';
44

55
const sampleIcons = [
66
{ name: 'IconClose', Component: IconClose },
@@ -71,4 +71,46 @@ describe('Icon components', () => {
7171
expect(svg.getAttribute('data-testid')).toBe('close-icon');
7272
expect(svg.getAttribute('aria-hidden')).toBe('true');
7373
});
74+
75+
});
76+
77+
describe('withSpin HOC', () => {
78+
const SpinClose = withSpin(IconClose);
79+
80+
it('applies spin animation style', () => {
81+
const { container } = render(<SpinClose />);
82+
const svg = container.querySelector('svg')!;
83+
expect(svg.style.animation).toBe('tiny-icon-spin 1s linear infinite');
84+
});
85+
86+
it('injects keyframes style tag into document head', () => {
87+
render(<SpinClose />);
88+
const styleEl = document.getElementById('__tiny_icon_spin__');
89+
expect(styleEl).toBeTruthy();
90+
expect(styleEl!.textContent).toContain('@keyframes tiny-icon-spin');
91+
});
92+
93+
it('passes through icon props', () => {
94+
const { container } = render(<SpinClose size={24} color="red" />);
95+
const svg = container.querySelector('svg')!;
96+
expect(svg.getAttribute('width')).toBe('24');
97+
expect(svg.getAttribute('fill')).toBe('red');
98+
});
99+
100+
it('merges custom style with spin animation', () => {
101+
const { container } = render(<SpinClose style={{ color: 'red' }} />);
102+
const svg = container.querySelector('svg')!;
103+
expect(svg.style.animation).toBe('tiny-icon-spin 1s linear infinite');
104+
expect(svg.style.color).toBe('red');
105+
});
106+
107+
it('forwards ref', () => {
108+
const ref = createRef<SVGSVGElement>();
109+
render(<SpinClose ref={ref} />);
110+
expect(ref.current).toBeInstanceOf(SVGSVGElement);
111+
});
112+
113+
it('sets displayName', () => {
114+
expect(SpinClose.displayName).toBe('withSpin(IconClose)');
115+
});
74116
});

packages/icons/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type { IconProps } from './types';
2+
export { withSpin } from './with-spin';
23

34
export { IconLoader } from './icon-loader';
45
export { IconLoader3quarter } from './icon-loader-3quarter';

packages/icons/src/with-spin.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { forwardRef, useRef, type ForwardRefExoticComponent, type RefAttributes, type CSSProperties } from 'react';
2+
import type { IconProps } from './types';
3+
4+
type IconComponent = ForwardRefExoticComponent<IconProps & RefAttributes<SVGSVGElement>>;
5+
6+
const STYLE_ID = '__tiny_icon_spin__';
7+
8+
function ensureKeyframes() {
9+
if (typeof document === 'undefined') return;
10+
if (document.getElementById(STYLE_ID)) return;
11+
const style = document.createElement('style');
12+
style.id = STYLE_ID;
13+
style.textContent =
14+
'@keyframes tiny-icon-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
15+
document.head.appendChild(style);
16+
}
17+
18+
const spinStyle: CSSProperties = { animation: 'tiny-icon-spin 1s linear infinite' };
19+
20+
export function withSpin(Icon: IconComponent): IconComponent {
21+
const SpinIcon = forwardRef<SVGSVGElement, IconProps>((props, ref) => {
22+
const injected = useRef(false);
23+
if (!injected.current) {
24+
ensureKeyframes();
25+
injected.current = true;
26+
}
27+
28+
return (
29+
<Icon
30+
ref={ref}
31+
{...props}
32+
style={{ ...spinStyle, ...props.style }}
33+
/>
34+
);
35+
});
36+
37+
const name = Icon.displayName || 'Icon';
38+
SpinIcon.displayName = `withSpin(${name})`;
39+
40+
return SpinIcon;
41+
}

packages/react/src/icon/demo/svg-spin.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22

33
### Spin
44

5-
Add a CSS animation to rotate icons. Useful for loading indicators.
5+
Use the `withSpin` HOC to create a spinning variant of any icon. Useful for loading indicators.
66

77
```jsx live
88
() => {
9-
const spinStyle = {
10-
animation: 'spin 1s linear infinite',
11-
};
9+
const SpinLoader = withSpin(IconLoader);
10+
const SpinLoaderQuarter = withSpin(IconLoaderQuarter);
11+
const SpinLoader3quarter = withSpin(IconLoader3quarter);
12+
const SpinLoaderCircle = withSpin(IconLoaderCircle);
13+
const SpinSync = withSpin(IconSync);
1214
return (
1315
<div style={{ display: 'flex', gap: 16, alignItems: 'center', fontSize: 24 }}>
14-
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
15-
<IconLoader style={spinStyle} />
16-
<IconLoaderQuarter style={spinStyle} />
17-
<IconLoader3quarter style={spinStyle} />
18-
<IconLoaderCircle style={spinStyle} />
19-
<IconSync style={spinStyle} />
16+
<SpinLoader />
17+
<SpinLoaderQuarter />
18+
<SpinLoader3quarter />
19+
<SpinLoaderCircle />
20+
<SpinSync />
2021
</div>
2122
);
2223
}

packages/react/src/icon/index.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ All icon components share the same props interface (`IconProps`), which extends
4242
| style | inline styles | CSSProperties | - |
4343
| ref | forwarded ref | Ref\<SVGSVGElement\> | - |
4444

45+
### withSpin
46+
47+
A higher-order component that wraps any icon to add a continuous spin animation.
48+
49+
```jsx
50+
import { IconLoader, withSpin } from '@tiny-design/icons';
51+
52+
const SpinLoader = withSpin(IconLoader);
53+
54+
<SpinLoader size={24} />
55+
```
56+
4557
## List of icons
4658

4759
<SvgIconList />

0 commit comments

Comments
 (0)