feat(a11y): implementa acessibilidade WCAG 2.1 AA em todo o portal público

- layout.scss: focus-visible ring, skip link, prefers-reduced-motion, touch targets ≥44px, font-size base 16px
- App.vue: skip link + aplica tema dinâmico no onMounted (após PrimeVue inicializado)
- composables/useMotion.js: detecta prefers-reduced-motion com listener reativo
- HomeView: aria-label em seções, carrossel respeita reduced-motion, contraste de texto melhorado
- LoginView: h1 correto, label+for+aria-describedby no campo senha, role=alert no erro, touch targets nos botões
- DocumentoInput: aria-label, aria-required, autocomplete=username
- AppHeader: aria-label no logo e nav, aria-current na rota ativa, aria-hidden nos ícones decorativos
- AppFooter: role=contentinfo, nav com aria-label, contraste text-slate-400→500, ícone decorativo aria-hidden
- PortalLayout: role=banner, aria-label no nav, aria-current nas rotas, aria-live no nome do usuário
- PublicLayout: tabindex=-1 no main para receber foco via skip link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabriel Bezerra 2026-05-18 00:28:40 -03:00
parent 5a7f4ba07a
commit 6b6d47ba8a
10 changed files with 215 additions and 64 deletions

View File

@ -12,6 +12,12 @@ onMounted(() => {
</script>
<template>
<!-- Skip link: invisível até receber foco via Tab permite ao usuário
de teclado pular a navegação e ir direto ao conteúdo principal -->
<a href="#main-content" class="skip-link">
Pular para o conteúdo principal
</a>
<RouterView />
<Toast />
</template>

View File

@ -1,11 +1,10 @@
/* Estilos globais de layout — sem @apply (regra do projeto) */
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 16px; /* base mínimo para leitura confortável */
}
#app {
@ -14,7 +13,7 @@ body {
flex-direction: column;
}
/* Transições de rota */
/* ── Transições de rota ────────────────────────────────────────────── */
.page-enter-active,
.page-leave-active {
transition: opacity 0.18s ease;
@ -23,3 +22,85 @@ body {
.page-leave-to {
opacity: 0;
}
/* ── Acessibilidade global ─────────────────────────────────────────── */
/*
* Focus ring visível e de alto contraste.
* Usa currentColor para se adaptar a qualquer tema de cor.
* Nunca remover usuários de teclado e leitores de tela dependem disso.
*/
:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
border-radius: 4px;
}
/*
* Skip link oculto até receber foco via Tab.
* Permite que usuários de teclado pulem a navegação e vão direto ao conteúdo.
*/
.skip-link {
position: fixed;
top: -100%;
left: 1rem;
z-index: 9999;
padding: 0.875rem 1.5rem;
background: #1e293b;
color: #fff;
font-weight: 600;
font-size: 1rem;
border-radius: 0.5rem;
text-decoration: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
transition: top 0.15s ease;
&:focus {
top: 1rem;
}
}
/*
* Respeitar a preferência de usuário por menos movimento (vestibular,
* epilepsia, sensibilidade visual). Afeta autoplay do carousel e transições.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Garante que transições de rota também param */
.page-enter-active,
.page-leave-active {
transition: none;
}
}
/* ── Alvo de toque mínimo (WCAG 2.5.5) ────────────────────────────── */
/*
* Botões e links pequenos precisam de área clicável mínima de 44×44px
* para usuários com dificuldade motora ou em telas touch.
*/
button,
[role='button'],
a {
min-height: 44px;
display: inline-flex;
align-items: center;
}
/*
* Exceção para elementos inline de parágrafo onde forçar altura quebra
* o layout (links dentro de texto corrido).
*/
p a,
li a,
.inline-link {
min-height: unset;
display: inline;
}

View File

@ -44,8 +44,11 @@ function onInput(e) {
:value="valorFormatado"
:placeholder="placeholder"
inputmode="numeric"
autocomplete="username"
class="w-full text-lg tracking-wide"
size="large"
aria-label="CPF ou CNPJ"
aria-required="true"
@input="onInput"
/>
<span

View File

@ -2,18 +2,18 @@
</script>
<template>
<footer class="bg-white border-t border-slate-200 mt-auto">
<footer class="bg-white border-t border-slate-200 mt-auto" role="contentinfo">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2 text-sm text-slate-500">
<i class="pi pi-building text-blue-600" />
<div class="flex items-center gap-2 text-sm text-slate-600">
<i class="pi pi-building text-primary" aria-hidden="true" />
<span>Portal do Contribuinte ModumFiscal</span>
</div>
<div class="flex items-center gap-4 text-xs text-slate-400">
<a href="#" class="hover:text-slate-600 transition-colors">Política de Privacidade</a>
<a href="#" class="hover:text-slate-600 transition-colors">Termos de Uso</a>
<a href="#" class="hover:text-slate-600 transition-colors">Acessibilidade</a>
</div>
<nav aria-label="Links institucionais" class="flex items-center gap-4 text-xs text-slate-500">
<a href="#" class="hover:text-slate-700 transition-colors">Política de Privacidade</a>
<a href="#" class="hover:text-slate-700 transition-colors">Termos de Uso</a>
<a href="#" class="hover:text-slate-700 transition-colors">Acessibilidade</a>
</nav>
</div>
</div>
</footer>

View File

@ -14,14 +14,18 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<RouterLink :to="{ name: 'home' }" class="flex items-center gap-3 min-w-0">
<RouterLink
:to="{ name: 'home' }"
class="flex items-center gap-3 min-w-0"
:aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
>
<img
:src="logoSrc"
alt="Logo da Prefeitura"
:alt="`Logo de ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
class="h-9 w-auto object-contain flex-shrink-0"
/>
<div class="leading-tight min-w-0">
<div class="leading-tight min-w-0" aria-hidden="true">
<p class="text-xs text-slate-500 font-normal truncate">Portal do Contribuinte</p>
<p class="text-sm font-semibold text-slate-800 truncate">
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
@ -29,10 +33,11 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
</div>
</RouterLink>
<nav class="hidden md:flex items-center gap-4">
<nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4">
<RouterLink
:to="{ name: 'servicos' }"
class="text-sm text-slate-600 hover:text-primary transition-colors"
:aria-current="$route.name === 'servicos' ? 'page' : undefined"
>
Serviços
</RouterLink>
@ -41,12 +46,12 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
<div class="flex items-center gap-3">
<template v-if="auth.isAuthenticated">
<RouterLink :to="{ name: 'painel' }">
<Button label="Meu Painel" icon="pi pi-user" size="small" />
<Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" />
</RouterLink>
</template>
<template v-else>
<RouterLink :to="{ name: 'home' }">
<Button label="Entrar" icon="pi pi-sign-in" size="small" />
<Button label="Entrar" icon="pi pi-sign-in" size="small" aria-label="Entrar no portal" />
</RouterLink>
</template>
</div>

View File

@ -0,0 +1,28 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Detecta a preferência do usuário por movimento reduzido.
* Usa a media query `prefers-reduced-motion` e reage a mudanças em tempo real.
* Uso: const { prefersReducedMotion } = useMotion()
*/
export function useMotion() {
const prefersReducedMotion = ref(false)
let mq
function onChange(e) {
prefersReducedMotion.value = e.matches
}
onMounted(() => {
mq = window.matchMedia('(prefers-reduced-motion: reduce)')
prefersReducedMotion.value = mq.matches
mq.addEventListener('change', onChange)
})
onUnmounted(() => {
mq?.removeEventListener('change', onChange)
})
return { prefersReducedMotion }
}

View File

@ -21,42 +21,50 @@ function sair() {
<template>
<div class="min-h-screen flex flex-col bg-slate-50">
<header class="bg-white border-b border-slate-200 sticky top-0 z-40">
<header class="bg-white border-b border-slate-200 sticky top-0 z-40" role="banner">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<RouterLink :to="{ name: 'painel' }" class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-700 rounded-lg flex items-center justify-center">
<i class="pi pi-building text-white text-sm" />
<RouterLink
:to="{ name: 'painel' }"
class="flex items-center gap-3"
aria-label="Ir para o painel principal"
>
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<i class="pi pi-building text-white text-sm" aria-hidden="true" />
</div>
<span class="font-semibold text-slate-800">Portal do Contribuinte</span>
</RouterLink>
<nav class="hidden md:flex items-center gap-1">
<nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="px-3 py-2 rounded-lg text-sm text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
active-class="bg-blue-50 text-blue-700 font-medium"
class="px-4 py-2 rounded-lg text-sm text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
active-class="bg-primary/10 text-primary font-semibold"
:aria-current="$route.name === item.name ? 'page' : undefined"
>
{{ item.label }}
</RouterLink>
</nav>
<div class="flex items-center gap-3">
<span class="hidden sm:block text-sm text-slate-600">{{ auth.nomeUsuario }}</span>
<span class="hidden sm:block text-sm text-slate-600" aria-live="polite">
{{ auth.nomeUsuario }}
</span>
<Button
label="Sair"
severity="secondary"
size="small"
icon="pi pi-sign-out"
outlined
aria-label="Sair do portal"
@click="sair"
/>
</div>
</div>
</header>
<main class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8">
<main id="main-content" tabindex="-1" class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 outline-none">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />

View File

@ -4,13 +4,16 @@
<template>
<div class="min-h-screen flex flex-col bg-slate-50">
<AppHeader />
<main class="flex-1">
<!-- tabindex="-1" permite que o skip link mova o foco para -->
<main id="main-content" tabindex="-1" class="flex-1 outline-none">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<AppFooter />
</div>
</template>

View File

@ -2,11 +2,14 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { useMotion } from '@/composables/useMotion'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
import ServiceCard from '@/components/common/ServiceCard.vue'
import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
const { prefersReducedMotion } = useMotion()
const router = useRouter()
const prefeitura = usePrefeituraStore()
@ -152,7 +155,7 @@ function continuar() {
Serviços municipais<br />
<span class="text-white/80">na palma da mão</span>
</h1>
<p class="text-white/65 text-base mb-8 leading-relaxed max-w-md">
<p class="text-white/80 text-base mb-8 leading-relaxed max-w-md">
Emita certidões, consulte débitos e acesse todos os serviços
da prefeitura sem precisar sair de casa.
</p>
@ -170,7 +173,7 @@ function continuar() {
</div>
<div>
<p class="text-sm font-semibold text-white group-hover:text-white/80 transition-colors">{{ s.titulo }}</p>
<p class="text-xs text-white/50">{{ s.descricao }}</p>
<p class="text-xs text-white/70">{{ s.descricao }}</p>
</div>
<i class="pi pi-chevron-right text-white/30 text-xs ml-auto opacity-0 group-hover:opacity-100 transition-opacity" />
</li>
@ -178,7 +181,7 @@ function continuar() {
<RouterLink
:to="{ name: 'servicos' }"
class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-white transition-colors font-medium"
class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium"
>
Ver todos os serviços disponíveis
<i class="pi pi-arrow-right text-xs" />
@ -254,15 +257,17 @@ function continuar() {
</section>
<!-- CAROUSEL DE AVISOS -->
<section class="bg-white border-b border-slate-100">
<section class="bg-white border-b border-slate-100" aria-label="Avisos e comunicados">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- autoplay desativado quando prefers-reduced-motion está ativo -->
<Carousel
:value="avisos"
:num-visible="1"
:num-scroll="1"
:autoplay-interval="6000"
:autoplay-interval="prefersReducedMotion ? 0 : 6000"
circular
class="aviso-carousel"
aria-label="Avisos da prefeitura"
>
<template #item="{ data: aviso }">
<div
@ -306,11 +311,11 @@ function continuar() {
</section>
<!-- SERVIÇOS AUTENTICADOS -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12" aria-labelledby="servicos-logados-titulo">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-slate-800">Área logada</h2>
<p class="text-sm text-slate-500 mt-0.5">Serviços disponíveis após login</p>
<h2 id="servicos-logados-titulo" class="text-xl font-bold text-slate-800">Área logada</h2>
<p class="text-sm text-slate-600 mt-0.5">Serviços disponíveis após login</p>
</div>
<span class="hidden sm:flex items-center gap-1.5 bg-primary/8 text-primary text-xs font-semibold px-3 py-1.5 rounded-full border border-primary/15">
<i class="pi pi-lock text-xs" />

View File

@ -8,6 +8,8 @@ const router = useRouter()
const senha = ref('')
const carregando = ref(false)
const erro = ref('')
const senhaId = 'campo-senha'
const erroId = 'erro-senha'
const docBruto = computed(() => (route.query.doc ?? '').replace(/\D/g, ''))
@ -34,7 +36,7 @@ function trocarDocumento() {
async function entrar() {
if (!senha.value) {
erro.value = 'Informe a senha.'
erro.value = 'Informe a senha para continuar.'
return
}
erro.value = ''
@ -51,45 +53,45 @@ async function entrar() {
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<!-- Card de login -->
<div class="bg-white rounded-2xl shadow-lg border border-slate-200 overflow-hidden">
<!-- Cabeçalho azul -->
<div class="bg-gradient-to-r from-blue-700 to-blue-800 px-8 py-6">
<!-- Cabeçalho h1 para hierarquia de heading correta -->
<div class="bg-gradient-to-r from-primary-700 to-primary-800 px-8 py-6 bg-primary">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-lock text-white text-lg" />
</div>
<div>
<p class="text-white font-semibold">Acesso seguro</p>
<p class="text-blue-200 text-xs">Portal do Contribuinte</p>
<h1 class="text-white font-bold text-base leading-tight">Acesso seguro</h1>
<p class="text-white/80 text-xs mt-0.5">Portal do Contribuinte</p>
</div>
</div>
</div>
<!-- Conteúdo -->
<div class="px-8 py-8 space-y-6">
<!-- Documento identificado -->
<div>
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">
<p class="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2">
Entrando como
</p>
<div class="flex items-center justify-between bg-slate-50 border border-slate-200 rounded-xl px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="pi pi-id-card text-blue-700 text-sm" />
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center" aria-hidden="true">
<i class="pi pi-id-card text-primary text-sm" />
</div>
<div>
<p class="font-mono font-medium text-slate-800 text-sm">{{ docFormatado }}</p>
<p class="text-xs text-slate-500">{{ tipoDoc }}</p>
<p class="font-mono font-semibold text-slate-800 text-sm">{{ docFormatado }}</p>
<p class="text-xs text-slate-600">{{ tipoDoc }}</p>
</div>
</div>
<!-- Alvo de 44px via py-3 px-3 -->
<button
class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 transition-colors"
class="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary font-semibold px-3 py-3 rounded-lg hover:bg-primary/8 transition-colors"
aria-label="Trocar documento de identificação"
@click="trocarDocumento"
>
<i class="pi pi-pencil text-xs" />
<i class="pi pi-pencil text-xs" aria-hidden="true" />
Trocar
</button>
</div>
@ -97,26 +99,36 @@ async function entrar() {
<!-- Senha -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">
<label :for="senhaId" class="block text-sm font-semibold text-slate-700 mb-1.5">
Senha
</label>
<Password
v-model="senha"
:input-id="senhaId"
:feedback="false"
toggle-mask
placeholder="Digite sua senha"
class="w-full"
input-class="w-full"
size="large"
:invalid="!!erro"
:input-props="{ 'aria-describedby': erro ? erroId : undefined, 'aria-invalid': !!erro || undefined }"
@keyup.enter="entrar"
/>
<p v-if="erro" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
<i class="pi pi-exclamation-circle" />
<!-- aria-live="assertive": anuncia o erro imediatamente para leitores de tela -->
<p
:id="erroId"
role="alert"
aria-live="assertive"
class="mt-2 text-sm text-red-700 font-medium flex items-center gap-1.5 min-h-[1.25rem]"
>
<template v-if="erro">
<i class="pi pi-exclamation-circle text-sm" aria-hidden="true" />
{{ erro }}
</template>
</p>
</div>
<!-- Botão entrar -->
<Button
label="Entrar"
icon="pi pi-sign-in"
@ -126,35 +138,35 @@ async function entrar() {
@click="entrar"
/>
<!-- Links secundários -->
<div class="text-center space-y-2">
<div class="text-center space-y-1">
<RouterLink
:to="{ name: 'primeiro-acesso' }"
class="block text-sm text-blue-600 hover:text-blue-800 transition-colors"
class="block text-sm text-primary font-medium py-2 hover:underline"
>
Esqueci minha senha
</RouterLink>
<RouterLink
:to="{ name: 'credenciamento' }"
class="block text-xs text-slate-500 hover:text-slate-700 transition-colors"
class="block text-sm text-slate-600 py-2 hover:text-slate-800 transition-colors"
>
Ainda não tem acesso? Credenciar-se
Ainda não tem acesso? <span class="text-primary font-semibold">Credenciar-se</span>
</RouterLink>
</div>
</div>
</div>
<!-- Voltar -->
<div class="text-center mt-6">
<!-- Voltar alvo de 44px via py-3 -->
<div class="text-center mt-4">
<button
class="text-sm text-slate-500 hover:text-slate-700 transition-colors flex items-center gap-1.5 mx-auto"
class="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-800 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100"
@click="router.push({ name: 'home' })"
>
<i class="pi pi-arrow-left text-xs" />
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial
</button>
</div>
</div>
</div>
</template>