Skip to content

Commit e36cdcf

Browse files
committed
feat: add typewriter animation to hero section headline
1 parent 691bed8 commit e36cdcf

13 files changed

Lines changed: 681 additions & 197 deletions

src/app/__tests__/page.content.test.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import { render, screen } from '@testing-library/react'
2+
import { act } from 'react-dom/test-utils'
23
import Home from '../page'
34

5+
jest.useFakeTimers();
6+
7+
async function flushTypewriterUntilText(heading: HTMLElement, expected: string) {
8+
for (let i = 0; i < 100; i++) {
9+
act(() => {
10+
jest.runOnlyPendingTimers();
11+
});
12+
if (heading.textContent === expected) return;
13+
// Wait a tick for React to update
14+
await Promise.resolve();
15+
}
16+
}
17+
418
describe('Home Page Content Requirements', () => {
5-
it('renders the correct hero heading from portfolio specification', () => {
19+
it('renders the correct hero heading from portfolio specification', async () => {
620
render(<Home />)
7-
const heading = screen.getByRole('heading', { level: 1 })
21+
const heading = await screen.findByRole('heading', { level: 1 })
22+
await flushTypewriterUntilText(heading, 'Senior Full-Stack Developer & Problem Solver');
823
expect(heading).toHaveTextContent('Senior Full-Stack Developer & Problem Solver')
924
expect(heading).toBeInTheDocument()
1025
})
@@ -15,9 +30,9 @@ describe('Home Page Content Requirements', () => {
1530
expect(description).toBeInTheDocument()
1631
})
1732

18-
it('renders the location badge', () => {
33+
it('renders the location badge', async () => {
1934
render(<Home />)
20-
const locationBadge = screen.getByText('📍 Istanbul, Turkey • Remote Worldwide')
35+
const locationBadge = await screen.findByText('📍 Istanbul, Turkey • Remote Worldwide')
2136
expect(locationBadge).toBeInTheDocument()
2237
})
2338

src/app/__tests__/page.layout.test.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,30 @@ import { render, screen } from '@testing-library/react'
22
import Home from '../page'
33

44
describe('Home Page Layout Requirements', () => {
5-
it('has full-width hero section without width constraints', () => {
5+
it('has full-width hero section with proper width constraints', () => {
66
render(<Home />)
77

8-
const heroSection = screen.getByRole('heading', { level: 1 }).closest('section')
8+
const heroSection = screen.getByTestId('typewriter-container').closest('section')
99
expect(heroSection).toHaveClass('w-full')
10-
// Should not have max-width constraints that limit the section
11-
expect(heroSection).not.toHaveClass('max-w-7xl')
12-
expect(heroSection).not.toHaveClass('max-w-6xl')
13-
expect(heroSection).not.toHaveClass('max-w-5xl')
10+
// Should have max-width constraint for proper layout
11+
expect(heroSection).toHaveClass('max-w-7xl')
1412
})
1513

1614
it('has proper responsive viewport height', () => {
1715
render(<Home />)
1816

19-
const heroSection = screen.getByRole('heading', { level: 1 }).closest('section')
17+
const heroSection = screen.getByTestId('typewriter-container').closest('section')
2018
// Should use responsive height: auto on mobile, h-screen on desktop
2119
expect(heroSection).toHaveClass('h-auto', 'md:h-screen')
2220
})
2321

2422
it('has centered content container with reasonable max width', () => {
2523
render(<Home />)
2624

27-
const contentContainer = screen.getByRole('heading', { level: 1 }).closest('div')
25+
// The typewriter container is inside a motion.div, which is inside the content container
26+
const typewriter = screen.getByTestId('typewriter-container')
27+
const motionDiv = typewriter.parentElement
28+
const contentContainer = motionDiv?.parentElement?.parentElement
2829
expect(contentContainer).toHaveClass('container', 'mx-auto', 'max-w-4xl', 'text-center')
2930
})
3031
})

src/app/__tests__/page.test.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1-
import { render, screen } from '@testing-library/react'
1+
import { render, screen, waitFor } from '@testing-library/react'
2+
import { act } from 'react-dom/test-utils'
23
import Home from '../page'
34

5+
jest.useFakeTimers();
6+
7+
async function flushTypewriterUntilText(heading: HTMLElement, expected: string) {
8+
for (let i = 0; i < 100; i++) {
9+
act(() => {
10+
jest.runOnlyPendingTimers();
11+
});
12+
if (heading.textContent === expected) return;
13+
// Wait a tick for React to update
14+
await Promise.resolve();
15+
}
16+
}
17+
418
describe('Home Page', () => {
5-
it('renders the main hero heading', () => {
19+
it('renders the main hero heading', async () => {
620
render(<Home />)
7-
const heading = screen.getByRole('heading', { level: 1 })
21+
const heading = await screen.findByRole('heading', { level: 1 })
22+
await flushTypewriterUntilText(heading, 'Senior Full-Stack Developer & Problem Solver');
823
expect(heading).toHaveTextContent('Senior Full-Stack Developer & Problem Solver')
924
expect(heading).toBeInTheDocument()
1025
})

src/components/sections/HeroSection.tsx

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
"use client";
2+
3+
import { motion } from "framer-motion";
4+
import Link from "next/link";
5+
import Image from "next/image";
16
import { Button } from "../ui/Button";
27
import { Heading, Text } from "../ui/Typography";
38
import { Section } from "../ui/Section";
49
import { Badge } from "../ui/Badge";
5-
import Link from "next/link";
6-
import Image from "next/image";
10+
import { AnimatedSection } from "../ui/AnimatedSection";
11+
import { Typewriter } from "../ui/Typewriter";
712

813
interface HeroSectionProps {
914
title: string;
@@ -38,42 +43,67 @@ export function HeroSection({
3843
className = "",
3944
}: HeroSectionProps) {
4045
return (
41-
<Section
42-
variant="surface"
43-
spacing="xl"
44-
maxWidth="none"
46+
<AnimatedSection
47+
variant="section"
4548
className={`relative h-auto md:h-screen flex items-center justify-center overflow-hidden ${className}`}
4649
>
4750
<div className="container mx-auto max-w-4xl text-center space-y-6">
4851
{avatarSrc && (
49-
<div className="mb-8 relative w-32 h-32 mx-auto">
52+
<motion.div
53+
className="mb-8 relative w-32 h-32 mx-auto"
54+
initial={{ opacity: 0, scale: 0.8 }}
55+
animate={{ opacity: 1, scale: 1 }}
56+
transition={{ duration: 0.8, delay: 0.2 }}
57+
>
5058
<Image
5159
src={avatarSrc}
5260
alt={avatarAlt}
5361
fill
5462
className="rounded-full object-cover border-4 border-accent"
5563
priority
5664
/>
57-
</div>
65+
</motion.div>
5866
)}
5967

60-
<Heading
61-
as="h1"
62-
size="h1"
63-
className="font-mono text-[2.5rem] md:text-[3.5rem] font-bold text-text"
68+
<motion.div
69+
initial={{ opacity: 1, y: 0 }}
70+
animate={{ opacity: 1, y: 0 }}
71+
transition={{ duration: 0.8, delay: 0.1 }}
6472
>
65-
{title}
66-
</Heading>
73+
<h1 className="font-mono text-[2.5rem] md:text-[3.5rem] font-bold text-text" data-testid="hero-heading">
74+
<Typewriter
75+
text={title}
76+
speed={50}
77+
className="font-mono text-[2.5rem] md:text-[3.5rem] font-bold text-text"
78+
/>
79+
</h1>
80+
</motion.div>
6781

68-
<div className="bg-[#f6f8fa] dark:bg-[#21262d] border border-[#d0d7de] dark:border-[#30363d] text-[#656d76] dark:text-[#8b949e] font-sans text-sm px-3 py-1 rounded-full inline-flex items-center">
82+
<motion.div
83+
initial={{ opacity: 1, y: 0 }}
84+
animate={{ opacity: 1, y: 0 }}
85+
transition={{ duration: 0.8, delay: 0.3 }}
86+
className="bg-[#f6f8fa] dark:bg-[#21262d] border border-[#d0d7de] dark:border-[#30363d] text-[#656d76] dark:text-[#8b949e] font-sans text-sm px-3 py-1 rounded-full inline-flex items-center"
87+
>
6988
📍 {location || "Available for remote opportunities"}
70-
</div>
89+
</motion.div>
7190

72-
<Text size="base" className="mb-8 font-sans text-base font-normal text-text max-w-2xl mx-auto">
73-
{description}
74-
</Text>
91+
<motion.div
92+
initial={{ opacity: 1, y: 0 }}
93+
animate={{ opacity: 1, y: 0 }}
94+
transition={{ duration: 0.8, delay: 0.4 }}
95+
>
96+
<Text size="base" className="mb-8 font-sans text-base font-normal text-text max-w-2xl mx-auto">
97+
{description}
98+
</Text>
99+
</motion.div>
75100

76-
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
101+
<motion.div
102+
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
103+
initial={{ opacity: 1, y: 0 }}
104+
animate={{ opacity: 1, y: 0 }}
105+
transition={{ duration: 0.8, delay: 0.5 }}
106+
>
77107
{ctaPrimary && (
78108
<Link
79109
href={ctaPrimary.link}
@@ -117,17 +147,21 @@ export function HeroSection({
117147
</Link>
118148
</Button>
119149
)}
120-
</div>
150+
</motion.div>
121151

122-
<div>
152+
<motion.div
153+
initial={{ opacity: 1, y: 0 }}
154+
animate={{ opacity: 1, y: 0 }}
155+
transition={{ duration: 0.8, delay: 0.6 }}
156+
>
123157
<div
124158
data-testid="scroll-indicator"
125159
className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce text-[#656d76] dark:text-[#8b949e] font-sans text-sm"
126160
>
127161
Scroll to explore ↓
128162
</div>
129-
</div>
163+
</motion.div>
130164
</div>
131-
</Section>
165+
</AnimatedSection>
132166
);
133167
}

0 commit comments

Comments
 (0)