Skip to content

Commit 9a0c806

Browse files
committed
refactor: clean up Typewriter components and improve code organization
- Create shared types file for typewriter components - Remove CSS-based tests and focus on functionality - Clean up unused typewriterVariants and CSS animations - Remove unnecessary comments and improve code readability - Update mocks and tests to use shared types - Maintain all existing functionality while improving maintainability
1 parent 2623256 commit 9a0c806

14 files changed

Lines changed: 359 additions & 178 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client";
2+
3+
import React, { useState, useEffect } from 'react';
4+
import { cn } from '@/lib';
5+
import { CenteredTypewriterProps } from './typewriter.types';
6+
7+
export function CenteredTypewriter({
8+
text,
9+
speed = 50,
10+
className,
11+
onComplete
12+
}: CenteredTypewriterProps) {
13+
const [displayText, setDisplayText] = useState('');
14+
const [isTyping, setIsTyping] = useState(false);
15+
const [isComplete, setIsComplete] = useState(false);
16+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
17+
18+
useEffect(() => {
19+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
20+
setPrefersReducedMotion(mediaQuery.matches);
21+
22+
const handleChange = (e: MediaQueryListEvent) => {
23+
setPrefersReducedMotion(e.matches);
24+
};
25+
26+
mediaQuery.addEventListener('change', handleChange);
27+
return () => mediaQuery.removeEventListener('change', handleChange);
28+
}, []);
29+
30+
useEffect(() => {
31+
setDisplayText('');
32+
setIsTyping(false);
33+
setIsComplete(false);
34+
}, [text]);
35+
36+
useEffect(() => {
37+
if (prefersReducedMotion) {
38+
setDisplayText(text);
39+
setIsComplete(true);
40+
onComplete?.();
41+
return;
42+
}
43+
44+
const timer = setTimeout(() => {
45+
setIsTyping(true);
46+
}, 100);
47+
48+
return () => clearTimeout(timer);
49+
}, [text, prefersReducedMotion, onComplete]);
50+
51+
useEffect(() => {
52+
if (!isTyping || prefersReducedMotion) {
53+
return;
54+
}
55+
56+
if (displayText.length < text.length) {
57+
const timer = setTimeout(() => {
58+
setDisplayText(text.slice(0, displayText.length + 1));
59+
}, speed);
60+
61+
return () => clearTimeout(timer);
62+
} else {
63+
setIsTyping(false);
64+
setIsComplete(true);
65+
onComplete?.();
66+
}
67+
}, [displayText, text, speed, isTyping, prefersReducedMotion, onComplete]);
68+
69+
const showCursor = !prefersReducedMotion && !isComplete;
70+
const visibleLength = prefersReducedMotion ? text.length : displayText.length;
71+
72+
return (
73+
<div
74+
className={cn("inline-block", className)}
75+
data-testid="typewriter-container"
76+
>
77+
<span className="font-mono">
78+
{text.split('').map((char, index) => (
79+
<React.Fragment key={index}>
80+
<span
81+
className={cn(
82+
"transition-colors duration-75",
83+
index < visibleLength
84+
? "text-current"
85+
: "text-transparent"
86+
)}
87+
>
88+
{char}
89+
</span>
90+
{showCursor && index === visibleLength - 1 && (
91+
<span
92+
data-testid="typewriter-cursor"
93+
className="animate-pulse text-current"
94+
>
95+
|
96+
</span>
97+
)}
98+
</React.Fragment>
99+
))}
100+
</span>
101+
</div>
102+
);
103+
}

src/components/micro/Typewriter.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,7 @@
22

33
import React, { useState, useEffect } from 'react';
44
import { cn } from '@/lib';
5-
6-
interface TypewriterProps {
7-
text: string;
8-
speed?: number;
9-
className?: string;
10-
onComplete?: () => void;
11-
}
5+
import { TypewriterProps } from './typewriter.types';
126

137
export function Typewriter({
148
text,
@@ -33,6 +27,12 @@ export function Typewriter({
3327
return () => mediaQuery.removeEventListener('change', handleChange);
3428
}, []);
3529

30+
useEffect(() => {
31+
setDisplayText('');
32+
setIsTyping(false);
33+
setIsComplete(false);
34+
}, [text]);
35+
3636
useEffect(() => {
3737
if (prefersReducedMotion) {
3838
setDisplayText(text);
@@ -41,8 +41,13 @@ export function Typewriter({
4141
return;
4242
}
4343

44-
setIsTyping(true);
44+
const timer = setTimeout(() => {
45+
setIsTyping(true);
46+
}, 100);
47+
48+
return () => clearTimeout(timer);
4549
}, [text, prefersReducedMotion, onComplete]);
50+
4651
useEffect(() => {
4752
if (!isTyping || prefersReducedMotion) {
4853
return;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react';
2+
import { CenteredTypewriterProps } from '../typewriter.types';
3+
4+
export function CenteredTypewriter({
5+
text,
6+
speed = 50,
7+
className,
8+
onComplete
9+
}: CenteredTypewriterProps) {
10+
return (
11+
<div
12+
className={className}
13+
data-testid="typewriter-container"
14+
>
15+
<span className="font-mono">
16+
{text.split('').map((char, index) => (
17+
<React.Fragment key={index}>
18+
<span
19+
className="transition-colors duration-75 text-current"
20+
>
21+
{char}
22+
</span>
23+
{index === text.length - 1 && (
24+
<span
25+
data-testid="typewriter-cursor"
26+
className="animate-pulse text-current"
27+
>
28+
|
29+
</span>
30+
)}
31+
</React.Fragment>
32+
))}
33+
</span>
34+
</div>
35+
);
36+
}
Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import React from 'react';
2+
import { TypewriterProps } from '../typewriter.types';
23

3-
interface TypewriterProps {
4-
text: string;
5-
className?: string;
6-
speed?: number;
7-
onComplete?: () => void;
8-
}
9-
10-
export function Typewriter({ text, className = '', onComplete }: TypewriterProps) {
11-
React.useEffect(() => {
12-
if (onComplete) {
13-
onComplete();
14-
}
15-
}, [onComplete]);
16-
17-
return <span className={className}>{text}</span>;
4+
export function Typewriter({
5+
text,
6+
speed = 50,
7+
className,
8+
onComplete
9+
}: TypewriterProps) {
10+
return (
11+
<div
12+
className={className}
13+
data-testid="typewriter-container"
14+
>
15+
<span className="font-mono">
16+
{text}
17+
<span
18+
data-testid="typewriter-cursor"
19+
className="animate-pulse"
20+
>
21+
|
22+
</span>
23+
</span>
24+
</div>
25+
);
1826
}

src/components/micro/__mocks__/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export * from './ProjectCard';
1010
export * from './ProgressBar';
1111
export * from './ThemeToggle';
1212
export * from './Typography';
13-
export * from './Typewriter';
13+
export * from './Typewriter';
14+
export * from './CenteredTypewriter';
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
import { render, screen, waitFor, act } from '@testing-library/react';
3+
import { CenteredTypewriter } from '../CenteredTypewriter';
4+
5+
jest.useFakeTimers();
6+
7+
describe('CenteredTypewriter', () => {
8+
beforeEach(() => {
9+
jest.clearAllTimers();
10+
});
11+
12+
it('renders with default props', () => {
13+
const originalMatchMedia = window.matchMedia;
14+
window.matchMedia = jest.fn().mockImplementation(query => ({
15+
matches: false,
16+
media: query,
17+
onchange: null,
18+
addListener: jest.fn(),
19+
removeListener: jest.fn(),
20+
addEventListener: jest.fn(),
21+
removeEventListener: jest.fn(),
22+
dispatchEvent: jest.fn(),
23+
}));
24+
25+
render(<CenteredTypewriter text="Hello World" />);
26+
expect(screen.getByTestId('typewriter-container')).toBeInTheDocument();
27+
28+
window.matchMedia = originalMatchMedia;
29+
});
30+
31+
it('types out text character by character', async () => {
32+
const originalMatchMedia = window.matchMedia;
33+
window.matchMedia = jest.fn().mockImplementation(query => ({
34+
matches: false,
35+
media: query,
36+
onchange: null,
37+
addListener: jest.fn(),
38+
removeListener: jest.fn(),
39+
addEventListener: jest.fn(),
40+
removeEventListener: jest.fn(),
41+
dispatchEvent: jest.fn(),
42+
}));
43+
44+
render(<CenteredTypewriter text="Hi" speed={100} />);
45+
46+
await act(async () => {
47+
jest.advanceTimersByTime(200);
48+
});
49+
50+
await waitFor(() => {
51+
expect(screen.getByTestId('typewriter-cursor')).toBeInTheDocument();
52+
});
53+
54+
await act(async () => {
55+
jest.advanceTimersByTime(100);
56+
});
57+
58+
await waitFor(() => {
59+
expect(screen.getByTestId('typewriter-container')).toHaveTextContent('Hi');
60+
expect(screen.queryByTestId('typewriter-cursor')).not.toBeInTheDocument();
61+
});
62+
63+
window.matchMedia = originalMatchMedia;
64+
});
65+
66+
it('calls onComplete when finished', async () => {
67+
const originalMatchMedia = window.matchMedia;
68+
window.matchMedia = jest.fn().mockImplementation(query => ({
69+
matches: false,
70+
media: query,
71+
onchange: null,
72+
addListener: jest.fn(),
73+
removeListener: jest.fn(),
74+
addEventListener: jest.fn(),
75+
removeEventListener: jest.fn(),
76+
dispatchEvent: jest.fn(),
77+
}));
78+
79+
const onComplete = jest.fn();
80+
render(<CenteredTypewriter text="Hi" speed={100} onComplete={onComplete} />);
81+
82+
await act(async () => {
83+
jest.advanceTimersByTime(300);
84+
});
85+
86+
await waitFor(() => {
87+
expect(onComplete).toHaveBeenCalled();
88+
});
89+
90+
window.matchMedia = originalMatchMedia;
91+
});
92+
93+
it('respects prefers-reduced-motion', () => {
94+
const originalMatchMedia = window.matchMedia;
95+
window.matchMedia = jest.fn().mockImplementation(query => ({
96+
matches: true,
97+
media: query,
98+
onchange: null,
99+
addListener: jest.fn(),
100+
removeListener: jest.fn(),
101+
addEventListener: jest.fn(),
102+
removeEventListener: jest.fn(),
103+
dispatchEvent: jest.fn(),
104+
}));
105+
106+
render(<CenteredTypewriter text="Hello World" />);
107+
108+
expect(screen.getByTestId('typewriter-container')).toHaveTextContent('Hello World');
109+
expect(screen.queryByTestId('typewriter-cursor')).not.toBeInTheDocument();
110+
111+
window.matchMedia = originalMatchMedia;
112+
});
113+
});

0 commit comments

Comments
 (0)