Skip to content

Commit d04f6e3

Browse files
committed
fix(landing): defer stack CTA focus until visible
1 parent 2078821 commit d04f6e3

2 files changed

Lines changed: 174 additions & 20 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type HTMLAttributes, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const animation = vi.hoisted(() => ({
9+
start: () => {},
10+
complete: (_state: string) => {},
11+
reduced: false,
12+
}))
13+
14+
vi.mock('@sim/emcn', () => ({
15+
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
16+
usePrefersReducedMotion: () => animation.reduced,
17+
}))
18+
vi.mock('@/lib/branding/wordmark', () => ({
19+
WORDMARK_PATHS: [],
20+
WORDMARK_VIEW_BOX: { width: 100, height: 40 },
21+
}))
22+
vi.mock('@/app/(landing)/components/landing-cta-link', () => ({
23+
LandingCtaLink: ({ children }: { children: ReactNode }) => <a href='/signup'>{children}</a>,
24+
}))
25+
vi.mock('framer-motion', () => ({
26+
motion: {
27+
div: ({
28+
children,
29+
onAnimationStart,
30+
onAnimationComplete,
31+
initial: _initial,
32+
animate: _animate,
33+
variants: _variants,
34+
transition: _transition,
35+
...props
36+
}: Omit<HTMLAttributes<HTMLDivElement>, 'onAnimationStart'> & {
37+
onAnimationStart: () => void
38+
onAnimationComplete: (state: string) => void
39+
initial: boolean
40+
animate: string
41+
variants: unknown
42+
transition: unknown
43+
}) => {
44+
animation.start = onAnimationStart
45+
animation.complete = onAnimationComplete
46+
return <div {...props}>{children}</div>
47+
},
48+
},
49+
}))
50+
51+
import { StackIntro } from '@/app/(landing)/components/sim-stack/stack-intro'
52+
53+
let root: Root
54+
let host: HTMLDivElement
55+
56+
function render(element: ReactNode) {
57+
const rerender = (next: ReactNode) => act(() => root.render(next))
58+
rerender(element)
59+
return { container: host, rerender }
60+
}
61+
62+
function getCta(container: HTMLElement) {
63+
return container.querySelector('[data-stack-completion-cta]') as HTMLElement
64+
}
65+
66+
function expectInteractive(cta: HTMLElement, interactive: boolean) {
67+
expect(cta.hasAttribute('inert')).toBe(!interactive)
68+
expect(cta.getAttribute('aria-hidden')).toBe(String(!interactive))
69+
}
70+
71+
describe('StackIntro CTA focus', () => {
72+
beforeEach(() => {
73+
animation.reduced = false
74+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
75+
host = document.createElement('div')
76+
document.body.appendChild(host)
77+
root = createRoot(host)
78+
})
79+
80+
afterEach(() => {
81+
act(() => root.unmount())
82+
host.remove()
83+
vi.unstubAllGlobals()
84+
})
85+
86+
it('keeps the CTA inert until its entrance finishes', () => {
87+
const { container, rerender } = render(<StackIntro progress={4} />)
88+
expectInteractive(getCta(container), false)
89+
rerender(<StackIntro progress={5} />)
90+
act(() => animation.start())
91+
expectInteractive(getCta(container), false)
92+
act(() => animation.complete('visible'))
93+
expectInteractive(getCta(container), true)
94+
})
95+
96+
it('disables focus immediately on rewind and waits for the next entrance', () => {
97+
const { container, rerender } = render(<StackIntro progress={6} />)
98+
expectInteractive(getCta(container), true)
99+
rerender(<StackIntro progress={4} />)
100+
expectInteractive(getCta(container), false)
101+
act(() => animation.start())
102+
rerender(<StackIntro progress={5} />)
103+
expectInteractive(getCta(container), false)
104+
act(() => animation.complete('visible'))
105+
expectInteractive(getCta(container), true)
106+
})
107+
108+
it('keeps an interrupted entrance inert when it finishes hidden', () => {
109+
const { container, rerender } = render(<StackIntro progress={4} />)
110+
rerender(<StackIntro progress={5} />)
111+
act(() => animation.start())
112+
rerender(<StackIntro progress={4} />)
113+
act(() => animation.complete('hidden'))
114+
expectInteractive(getCta(container), false)
115+
})
116+
117+
it('makes a completed initial scene interactive with reduced motion', () => {
118+
animation.reduced = true
119+
const { container } = render(<StackIntro progress={6} />)
120+
expectInteractive(getCta(container), true)
121+
})
122+
})

apps/sim/app/(landing)/components/sim-stack/stack-intro.tsx

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

3-
import { useId } from 'react'
4-
import { cn } from '@sim/emcn'
3+
import { type ReactNode, useId, useState } from 'react'
4+
import { cn, usePrefersReducedMotion } from '@sim/emcn'
5+
import { motion } from 'framer-motion'
56
import { WORDMARK_PATHS, WORDMARK_VIEW_BOX } from '@/lib/branding/wordmark'
67
import { LandingCtaLink } from '@/app/(landing)/components/landing-cta-link'
78
import {
@@ -17,10 +18,45 @@ interface StackIntroProps {
1718
progress: number
1819
}
1920

21+
interface AnimatedCtaProps {
22+
show: boolean
23+
children: ReactNode
24+
}
25+
26+
function AnimatedCta({ show, children }: AnimatedCtaProps) {
27+
const [visible, setVisible] = useState(show)
28+
const interactive = show && visible
29+
return (
30+
<motion.div
31+
data-stack-completion-cta
32+
initial={false}
33+
animate={show ? 'visible' : 'hidden'}
34+
variants={{ visible: { opacity: 1 }, hidden: { opacity: 0 } }}
35+
transition={{ duration: 0.3 }}
36+
onAnimationStart={() => setVisible(false)}
37+
onAnimationComplete={(state) => setVisible(state === 'visible')}
38+
inert={!interactive}
39+
aria-hidden={!interactive}
40+
className={cn(
41+
'col-start-1 row-start-1',
42+
interactive ? 'pointer-events-auto' : 'pointer-events-none'
43+
)}
44+
>
45+
{children}
46+
</motion.div>
47+
)
48+
}
49+
2050
/** The heading remains above the arriving planes; supporting copy fades once exploration begins. */
2151
export function StackIntro({ progress }: StackIntroProps) {
2252
const wordmarkInkId = useId()
2353
const showCta = getStackLayerProgress(progress, STACK_LAYERS.length - 1).entrance > 0
54+
const reducedMotion = usePrefersReducedMotion()
55+
const cta = (
56+
<LandingCtaLink variant='outline' size='display' href={SIGNUP_HREF} prefetch={false} withArrow>
57+
Start building
58+
</LandingCtaLink>
59+
)
2460
return (
2561
<div
2662
className={cn(
@@ -66,25 +102,21 @@ export function StackIntro({ progress }: StackIntroProps) {
66102
The complete stack for AI agents. From your data and models to the workflows they power,
67103
all built on a foundation of control.
68104
</p>
69-
<div
70-
data-stack-completion-cta
71-
inert={!showCta}
72-
aria-hidden={!showCta}
73-
className={cn(
74-
'col-start-1 row-start-1 transition-opacity duration-300 motion-reduce:transition-none',
75-
showCta ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
76-
)}
77-
>
78-
<LandingCtaLink
79-
variant='outline'
80-
size='display'
81-
href={SIGNUP_HREF}
82-
prefetch={false}
83-
withArrow
105+
{reducedMotion ? (
106+
<div
107+
data-stack-completion-cta
108+
inert={!showCta}
109+
aria-hidden={!showCta}
110+
className={cn(
111+
'col-start-1 row-start-1 transition-none',
112+
showCta ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
113+
)}
84114
>
85-
Start building
86-
</LandingCtaLink>
87-
</div>
115+
{cta}
116+
</div>
117+
) : (
118+
<AnimatedCta show={showCta}>{cta}</AnimatedCta>
119+
)}
88120
</div>
89121
</div>
90122
)

0 commit comments

Comments
 (0)