diff --git a/app/(en)/en/template.tsx b/app/(en)/en/template.tsx index 6e1357b..a77b70b 100644 --- a/app/(en)/en/template.tsx +++ b/app/(en)/en/template.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' +import { PageEnter } from '@/components/layout/page-enter' export default function Template({ children }: { children: ReactNode }) { - return
{children}
+ return {children} } diff --git a/app/(pt)/template.tsx b/app/(pt)/template.tsx index 6e1357b..a77b70b 100644 --- a/app/(pt)/template.tsx +++ b/app/(pt)/template.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' +import { PageEnter } from '@/components/layout/page-enter' export default function Template({ children }: { children: ReactNode }) { - return
{children}
+ return {children} } diff --git a/components/layout/navigation-progress.test.tsx b/components/layout/navigation-progress.test.tsx index e42e08a..9953f3c 100644 --- a/components/layout/navigation-progress.test.tsx +++ b/components/layout/navigation-progress.test.tsx @@ -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( + + + , + ) +} + +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() + 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, + pathname: string, +) { + navigation.pathname = pathname + view.rerender( + + + , + ) +} + test('tracks another internal page and a new search', () => { expect(isInternalPageNavigation('/about', here)).toBe(true) expect(isInternalPageNavigation('/blog?page=2', here)).toBe(true) @@ -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() +}) diff --git a/components/layout/navigation-progress.tsx b/components/layout/navigation-progress.tsx index f46eb40..83a92d9 100644 --- a/components/layout/navigation-progress.tsx +++ b/components/layout/navigation-progress.tsx @@ -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 } @@ -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() @@ -43,37 +62,85 @@ function NavigationProgressIndicator() { const showTimerRef = useRef(null) const messageTimerRef = useRef(null) const hideTimerRef = useRef(null) + const holdTimerRef = useRef(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) @@ -100,6 +167,7 @@ function NavigationProgressIndicator() { clearTimer(showTimerRef) clearTimer(messageTimerRef) clearTimer(hideTimerRef) + clearTimer(holdTimerRef) } }, []) diff --git a/components/layout/page-enter.test.tsx b/components/layout/page-enter.test.tsx new file mode 100644 index 0000000..24cfc9f --- /dev/null +++ b/components/layout/page-enter.test.tsx @@ -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(documento) + expect(html).not.toContain('animate-page-in') + expect(willAnimateNextEnter()).toBe(false) + + const first = render(home) + 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(about) + const secondEnter = second.container.querySelector('[data-page-enter]') + expect(secondEnter?.className).toContain('motion-safe:animate-page-in') +}) diff --git a/components/layout/page-enter.tsx b/components/layout/page-enter.tsx new file mode 100644 index 0000000..b79c58a --- /dev/null +++ b/components/layout/page-enter.tsx @@ -0,0 +1,26 @@ +'use client' + +import { type ReactNode, useEffect, useState } from 'react' + +let seenFirstMount = false + +export function willAnimateNextEnter() { + return seenFirstMount +} + +export function PageEnter({ children }: { children: ReactNode }) { + const [animate] = useState(seenFirstMount) + + useEffect(() => { + seenFirstMount = true + }, []) + + return ( +
+ {children} +
+ ) +} diff --git a/e2e/page-enter.spec.ts b/e2e/page-enter.spec.ts new file mode 100644 index 0000000..62ad7b0 --- /dev/null +++ b/e2e/page-enter.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test' + +const postPath = '/blog/postgresql-e-pgadmin-com-docker-compose' + +for (const path of ['/', postPath] as const) { + test(`document load on ${path} does not run page-in`, async ({ page }) => { + await page.goto(path) + await expect(page.locator('[data-page-enter]')).toHaveCSS( + 'animation-name', + 'none', + ) + }) +} + +test('client navigation fades the page in', async ({ page }) => { + await page.goto('/') + await page.getByRole('banner').getByRole('link', { name: 'Sobre' }).click() + await expect(page).toHaveURL('/about') + await expect(page.locator('[data-page-enter]')).toHaveCSS( + 'animation-name', + 'page-in', + ) +}) + +test('reduced motion skips the navigation fade', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('/') + await page.getByRole('banner').getByRole('link', { name: 'Sobre' }).click() + await expect(page).toHaveURL('/about') + await expect(page.locator('[data-page-enter]')).toHaveCSS( + 'animation-name', + 'none', + ) +})