Acessibilidade (a11y) no frontend moderno

1. Fundamentos da Acessibilidade Web

Acessibilidade web, abreviada como a11y (a + 11 letras + y), é a prática de desenvolver sites e aplicações que possam ser utilizados por todas as pessoas, independentemente de suas capacidades físicas ou cognitivas. No desenvolvimento moderno com JavaScript, Node.js e React, a acessibilidade não é opcional — é um requisito fundamental que impacta diretamente a experiência do usuário, a conformidade legal e o SEO.

As WCAG (Web Content Accessibility Guidelines) estabelecem quatro princípios fundamentais: Percebível, Operável, Compreensível e Robusto (POUR). Cada princípio possui critérios de sucesso organizados em três níveis de conformidade: A (mínimo), AA (recomendado) e AAA (avançado). Para a maioria dos projetos, o nível AA é o alvo prático.

Ferramentas de auditoria automatizada como Lighthouse (integrado ao Chrome DevTools), axe DevTools e WAVE ajudam a identificar problemas rapidamente, mas não substituem testes manuais com leitores de tela.

2. HTML Semântico e ARIA no React

A base da acessibilidade começa com HTML semântico. Elementos como <nav>, <main>, <aside> e <footer> criam landmarks que leitores de tela utilizam para navegação rápida.

function Layout({ children }) {
  return (
    <>
      <nav aria-label="Navegação principal">
        <a href="/">Home</a>
      </nav>
      <main id="conteudo-principal">
        {children}
      </main>
      <aside aria-label="Barra lateral">
        <h2>Artigos relacionados</h2>
      </aside>
    </>
  );
}

ARIA (Accessible Rich Internet Applications) deve ser usado com cautela. A regra de ouro: não use ARIA se o HTML nativo resolver o problema. Por exemplo, um <button> nativo já é acessível por teclado e leitores de tela, enquanto um <div> com role="button" exige implementação manual de eventos de teclado.

// ❌ Evite: reinventar a roda sem necessidade
function BadButton({ onClick, children }) {
  return (
    <div role="button" tabIndex={0} onClick={onClick}>
      {children}
    </div>
  );
}

// ✅ Prefira: elemento nativo
function GoodButton({ onClick, children }) {
  return <button onClick={onClick}>{children}</button>;
}

3. Navegação por Teclado e Foco Gerenciado

O gerenciamento de foco é crítico em SPAs. Use useRef e focus() programático para direcionar o foco após transições de rota ou abertura de modais.

import { useRef, useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function SkipLink() {
  const mainRef = useRef(null);
  const location = useLocation();

  useEffect(() => {
    mainRef.current?.focus();
  }, [location]);

  return (
    <>
      <a href="#conteudo" className="skip-link">
        Pular para o conteúdo principal
      </a>
      <main ref={mainRef} tabIndex={-1} id="conteudo">
        <h1>Página atual</h1>
      </main>
    </>
  );
}

Roving tabindex é uma técnica para navegação por setas em listas. Apenas um elemento mantém tabIndex={0}, enquanto os demais têm tabIndex={-1}:

function CardList({ items }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e) => {
    if (e.key === 'ArrowDown') {
      setActiveIndex((prev) => Math.min(prev + 1, items.length - 1));
    } else if (e.key === 'ArrowUp') {
      setActiveIndex((prev) => Math.max(prev - 1, 0));
    }
  };

  return (
    <ul role="listbox" onKeyDown={handleKeyDown}>
      {items.map((item, index) => (
        <li
          key={item.id}
          role="option"
          tabIndex={index === activeIndex ? 0 : -1}
          aria-selected={index === activeIndex}
        >
          {item.title}
        </li>
      ))}
    </ul>
  );
}

4. Componentes Acessíveis com React Hooks

O hook useId (React 18+) gera identificadores únicos, essenciais para associar labels e descrições:

import { useId, useState } from 'react';

function Accordion({ title, children }) {
  const id = useId();
  const [expanded, setExpanded] = useState(false);

  return (
    <div>
      <button
        aria-expanded={expanded}
        aria-controls={id}
        onClick={() => setExpanded(!expanded)}
      >
        {title}
      </button>
      <div id={id} role="region" hidden={!expanded}>
        {children}
      </div>
    </div>
  );
}

Para trap focus em modais, combine useEffect com um callback que restringe o foco ao container:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      modalRef.current?.focus();
      const handleTab = (e) => {
        const focusable = modalRef.current.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        if (e.key === 'Tab') {
          if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
          } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
          }
        }
      };
      document.addEventListener('keydown', handleTab);
      return () => document.removeEventListener('keydown', handleTab);
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div role="dialog" aria-modal="true" ref={modalRef}>
      <button onClick={onClose}>Fechar</button>
      {children}
    </div>
  );
}

5. Formulários Acessíveis e Validação

Labels explícitos com htmlFor são preferíveis. Use aria-describedby para associar mensagens de erro:

import { useId, useState } from 'react';

function LoginForm() {
  const emailId = useId();
  const errorId = useId();
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!email.includes('@')) {
      setError('Email inválido');
    }
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor={emailId}>Email</label>
      <input
        id={emailId}
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <p id={errorId} role="alert" style={{ color: 'red' }}>
          {error}
        </p>
      )}
      <button type="submit">Enviar</button>
    </form>
  );
}

Com React Hook Form, a validação em tempo real fica mais limpa:

import { useForm } from 'react-hook-form';

function FormAcessivel() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <label htmlFor="nome">Nome completo</label>
      <input
        id="nome"
        {...register('nome', { required: 'Campo obrigatório' })}
        aria-invalid={!!errors.nome}
        aria-describedby={errors.nome ? 'erro-nome' : undefined}
      />
      {errors.nome && (
        <p id="erro-nome" role="alert">{errors.nome.message}</p>
      )}
    </form>
  );
}

6. Testes de Acessibilidade Automatizados e Manuais

Com jest-axe e @testing-library/react, é possível testar violações automaticamente:

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('botão não deve ter violações de acessibilidade', async () => {
  const { container } = render(<button>Clique aqui</button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Para testes de navegação por teclado com Cypress ou Playwright:

// Exemplo com Cypress
cy.visit('/');
cy.get('body').tab(); // Avança para o próximo elemento focável
cy.focused().should('have.text', 'Pular para conteúdo');

Testes manuais essenciais:
- Zoom para 200% — o layout deve permanecer funcional
- Modo de alto contraste do sistema operacional
- Navegação completa apenas com teclado (Tab, Enter, Espaço, Setas)

7. Performance e Acessibilidade no Ecossistema Node.js

No Next.js, o SSR permite gerar HTML acessível desde o primeiro carregamento:

// pages/_document.js
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="pt-BR">
      <Head>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Lazy loading de imagens com descrição adequada:

function Galeria({ imagens }) {
  return (
    <div>
      {imagens.map((img) => (
        <img
          key={img.id}
          src={img.url}
          alt={img.descricao}
          loading="lazy"
          width={400}
          height={300}
        />
      ))}
    </div>
  );
}

Para internacionalização (i18n), um middleware no Node.js pode detectar o idioma preferido:

// middleware.js (Next.js)
import { NextResponse } from 'next/server';

export function middleware(request) {
  const acceptLang = request.headers.get('accept-language');
  const lang = acceptLang?.startsWith('pt') ? 'pt-BR' : 'en';
  return NextResponse.redirect(new URL(`/${lang}`, request.url));
}

Referências