Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/(en)/en/template.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { PageEnter } from '@/components/layout/page-enter'

export default function Template({ children }: { children: ReactNode }) {
return <div className="motion-safe:animate-page-in">{children}</div>
return <PageEnter>{children}</PageEnter>
}
3 changes: 2 additions & 1 deletion app/(pt)/template.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { PageEnter } from '@/components/layout/page-enter'

export default function Template({ children }: { children: ReactNode }) {
return <div className="motion-safe:animate-page-in">{children}</div>
return <PageEnter>{children}</PageEnter>
}
190 changes: 188 additions & 2 deletions components/layout/navigation-progress.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,105 @@
import { expect, test } from 'vitest'
import { isInternalPageNavigation } from './navigation-progress'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { InterfaceLanguageProvider } from '@/components/interface-language-provider'
import {
isInternalPageNavigation,
NavigationProgress,
} from './navigation-progress'

const here = 'http://localhost:3000/blog'

const navigation = vi.hoisted(() => ({
pathname: '/',
animateNext: false,
}))

vi.mock('next/navigation', () => ({
usePathname: () => navigation.pathname,
useSearchParams: () => new URLSearchParams(),
}))

vi.mock('@/components/layout/page-enter', () => ({
willAnimateNextEnter: () => navigation.animateNext,
}))

beforeEach(() => {
navigation.pathname = '/'
navigation.animateNext = false
vi.stubGlobal('matchMedia', ((query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia)
})

afterEach(() => {
cleanup()
document.body.replaceChildren()
vi.useRealTimers()
vi.unstubAllGlobals()
})

function renderProgress() {
return render(
<InterfaceLanguageProvider language="pt">
<NavigationProgress />
</InterfaceLanguageProvider>,
)
}

function clickAbout() {
const anchor = document.createElement('a')
anchor.href = '/about'
document.body.append(anchor)
anchor.addEventListener('click', (event) => event.preventDefault())
anchor.click()
anchor.remove()
}

function installAnimationFrames() {
const queued = new Map<number, FrameRequestCallback>()
let nextId = 1
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextId
nextId += 1
queued.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
queued.delete(id)
})
return () => {
const pending = [...queued.values()]
queued.clear()
act(() => {
for (const callback of pending) callback(0)
})
}
}

function mountPageEnter(playState: AnimationPlayState) {
const enter = document.createElement('div')
enter.dataset.pageEnter = ''
enter.className = 'motion-safe:animate-page-in'
enter.getAnimations = () =>
[{ animationName: 'page-in', playState }] as unknown as Animation[]
document.body.append(enter)
return enter
}

function commitRoute(
view: ReturnType<typeof renderProgress>,
pathname: string,
) {
navigation.pathname = pathname
view.rerender(
<InterfaceLanguageProvider language="pt">
<NavigationProgress />
</InterfaceLanguageProvider>,
)
}

test('tracks another internal page and a new search', () => {
expect(isInternalPageNavigation('/about', here)).toBe(true)
expect(isInternalPageNavigation('/blog?page=2', here)).toBe(true)
Expand All @@ -19,3 +116,92 @@ test('ignores the current url, hash-only jumps, and other sites', () => {
expect(isInternalPageNavigation('/blog#logs', here)).toBe(false)
expect(isInternalPageNavigation('https://linkedin.com', here)).toBe(false)
})

test('shows the bar on click and keeps the message until the page has been loading for one second', () => {
vi.useFakeTimers()
navigation.animateNext = true
renderProgress()

act(() => {
clickAbout()
})

expect(screen.queryByRole('status')).toBeNull()
expect(document.querySelector('[data-phase="loading"]')).not.toBeNull()

act(() => {
vi.advanceTimersByTime(999)
})
expect(screen.queryByRole('status')).toBeNull()

act(() => {
vi.advanceTimersByTime(1)
})
expect(screen.getByRole('status').textContent).toContain(
'O conteúdo está sendo carregado',
)
})

test('hides the bar when the entering page finishes fading in', () => {
navigation.animateNext = true
const flushFrame = installAnimationFrames()
const view = renderProgress()

act(() => {
clickAbout()
})

const enter = mountPageEnter('running')
commitRoute(view, '/about')
flushFrame()

expect(screen.queryByRole('status')).toBeNull()
expect(document.querySelector('[data-phase="loading"]')).not.toBeNull()

act(() => {
const event = new Event('animationend')
Object.defineProperty(event, 'animationName', { value: 'page-in' })
enter.dispatchEvent(event)
})

expect(screen.queryByRole('status')).toBeNull()
expect(document.querySelector('[data-phase="complete"]')).not.toBeNull()
})

test('settles on the next frame when the template does not remount', () => {
navigation.animateNext = true
const flushFrame = installAnimationFrames()
const view = renderProgress()

act(() => {
clickAbout()
})

mountPageEnter('finished')
commitRoute(view, '/blog/postgresql-e-pgadmin-com-docker-compose')

expect(document.querySelector('[data-phase="loading"]')).not.toBeNull()

flushFrame()

expect(document.querySelector('[data-phase="loading"]')).toBeNull()
expect(document.querySelector('[data-phase="complete"]')).not.toBeNull()
})

test('keeps a fast navigation quiet when the page will not fade', () => {
vi.useFakeTimers()
renderProgress()

act(() => {
clickAbout()
})

expect(screen.queryByRole('status')).toBeNull()
expect(document.querySelector('[data-phase="loading"]')).toBeNull()

act(() => {
vi.advanceTimersByTime(99)
})

expect(document.querySelector('[data-phase="loading"]')).toBeNull()
})
94 changes: 81 additions & 13 deletions components/layout/navigation-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
LoadingBar,
LoadingStatus,
} from '@/components/layout/loading-indicator'
import { willAnimateNextEnter } from '@/components/layout/page-enter'

const SHOW_AFTER_MS = 100
const MESSAGE_AFTER_MS = 2000
const MESSAGE_AFTER_MS = 1000
const HIDE_AFTER_MS = 320
const ENTER_HOLD_MS = 400

type Phase = 'idle' | 'loading' | 'complete'
type TimerRef = { current: number | null }
Expand All @@ -31,6 +33,23 @@ function clearTimer(timerRef: TimerRef) {
timerRef.current = null
}

function navigationFadesIn() {
return (
willAnimateNextEnter() &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
}

function hasRunningPageIn(enter: Element) {
return enter.getAnimations().some((animation) => {
return (
'animationName' in animation &&
animation.animationName === 'page-in' &&
animation.playState === 'running'
)
})
}

function NavigationProgressIndicator() {
const pathname = usePathname()
const searchParams = useSearchParams()
Expand All @@ -43,37 +62,85 @@ function NavigationProgressIndicator() {
const showTimerRef = useRef<number | null>(null)
const messageTimerRef = useRef<number | null>(null)
const hideTimerRef = useRef<number | null>(null)
const holdTimerRef = useRef<number | null>(null)

// biome-ignore lint/correctness/useExhaustiveDependencies: routeKey is the commit signal; the effect reads refs
useEffect(() => {
if (!pendingRef.current) return

pendingRef.current = false
const wasVisible = visibleRef.current
clearTimer(showTimerRef)
clearTimer(messageTimerRef)
setShowMessage(false)

const wasVisible = visibleRef.current
visibleRef.current = false
if (!wasVisible) return
let cancelled = false

const settle = () => {
if (cancelled) return
cancelled = true
pendingRef.current = false
visibleRef.current = false
clearTimer(holdTimerRef)
clearTimer(messageTimerRef)
setShowMessage(false)
if (!wasVisible) return

setPhase('complete')
hideTimerRef.current = window.setTimeout(() => {
setPhase('idle')
}, HIDE_AFTER_MS)
}

setPhase('complete')
hideTimerRef.current = window.setTimeout(() => {
setPhase('idle')
}, HIDE_AFTER_MS)
if (!wasVisible || !navigationFadesIn()) {
settle()
return
}

let detach = () => {}
const frame = window.requestAnimationFrame(() => {
if (cancelled) return
const enter = document.querySelector('[data-page-enter]')
if (!(enter instanceof HTMLElement) || !hasRunningPageIn(enter)) {
settle()
return
}

const onEnd = (event: AnimationEvent) => {
if (event.target !== enter || event.animationName !== 'page-in') {
return
}
settle()
}
enter.addEventListener('animationend', onEnd)
detach = () => enter.removeEventListener('animationend', onEnd)
holdTimerRef.current = window.setTimeout(settle, ENTER_HOLD_MS)
})

return () => {
cancelled = true
window.cancelAnimationFrame(frame)
detach()
clearTimer(holdTimerRef)
}
}, [routeKey])

useEffect(() => {
const begin = () => {
clearTimer(hideTimerRef)
clearTimer(holdTimerRef)
pendingRef.current = true
setShowMessage(false)
clearTimer(showTimerRef)
clearTimer(messageTimerRef)
showTimerRef.current = window.setTimeout(() => {

if (navigationFadesIn()) {
visibleRef.current = true
setPhase('loading')
}, SHOW_AFTER_MS)
} else {
showTimerRef.current = window.setTimeout(() => {
visibleRef.current = true
setPhase('loading')
}, SHOW_AFTER_MS)
}

messageTimerRef.current = window.setTimeout(() => {
setShowMessage(true)
}, MESSAGE_AFTER_MS)
Expand All @@ -100,6 +167,7 @@ function NavigationProgressIndicator() {
clearTimer(showTimerRef)
clearTimer(messageTimerRef)
clearTimer(hideTimerRef)
clearTimer(holdTimerRef)
}
}, [])

Expand Down
27 changes: 27 additions & 0 deletions components/layout/page-enter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cleanup, render } from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { afterEach, expect, test } from 'vitest'
import { PageEnter, willAnimateNextEnter } from './page-enter'

afterEach(() => {
cleanup()
})

test('animates only on mounts after the first one in the module', () => {
expect(willAnimateNextEnter()).toBe(false)

const html = renderToString(<PageEnter>documento</PageEnter>)
expect(html).not.toContain('animate-page-in')
expect(willAnimateNextEnter()).toBe(false)

const first = render(<PageEnter>home</PageEnter>)
const firstEnter = first.container.querySelector('[data-page-enter]')
expect(firstEnter).not.toBeNull()
expect(firstEnter?.className ?? '').not.toContain('animate-page-in')
expect(willAnimateNextEnter()).toBe(true)
first.unmount()

const second = render(<PageEnter>about</PageEnter>)
const secondEnter = second.container.querySelector('[data-page-enter]')
expect(secondEnter?.className).toContain('motion-safe:animate-page-in')
})
Loading
Loading