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
6 changes: 6 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
@import "./theme/spacing.css";
@import "./theme/containers.css";

/* Hash focus from the skip link. Beats the preflight :focus-visible outline. */
#conteudo:focus,
#conteudo:focus-visible {
outline: none;
}

@custom-variant dark (&:where(.dark, .dark *));

@theme {
Expand Down
9 changes: 8 additions & 1 deletion components/layout/site-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { Footer } from '@/components/layout/footer'
import { Header } from '@/components/layout/header'
import { LoadingBar } from '@/components/layout/loading-indicator'
import { NavigationProgress } from '@/components/layout/navigation-progress'
import { SkipLink } from '@/components/layout/skip-link'
import { ThemeProvider } from '@/components/theme-provider'
import { getDictionary } from '@/lib/i18n/dictionary'
import { htmlLang, type InterfaceLanguage } from '@/lib/i18n/types'
import '@/app/globals.css'

Expand All @@ -31,6 +33,8 @@ export function SiteShell({
language: InterfaceLanguage
children: ReactNode
}) {
const skipLabel = getDictionary(language).skipLink.label

return (
<html
lang={htmlLang[language]}
Expand All @@ -46,8 +50,11 @@ export function SiteShell({
/>
<BootProgress />
<NavigationProgress />
<SkipLink label={skipLabel} />
<Header language={language} />
{children}
<div id="conteudo" tabIndex={-1}>
{children}
</div>
<Footer />
</InterfaceLanguageProvider>
</ThemeProvider>
Expand Down
22 changes: 22 additions & 0 deletions components/layout/skip-link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, test } from 'vitest'
import { SkipLink } from './skip-link'

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

test('points at #conteudo in Portuguese and English', () => {
const { unmount } = render(<SkipLink label="Pular para o conteúdo" />)

const link = screen.getByRole('link', { name: 'Pular para o conteúdo' })
expect(link.getAttribute('href')).toBe('#conteudo')
expect(link.closest('nav')).toBeNull()

unmount()
render(<SkipLink label="Skip to content" />)

expect(
screen.getByRole('link', { name: 'Skip to content' }).getAttribute('href'),
).toBe('#conteudo')
})
19 changes: 19 additions & 0 deletions components/layout/skip-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// not-sr-only sets position: static and fights with fixed. Reset the clip on focus.
const skipLinkClassName = [
'sr-only fixed z-50 bg-background text-sm font-medium text-foreground',
'outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35',
'focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'focus-visible:fixed focus-visible:top-4 focus-visible:left-4',
'focus-visible:m-0 focus-visible:h-auto focus-visible:w-auto',
'focus-visible:overflow-visible focus-visible:px-4 focus-visible:py-2',
'focus-visible:whitespace-normal focus-visible:rounded-md',
'focus-visible:[clip-path:none]',
].join(' ')

export function SkipLink({ label }: { label: string }) {
return (
<a href="#conteudo" className={skipLinkClassName}>
{label}
</a>
)
}
17 changes: 13 additions & 4 deletions components/views/blog-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,23 @@ test('list view formats month and year in English', () => {
expect(within(articleLink).getByRole('time').textContent).toBe('Sep 2026')
})

test('grid view hides descriptions and shows cover images', () => {
renderBlog('pt', { view: 'grid' })
test('grid view hides descriptions and treats covers as decorative', () => {
const { container } = renderBlog('pt', { view: 'grid' })

expect(
screen.queryByText('Um passeio pelo pipeline de ponta a ponta.'),
).toBeNull()
expect(screen.getAllByRole('img')).toHaveLength(3)
expect(screen.getByRole('img', { name: 'Hello World' })).toBeTruthy()
const covers = container.querySelectorAll('img')
expect(covers).toHaveLength(3)
for (const cover of covers) {
expect(cover.getAttribute('alt')).toBe('')
}
expect(
screen.getByRole('link', {
name: (accessibleName) =>
accessibleName.split('Hello World').length - 1 === 1,
}),
).toBeTruthy()
})

test('view switch links preserve the current page', () => {
Expand Down
2 changes: 1 addition & 1 deletion components/views/blog-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function GridCard({
{coverSrc ? (
<Image
src={coverSrc}
alt={post.title}
alt=""
width={COVER_WIDTH}
height={COVER_HEIGHT}
className="size-full object-cover"
Expand Down
67 changes: 67 additions & 0 deletions components/views/post-article.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, expect, test, vi } from 'vitest'
import type { Post } from '@/lib/content/posts'
import { PostArticle } from './post-article'

vi.mock('next/image', () => ({
default: function MockImage({ alt, src }: { alt: string; src: string }) {
return <img alt={alt} src={src} />
},
}))

vi.mock('@/lib/content/mdx', () => ({
MdxContent: ({ source }: { source: string }) => <div>{source}</div>,
}))

vi.mock('next/link', () => ({
default: function MockLink({
children,
href,
className,
}: {
children: ReactNode
href: string
className?: string
}) {
return (
<a href={href} className={className}>
{children}
</a>
)
},
}))

const post: Post = {
slug: 'hello-world',
content: 'Body',
frontmatter: {
title: 'Hello World',
description: 'A post',
date: new Date('2026-09-21T00:00:00.000Z'),
tags: [],
cover: 'hello-world.png',
language: 'pt',
},
}

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

test('wraps the article frame in main and keeps the top button outside the frame', () => {
const { container } = render(<PostArticle post={post} language="pt" />)

const main = screen.getByRole('main')
const frame = container.querySelector('.article-frame')
const article = container.querySelector('article')
expect(frame).toBeTruthy()
expect(main.contains(frame)).toBe(true)
expect(frame?.contains(article)).toBe(true)
expect(article?.getAttribute('data-analytics-source')).toBe('post')

const backToTop = screen.getByRole('button', { name: 'Voltar ao topo' })
expect(main.contains(backToTop)).toBe(true)
expect(frame?.contains(backToTop)).toBe(false)
expect(container.querySelector('img')?.getAttribute('alt')).toBe('')
})
6 changes: 3 additions & 3 deletions components/views/post-article.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function PostArticle({
: undefined

return (
<>
<main>
<div className="article-frame">
<Container
as="article"
Expand All @@ -87,7 +87,7 @@ export function PostArticle({
{coverSrc ? (
<Image
src={coverSrc}
alt={frontmatter.title}
alt=""
width={COVER_WIDTH}
height={COVER_HEIGHT}
className={articleCoverClassName}
Expand Down Expand Up @@ -123,6 +123,6 @@ export function PostArticle({
</Container>
</div>
<ArticleScrollToTop label={copy.backToTop} />
</>
</main>
)
}
52 changes: 52 additions & 0 deletions components/views/view-mode-switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, test } from 'vitest'
import { ViewModeSwitch } from './view-mode-switch'

const options = [
{ id: 'list' as const, label: 'Lista', icon: <span>list</span> },
{ id: 'grid' as const, label: 'Grade', icon: <span>grid</span> },
]

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

test('link mode marks the committed view with aria-current', () => {
render(
<ViewModeSwitch
ariaLabel="Formato"
view="list"
idPrefix="blog-view"
hrefFor={(id) => (id === 'list' ? '/blog' : '/blog?view=grid')}
options={options}
/>,
)

const list = screen.getByRole('link', { name: 'Lista' })
const grid = screen.getByRole('link', { name: 'Grade' })

expect(list.getAttribute('aria-current')).toBe('page')
expect(grid.hasAttribute('aria-current')).toBe(false)
expect(list.hasAttribute('aria-pressed')).toBe(false)
expect(grid.hasAttribute('aria-pressed')).toBe(false)
expect(list.className.split(/\s+/)).toContain('text-foreground')
})

test('button mode keeps aria-pressed and omits aria-current', () => {
render(
<ViewModeSwitch
ariaLabel="Formato"
view="grid"
idPrefix="community-view"
options={options}
/>,
)

const list = screen.getByRole('button', { name: 'Lista' })
const grid = screen.getByRole('button', { name: 'Grade' })

expect(grid.getAttribute('aria-pressed')).toBe('true')
expect(list.getAttribute('aria-pressed')).toBe('false')
expect(list.hasAttribute('aria-current')).toBe(false)
expect(grid.hasAttribute('aria-current')).toBe(false)
})
2 changes: 1 addition & 1 deletion components/views/view-mode-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function ViewModeSwitch({
<Link
key={option.id}
href={hrefFor(option.id)}
aria-pressed={pressed}
aria-current={pressed ? 'page' : undefined}
aria-label={option.label}
aria-describedby={tooltipId}
data-highlight-target={option.id}
Expand Down
72 changes: 72 additions & 0 deletions e2e/skip-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { expect, type Page, test } from '@playwright/test'

async function skipToContent(page: Page, path: string) {
await page.goto(path)
await page.keyboard.press('Tab')

const skip = page.getByRole('link', { name: 'Pular para o conteúdo' })
await expect(skip).toBeFocused()

const box = await skip.boundingBox()
expect(box?.width ?? 0).toBeGreaterThan(40)
expect(box?.height ?? 0).toBeGreaterThan(16)
await expect(skip).toHaveCSS('clip-path', 'none')

await page.keyboard.press('Enter')

const target = page.locator('#conteudo')
await expect(target).toBeFocused()
await expect(target).toHaveCSS('outline-style', 'none')
}

test('first Tab skips to content on the home page', async ({ page }) => {
await skipToContent(page, '/')
})

test('first Tab skips to content on a post', async ({ page }) => {
await skipToContent(page, '/blog/meu-blog-profissional')
})

test('open mobile menu makes the skip link inert', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await page.getByRole('button', { name: 'Menu' }).click()

const skip = page.locator('a[href="#conteudo"]')
await expect(skip).toHaveAttribute('inert')

for (let step = 0; step < 8; step += 1) {
await page.keyboard.press('Tab')
await expect(skip).not.toBeFocused()
}
})

test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false })

test('Enter then Tab reaches the first content link on the home page', async ({
page,
}) => {
await skipWithoutJavaScript(page, '/')
})

test('Enter then Tab reaches the first content link on a post', async ({
page,
}) => {
await skipWithoutJavaScript(page, '/blog/meu-blog-profissional')
})
})

async function skipWithoutJavaScript(page: Page, path: string) {
await page.goto(path)
await page.keyboard.press('Tab')
await expect(
page.getByRole('link', { name: 'Pular para o conteúdo' }),
).toBeFocused()

await page.keyboard.press('Enter')
await expect(page.locator('#conteudo')).toBeFocused()

await page.keyboard.press('Tab')
await expect(page.locator('#conteudo a').first()).toBeFocused()
}
4 changes: 4 additions & 0 deletions lib/i18n/dictionary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ test('pt and en dictionaries share the same key shape', () => {
expect(Object.keys(en.language)).toEqual(Object.keys(pt.language))
expect(Object.keys(pt.mobileMenu)).toEqual(['trigger', 'navAriaLabel'])
expect(Object.keys(en.mobileMenu)).toEqual(Object.keys(pt.mobileMenu))
expect(Object.keys(pt.skipLink)).toEqual(['label'])
expect(Object.keys(en.skipLink)).toEqual(Object.keys(pt.skipLink))
expect(Object.keys(pt.navigation)).toEqual(['loading'])
expect(Object.keys(en.navigation)).toEqual(Object.keys(pt.navigation))
})
Expand Down Expand Up @@ -71,6 +73,8 @@ test('includes theme, language switch, and mobile menu copy in both languages',
expect(en.language.switchToPt).toBe('Switch to Portuguese')
expect(pt.mobileMenu.trigger).toBe('Menu')
expect(en.mobileMenu.navAriaLabel).toBe('Main navigation')
expect(pt.skipLink.label).toBe('Pular para o conteúdo')
expect(en.skipLink.label).toBe('Skip to content')
expect(pt.navigation.loading).toBe('O conteúdo está sendo carregado')
expect(en.navigation.loading).toBe('The content is loading')
})
Loading
Loading