Skip to content

Commit ef83d18

Browse files
committed
Redo the hero animation
1 parent 66f74b0 commit ef83d18

12 files changed

Lines changed: 377 additions & 140 deletions

File tree

src/components/Hero/Home/__tests__/index.spec.ts renamed to src/components/Animations/Hero/__tests__/index.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('Home Hero (Astro)', () => {
1010
})
1111

1212
test('labels the hero section and renders the hardcoded CTAs', async () => {
13-
const HomeHero = (await import('@components/Hero/Home/index.astro')).default
13+
const HomeHero = (await import('@components/Animations/Hero/index.astro')).default
1414

1515
const renderedHtml = await container.renderToString(HomeHero, {
1616
props: {
@@ -34,13 +34,25 @@ describe('Home Hero (Astro)', () => {
3434
expect(primaryLink).toBeTruthy()
3535
expect(primaryLink?.getAttribute('class')).toContain('bg-success')
3636

37-
const secondaryLink = window.document.querySelector('a[href="/contact"]')
37+
const secondaryLink = window.document.querySelector(
38+
'a[href="/contact"]:not([data-hero-ready-link])'
39+
)
3840
expect(secondaryLink).toBeTruthy()
3941
const secondaryClass = secondaryLink?.getAttribute('class') || ''
4042
expect(secondaryClass).toContain('decoration-dotted')
4143
expect(secondaryClass).toContain('focus-visible:decoration-dotted')
4244
expect(secondaryClass).toContain('hover:decoration-content-offset')
4345
expect(secondaryClass).toContain('focus-visible:decoration-content-offset')
46+
47+
const readyLink = window.document.querySelector('a[data-hero-ready-link]')
48+
expect(readyLink).toBeTruthy()
49+
expect(readyLink?.getAttribute('href')).toBe('/contact')
50+
51+
const readyClass = readyLink?.getAttribute('class') || ''
52+
expect(readyClass).toContain('no-underline')
53+
expect(readyClass).toContain('text-success')
54+
expect(readyClass).toContain('hover:text-success-offset')
55+
expect(readyClass).toContain('focus-visible:text-success-offset')
4456
})
4557
})
4658
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import { withJsdomEnvironment } from '@test/unit/helpers/litRuntime'
4+
import {
5+
__resetAnimationLifecycleForTests,
6+
clearAnimationPreference,
7+
getAnimationPreference,
8+
} from '@components/scripts/store'
9+
10+
const READY_TEXT = 'ready...'
11+
const STEP_MS = 500
12+
13+
describe('HomeHeroElement (Lit)', () => {
14+
let container: AstroContainer
15+
16+
beforeEach(async () => {
17+
container = await AstroContainer.create()
18+
})
19+
20+
afterEach(() => {
21+
vi.useRealTimers()
22+
__resetAnimationLifecycleForTests()
23+
})
24+
25+
const renderHero = async (): Promise<string> => {
26+
const Hero = (await import('@components/Animations/Hero/index.astro')).default
27+
28+
return container.renderToString(Hero, {
29+
props: {
30+
pretitle: 'Test pretitle',
31+
benefits: ['One', 'Two', 'Three'],
32+
},
33+
})
34+
}
35+
36+
test('types the ready prompt one character every 500ms and stops when complete', async () => {
37+
vi.useFakeTimers()
38+
39+
await withJsdomEnvironment(async ({ window }) => {
40+
window.matchMedia = (() =>
41+
({
42+
matches: false,
43+
addEventListener: () => undefined,
44+
removeEventListener: () => undefined,
45+
}) as unknown as MediaQueryList) as unknown as typeof window.matchMedia
46+
47+
const { registerHomeHeroWebComponent } = await import('@components/Animations/Hero/client')
48+
await registerHomeHeroWebComponent()
49+
50+
window.document.body.innerHTML = await renderHero()
51+
52+
const readyText = window.document.querySelector<HTMLElement>('[data-hero-ready-text]')
53+
expect(readyText).toBeTruthy()
54+
expect(readyText?.textContent).toBe('')
55+
56+
vi.advanceTimersByTime(STEP_MS)
57+
expect(readyText?.textContent).toBe('r')
58+
59+
vi.advanceTimersByTime(STEP_MS)
60+
expect(readyText?.textContent).toBe('re')
61+
62+
vi.advanceTimersByTime(STEP_MS * (READY_TEXT.length - 2))
63+
expect(readyText?.textContent).toBe(READY_TEXT)
64+
65+
vi.advanceTimersByTime(STEP_MS * 5)
66+
expect(readyText?.textContent).toBe(READY_TEXT)
67+
68+
expect(getAnimationPreference('home-hero-ready')).toBeUndefined()
69+
})
70+
})
71+
72+
test('skips animation and shows final text when reduced motion is preferred', async () => {
73+
vi.useFakeTimers()
74+
75+
await withJsdomEnvironment(async ({ window }) => {
76+
window.matchMedia = (() =>
77+
({
78+
matches: true,
79+
addEventListener: () => undefined,
80+
removeEventListener: () => undefined,
81+
}) as unknown as MediaQueryList) as unknown as typeof window.matchMedia
82+
83+
const { registerHomeHeroWebComponent } = await import('@components/Animations/Hero/client')
84+
await registerHomeHeroWebComponent()
85+
86+
window.document.body.innerHTML = await renderHero()
87+
88+
const readyText = window.document.querySelector<HTMLElement>('[data-hero-ready-text]')
89+
expect(readyText).toBeTruthy()
90+
expect(readyText?.textContent).toBe(READY_TEXT)
91+
92+
vi.advanceTimersByTime(STEP_MS * READY_TEXT.length)
93+
expect(readyText?.textContent).toBe(READY_TEXT)
94+
95+
clearAnimationPreference('home-hero-ready')
96+
expect(getAnimationPreference('home-hero-ready')).toBeUndefined()
97+
})
98+
})
99+
})
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { LitElement } from 'lit'
2+
import { addScriptBreadcrumb } from '@components/scripts/errors'
3+
import { handleScriptError } from '@components/scripts/errors/handler'
4+
import {
5+
createAnimationController,
6+
type AnimationControllerHandle,
7+
type AnimationPlayState,
8+
} from '@components/scripts/store'
9+
import { defineCustomElement } from '@components/scripts/utils'
10+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
11+
import { queryHeroReadyTextElement } from './selectors'
12+
13+
const SCRIPT_NAME = 'HomeHeroElement'
14+
15+
const READY_TEXT = 'ready...'
16+
const READY_DELAY_MS = 500
17+
18+
export class HomeHeroElement extends LitElement {
19+
static registeredName = 'home-hero'
20+
21+
private initialized = false
22+
private readyTextElement: HTMLSpanElement | null = null
23+
private animationController: AnimationControllerHandle | undefined
24+
private timeoutId: number | null = null
25+
private currentIndex = 0
26+
private completed = false
27+
private renderContainer: HTMLDivElement | undefined
28+
29+
protected override createRenderRoot() {
30+
// Preserve server-rendered HTML inside the element.
31+
// Lit needs a render root, so we render into a hidden container that does not affect layout.
32+
if (!this.renderContainer) {
33+
this.renderContainer = document.createElement('div')
34+
this.renderContainer.setAttribute('data-lit-root', '')
35+
this.renderContainer.style.display = 'none'
36+
this.appendChild(this.renderContainer)
37+
}
38+
39+
return this.renderContainer
40+
}
41+
42+
override connectedCallback(): void {
43+
super.connectedCallback()
44+
this.initialize()
45+
}
46+
47+
override disconnectedCallback(): void {
48+
this.teardown()
49+
super.disconnectedCallback()
50+
}
51+
52+
private initialize(): void {
53+
if (this.initialized) return
54+
55+
const context = { scriptName: SCRIPT_NAME, operation: 'initialize' }
56+
addScriptBreadcrumb(context)
57+
58+
try {
59+
this.readyTextElement = queryHeroReadyTextElement(this)
60+
61+
if (!this.readyTextElement) {
62+
this.initialized = true
63+
return
64+
}
65+
66+
// Baseline state: show prompt only until we decide to animate.
67+
this.readyTextElement.textContent = ''
68+
69+
this.animationController = createAnimationController({
70+
animationId: 'home-hero-ready',
71+
debugLabel: SCRIPT_NAME,
72+
defaultState: 'playing',
73+
onPlay: () => {
74+
this.handlePlay()
75+
},
76+
onPause: () => {
77+
this.handlePause()
78+
},
79+
})
80+
81+
this.initialized = true
82+
} catch (error) {
83+
handleScriptError(error, context)
84+
}
85+
}
86+
87+
private teardown(): void {
88+
this.clearTimeout()
89+
this.animationController?.destroy()
90+
this.animationController = undefined
91+
this.initialized = false
92+
this.readyTextElement = null
93+
this.currentIndex = 0
94+
this.completed = false
95+
}
96+
97+
private handlePlay(): void {
98+
if (this.completed) return
99+
if (!this.readyTextElement) return
100+
if (this.timeoutId !== null) return
101+
102+
this.currentIndex = 0
103+
this.readyTextElement.textContent = ''
104+
this.scheduleNextTick()
105+
}
106+
107+
private handlePause(): void {
108+
if (this.completed) return
109+
if (!this.readyTextElement) return
110+
111+
this.clearTimeout()
112+
this.readyTextElement.textContent = READY_TEXT
113+
this.completed = true
114+
}
115+
116+
private scheduleNextTick(): void {
117+
if (!this.readyTextElement) return
118+
119+
this.timeoutId = window.setTimeout(() => {
120+
this.timeoutId = null
121+
this.currentIndex += 1
122+
123+
this.readyTextElement!.textContent = READY_TEXT.slice(0, this.currentIndex)
124+
125+
if (this.currentIndex >= READY_TEXT.length) {
126+
this.completed = true
127+
return
128+
}
129+
130+
this.scheduleNextTick()
131+
}, READY_DELAY_MS)
132+
}
133+
134+
private clearTimeout(): void {
135+
if (this.timeoutId === null) return
136+
window.clearTimeout(this.timeoutId)
137+
this.timeoutId = null
138+
}
139+
}
140+
141+
export const registerWebComponent = async (tagName = HomeHeroElement.registeredName) => {
142+
if (typeof window === 'undefined') return
143+
defineCustomElement(tagName, HomeHeroElement)
144+
}
145+
146+
export const registerHomeHeroWebComponent = registerWebComponent
147+
148+
export const webComponentModule: WebComponentModule<HomeHeroElement> = {
149+
registeredName: HomeHeroElement.registeredName,
150+
componentCtor: HomeHeroElement,
151+
registerWebComponent,
152+
}
153+
154+
export type { AnimationPlayState }
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { isType1Element } from '@components/scripts/assertions/elements'
2+
3+
const SELECTORS = {
4+
readyText: '[data-hero-ready-text]',
5+
} as const
6+
7+
function isSpanElement(element: unknown): element is HTMLSpanElement {
8+
return isType1Element(element) && element.tagName === 'SPAN'
9+
}
10+
11+
export function queryHeroReadyTextElement(scope: ParentNode): HTMLSpanElement | null {
12+
const element = scope.querySelector(SELECTORS.readyText)
13+
if (!element) return null
14+
return isSpanElement(element) ? element : null
15+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
import ComputersAnimation from '@components/Animations/Computers/index.astro'
3+
import Button from '@components/Button/index.astro'
4+
5+
export interface Props {
6+
pretitle: string
7+
benefits: string[]
8+
}
9+
10+
const { pretitle, benefits } = Astro.props as Props
11+
---
12+
13+
<home-hero class="block" data-testid="home-hero">
14+
<section
15+
class="container mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8 lg:py-12"
16+
aria-labelledby="home-hero-title"
17+
>
18+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
19+
<div class="hidden lg:block lg:order-2">
20+
<ComputersAnimation />
21+
</div>
22+
23+
<div class="order-2 lg:order-1 space-y-4 font-mono">
24+
<div class="text-primary ml-1 mb-1">$ whoami</div>
25+
26+
<h1
27+
id="home-hero-title"
28+
class="text-4xl md:text-5xl lg:text-7xl font-bold text-primary leading-none"
29+
>
30+
Senior<br />
31+
Platform<br />
32+
<span class="text-content-offset">Authority.</span>
33+
</h1>
34+
35+
<p class="text-base md:text-lg text-content font-sans max-w-xl">{pretitle}</p>
36+
37+
<div
38+
class="bg-content text-page-base p-6 rounded-lg transition-transform duration-200 ease-out will-change-transform hover:scale-[1.02] hover:-translate-y-px"
39+
>
40+
<div class="text-xs text-page-base/60 mb-3">// What I deliver</div>
41+
<ul class="space-y-2 text-sm">
42+
{benefits.map(benefit => (
43+
<li class="flex items-center gap-2">
44+
<span class="text-success">✓</span>
45+
<span>{benefit}</span>
46+
</li>
47+
))}
48+
49+
<li class="flex items-center gap-2">
50+
<span class="text-success">$</span>
51+
<a
52+
href="/contact"
53+
class="inline-flex items-center gap-1 no-underline text-success transition-colors hover:text-success-offset focus-visible:text-success-offset"
54+
data-hero-ready-link
55+
aria-label="ready"
56+
>
57+
<span aria-hidden="true"> </span>
58+
<span data-hero-ready-text aria-hidden="true"></span>
59+
</a>
60+
</li>
61+
</ul>
62+
</div>
63+
64+
<div class="flex items-center gap-4 ml-1">
65+
<Button href="/about" variant="success" text="Read My Story" />
66+
<a
67+
href="/contact"
68+
class="py-3 text-primary font-bold underline underline-offset-4 decoration-2 decoration-dotted decoration-primary transition-colors hover:text-content-offset hover:decoration-content-offset focus-visible:text-content-offset focus-visible:underline focus-visible:decoration-2 focus-visible:decoration-dotted focus-visible:decoration-content-offset"
69+
>
70+
Get in Touch →
71+
</a>
72+
</div>
73+
</div>
74+
</div>
75+
</section>
76+
</home-hero>
77+
78+
<script>
79+
import { registerHomeHeroWebComponent } from './client'
80+
registerHomeHeroWebComponent()
81+
</script>

0 commit comments

Comments
 (0)