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> </script>
<template> <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 /> <RouterView />
<Toast /> <Toast />
</template> </template>

View File

@ -1,11 +1,10 @@
/* Estilos globais de layout — sem @apply (regra do projeto) */
html, html,
body { body {
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: 'DM Sans', system-ui, sans-serif; font-family: 'DM Sans', system-ui, sans-serif;
font-size: 16px; /* base mínimo para leitura confortável */
} }
#app { #app {
@ -14,7 +13,7 @@ body {
flex-direction: column; flex-direction: column;
} }
/* Transições de rota */ /* ── Transições de rota ────────────────────────────────────────────── */
.page-enter-active, .page-enter-active,
.page-leave-active { .page-leave-active {
transition: opacity 0.18s ease; transition: opacity 0.18s ease;
@ -23,3 +22,85 @@ body {
.page-leave-to { .page-leave-to {
opacity: 0; 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" :value="valorFormatado"
:placeholder="placeholder" :placeholder="placeholder"
inputmode="numeric" inputmode="numeric"
autocomplete="username"
class="w-full text-lg tracking-wide" class="w-full text-lg tracking-wide"
size="large" size="large"
aria-label="CPF ou CNPJ"
aria-required="true"
@input="onInput" @input="onInput"
/> />
<span <span

View File

@ -2,18 +2,18 @@
</script> </script>
<template> <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="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 flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2 text-sm text-slate-500"> <div class="flex items-center gap-2 text-sm text-slate-600">
<i class="pi pi-building text-blue-600" /> <i class="pi pi-building text-primary" aria-hidden="true" />
<span>Portal do Contribuinte ModumFiscal</span> <span>Portal do Contribuinte ModumFiscal</span>
</div> </div>
<div class="flex items-center gap-4 text-xs text-slate-400"> <nav aria-label="Links institucionais" class="flex items-center gap-4 text-xs text-slate-500">
<a href="#" class="hover:text-slate-600 transition-colors">Política de Privacidade</a> <a href="#" class="hover:text-slate-700 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-700 transition-colors">Termos de Uso</a>
<a href="#" class="hover:text-slate-600 transition-colors">Acessibilidade</a> <a href="#" class="hover:text-slate-700 transition-colors">Acessibilidade</a>
</div> </nav>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -14,14 +14,18 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
<header class="bg-white border-b border-slate-200"> <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"> <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 <img
:src="logoSrc" :src="logoSrc"
alt="Logo da Prefeitura" :alt="`Logo de ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
class="h-9 w-auto object-contain flex-shrink-0" 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-xs text-slate-500 font-normal truncate">Portal do Contribuinte</p>
<p class="text-sm font-semibold text-slate-800 truncate"> <p class="text-sm font-semibold text-slate-800 truncate">
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }} {{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
@ -29,10 +33,11 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
</div> </div>
</RouterLink> </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 <RouterLink
:to="{ name: 'servicos' }" :to="{ name: 'servicos' }"
class="text-sm text-slate-600 hover:text-primary transition-colors" class="text-sm text-slate-600 hover:text-primary transition-colors"
:aria-current="$route.name === 'servicos' ? 'page' : undefined"
> >
Serviços Serviços
</RouterLink> </RouterLink>
@ -41,12 +46,12 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<template v-if="auth.isAuthenticated"> <template v-if="auth.isAuthenticated">
<RouterLink :to="{ name: 'painel' }"> <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> </RouterLink>
</template> </template>
<template v-else> <template v-else>
<RouterLink :to="{ name: 'home' }"> <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> </RouterLink>
</template> </template>
</div> </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> <template>
<div class="min-h-screen flex flex-col bg-slate-50"> <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"> <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"> <RouterLink
<div class="w-8 h-8 bg-blue-700 rounded-lg flex items-center justify-center"> :to="{ name: 'painel' }"
<i class="pi pi-building text-white text-sm" /> 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> </div>
<span class="font-semibold text-slate-800">Portal do Contribuinte</span> <span class="font-semibold text-slate-800">Portal do Contribuinte</span>
</RouterLink> </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 <RouterLink
v-for="item in navItems" v-for="item in navItems"
:key="item.name" :key="item.name"
:to="{ name: 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" 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-blue-50 text-blue-700 font-medium" active-class="bg-primary/10 text-primary font-semibold"
:aria-current="$route.name === item.name ? 'page' : undefined"
> >
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="flex items-center gap-3"> <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 <Button
label="Sair" label="Sair"
severity="secondary" severity="secondary"
size="small" size="small"
icon="pi pi-sign-out" icon="pi pi-sign-out"
outlined outlined
aria-label="Sair do portal"
@click="sair" @click="sair"
/> />
</div> </div>
</div> </div>
</header> </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 }"> <RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in"> <Transition name="page" mode="out-in">
<component :is="Component" /> <component :is="Component" />

View File

@ -4,13 +4,16 @@
<template> <template>
<div class="min-h-screen flex flex-col bg-slate-50"> <div class="min-h-screen flex flex-col bg-slate-50">
<AppHeader /> <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 }"> <RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in"> <Transition name="page" mode="out-in">
<component :is="Component" /> <component :is="Component" />
</Transition> </Transition>
</RouterView> </RouterView>
</main> </main>
<AppFooter /> <AppFooter />
</div> </div>
</template> </template>

View File

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

View File

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